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 (
DeferWPFieldsManagerMixin,
WPAwareManager,
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):
objects = WPAwareManager()
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
)
class EventPageManager(
DeferWPFieldsManagerMixin,
PageManager.from_queryset(EventPageQuerySet),
):
pass
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 = ""
class EventOccurrence(Orderable):
objects = WPAwareManager()
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")