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,131 @@ 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+ website = self .env ['website' ].get_current_website ()
665+ base_url = website .get_base_url ()
666+ image_jsonld = None
667+ if image_url := website .image_url (self , 'image_1920' ):
668+ image_jsonld = JsonLd ("ImageObject" , {"url" : f"{ base_url } { image_url } " })
669+ address = self .address_id .sudo ()
670+ street = ', ' .join (part for part in (address .street , address .street2 ) if part )
671+ location_jsonld = JsonLd ("Place" , {"name" : self .address_name }).add_nested ({
672+ "address" : JsonLd ("PostalAddress" , {
673+ "streetAddress" : street or None ,
674+ "addressLocality" : address .city or None ,
675+ "postalCode" : address .zip or None ,
676+ "addressRegion" : address .state_id .code or None ,
677+ "addressCountry" : address .country_id .code or None ,
678+ }),
679+ })
680+ return JsonLd ("Event" , {
681+ "name" : self .name ,
682+ "url" : self .website_absolute_url ,
683+ "startDate" : JsonLd .to_iso_datetime (self .date_begin ),
684+ }).add_nested ({
685+ "image" : image_jsonld ,
686+ "location" : location_jsonld ,
687+ })
688+
689+ def _to_structured_data_event (self ):
690+ """Return full structured data for a single event detail page."""
691+ self .ensure_one ()
692+ event_data = self ._to_structured_data_summary_event ()
693+ if not event_data :
694+ return None
695+
696+ description = self .subtitle or (self .description and text_from_html (self .description , True ))
697+ organizer_jsonld = None
698+ if self .organizer_id :
699+ organizer_sudo = self .organizer_id .sudo ()
700+ if organizer_sudo == self .env .company .partner_id :
701+ base_url = self .env ['website' ].get_current_website ().get_base_url ()
702+ organizer_jsonld = JsonLd ("Organization" , {"@id" : f"{ base_url } /#organization" })
703+ else :
704+ organizer_jsonld = JsonLd ("Organization" , {"name" : organizer_sudo .name })
705+ if organizer_sudo .website :
706+ organizer_jsonld .set ({"url" : organizer_sudo .website })
707+
708+ event_status = "EventCancelled" if self .kanban_state == 'cancel' else "EventScheduled"
709+ offers_jsonld = [self ._to_structured_data_ticket_offer (ticket ) for ticket in self .event_ticket_ids ]
710+ return event_data .set ({
711+ "endDate" : JsonLd .to_iso_datetime (self .date_end ),
712+ "description" : description ,
713+ "eventStatus" : f"https://schema.org/{ event_status } " ,
714+ "eventAttendanceMode" : "https://schema.org/OfflineEventAttendanceMode" ,
715+ }).add_nested ({
716+ "organizer" : organizer_jsonld ,
717+ "offers" : offers_jsonld ,
718+ })
719+
720+ def _to_structured_data_ticket_offer (self , ticket ):
721+ """Create a JSON-LD Offer for a single event ticket."""
722+ now = fields .Datetime .now ()
723+ is_expired = ticket .end_sale_datetime and ticket .end_sale_datetime < now
724+ availability = (
725+ "https://schema.org/SoldOut"
726+ if ticket .is_sold_out or is_expired
727+ else "https://schema.org/InStock"
728+ )
729+
730+ offer_jsonld = JsonLd ("Offer" , {
731+ "name" : ticket .name ,
732+ "availability" : availability ,
733+ "validFrom" : JsonLd .to_iso_datetime (ticket .start_sale_datetime ),
734+ "validThrough" : JsonLd .to_iso_datetime (ticket .end_sale_datetime ),
735+ })
736+ if availability == "https://schema.org/InStock" :
737+ offer_jsonld .set ({"url" : f"{ self .website_absolute_url } /register" })
738+ return offer_jsonld
739+
740+ def _to_structured_data_collectionpage (self ):
741+ """Return CollectionPage structured data for the events listing page."""
742+ website = self .env ['website' ].get_current_website ()
743+ base_url = website .get_base_url ()
744+ haspart_jsonld = [event ._to_structured_data_summary_event () for event in self ]
745+ ispartof_jsonld = JsonLd ("Organization" , {"@id" : f"{ base_url } /#organization" })
746+ return JsonLd ("CollectionPage" , {
747+ "name" : self .env ._ ("Events" ),
748+ "url" : f"{ base_url } /event" ,
749+ }).add_nested ({
750+ "hasPart" : haspart_jsonld ,
751+ "isPartOf" : ispartof_jsonld ,
752+ })
753+
754+ def _get_breadcrumb_items (self ):
755+ """Return breadcrumb items for a single event page."""
756+ self .ensure_one ()
757+ website = self .env ['website' ].get_current_website ()
758+ base_url = website .get_base_url ()
759+ return [
760+ (website .name , base_url ),
761+ (self .env ._ ("Events" ), f"{ base_url } /event" ),
762+ (self .name , f"{ base_url } { self .website_url } " ),
763+ ]
764+
765+ def _build_structured_data (self , is_detail_page = False ):
766+ """Return rendered structured data for event list and detail pages."""
767+ schemas = super ()._build_structured_data (is_detail_page = is_detail_page )
768+ if is_detail_page :
769+ self .ensure_one ()
770+ if event_schema := self ._to_structured_data_event ():
771+ schemas .append (event_schema )
772+ items = self ._get_breadcrumb_items ()
773+ else :
774+ website = self .env ['website' ].get_current_website ()
775+ base_url = website .get_base_url ()
776+ if self :
777+ schemas .append (self ._to_structured_data_collectionpage ())
778+ items = [
779+ (website .name , base_url ),
780+ (self .env ._ ("Events" ), f"{ base_url } /event" ),
781+ ]
782+ schemas .append (self ._build_breadcrumb_schema (items ))
783+ return schemas
0 commit comments