1010from markupsafe import Markup
1111
1212from 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
1315from odoo .exceptions import UserError , ValidationError
1416from odoo .fields import Domain
1517from 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 )
0 commit comments