Skip to content

Commit ffa2114

Browse files
committed
[IMP] website_event, website_event_sale: adapt JsonLd builder
1 parent 871ec4b commit ffa2114

4 files changed

Lines changed: 161 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._to_structured_data(),
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._to_structured_data(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._to_structured_data(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: 128 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,128 @@ 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+
self.ensure_one()
660+
if not self.address_id or self.is_ongoing or self.is_done or self.website_visibility != 'public':
661+
# Google event rich results require a public upcoming event with
662+
# a physical place.
663+
return None
664+
665+
website = self.env['website'].get_current_website()
666+
base_url = website.get_base_url()
667+
668+
image = None
669+
if image_url := website.image_url(self, 'image_1920'):
670+
image = JsonLd("ImageObject", {"url": f"{base_url}{image_url}"})
671+
672+
address = self.address_id.sudo()
673+
street = ', '.join(part for part in (address.street, address.street2) if part)
674+
location = JsonLd("Place", {"name": self.address_name}).add_nested({
675+
"address": JsonLd("PostalAddress", {
676+
"street_address": street or None,
677+
"address_locality": address.city or None,
678+
"postal_code": address.zip or None,
679+
"address_region": address.state_id.code or None,
680+
"address_country": address.country_id.code or None,
681+
}),
682+
})
683+
684+
return JsonLd("Event", {
685+
"name": self.name,
686+
"url": self.website_absolute_url,
687+
"start_date": JsonLd.datetime(self.date_begin),
688+
}).add_nested({
689+
"image": image,
690+
"location": location,
691+
})
692+
693+
def _to_structured_data_event(self):
694+
"""Return full structured data for a single event detail page."""
695+
self.ensure_one()
696+
event_data = self._to_structured_data_summary_event()
697+
if not event_data:
698+
return None
699+
700+
description = self.subtitle or (self.description and text_from_html(self.description, True))
701+
organizer = None
702+
if self.organizer_id:
703+
organizer_sudo = self.organizer_id.sudo()
704+
organizer = JsonLd("Organization", {"name": organizer_sudo.name})
705+
if organizer_sudo.website:
706+
organizer.set({"url": organizer_sudo.website})
707+
708+
event_status = "EventCancelled" if self.kanban_state == 'cancel' else "EventScheduled"
709+
return event_data.set({
710+
"end_date": JsonLd.datetime(self.date_end),
711+
"description": description,
712+
"event_status": f"https://schema.org/{event_status}",
713+
"event_attendance_mode": "https://schema.org/OfflineEventAttendanceMode",
714+
}).add_nested({
715+
"organizer": organizer,
716+
"offers": [self._to_structured_data_ticket_offer(ticket) for ticket in self.event_ticket_ids],
717+
})
718+
719+
def _to_structured_data_ticket_offer(self, ticket):
720+
"""Create a JSON-LD Offer for a single event ticket."""
721+
now = fields.Datetime.now()
722+
is_expired = ticket.end_sale_datetime and ticket.end_sale_datetime < now
723+
availability = (
724+
"https://schema.org/SoldOut"
725+
if ticket.is_sold_out or is_expired
726+
else "https://schema.org/InStock"
727+
)
728+
729+
offer = JsonLd("Offer", {
730+
"name": ticket.name,
731+
"availability": availability,
732+
"valid_from": JsonLd.datetime(ticket.start_sale_datetime),
733+
"valid_through": JsonLd.datetime(ticket.end_sale_datetime),
734+
})
735+
if availability == "https://schema.org/InStock":
736+
offer.set({"url": f"{self.website_absolute_url}/register"})
737+
return offer
738+
739+
def _to_structured_data_collectionpage(self):
740+
"""Return CollectionPage structured data for the events listing page."""
741+
website = self.env['website'].get_current_website()
742+
base_url = website.get_base_url()
743+
return JsonLd("CollectionPage", {
744+
"name": self.env._("Events"),
745+
"url": f"{base_url}/event",
746+
}).add_nested({
747+
"has_part": [event._to_structured_data_summary_event() for event in self],
748+
"is_part_of": JsonLd("Organization", {"id": f"{base_url}/#organization"}),
749+
})
750+
751+
def _get_breadcrumb_items(self):
752+
"""Return breadcrumb items for a single event page."""
753+
self.ensure_one()
754+
website = self.env['website'].get_current_website()
755+
base_url = website.get_base_url()
756+
return [
757+
(website.name, base_url),
758+
(self.env._("Events"), f"{base_url}/event"),
759+
(self.name, f"{base_url}{self.website_url}"),
760+
]
761+
762+
def _to_structured_data(self, is_detail_page=False):
763+
"""Return rendered structured data for event list and detail pages."""
764+
schemas = super()._to_structured_data(is_detail_page=is_detail_page)
765+
if is_detail_page:
766+
self.ensure_one()
767+
if event_schema := self._to_structured_data_event():
768+
schemas.append(event_schema)
769+
items = self._get_breadcrumb_items()
770+
else:
771+
website = self.env['website'].get_current_website()
772+
base_url = website.get_base_url()
773+
if self:
774+
schemas.append(self._to_structured_data_collectionpage())
775+
items = [
776+
(website.name, base_url),
777+
(self.env._("Events"), f"{base_url}/event"),
778+
]
779+
schemas.append(self._get_breadcrumb_structured_data(items))
780+
return JsonLd.render_structured_data(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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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 = super()._to_structured_data_ticket_offer(ticket)
17+
if not offer:
18+
return offer
19+
website = self.env['website'].get_current_website()
20+
if website.show_line_subtotals_tax_selection == 'tax_excluded':
21+
price = ticket.total_price_reduce
22+
else:
23+
price = ticket.total_price_reduce_taxinc
24+
offer.set({
25+
'price': price,
26+
'price_currency': self.company_id.currency_id.name,
27+
})
28+
return offer

0 commit comments

Comments
 (0)