Source code for cdhweb.events.models

# -*- coding: utf-8 -*-

from datetime import datetime

import icalendar
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.fields.related import RelatedField
from django.urls import reverse
from django.utils import timezone
from django.utils.html import strip_tags
from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from taggit.managers import TaggableManager
from taggit.models import TaggedItemBase
from wagtail.admin.edit_handlers import (
    FieldPanel,
    FieldRowPanel,
    InlinePanel,
    MultiFieldPanel,
    StreamFieldPanel,
)
from wagtail.core.models import Page, PageManager, PageQuerySet
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.search import index

from cdhweb.pages.models import BasePage, LinkPage
from cdhweb.people.models import Person


class EventTypeManager(models.Manager):
    def get_by_natural_key(self, name):
        return self.get(name=name)


[docs]class EventType(models.Model): name = models.CharField(max_length=255, unique=True) objects = EventTypeManager() class Meta: ordering = ("name",) def __str__(self): return self.name def natural_key(self): return (self.name,)
[docs]class Location(models.Model): name = models.CharField(max_length=255, help_text="Name of the location") short_name = models.CharField(max_length=80, blank=True) address = models.CharField( max_length=255, null=True, blank=True, help_text="Address of the location (will not display if same as name)", ) is_virtual = models.BooleanField( verbose_name="Virtual", default=False, help_text="Virtual platforms, i.e. Zoom or Google Hangouts", ) def __str__(self): return self.short_name or self.name class Meta: ordering = ("name",) @property def display_name(self): """A name for template display""" if self.name and self.address and self.name != self.address: return ", ".join([self.name, self.address]) return self.name
[docs] def clean(self): # address is required for non-virtual events if not self.is_virtual and not self.address: raise ValidationError("Address is required for non-virtual events")
[docs]class EventQuerySet(PageQuerySet):
[docs] def upcoming(self): """Find upcoming events. Includes events that end on the current day even if the start time is past.""" now = timezone.now() # construct a datetime based on now but with zero hour/minute/second today = datetime( now.year, now.month, now.day, tzinfo=timezone.get_default_timezone() ) return self.filter(end_time__gte=today)
[docs] def recent(self): """Find past events, most recent first. Only includes events with end date in the past.""" now = timezone.now() # construct a datetime based on now but with zero hour/minute/second today = datetime( now.year, now.month, now.day, tzinfo=timezone.get_default_timezone() ) return self.filter(end_time__lt=today).order_by("-start_time")
# custom manager for wagtail pages, see: # https://docs.wagtail.io/en/stable/topics/pages.html#custom-page-managers EventManager = PageManager.from_queryset(EventQuerySet)
[docs]class EventTag(TaggedItemBase): """Tags for Event pages.""" content_object = ParentalKey( "events.Event", on_delete=models.CASCADE, related_name="tagged_items" )
[docs]class Speaker(models.Model): """Relationship between Person and Event.""" event = ParentalKey("events.Event", related_name="speakers") person = models.ForeignKey(Person, on_delete=models.CASCADE) panels = [FieldPanel("person")] class Meta: # can't sort on related fields due to modelcluster limitations ordering = ("person",) def __str__(self) -> str: return "%s at %s" % (self.person, self.event)
[docs]class Event(BasePage, ClusterableModel): """Page type for an event, such as a workshop, lecture, or conference.""" sponsor = models.CharField(max_length=80, null=True, blank=True) start_time = models.DateTimeField() end_time = models.DateTimeField() location = models.ForeignKey( Location, null=True, blank=True, on_delete=models.SET_NULL ) type = models.ForeignKey(EventType, null=True, on_delete=models.SET_NULL) attendance = models.PositiveIntegerField( null=True, blank=True, help_text="Total number of people who attended the event. (Internal only, for reporting purposes.)", ) join_url = models.URLField( verbose_name="Join URL", null=True, blank=True, help_text="Join URL for virtual events, e.g. Zoom meetings.", ) image = models.ForeignKey( "wagtailimages.image", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", help_text="Image for display on event detail page (optional)", ) thumbnail = models.ForeignKey( "wagtailimages.image", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", help_text="Image for display on event card (optional)", ) people = models.ManyToManyField(Person, through=Speaker, related_name="events") tags = ClusterTaggableManager(through=EventTag, blank=True) # TODO attachments (#245) # can only be created underneath special link page parent_page_types = ["events.EventsLinkPage"] # no allowed subpages subpage_types = [] # admin edit configuration content_panels = Page.content_panels + [ FieldPanel("type"), FieldRowPanel((FieldPanel("start_time"), FieldPanel("end_time")), "Details"), FieldPanel("location"), FieldPanel("join_url"), FieldRowPanel((FieldPanel("sponsor"), FieldPanel("attendance")), "Tracking"), FieldRowPanel( (ImageChooserPanel("thumbnail"), ImageChooserPanel("image")), "Images" ), MultiFieldPanel( (InlinePanel("speakers", label="Speaker"),), heading="Speakers" ), StreamFieldPanel("body"), StreamFieldPanel("attachments"), ] promote_panels = Page.promote_panels + [FieldPanel("tags")] # custom manager/queryset logic objects = EventManager() # index fields search_fields = BasePage.search_fields + [ index.SearchField("sponsor"), index.RelatedFields( "location", [ index.SearchField("name"), ], ), index.RelatedFields( "type", [ index.SearchField("name"), ], ), index.RelatedFields( "people", [ index.SearchField("first_name"), index.SearchField("last_name"), ], ), ] def __str__(self): return " - ".join([self.title, self.start_time.strftime("%b %d, %Y")]) @property def speaker_list(self): """Comma-separated list of speaker names.""" return ", ".join(str(speaker.person) for speaker in self.speakers.all())
[docs] def clean(self): """Validate that a type was specified for this event.""" # NOTE we can't cascade deletes to wagtail pages without corrupting the # page tree. Instead, we use SET_NULL and a clean() handler so that we # can make sure that it's impossible to create an Event without a type. # More info: https://github.com/wagtail/wagtail/issues/1602 if not self.type: raise ValidationError("Event must specify a type.")
[docs] def get_url_parts(self, *args, **kwargs): """Custom event page URLs of the form /events/2014/03/my-event.""" url_parts = super().get_url_parts(*args, **kwargs) # NOTE evidently this can sometimes be None; unclear why – perhaps it # gets called in a context where the request is unavailable? Only # happens in QA, not locally. if url_parts: site_id, root_url, _ = url_parts page_path = reverse( "events:detail", kwargs={ "year": self.start_time.year, # force two-digit month "month": "%02d" % self.start_time.month, "slug": self.slug, }, ) return site_id, root_url, page_path
[docs] def get_ical_url(self): """URL to download this event as a .ics (iCal) file.""" return reverse( "events:ical", kwargs={ "year": self.start_time.year, # force two-digit month "month": "%02d" % self.start_time.month, "slug": self.slug, }, )
[docs] def is_virtual(self): """If an event takes place in a virtual location, it is virtual""" if self.location: return self.location.is_virtual return False
is_virtual.boolean = True is_virtual.short_description = "Virtual"
[docs] def when(self): """Event start/end date and time, formatted for display. Removes leading zeros from hours and converts am/pm to lower case. """ # NOTE: - in %-I is to remove leading zero # (possibly platform specific?) local_tz = timezone.get_default_timezone() # convert dates to local timezone for display local_start = self.start_time.astimezone(local_tz) local_end = self.end_time.astimezone(local_tz) start = " ".join( [local_start.strftime("%b %d"), local_start.strftime("%-I:%M")] ) start_ampm = local_start.strftime("%p") # include start am/pm if *different* from end if start_ampm != local_end.strftime("%p"): start += " %s" % start_ampm.lower() # include end month and day if *different* from start end_pieces = [] if local_start.month != local_end.month: end_pieces.append(local_end.strftime("%b %d")) elif local_start.day != local_end.day: end_pieces.append(local_end.strftime("%d")) end_pieces.append(local_end.strftime("%-I:%M %p").lower()) end = " ".join(end_pieces) return " – ".join([start, end])
[docs] def duration(self): """duration between start and end time as :class:`datetime.timedelta`""" return self.end_time - self.start_time
[docs] def ical_event(self): """Return the current event as a :class:`icalendar.Event` for inclusion in a :class:`icalendar.Calendar`""" event = icalendar.Event() # use absolute url for event id and in event content absurl = self.get_full_url() event.add("uid", absurl) event.add("summary", self.title) event.add("dtstart", self.start_time) event.add("dtend", self.end_time) if self.location: if self.is_virtual() and self.join_url: event.add("location", self.join_url) else: event.add("location", self.location.display_name) event.add("description", "\n".join([strip_tags(self.body), "", absurl])) return event
[docs]class EventsLinkPage(LinkPage): """Container page that defines where Project pages can be created.""" # NOTE this page can't be created in the page editor; it is only ever made # via a script or the console, since there's only one. parent_page_types = [] # NOTE the only allowed child page type is an Event; this is so that # Events made in the admin automatically are created here. subpage_types = [Event]