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,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
0 commit comments