from django import forms from django.core.exceptions import ValidationError from django.db import models from django.db.models import Min, Prefetch, Q, UniqueConstraint from django.utils import timezone from django.utils.html import mark_safe from django.utils.translation import gettext_lazy as _ from grapple.helpers import register_query_field, register_singular_query_field from grapple.models import ( GraphQLBoolean, GraphQLCollection, GraphQLForeignKey, GraphQLImage, GraphQLRichText, GraphQLStreamfield, GraphQLString, ) from modelcluster.fields import ParentalKey, ParentalManyToManyField from modelcluster.models import ClusterableModel from wagtail.admin.panels import ( FieldPanel, FieldRowPanel, HelpPanel, InlinePanel, MultiFieldPanel, MultipleChooserPanel, TitleFieldPanel, ) from wagtail.fields import RichTextField from wagtail.models import Orderable, Page, PageManager, PageQuerySet from wagtail.search import index from wagtail.snippets.models import register_snippet from wagtail_headless_preview.models import HeadlessMixin from associations.widgets import AssociationChooserWidget from dnscms.fields import CommonStreamField from dnscms.options import ALL_PIGS from dnscms.wordpress.models import WPImportedPageMixin from venues.models import VenuePage @register_singular_query_field("eventIndex") class EventIndex(HeadlessMixin, Page): max_count = 1 subpage_types = ["events.EventPage"] def future_events(self, info, **kwargs): return ( EventPage.objects.live() .future() .order_by("next_occurrence") .select_related("featured_image") .prefetch_related( Prefetch( "occurrences", queryset=EventOccurrence.objects.select_related("venue"), ), "categories", Prefetch( "organizers", queryset=EventOrganizer.objects.select_related("association"), ), ) ) graphql_fields = [ GraphQLCollection( GraphQLForeignKey, "future_events", "events.EventPage", required=True, item_required=True, is_queryset=True, ), ] search_fields = [] @register_snippet @register_query_field("eventCategory", "eventCategories") class EventCategory(models.Model): name = models.CharField( max_length=100, null=False, blank=False, ) slug = models.SlugField( verbose_name=_("slug"), max_length=255, help_text=_("The name of the category as it will appear in URLs."), ) show_in_filters = models.BooleanField( default=False, help_text=_("Should this category be available as a filter in the event programme?"), ) PIG_CHOICES = [ ("", _("None")), ] + ALL_PIGS pig = models.CharField( max_length=32, choices=PIG_CHOICES, default="", blank=True, help_text=_("Default pig for events of this kind."), ) panels = [ TitleFieldPanel("name"), FieldPanel("slug"), FieldPanel("show_in_filters"), FieldPanel("pig", heading=_("Pig")), ] graphql_fields = [ GraphQLString("name", required=True), GraphQLString("slug", required=True), GraphQLBoolean("show_in_filters", required=True), GraphQLString("pig", required=True), ] class Meta: verbose_name = _("event category") verbose_name_plural = _("event categories") ordering = ["name"] def __str__(self): return self.name class EventOrganizerLink(Orderable): event = ParentalKey( "events.EventPage", on_delete=models.CASCADE, related_name="organizer_links" ) organizer = models.ForeignKey( "events.EventOrganizer", on_delete=models.PROTECT, related_name="organized_events", help_text="", ) panels = [FieldPanel("organizer", heading="", help_text="")] graphql_fields = [ GraphQLForeignKey("organizer", "events.EventOrganizer"), ] def __str__(self): return f"{self.organizer.name}" class Meta: verbose_name = _("organizer") verbose_name_plural = _("organizers") constraints = [ UniqueConstraint( "event", "organizer", name="event_organizer_link_event_organizer_unique" ) ] @register_snippet @register_query_field("eventOrganizer", "eventOrganizers") class EventOrganizer(ClusterableModel): name = models.CharField( max_length=100, null=False, blank=False, ) slug = models.SlugField( verbose_name=_("slug"), max_length=255, help_text=_("The name of the organizer as it will appear in URLs."), ) association = models.ForeignKey( "associations.AssociationPage", null=True, blank=True, on_delete=models.PROTECT, related_name="organizers", help_text=_("If a DNS association or committee is behind it, choose it here."), ) external_url = models.URLField( blank=True, max_length=512, help_text=_("Link to the external organizer's website"), ) panels = [ TitleFieldPanel("name"), FieldPanel("slug"), FieldPanel( "association", widget=AssociationChooserWidget(linked_fields={"association": "#id_association"}), heading=_("Internal organizer"), ), MultiFieldPanel( heading=_("External organizer"), children=[ FieldPanel( "external_url", heading=_("Website"), help_text=_("Leave this empty if the organizer exists in the list above."), ), ], ), ] graphql_fields = [ GraphQLString("name", required=True), GraphQLString("slug", required=True), GraphQLForeignKey("association", "associations.AssociationPage", required=False), GraphQLString("external_url"), ] class Meta: verbose_name = _("event organizer") verbose_name_plural = _("event organizers") ordering = ["name"] def __str__(self): return self.name class EventPageQuerySet(PageQuerySet): def future(self): now = timezone.now() today_start = timezone.localtime(now).replace(hour=0, minute=0, second=0, microsecond=0) next_occurrence = Min("occurrences__start", filter=Q(occurrences__start__gte=today_start)) return self.filter(occurrences__start__gte=today_start).annotate( next_occurrence=next_occurrence ) EventPageManager = PageManager.from_queryset(EventPageQuerySet) class EventPage(HeadlessMixin, WPImportedPageMixin, Page): subpage_types = [] parent_page_types = ["events.EventIndex"] show_in_menus = False objects = EventPageManager() featured_image = models.ForeignKey( "images.CustomImage", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", help_text=_( "Choose an image for use in the programme and other surfaces. " "Should be a photo or an illustration without too much text " "– don't reuse a Facebook cover uncritically!" ), ) subtitle = models.CharField( blank=True, max_length=128, help_text=_( "A short text that appears right below the title. " "Feel free to leave it empty if you fit most of it in the main title." ), ) lead = RichTextField(features=["italic", "link"], blank=True) body = CommonStreamField categories = ParentalManyToManyField( "events.EventCategory", blank=True, ) organizers = models.ManyToManyField( "events.EventOrganizer", through="events.EventOrganizerLink" ) PIG_CHOICES = [ ("", _("None")), ("automatic", _("Automatic")), ] + ALL_PIGS pig = models.CharField( max_length=32, choices=PIG_CHOICES, default="automatic", blank=True, help_text=_( "The pig that hangs out on the event page. " "Automatic causes one to be chosen based on the event's category." ), ) ticket_url = models.URLField( blank=True, max_length=1024, help_text=_("Direct link to ticket purchase, e.g. TicketCo, Billetto or Ticketmaster"), ) facebook_url = models.URLField( blank=True, max_length=1024, help_text=_("Direct link to the event on Facebook"), ) free = models.BooleanField(null=False, default=False) price_regular = models.CharField(max_length=32, blank=True) price_student = models.CharField(max_length=32, blank=True) price_member = models.CharField(max_length=32, blank=True) ticket_panels = [ FieldPanel("free", heading=_("Free"), help_text=_("Is this event free for everyone?")), MultiFieldPanel( children=[ FieldRowPanel( children=[ FieldPanel("price_regular", heading=_("Regular price")), FieldPanel("price_student", heading=_("Price for students")), FieldPanel("price_member", heading=_("Price for DNS members")), ], help_text="", ), HelpPanel( content=mark_safe( _( "Write 0 for free. " "An empty field hides the price category. " "If possible, write digits only." ) ) ), ], attrs={"id": "specific_pricing_panel"}, ), FieldPanel("ticket_url", heading=_("Ticket purchase link")), ] content_panels = Page.content_panels + [ FieldPanel("subtitle", heading=_("Subtitle")), FieldPanel("featured_image"), FieldPanel("lead", heading=_("Lead")), FieldPanel("body"), FieldPanel("categories", widget=forms.CheckboxSelectMultiple), MultiFieldPanel( heading=_("Organizers"), children=[ HelpPanel( content=_("Who is behind the event?"), ), MultipleChooserPanel( "organizer_links", chooser_field_name="organizer", label=_("Organizer") ), ], ), FieldPanel("pig", heading=_("Pig")), FieldPanel( "facebook_url", heading=_("Facebook link"), help_text=_("Direct link to the event on Facebook."), ), MultiFieldPanel(heading=_("Pricing and tickets"), children=ticket_panels), MultiFieldPanel( heading=_("Date, time and venue"), children=[ HelpPanel( content=_( "If the event spans several days, add each day as a separate occurrence." ), ), InlinePanel("occurrences", min_num=1, label=_("Occurrence")), ], ), ] graphql_fields = [ GraphQLString("subtitle"), GraphQLImage("featured_image"), GraphQLRichText("lead"), GraphQLStreamfield("body"), GraphQLString("pig", required=True), GraphQLString("ticket_url"), GraphQLString("facebook_url"), GraphQLBoolean("free"), GraphQLString("price_regular"), GraphQLString("price_student"), GraphQLString("price_member"), GraphQLCollection( GraphQLForeignKey, "categories", "events.EventCategory", required=True, item_required=True, ), GraphQLCollection( GraphQLForeignKey, "occurrences", "events.EventOccurrence", required=True, item_required=True, ), GraphQLCollection( GraphQLForeignKey, "organizers", "events.EventOrganizer", required=True, item_required=True, ), ] search_fields = Page.search_fields + [index.SearchField("body")] class Meta: verbose_name = _("event") verbose_name_plural = _("events") def clean(self): super().clean() # remove duped organizers based on name organizers = [x.organizer.name for x in self.organizer_links.all()] if len(organizers) != len(set(organizers)): seen_organizers = set() for link in self.organizer_links.all(): if link.organizer.name in seen_organizers: self.organizer_links.remove(link) else: seen_organizers.add(link.organizer.name) # if the event is free, all specific pricing is unset if self.free: self.price_regular = "" self.price_student = "" self.price_member = "" def import_wordpress_data(self, data): import datetime import html from zoneinfo import ZoneInfo from django.core.validators import URLValidator validate_url = URLValidator(schemes=["http", "https"]) def fix_url(url): if not url: return None url = url.strip() try: validate_url(url) except Exception: print(f"Bogus URL for {self.wp_post_id}: {url}") return None return url # Wagtail page model fields self.title = html.unescape(data["title"]) self.slug = data["slug"] self.first_published_at = data["first_published_at"] self.last_published_at = data["last_published_at"] self.latest_revision_created_at = data["latest_revision_created_at"] self.search_description = data["search_description"] # debug fields self.wp_post_id = data["wp_post_id"] self.wp_post_type = data["wp_post_type"] self.wp_link = data["wp_link"] self.wp_raw_content = data["wp_raw_content"] self.wp_block_json = data["wp_block_json"] self.wp_processed_content = data["wp_processed_content"] self.wp_normalized_styles = data["wp_normalized_styles"] self.wp_post_meta = data["wp_post_meta"] # own model fields self.body = data["body"] or "" # categories (organizers and event types) wp_categories = data["wp_categories"] # organizers organizer_cats = [x for x in wp_categories if x["domain"] == "event_organizer"] organizers = [] for x in organizer_cats: try: organizer = EventOrganizer.objects.get(slug=x["nicename"]) except EventOrganizer.DoesNotExist: organizer = EventOrganizer.objects.create(name=x["name"], slug=x["nicename"]) organizers.append(organizer) self.organizer_links.set( [EventOrganizerLink(event=self, organizer=organizer) for organizer in organizers] ) ## event types # type_cats = [x for x in wp_categories if x["domain"] == "event_type"] # event_categories = [] # for x in type_cats: # try: # event_category = EventCategory.objects.get(slug=x["nicename"]) # except EventCategory.DoesNotExist: # event_category = EventCategory.objects.create( # name=x["name"], slug=x["nicename"], show_in_filters=False # ) # event_categories.append(event_category) # self.categories.set(event_categories) meta = data["wp_post_meta"] start_ts = meta.get("neuf_events_starttime") or 1337 end_ts = meta.get("neuf_events_endtime") tz = ZoneInfo("Europe/Oslo") start = start_ts and datetime.datetime.fromtimestamp(start_ts, datetime.UTC).replace( tzinfo=tz ) end = end_ts and datetime.datetime.fromtimestamp(end_ts, datetime.UTC).replace(tzinfo=tz) venue_id = meta.get("neuf_events_venue_id") venue_custom = meta.get("neuf_events_venue") venue = None if venue_id: venue = VenuePage.objects.get(wp_post_id=venue_id) venue_custom = "" else: venue_custom = venue_custom or "" occurrence = EventOccurrence( event=self, start=start, end=end, venue=venue, venue_custom=venue_custom ) self.occurrences.set([occurrence]) self.ticket_url = fix_url(meta.get("neuf_events_bs_url")) or "" self.facebook_url = fix_url(meta.get("neuf_events_fb_url")) or "" def parse_price(price): if price is None: return "" if type(price) is int: return price p = price.strip() if p == "": return "" try: return int(p) except ValueError: pass free = ["gratis", "free", "gratis/free", "free/gratis"] if p.lower() in free: return 0 return price price_regular = parse_price(meta.get("neuf_events_price_regular")) price_member = parse_price(meta.get("neuf_events_price_member")) if not price_regular and not price_member: self.free = True else: self.price_regular = parse_price(meta.get("neuf_events_price_regular")) self.price_member = parse_price(meta.get("neuf_events_price_member")) class EventOccurrence(Orderable): event = ParentalKey(EventPage, on_delete=models.CASCADE, related_name="occurrences") start = models.DateTimeField() end = models.DateTimeField(null=True, blank=True) venue = models.ForeignKey( VenuePage, on_delete=models.PROTECT, related_name="event_occurrences", blank=True, null=True, ) venue_custom = models.CharField( blank=True, max_length=128, help_text=mark_safe( _( "Use this if none of the venues that can be selected on the left fit. " "E.g. Frederikkeplassen or Sirkusteltet." ) ), ) panels = [ FieldRowPanel( children=[ FieldPanel("start", heading=_("Start")), FieldPanel("end", heading=_("End")), ], ), FieldRowPanel( children=[ FieldPanel("venue", heading=_("Venue")), FieldPanel("venue_custom", heading=_("Venue as free text")), ], ), ] graphql_fields = [ GraphQLString("start", required=True), GraphQLString("end"), GraphQLForeignKey("venue", "venues.VenuePage"), GraphQLString("venue_custom"), ] def clean(self): if self.venue and self.venue_custom: raise ValidationError( { "venue_custom": _( "You can't both pick a venue and write something in this field." ) } ) if not self.venue and not self.venue_custom: raise ValidationError({"venue": _("Venue is required.")}) def __str__(self): return f"{self.start}--{self.end}" class Meta: verbose_name = _("occurrence") verbose_name_plural = _("occurrences")