Skip to content

Commit 2c13541

Browse files
committed
[IMP] website_event, website_event_sale: adapt JsonLd builder
1 parent a2ab279 commit 2c13541

4 files changed

Lines changed: 193 additions & 1 deletion

File tree

addons/website_event/controllers/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ def events(self, page=1, slug_tags=None, **searches):
187187
'slugify_tags': self._slugify_tags,
188188
'search_count': event_count,
189189
'original_search': fuzzy_search_term and search,
190-
'website': website
190+
'website': website,
191+
'structured_data': events.get_json_ld(),
191192
}
192193

193194
return request.render("website_event.index", values)
@@ -219,6 +220,7 @@ def event_page(self, event, page, **post):
219220
page = view.key if view else page
220221
values['seo_object'] = request.website.get_template(page)
221222
values['main_object'] = event
223+
values['structured_data'] = event.get_json_ld(is_detail_page=True)
222224
except ValueError:
223225
# page not found
224226
page = 'website.page_404'
@@ -264,6 +266,7 @@ def _prepare_event_register_values(self, event, **post):
264266
return {
265267
'event': event,
266268
'main_object': event,
269+
'structured_data': event.get_json_ld(is_detail_page=True),
267270
'range': range,
268271
'google_url': lazy(lambda: urls.get('google_url')),
269272
'iCal_url': lazy(lambda: urls.get('iCal_url')),

addons/website_event/models/event_event.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from markupsafe import Markup
1111

1212
from odoo import api, fields, models, tools, _
13+
from odoo.addons.website.helpers.jsonld_builder import JsonLd
14+
from odoo.addons.website.tools import text_from_html
1315
from odoo.exceptions import UserError, ValidationError
1416
from odoo.fields import Domain
1517
from odoo.tools.misc import get_lang, format_date
@@ -26,6 +28,7 @@ class EventEvent(models.Model):
2628
'website.cover_properties.mixin',
2729
'website.searchable.mixin',
2830
'website.page_visibility_options.mixin',
31+
'website.structured_data.mixin',
2932
]
3033

