521 lines
16 KiB
Python
521 lines
16 KiB
Python
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 <strong>0</strong> 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 <em>if none of the venues that can be selected on the left</em> fit. "
|
||
"E.g. <em>Frederikkeplassen</em> or <em>Sirkusteltet</em>."
|
||
)
|
||
),
|
||
)
|
||
|
||
panels = [
|
||
FieldRowPanel(
|
||
children=[
|
||
FieldPanel("start", heading=_("Start")),
|
||
FieldPanel("end", heading=_("End")),
|
||
],
|
||
),
|
||
FieldRowPanel(
|
||
children=[
|
||
FieldPanel("venue", heading=_("Venue"), widget=forms.Select),
|
||
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_custom:
|
||
trimmed = self.venue_custom.strip()
|
||
self.venue_custom = trimmed
|
||
if trimmed:
|
||
match = VenuePage.objects.filter(title=trimmed).first()
|
||
if match:
|
||
self.venue = match
|
||
self.venue_custom = ""
|
||
|
||
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")
|