3134
def _default_cover_properties(self):
@@ -650,3 +653,162 @@ def _search_render_results(self, fetch_fields, mapping, icon, limit):
650653
data['tag_ids'] = event.tag_ids.read(['name'])
651654
data['image_url'] = event._get_image_url()
652655
return results_data
656+
657+
def _to_structured_data_summary_event(self):
658+
"""Return summary structured data for a single event card.
659+
Returns None if event is not public, ongoing, or lacks a physical address.
660+
:returns: JsonLd event schema or None
661+
:rtype: JsonLd | None
662+
"""
663+
self.ensure_one()
664+
if not self.address_id or self.is_ongoing or self.is_done or self.website_visibility != 'public':
665+
# Google event rich results require a public upcoming event with
666+
# a physical place.
667+
return None
668+
website = self.env['website'].get_current_website()
669+
base_url = self.get_base_url()
670+
address = self.address_id.sudo()
671+
street = ', '.join(part for part in (address.street, address.street2) if part)
672+
address_schema_data = {}
673+
if street:
674+
address_schema_data["streetAddress"] = street
675+
if address.city:
676+
address_schema_data["addressLocality"] = address.city
677+
if address.zip:
678+
address_schema_data["postalCode"] = address.zip
679+
if address.state_id.code:
680+
address_schema_data["addressRegion"] = address.state_id.code
681+
if address.country_id.code:
682+
address_schema_data["addressCountry"] = address.country_id.code
683+
schema_data = {
684+
"name": self.name,
685+
"url": self.website_absolute_url,
686+
"startDate": JsonLd.to_iso_datetime(self.date_begin),
687+
}
688+
nested_schema_data: dict[str, JsonLd | list[JsonLd] | None] = {}
689+
if image_url := website.image_url(self, 'image_1920'):
690+
nested_schema_data["image"] = JsonLd("ImageObject", {"url": f"{base_url}{image_url}"})
691+
nested_schema_data["location"] = JsonLd("Place", {"name": self.address_name}).add_nested({"address": JsonLd("PostalAddress", address_schema_data)})
692+
return JsonLd("Event", schema_data).add_nested(nested_schema_data)
693+
694+
def _to_structured_data_event(self):
695+
"""Return full structured data for a single event detail page.
696+
Extends summary data with organizer, offers, and event status.
697+
:returns: JsonLd event schema with all details or None if summary invalid
698+
:rtype: JsonLd | None
699+
"""
700+
self.ensure_one()
701+
event_data = self._to_structured_data_summary_event()
702+
if not event_data:
703+
return None
704+
description = self.subtitle or (self.description and text_from_html(self.description, True))
705+
nested_schema_data: dict[str, JsonLd | list[JsonLd] | None] = {}
706+
if self.organizer_id:
707+
organizer_sudo = self.organizer_id.sudo()
708+
if organizer_sudo == self.env.company.partner_id:
709+
base_url = self.env['website'].get_current_website().get_base_url()
710+
nested_schema_data["organizer"] = JsonLd("Organization", {"@id": f"{base_url}/#organization"})
711+
else:
712+
organizer_jsonld = JsonLd("Organization", {"name": organizer_sudo.name})
713+
if organizer_sudo.website:
714+
organizer_jsonld.set({"url": organizer_sudo.website})
715+
nested_schema_data["organizer"] = organizer_jsonld
716+
717+
event_status = "EventCancelled" if self.kanban_state == 'cancel' else "EventScheduled"
718+
offers_jsonld = [self._to_structured_data_ticket_offer(ticket) for ticket in self.event_ticket_ids]
719+
schema_data = {
720+
"endDate": JsonLd.to_iso_datetime(self.date_end),
721+
"eventStatus": f"https://schema.org/{event_status}",
722+
"eventAttendanceMode": "https://schema.org/OfflineEventAttendanceMode",
723+
}
724+
if description is not None:
725+
schema_data["description"] = description
726+
nested_schema_data["offers"] = offers_jsonld
727+
return event_data.set(schema_data).add_nested(nested_schema_data)
728+
729+
def _to_structured_data_ticket_offer(self, ticket):
730+
"""Create a JSON-LD Offer for a single event ticket.
731+
Availability is SoldOut if ticket is sold out or past end_sale_datetime.
732+
:param ticket: Event ticket record
733+
:returns: JsonLd offer schema
734+
:rtype: JsonLd
735+
"""
736+
now = fields.Datetime.now()
737+
is_expired = ticket.end_sale_datetime and ticket.end_sale_datetime < now
738+
availability = (
739+
"https://schema.org/SoldOut"
740+
if ticket.is_sold_out or is_expired
741+
else "https://schema.org/InStock"
742+
)
743+
744+
offer_jsonld = JsonLd("Offer", {
745+
"name": ticket.name,
746+
"availability": availability,
747+
"validFrom": JsonLd.to_iso_datetime(ticket.start_sale_datetime),
748+
"validThrough": JsonLd.to_iso_datetime(ticket.end_sale_datetime),
749+
})
750+
if availability == "https://schema.org/InStock":
751+
offer_jsonld.set({"url": f"{self.website_absolute_url}/register"})
752+
return offer_jsonld
753+
754+
def _to_structured_data_collectionpage(self):
755+
"""Return CollectionPage structured data for the events listing page.
756+
:returns: JsonLd collection page schema with valid events
757+
:rtype: JsonLd
758+
"""
759+
website = self.env['website'].get_current_website()
760+
base_url = website.get_base_url()
761+
haspart_jsonld = [
762+
event_schema
763+
for event in self
764+
if (event_schema := event._to_structured_data_summary_event())
765+
]
766+
schema_data = {
767+
"name": self.env._("Events"),
768+
"url": f"{base_url}/event",
769+
}
770+
nested_schema_data: dict[str, JsonLd | list[JsonLd] | None] = {
771+
"isPartOf": JsonLd("Organization", {"@id": f"{base_url}/#organization"}),
772+
}
773+
if haspart_jsonld:
774+
nested_schema_data["hasPart"] = haspart_jsonld
775+
return JsonLd("CollectionPage", schema_data).add_nested(nested_schema_data)
776+
777+
def _get_breadcrumb_items(self, is_detail_page=False):
778+
"""Return breadcrumb items for a single event page.
779+
:param is_detail_page: If True, includes event detail in breadcrumb
780+
:returns: List of (label, url) tuples for breadcrumb trail
781+
:rtype: list[tuple[str, str]]
782+
"""
783+
website = self.env['website'].get_current_website()
784+
base_url = website.get_base_url()
785+
item = [
786+
(website.name, base_url),
787+
(f"{self.env._('Events')} | {website.name}", f"{base_url}/event"),
788+
]
789+
if is_detail_page:
790+
item.append((f"{self.name} | {website.name}", f"{base_url}{self.website_url}"))
791+
return item
792+
793+
def _build_structured_data(self, is_detail_page=False):
794+
"""Return rendered structured data for event list and detail pages.
795+
Always includes breadcrumb schema; detail pages include event schema.
796+
:param is_detail_page: If True, build detail page schemas; else listing page
797+
:returns: List of JsonLd schemas
798+
:rtype: list[JsonLd]
799+
"""
800+
schemas = super()._build_structured_data(is_detail_page=is_detail_page)
801+
breadcrumb = self._build_breadcrumb_schema(
802+
self._get_breadcrumb_items(is_detail_page),
803+
)
804+
if is_detail_page:
805+
self.ensure_one()
806+
if event_schema := self._to_structured_data_event():
807+
schemas.append(event_schema)
808+
schemas.append(breadcrumb)
809+
return schemas
810+
schemas.extend([
811+
self._to_structured_data_collectionpage(),
812+
breadcrumb,
813+
])
814+
return schemas

addons/website_event_sale/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from . import event_event
12
from . import product
23
from . import product_pricelist
34
from . import sale_order
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import models
4+
5+
6+
class EventEvent(models.Model):
7+
_inherit = 'event.event'
8+
9+
def _to_structured_data_ticket_offer(self, ticket):
10+
"""Add ticket pricing to the structured data offer.
11+
12+
Uses the tax-included or tax-excluded price depending on the
13+
website ``show_line_subtotals_tax_selection`` setting, matching
14+
the price displayed on the event page.
15+
"""
16+
offer_jsonld = super()._to_structured_data_ticket_offer(ticket)
17+
website = self.env['website'].get_current_website()
18+
if website.show_line_subtotals_tax_selection == 'tax_excluded':
19+
price = ticket.total_price_reduce
20+
else:
21+
price = ticket.total_price_reduce_taxinc
22+
offer_jsonld.set({
23+
'price': price,
24+
'priceCurrency': self.company_id.currency_id.name,
25+
})
26+
return offer_jsonld

0 commit comments

Comments
 (0)