bornhack-website/src/program/models.py
2021-07-19 15:06:10 +02:00

1719 lines
57 KiB
Python

import logging
import uuid
from datetime import timedelta
import icalendar
from conference_scheduler import resources
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateTimeRangeField, RangeOperators
from django.core.exceptions import ValidationError
from django.core.files.storage import FileSystemStorage
from django.db import models
from django.db.models import F, Q
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.safestring import mark_safe
from psycopg2.extras import DateTimeTZRange
from taggit.managers import TaggableManager
from utils.database import CastToInteger
from utils.models import (
CampRelatedModel,
CreatedUpdatedModel,
UUIDModel,
UUIDTaggedItem,
)
from utils.slugs import unique_slugify
from .email import (
add_event_proposal_accepted_email,
add_event_proposal_rejected_email,
add_speaker_proposal_accepted_email,
add_speaker_proposal_rejected_email,
)
logger = logging.getLogger("bornhack.%s" % __name__)
class UrlType(CreatedUpdatedModel):
"""
Each Url object has a type.
"""
name = models.CharField(
max_length=25, help_text="The name of this type", unique=True
)
icon = models.CharField(
max_length=100,
default="fas fa-link",
help_text="Name of the fontawesome icon to use, including the 'fab fa-' or 'fas fa-' part.",
)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Url(CampRelatedModel):
"""
This model contains URLs related to
- SpeakerProposals
- EventProposals
- Speakers
- Events
Each URL has a UrlType and a ForeignKey to the model to which it belongs.
When a SpeakerProposal or EventProposal is approved the related URLs will
be copied with FK to the new Speaker/Event objects.
"""
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
url = models.URLField(help_text="The actual URL")
url_type = models.ForeignKey(
"program.UrlType", help_text="The type of this URL", on_delete=models.PROTECT
)
speaker_proposal = models.ForeignKey(
"program.SpeakerProposal",
null=True,
blank=True,
help_text="The speaker proposal object this URL belongs to",
on_delete=models.PROTECT,
related_name="urls",
)
event_proposal = models.ForeignKey(
"program.EventProposal",
null=True,
blank=True,
help_text="The event proposal object this URL belongs to",
on_delete=models.PROTECT,
related_name="urls",
)
speaker = models.ForeignKey(
"program.Speaker",
null=True,
blank=True,
help_text="The speaker proposal object this URL belongs to",
on_delete=models.PROTECT,
related_name="urls",
)
event = models.ForeignKey(
"program.Event",
null=True,
blank=True,
help_text="The event proposal object this URL belongs to",
on_delete=models.PROTECT,
related_name="urls",
)
def __str__(self):
return self.url
def clean_fk(self):
"""Make sure we have exactly one FK"""
fks = 0
if self.speaker_proposal:
fks += 1
if self.event_proposal:
fks += 1
if self.speaker:
fks += 1
if self.event:
fks += 1
if fks != 1:
raise ValidationError(
f"Url objects must have exactly one FK, this has {fks}"
)
def save(self, *args, **kwargs):
"""Just clean_fk() and super save()."""
self.clean_fk()
super().save(*args, **kwargs)
@property
def owner(self):
"""
Return the object this Url belongs to
"""
if self.speaker_proposal:
return self.speaker_proposal
elif self.event_proposal:
return self.event_proposal
elif self.speaker:
return self.speaker
elif self.event:
return self.event
else:
return None
@property
def camp(self):
return self.owner.camp
camp_filter = [
"speaker_proposal__camp",
"event_proposal__track__camp",
"speaker__camp",
"event__track__camp",
]
###############################################################################
class Availability(CampRelatedModel, UUIDModel):
"""
This model contains all the availability info for speaker_proposals and
speakers. It is inherited by SpeakerProposalAvailability and SpeakerAvailability
models.
"""
class Meta:
abstract = True
when = DateTimeRangeField(
db_index=True,
help_text="The period when this speaker is available or unavailable. Must be 1 hour!",
)
available = models.BooleanField(
db_index=True,
help_text="Is the speaker available or unavailable during this hour? Check for available, uncheck for unavailable.",
)
class SpeakerProposalAvailability(Availability):
"""Availability info for SpeakerProposal objects"""
class Meta:
"""Add ExclusionConstraints preventing overlaps and adjacent ranges with same availability"""
constraints = [
# we do not want overlapping ranges
ExclusionConstraint(
name="prevent_speaker_proposal_availability_overlaps",
expressions=[
(F("speaker_proposal"), RangeOperators.EQUAL),
("when", RangeOperators.OVERLAPS),
],
),
# we do not want adjacent ranges with same availability
ExclusionConstraint(
name="prevent_speaker_proposal_availability_adjacent_mergeable",
expressions=[
("speaker_proposal", RangeOperators.EQUAL),
(CastToInteger("available"), RangeOperators.EQUAL),
("when", RangeOperators.ADJACENT_TO),
],
),
]
speaker_proposal = models.ForeignKey(
"program.SpeakerProposal",
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="availabilities",
help_text="The speaker proposal object this availability belongs to",
)
@property
def camp(self):
return self.speaker_proposal.camp
camp_filter = "speaker_proposal__camp"
def clean(self):
if SpeakerProposalAvailability.objects.filter(
speaker_proposal=self.speaker_proposal,
when__adjacent_to=self.when,
available=self.available,
).exists():
raise ValidationError(
f"An adjacent SpeakerProposalAvailability object for this SpeakerProposal already exists with the same value for available, cannot save() {self.when}"
)
def __str__(self):
return f"SpeakerProposalAvailability: {self.speaker_proposal.name} is {'not ' if not self.available else ''}available from {self.when.lower} to {self.when.upper}"
class SpeakerAvailability(Availability):
"""Availability info for Speaker objects"""
class Meta:
"""Add ExclusionConstraints preventing overlaps and adjacent ranges with same availability"""
constraints = [
# we do not want overlapping ranges
ExclusionConstraint(
name="prevent_speaker_availability_overlaps",
expressions=[
(F("speaker"), RangeOperators.EQUAL),
("when", RangeOperators.OVERLAPS),
],
),
# we do not want adjacent ranges with same availability
ExclusionConstraint(
name="prevent_speaker_availability_adjacent_mergeable",
expressions=[
("speaker", RangeOperators.EQUAL),
(CastToInteger("available"), RangeOperators.EQUAL),
("when", RangeOperators.ADJACENT_TO),
],
),
]
speaker = models.ForeignKey(
"program.Speaker",
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="availabilities",
help_text="The speaker object this availability belongs to (if any)",
)
@property
def camp(self):
return self.speaker.camp
camp_filter = "speaker__camp"
def clean(self):
# this should be an ExclusionConstraint but the boolean condition isn't conditioning :/
if SpeakerAvailability.objects.filter(
speaker=self.speaker,
when__adjacent_to=self.when,
available=self.available,
).exists():
raise ValidationError(
"An adjacent SpeakerAvailability object for this Speaker already exists with the same value for available, cannot save()"
)
###############################################################################
class UserSubmittedModel(CampRelatedModel):
"""
An abstract model containing the stuff that is shared
between the SpeakerProposal and EventProposal models.
"""
class Meta:
abstract = True
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey("auth.User", on_delete=models.PROTECT)
PROPOSAL_PENDING = "pending"
PROPOSAL_APPROVED = "approved"
PROPOSAL_REJECTED = "rejected"
PROPOSAL_STATUSES = [PROPOSAL_PENDING, PROPOSAL_APPROVED, PROPOSAL_REJECTED]
PROPOSAL_STATUS_CHOICES = [
(PROPOSAL_PENDING, "Pending approval"),
(PROPOSAL_APPROVED, "Approved"),
(PROPOSAL_REJECTED, "Rejected"),
]
proposal_status = models.CharField(
max_length=50, choices=PROPOSAL_STATUS_CHOICES, default=PROPOSAL_PENDING
)
reason = models.TextField(
blank=True,
help_text="The reason this proposal was accepted or rejected. This text will be included in the email to the submitter. Leave blank to send a standard email.",
)
def __str__(self):
return "%s (submitted by: %s, status: %s)" % (
self.headline,
self.user,
self.proposal_status,
)
def save(self, **kwargs):
if not self.camp.call_for_participation_open:
message = "Call for participation is not open"
if hasattr(self, "request"):
messages.error(self.request, message)
raise ValidationError(message)
super().save(**kwargs)
def delete(self, **kwargs):
if not self.camp.call_for_participation_open:
message = "Call for participation is not open"
if hasattr(self, "request"):
messages.error(self.request, message)
raise ValidationError(message)
super().delete(**kwargs)
class SpeakerProposal(UserSubmittedModel):
"""A speaker proposal"""
camp = models.ForeignKey(
"camps.Camp",
related_name="speaker_proposals",
on_delete=models.PROTECT,
editable=False,
)
name = models.CharField(
max_length=150, help_text="Name or alias of the speaker/artist/host"
)
email = models.EmailField(
blank=True,
max_length=150,
help_text="The email of the speaker/artist/host. Defaults to the logged in users email if empty.",
)
biography = models.TextField(
help_text="Biography of the speaker/artist/host. Markdown is supported."
)
submission_notes = models.TextField(
help_text="Private notes for this speaker/artist/host. Only visible to the submitting user and the BornHack organisers.",
blank=True,
)
needs_oneday_ticket = models.BooleanField(
default=False,
help_text="Check if BornHack needs to provide a free one-day ticket for this speaker",
)
event_conflicts = models.ManyToManyField(
"program.Event",
related_name="speaker_proposal_conflicts",
blank=True,
help_text="Pick the Events this person wishes to attend, and we will attempt to avoid scheduling conflicts.",
)
@property
def headline(self):
return self.name
def get_absolute_url(self):
return reverse_lazy(
"program:speaker_proposal_detail",
kwargs={"camp_slug": self.camp.slug, "pk": self.uuid},
)
def mark_as_approved(self, request=None):
"""Marks a SpeakerProposal as approved, including creating/updating the related Speaker object"""
speaker_proposalmodel = apps.get_model("program", "SpeakerProposal")
# create a Speaker if we don't have one
if not hasattr(self, "speaker"):
speakermodel = apps.get_model("program", "Speaker")
speaker = speakermodel()
speaker.proposal = self
else:
# this proposal has been approved before, update the existing speaker
speaker = self.speaker
# set Speaker data
speaker.camp = self.camp
speaker.email = self.email if self.email else self.user.email
speaker.name = self.name
speaker.biography = self.biography
speaker.needs_oneday_ticket = self.needs_oneday_ticket
speaker.save()
# save speaker availability, start with a clean slate
speaker.availabilities.all().delete()
for availability in self.availabilities.all():
SpeakerAvailability.objects.create(
speaker=speaker,
when=availability.when,
available=availability.available,
)
# mark as approved and save
self.proposal_status = speaker_proposalmodel.PROPOSAL_APPROVED
self.save()
# copy all the URLs to the speaker object
speaker.urls.clear()
for url in self.urls.all():
Url.objects.create(url=url.url, url_type=url.url_type, speaker=speaker)
# copy event conflicts to the speaker object
speaker.event_conflicts.clear()
speaker.event_conflicts.set(self.event_conflicts.all())
# a message to the admin (if we have a request)
if request:
messages.success(
request, "Speaker object %s has been created/updated" % speaker
)
add_speaker_proposal_accepted_email(self)
def mark_as_rejected(self, request=None):
speaker_proposalmodel = apps.get_model("program", "SpeakerProposal")
self.proposal_status = speaker_proposalmodel.PROPOSAL_REJECTED
self.save()
if request:
messages.success(
request, "SpeakerProposal %s has been rejected" % self.name
)
add_speaker_proposal_rejected_email(self)
@property
def event_types(self):
"""Return a queryset of the EventType objects for the EventProposals"""
return EventType.objects.filter(
id__in=self.event_proposals.all().values_list("event_type", flat=True)
)
@property
def title(self):
"""Convenience method to return the proper host_title"""
if self.event_proposals.values_list("event_type").distinct().count() != 1:
# we have no events, or events of different eventtypes, use generic title
return "Person"
else:
return self.event_proposals.first().event_type.host_title
class EventProposal(UserSubmittedModel):
"""An event proposal"""
track = models.ForeignKey(
"program.EventTrack",
related_name="event_proposals",
help_text="The track this event belongs to",
on_delete=models.PROTECT,
)
title = models.CharField(
max_length=255,
help_text="The title of this event. Keep it short and memorable.",
)
abstract = models.TextField(
help_text="The abstract for this event. Describe what the audience can expect to see/hear.",
blank=True,
)
event_type = models.ForeignKey(
"program.EventType",
help_text="The type of event",
on_delete=models.PROTECT,
related_name="event_proposals",
)
speakers = models.ManyToManyField(
"program.SpeakerProposal",
blank=True,
help_text="Pick the speaker(s) for this event. If you cannot see anything here you need to go back and create Speaker Proposal(s) first.",
related_name="event_proposals",
)
allow_video_recording = models.BooleanField(
default=False,
help_text="Recordings are made available under the <b>CC BY-SA 4.0</b> license. Uncheck if you do not want the event recorded, or if you cannot accept the license.",
)
duration = models.IntegerField(
blank=True,
help_text="How much time (in minutes) should we set aside for this event?",
)
submission_notes = models.TextField(
help_text="Private notes for this event. Only visible to the submitting user and the BornHack organisers.",
blank=True,
)
use_provided_speaker_laptop = models.BooleanField(
help_text="Will you be using the provided speaker laptop?", default=True
)
tags = TaggableManager(
through=UUIDTaggedItem,
blank=True,
)
@property
def camp(self):
return self.track.camp
camp_filter = "track__camp"
@property
def headline(self):
return self.title
def save(self, **kwargs):
if not self.duration:
self.duration = self.event_type.event_duration_minutes
super().save(**kwargs)
def get_absolute_url(self):
return reverse_lazy(
"program:event_proposal_detail",
kwargs={"camp_slug": self.camp.slug, "pk": self.uuid},
)
def get_available_speaker_proposals(self):
"""
Return all SpeakerProposals submitted by the user who submitted this EventProposal,
which are not already added to this EventProposal
"""
return SpeakerProposal.objects.filter(
camp=self.track.camp, user=self.user
).exclude(uuid__in=self.speakers.all().values_list("uuid"))
def mark_as_approved(self, request=None):
eventmodel = apps.get_model("program", "Event")
event_proposalmodel = apps.get_model("program", "EventProposal")
# use existing event if we have one
if not hasattr(self, "event"):
event = eventmodel()
else:
event = self.event
event.track = self.track
event.title = self.title
event.abstract = self.abstract
event.event_type = self.event_type
event.proposal = self
event.video_recording = self.allow_video_recording
event.save()
# loop through the speaker_proposals linked to this event_proposal and associate any related speaker objects with this event
for sp in self.speakers.all():
if sp.proposal_status != "approved":
raise ValidationError("Not all speakers are approved or created yet.")
event.speakers.add(sp.speaker)
self.proposal_status = event_proposalmodel.PROPOSAL_APPROVED
self.save()
# clear any old urls from the event object and copy all the URLs from the proposal
event.urls.clear()
for url in self.urls.all():
Url.objects.create(url=url.url, url_type=url.url_type, event=event)
# set event tags
event.tags.add(*self.tags.names())
if request:
messages.success(
request, "Event object %s has been created/updated" % event
)
add_event_proposal_accepted_email(self)
def mark_as_rejected(self, request=None):
event_proposalmodel = apps.get_model("program", "EventProposal")
self.proposal_status = event_proposalmodel.PROPOSAL_REJECTED
self.save()
if request:
messages.success(request, "EventProposal %s has been rejected" % self.title)
add_event_proposal_rejected_email(self)
@property
def can_be_approved(self):
"""We cannot approve an EventProposal until all SpeakerProposals are approved"""
if self.speakers.exclude(proposal_status="approved").exists():
return False
else:
return True
###############################################################################
class EventTrack(CampRelatedModel):
"""All events belong to a track. Administration of a track can be delegated to one or more users."""
name = models.CharField(max_length=100, help_text="The name of this Track")
slug = models.SlugField(help_text="The url slug for this Track")
camp = models.ForeignKey(
"camps.Camp",
related_name="event_tracks",
on_delete=models.PROTECT,
help_text="The Camp this Track belongs to",
)
managers = models.ManyToManyField(
"auth.User",
related_name="managed_tracks",
blank=True,
help_text="If this track is managed by someone other than the Content team pick the users here.",
)
def __str__(self):
return f"{self.name} ({self.camp.title})"
class Meta:
unique_together = (("camp", "slug"), ("camp", "name"))
def serialize(self):
return {"name": self.name, "slug": self.slug}
class EventLocation(CampRelatedModel):
"""The places where stuff happens"""
name = models.CharField(max_length=100)
slug = models.SlugField()
icon = models.CharField(
max_length=100,
help_text="Name of the fontawesome icon to use without the 'fa-' part",
)
camp = models.ForeignKey(
"camps.Camp", related_name="event_locations", on_delete=models.PROTECT
)
capacity = models.PositiveIntegerField(
default=20,
help_text="The capacity of this location. Used by the autoscheduler.",
)
conflicts = models.ManyToManyField(
"self",
blank=True,
help_text="Select the locations which this location conflicts with. Nothing can be scheduled in a location if a conflicting location has a scheduled Event at the same time. Example: If one room can be split into two, then the big room would conflict with each of the two small rooms (but the small rooms would not conflict with eachother).",
)
def __str__(self):
return "{} ({})".format(self.name, self.camp)
class Meta:
unique_together = (("camp", "slug"), ("camp", "name"))
def save(self, **kwargs):
"""Create a slug"""
if not self.slug:
self.slug = unique_slugify(
self.name,
self.__class__.objects.filter(camp=self.camp).values_list(
"slug", flat=True
),
)
super().save(**kwargs)
def serialize(self):
return {"name": self.name, "slug": self.slug, "icon": self.icon}
@property
def icon_html(self):
return mark_safe(f'<i class="fas fa-{ self.icon } fa-fw"></i>')
@property
def event_slots(self):
return self.camp.event_slots.filter(event_session__event_location=self)
def scheduled_event_slots(self):
"""Returns a QuerySet of all EventSlots scheduled in this EventLocation"""
return self.event_slots.filter(event__isnull=False)
def is_available(self, when, ignore_event_slot_ids=[]):
"""A location is available if nothing is scheduled in it at that time"""
if (
self.event_slots.filter(event__isnull=False, when__overlap=when)
.exclude(pk__in=ignore_event_slot_ids)
.exists()
):
# something is scheduled, the location is not available at this time
return False
# nothing is scheduled, location is available
return True
class EventType(CreatedUpdatedModel):
"""Every event needs to have a type."""
name = models.CharField(
max_length=100, unique=True, help_text="The name of this event type"
)
slug = models.SlugField()
description = models.TextField(
default="",
help_text="The description of this type of event. Used in content submission flow.",
blank=True,
)
color = models.CharField(
max_length=50, help_text="The background color of this event type"
)
light_text = models.BooleanField(
default=False, help_text="Check if this event type should use white text color"
)
icon = models.CharField(
max_length=25,
help_text="Name of the fontawesome icon to use, without the 'fa-' part",
default="wrench",
)
notifications = models.BooleanField(
default=False, help_text="Check to send notifications for this event type"
)
public = models.BooleanField(
default=False, help_text="Check to permit users to submit events of this type"
)
include_in_event_list = models.BooleanField(
default=True, help_text="Include events of this type in the event list?"
)
host_title = models.CharField(
max_length=30,
help_text='What to call someone hosting this type of event. Like "Artist" for Music or "Speaker" for talks.',
default="Person",
)
event_duration_minutes = models.PositiveIntegerField(
null=True,
blank=True,
help_text="The default duration of an event of this type, in minutes. Optional. This default can be overridden in individual EventSessions as needed.",
)
support_autoscheduling = models.BooleanField(
default=False,
help_text="Check to enable this EventType in the autoscheduler",
)
support_speaker_event_conflicts = models.BooleanField(
default=True,
help_text="True if Events of this type should be selectable in the EventConflict m2m for SpeakerProposal and Speaker objects.",
)
def __str__(self):
return self.name
def serialize(self):
return {
"name": self.name,
"slug": self.slug,
"color": self.color,
"light_text": self.light_text,
}
def clean(self):
if self.support_autoscheduling and not self.event_duration_minutes:
raise ValidationError(
"You must specify event_duration_minutes to support autoscheduling"
)
@property
def duration(self):
"""Just return a timedelta of the lenght of this Session"""
return timedelta(minutes=self.event_duration_minutes)
def icon_html(self):
return mark_safe(
f'<i class="fas fa-{ self.icon } fa-fw" style="color: { self.color };"></i>'
)
class EventSession(CampRelatedModel):
"""
An EventSession define the "opening hours" for an EventType in an EventLocation.
Creating an EventSession also creates the related EventSlots. Updating an EventSesion
adds or removes EventSlots as needed.
Multiple EventSessions can happen at the same time at the same location, for
example we have both Meetups and Music Acts in the Bar Area in the evenings.
EventSessions are used to allow submitters to specify speaker availability
when submitting, and to assist and validate event scheduling (auto and manual).
"""
class Meta:
ordering = ["when", "event_type", "event_location"]
constraints = [
# We do not want overlapping sessions for the same EventType/EventLocation/duration combo.
ExclusionConstraint(
name="prevent_event_session_event_type_event_location_overlaps",
expressions=[
("when", RangeOperators.OVERLAPS),
("event_location", RangeOperators.EQUAL),
("event_type", RangeOperators.EQUAL),
("event_duration_minutes", RangeOperators.EQUAL),
],
),
# we do not want adjacent sessions for the same type and location and duration
ExclusionConstraint(
name="prevent_adjacent_eventsessions",
expressions=[
("event_location", RangeOperators.EQUAL),
("event_type", RangeOperators.EQUAL),
("event_duration_minutes", RangeOperators.EQUAL),
("when", RangeOperators.ADJACENT_TO),
],
),
]
camp = models.ForeignKey(
"camps.Camp",
related_name="event_sessions",
on_delete=models.PROTECT,
help_text="The Camp this EventSession belongs to",
)
event_type = models.ForeignKey(
"program.EventType",
on_delete=models.PROTECT,
related_name="event_sessions",
help_text="The type of event this session is for",
)
event_location = models.ForeignKey(
"program.EventLocation",
on_delete=models.PROTECT,
related_name="event_sessions",
help_text="The event location this session is for",
)
when = DateTimeRangeField(
help_text="A period of time where this type of event can be scheduled. Input format is <i>YYYY-MM-DD HH:MM</i>"
)
event_duration_minutes = models.PositiveIntegerField(
blank=True,
help_text="The duration of events in this EventSession. Defaults to the value from the EventType of this EventSession.",
)
description = models.TextField(
blank=True,
help_text="Description of this session (optional).",
)
def __str__(self):
return f"EventSession for {self.event_type} in {self.event_location.name}: {self.when}"
def save(self, **kwargs):
if not self.event_duration_minutes:
self.event_duration_minutes = self.event_type.event_duration_minutes
super().save(**kwargs)
@property
def duration(self):
"""Just return a timedelta of the lenght of this Session"""
return self.when.upper - self.when.lower
@property
def free_time(self):
"""Returns a timedelta of the free time in this Session."""
return self.duration - timedelta(
minutes=self.event_duration_minutes * self.get_unavailable_slots().count()
)
def get_available_slots(self, count_autoscheduled_as_free=False, bounds="()"):
"""
Return a queryset of slots that have nothing scheduled, remember to consider
conflicting locations too.
"""
# do we want to count slots with autoscheduled Events as free or not?
if count_autoscheduled_as_free:
# a slot is available if nothing is scheduled, or if the event is autoscheduled
availablefilter = Q(event__isnull=True) | Q(autoscheduled=True)
# a slot is busy if something is manually scheduled
busyfilter = Q(autoscheduled=False)
else:
# a slot is available if nothing is scheduled
availablefilter = Q(event__isnull=True)
# a slot is busy if something is scheduled
busyfilter = Q(event__isnull=False)
# get the times of all busy slots in the same or conflicting
# locations which overlap with this session
conflict_slot_times = self.camp.event_slots.filter(
# get slots at the same or a conflicting location
Q(event_session__event_location__in=self.event_location.conflicts.all())
| Q(event_session__event_location=self.event_location),
# which have something scheduled in them
busyfilter,
# at the same time as this session
when__overlap=self.when,
).values_list("when", flat=True)
# build the excludefilter so we exclude any slots that overlap with any
# of our conflict_slot_times
excludefilter = Q()
for slot in conflict_slot_times:
# slot is a DateTimeTZRange with bounds "[)"
excludefilter |= Q(when__overlap=slot)
# do the thing
return self.event_slots.filter(availablefilter).exclude(excludefilter)
def get_unavailable_slots(self, count_autoscheduled_as_free=False, bounds="[)"):
"""Return a list of slots that are not available for some reason"""
return self.event_slots.exclude(
id__in=self.get_available_slots(
count_autoscheduled_as_free=count_autoscheduled_as_free,
bounds=bounds,
).values_list("id", flat=True),
)
def get_slot_times(self, bounds="[)"):
"""Return a list of the DateTimeTZRanges we want EventSlots to exists for"""
slots = []
period = self.when
duration = timedelta(minutes=self.event_duration_minutes)
if period.upper - period.lower < duration:
# this period is shorter than the duration, no slots
return slots
# create the first slot
slot = DateTimeTZRange(period.lower, period.lower + duration, bounds=bounds)
# loop until we pass the end
while slot.upper < period.upper:
slots.append(slot)
# the next slot starts when this one ends
slot = DateTimeTZRange(slot.upper, slot.upper + duration, bounds=bounds)
# append the final slot to the list unless it continues past the end
if not slot.upper > period.upper:
slots.append(slot)
return slots
def fixup_event_slots(self):
"""This method takes care of creating and deleting EventSlots when the EventSession is created, updated or deleted"""
# get a set of DateTimeTZRange objects representing the EventSlots we need
needed_slot_times = set(self.get_slot_times(bounds="[)"))
# get a set of DateTimeTZRange objects representing the EventSlots we have in DB
db_slot_times = set(self.event_slots.all().values_list("when", flat=True))
# loop over and delete unneeded slots
for slot in db_slot_times.difference(needed_slot_times):
self.event_slots.get(when=slot).delete()
# loop over and create missing slots
for slot in needed_slot_times.difference(db_slot_times):
self.event_slots.create(event_session=self, when=slot)
def scheduled_event_slots(self):
return self.event_slots.filter(event__isnull=False)
class EventSlot(CampRelatedModel):
"""
An EventSlot defines a window where we can schedule an Event.
The EventType and EventLocation is defined by the EventSession this
EventSlot belongs to. EventSlots are created and deleted by a post_save
signal when the parent EventSession is created, updated or deleted.
If the EventSession has a duration of 6 hours and event_duration_minutes=60
then 6 instances of this model would exist for that EventSession.
"""
class Meta:
ordering = ["when"]
constraints = [
# we do not want overlapping slots for the same EventSession
ExclusionConstraint(
name="prevent_slot_session_overlaps",
expressions=[
("when", RangeOperators.OVERLAPS),
("event_session", RangeOperators.EQUAL),
],
),
]
event_session = models.ForeignKey(
"program.EventSession",
related_name="event_slots",
on_delete=models.PROTECT,
help_text="The EventSession this EventSlot belongs to",
)
when = DateTimeRangeField(
help_text="Start and end time of this slot",
)
event = models.ForeignKey(
"program.Event",
null=True,
blank=True,
related_name="event_slots",
on_delete=models.SET_NULL,
help_text="The Event scheduled in this EventSlot",
)
autoscheduled = models.BooleanField(
blank=True,
null=True,
default=None,
help_text="True if the Event was scheduled by the AutoScheduler, False if it was scheduled manually, None if there is nothing scheduled in this EventSlot.",
)
@property
def camp(self):
return self.event_session.camp
camp_filter = "event_session__camp"
def __str__(self):
return f"{self.when} ({self.event_session.event_location.name}, {self.event_session.event_type})"
def clean(self):
"""Validate EventSlot length, time, and autoscheduled status"""
if self.when.upper - self.when.lower != timedelta(
minutes=self.event_session.event_duration_minutes
):
raise ValidationError(
f"This EventSlot has the wrong length. It must be {self.event_session.event_duration_minutes} minutes long."
)
# remember to use "[)" bounds when comparing
if self.when not in self.event_session.get_slot_times(bounds="[)"):
raise ValidationError(
"This EventSlot is not inside this EventSession, or it might be misaligned"
)
# if we have an Event we want to know if it was autoscheduled or not
if self.event and self.autoscheduled is None:
raise ValidationError(
"An EventSlot with a scheduled Event must have autoscheduled set to either True or False, not None"
)
self.clean_speakers()
self.clean_location()
def get_autoscheduler_slot(self):
"""Return a conference_scheduler.resources.Slot object matching this EventSlot"""
return resources.Slot(
venue=self.event_session.event_location.id,
starts_at=self.when.lower,
duration=int((self.when.upper - self.when.lower).total_seconds() / 60),
session=self.event_session.id,
capacity=self.event_session.event_location.capacity,
)
@property
def event_type(self):
return self.event_session.event_type
@property
def event_location(self):
return self.event_session.event_location
def clean_speakers(self):
"""Check if all speakers are available"""
if self.event:
for speaker in self.event.speakers.all():
if not speaker.is_available(
when=self.when, ignore_event_slot_ids=[self.pk]
):
raise ValidationError(
f"The speaker {speaker} is not available at this time"
)
def clean_location(self):
"""Make sure the location is available"""
if self.event:
if not self.event_location.is_available(
when=self.when, ignore_event_slot_ids=[self.pk]
):
raise ValidationError(
f"The location {self.event_location} is not available at this time"
)
def unschedule(self):
"""Clear the Event FK and autoscheduled status, removing the Event from the schedule"""
self.event = None
self.autoscheduled = None
self.save()
@property
def overflow(self):
"""If we have more demand than capacity return the overflow"""
if self.event and self.event.demand > self.event_location.capacity:
return (self.event_location.capacity - self.event.demand) * -1
else:
return 0
@property
def duration(self):
return self.when.upper - self.when.lower
@property
def duration_minutes(self):
return int(self.duration.total_seconds() // 60)
def get_ics_event(self):
if not self.event:
return False
ievent = icalendar.Event()
ievent["summary"] = self.event.title
ievent["description"] = self.event.abstract
ievent["dtstart"] = icalendar.vDatetime(self.when.lower).to_ical()
ievent["dtend"] = icalendar.vDatetime(
self.when.lower + self.event.duration
).to_ical()
ievent["dtstamp"] = icalendar.vDatetime(timezone.now()).to_ical()
ievent["uid"] = self.uuid
ievent["location"] = icalendar.vText(self.event_location.name)
return ievent
@property
def uuid(self):
"""
Returns a consistent UUID for this EventSlot with this Event (if any).
We want the UUID to be the same even if this EventSlot is deleted and replaced by
another at the same start time and location, so it cannot be a regular UUIDField.
We want the UUID to depend on event, location and start time, meaning if any of
these change we consider it a "schedule change" and create a new UUID.
We create the UUID from 32 hex digits, which are the unix timestamp of the starttime,
the event id, and the location id, and the rest is padded with the event uuid as needed.
Examples:
# timestamp=1472374800 location_id=1 event_id=27 event_uuid=748316fa-78a5-4172-850b-341fc41ba2ba
In [1]: EventSlot.objects.filter(event__isnull=False).first().uuid
Out[1]: UUID('14723748-0012-7748-316f-a78a54172850')
# timestamp=1472378400 location_id=3 event_id=0 event_uuid=00000000-0000-0000-0000-000000000000
In [2]: EventSlot.objects.filter(event__isnull=True).first().uuid
Out[2]: UUID('14723784-0030-0000-0000-000000000000')
"""
# get starttime as unix timestamp, 10 bytes (until Sat, Nov. 20th 2286 at 18:46:40 CET)
start_timestamp = int(self.when.lower.timestamp())
# get location_id
location_id = self.event_location.id # 1-3? bytes
# do we have an event?
if self.event:
event_id = self.event.id # 1-4? bytes
event_uuid = str(self.event.uuid).replace("-", "")
else:
event_id = 0
event_uuid = "0" * 32
# put the start of the UUID together
uuidbase = f"{start_timestamp}{location_id}{event_id}"
# pad using event_uuid up to 32 hex chars and return
return uuid.UUID(f"{uuidbase}{event_uuid[0:32-len(uuidbase)]}")
class Event(CampRelatedModel):
"""Something that is on the program one or more times."""
uuid = models.UUIDField(
default=uuid.uuid4,
# unique=True,
editable=False,
help_text="This field is not the PK of the model. It is used to create EventSlot UUID for FRAB and iCal and other calendaring purposes.",
)
title = models.CharField(max_length=255, help_text="The title of this event")
abstract = models.TextField(help_text="The abstract for this event")
event_type = models.ForeignKey(
"program.EventType",
help_text="The type of this event",
related_name="events",
on_delete=models.PROTECT,
)
slug = models.SlugField(
blank=True,
max_length=255,
help_text="The slug for this event, created automatically",
)
track = models.ForeignKey(
"program.EventTrack",
related_name="events",
help_text="The track this event belongs to",
on_delete=models.PROTECT,
)
video_url = models.URLField(
max_length=1000, null=True, blank=True, help_text="URL to the recording"
)
video_recording = models.BooleanField(
default=True, help_text="Do we intend to record video of this event?"
)
proposal = models.OneToOneField(
"program.EventProposal",
null=True,
blank=True,
help_text="The event proposal object this event was created from",
on_delete=models.PROTECT,
editable=False,
)
duration_minutes = models.PositiveIntegerField(
default=None,
blank=True,
help_text="The duration of this event in minutes. Leave blank to use the default from the event_type.",
)
demand = models.PositiveIntegerField(
default=0,
help_text="The estimated demand for this event. Used by the autoscheduler to pick the optimal location for events. Set to 0 to disable demand constraints for this event.",
)
tags = TaggableManager(blank=True)
class Meta:
ordering = ["title"]
unique_together = (("track", "slug"), ("track", "title"))
def __str__(self):
return self.title
def save(self, **kwargs):
"""Create a slug and get duration"""
if not self.slug:
self.slug = unique_slugify(
self.title,
slugs_in_use=self.__class__.objects.filter(
track__camp=self.track.camp
).values_list("slug", flat=True),
)
if not self.duration_minutes:
# we default to the duration of the event_type
self.duration_minutes = self.event_type.event_duration_minutes
super().save(**kwargs)
@property
def camp(self):
return self.track.camp
camp_filter = "track__camp"
@property
def speakers_list(self):
if self.speakers.exists():
return ", ".join(self.speakers.all().values_list("name", flat=True))
return False
def get_absolute_url(self):
return reverse(
"program:event_detail",
kwargs={"camp_slug": self.camp.slug, "event_slug": self.slug},
)
def serialize(self):
data = {
"title": self.title,
"slug": self.slug,
"abstract": self.abstract,
"speaker_slugs": [speaker.slug for speaker in self.speakers.all()],
"event_type": self.event_type.name,
}
if self.video_url:
video_state = "has-recording"
data["video_url"] = self.video_url
elif self.video_recording:
video_state = "to-be-recorded"
elif not self.video_recording:
video_state = "not-to-be-recorded"
data["video_state"] = video_state
return data
@property
def duration(self):
return timedelta(minutes=self.duration_minutes)
class EventInstance(CampRelatedModel):
"""The old way of scheduling events. Model to be deleted after prod data migration"""
uuid = models.UUIDField(
default=uuid.uuid4,
unique=True,
editable=False,
help_text="This field is mostly here to keep Frab happy, it is not the PK of the model",
)
event = models.ForeignKey(
"program.event", related_name="instances", on_delete=models.PROTECT
)
when = DateTimeRangeField(null=True, blank=True)
notifications_sent = models.BooleanField(default=False)
location = models.ForeignKey(
"program.EventLocation",
related_name="eventinstances",
on_delete=models.PROTECT,
null=True,
blank=True,
)
autoscheduled = models.BooleanField(
default=False,
help_text="True if this was created by the autoscheduler.",
)
class Meta:
ordering = ["when"]
# we do not want overlapping instances in the same location
constraints = [
ExclusionConstraint(
name="prevent_eventinstance_location_overlaps",
expressions=[
("when", RangeOperators.OVERLAPS),
("location", RangeOperators.EQUAL),
],
),
]
def __str__(self):
return "%s (%s)" % (self.event, self.when)
def clean_speakers(self):
"""Check if all speakers are available"""
for speaker in self.event.speakers.all():
if not speaker.is_available(
when=self.event_slot.when, ignore_eventinstances=[self.pk]
):
raise ValidationError(
f"The speaker {speaker} is not available at this time"
)
def save(self, *args, clean_speakers=True, **kwargs):
"""Validate speakers (unless we are asked not to)"""
if "commit" not in kwargs or kwargs["commit"]:
# we are saving for real
if clean_speakers:
self.clean_speakers()
super().save(*args, **kwargs)
@property
def camp(self):
return self.event.camp
camp_filter = "event__track__camp"
@property
def schedule_date(self):
"""
Returns the schedule date of this eventinstance. Schedule date is determined by substracting
settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS from the eventinstance start time. This means that if
an event is scheduled for 00:30 wednesday evening (technically thursday) then the date
after substracting 5 hours would be wednesdays date, not thursdays
(given settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS=5)
"""
return (
self.when.lower - timedelta(hours=settings.SCHEDULE_MIDNIGHT_OFFSET_HOURS)
).date()
@property
def timeslots(self):
"""Find the number of timeslots this eventinstance takes up"""
seconds = (self.when.upper - self.when.lower).seconds
minutes = seconds / 60
return minutes / settings.SCHEDULE_TIMESLOT_LENGTH_MINUTES
def get_ics_event(self):
ievent = icalendar.Event()
ievent["summary"] = self.event.title
ievent["description"] = self.event.abstract
ievent["dtstart"] = icalendar.vDatetime(self.when.lower).to_ical()
ievent["dtend"] = icalendar.vDatetime(self.when.upper).to_ical()
ievent["location"] = icalendar.vText(self.location.name)
return ievent
def serialize(self, user=None):
data = {
"title": self.event.title,
"slug": self.event.slug + "-" + str(self.id),
"event_slug": self.event.slug,
"from": self.when.lower.isoformat(),
"to": self.when.upper.isoformat(),
"url": str(self.event.get_absolute_url()),
"id": self.id,
"bg-color": self.event.event_type.color,
"fg-color": "#fff" if self.event.event_type.light_text else "#000",
"event_type": self.event.event_type.slug,
"event_track": self.event.track.slug,
"location": self.location.slug,
"location_icon": self.location.icon,
"timeslots": self.timeslots,
}
if self.event.video_url:
video_state = "has-recording"
data["video_url"] = self.event.video_url
elif self.event.video_recording:
video_state = "to-be-recorded"
elif not self.event.video_recording:
video_state = "not-to-be-recorded"
data["video_state"] = video_state
if user and user.is_authenticated:
is_favorited = user.favorites.filter(event_instance=self).exists()
data["is_favorited"] = is_favorited
return data
@property
def duration(self):
"""Return a timedelta of the lenght of this EventInstance"""
return self.when.upper - self.when.lower
@property
def overflow(self):
if self.event.demand > self.location.capacity:
return (self.location.capacity - self.event.demand) * -1
else:
return 0
class Speaker(CampRelatedModel):
"""A Person (co)anchoring one or more events on a camp."""
name = models.CharField(max_length=150, help_text="Name or alias of the speaker")
email = models.EmailField(max_length=150, help_text="The email of the speaker.")
biography = models.TextField(help_text="Markdown is supported.")
slug = models.SlugField(
blank=True,
max_length=255,
help_text="The slug for this speaker, will be autocreated",
)
camp = models.ForeignKey(
"camps.Camp",
null=True,
related_name="speakers",
help_text="The camp this speaker belongs to",
on_delete=models.PROTECT,
)
events = models.ManyToManyField(
Event,
blank=True,
help_text="The event(s) this speaker is anchoring",
related_name="speakers",
)
proposal = models.OneToOneField(
"program.SpeakerProposal",
null=True,
blank=True,
help_text="The speaker proposal object this speaker was created from",
on_delete=models.PROTECT,
editable=False,
)
needs_oneday_ticket = models.BooleanField(
default=False,
help_text="Check if BornHack needs to provide a free one-day ticket for this speaker",
)
event_conflicts = models.ManyToManyField(
"program.Event",
related_name="speaker_conflicts",
help_text="The Events this person wishes to attend. The AutoScheduler will avoid conflicts.",
)
class Meta:
ordering = ["name"]
unique_together = (("camp", "name"), ("camp", "slug"))
def __str__(self):
return "%s (%s)" % (self.name, self.camp)
def save(self, **kwargs):
if not self.slug:
self.slug = unique_slugify(
self.name,
slugs_in_use=self.__class__.objects.filter(camp=self.camp).values_list(
"slug", flat=True
),
)
super().save(**kwargs)
def get_absolute_url(self):
return reverse_lazy(
"program:speaker_detail",
kwargs={"camp_slug": self.camp.slug, "slug": self.slug},
)
def serialize(self):
data = {"name": self.name, "slug": self.slug, "biography": self.biography}
return data
def is_available(self, when, ignore_event_slot_ids=[]):
"""A speaker is available if the person has positive availability for the period and
if the speaker is not in another event at the time, or if the person has not submitted
any availability at all"""
if not self.availabilities.exists():
# we have no availability at all for this speaker, assume they are available
return True
if not self.availabilities.filter(when__contains=when, available=True).exists():
# we have no positive availability for this speaker
return False
# get all slots for this speaker which overlap with the period
slots = self.camp.event_slots.filter(event__speakers=self, when__overlap=when)
# do we have any slots we want to ignore?
if ignore_event_slot_ids:
slots = slots.exclude(pk__in=ignore_event_slot_ids)
if slots.exists():
# speaker is in another event at this time
return False
# speaker is available
return True
def scheduled_event_slots(self):
"""Returns a QuerySet of all EventSlots scheduled for this speaker"""
return self.camp.event_slots.filter(event__speakers=self)
@property
def title(self):
"""Convenience method to return the proper host_title"""
if self.events.values_list("event_type").distinct().count() > 1:
# we have different eventtypes, use generic title
return "Person"
else:
return self.events.first().event_type.host_title
class Favorite(models.Model):
user = models.ForeignKey(
"auth.User", related_name="favorites", on_delete=models.PROTECT
)
event_instance = models.ForeignKey(
"program.EventInstance", on_delete=models.PROTECT
)
class Meta:
unique_together = ["user", "event_instance"]
###############################################################################
class EventFeedback(CampRelatedModel, UUIDModel):
"""
This model contains all feedback for Events
Each user can submit exactly one feedback per Event
"""
class Meta:
unique_together = [("user", "event")]
YESNO_CHOICES = [(True, "Yes"), (False, "No")]
user = models.ForeignKey(
"auth.User",
on_delete=models.PROTECT,
help_text="The User who wrote this feedback",
)
event = models.ForeignKey(
"program.event",
related_name="feedbacks",
on_delete=models.PROTECT,
help_text="The Event this feedback is about",
)
expectations_fulfilled = models.BooleanField(
choices=YESNO_CHOICES,
help_text="Did the event live up to your expectations?",
)
attend_speaker_again = models.BooleanField(
choices=YESNO_CHOICES,
help_text="Would you attend another event with the same speaker?",
)
RATING_CHOICES = [(n, f"{n}") for n in range(0, 6)]
rating = models.IntegerField(
choices=RATING_CHOICES,
help_text="Rating/Score (5 is best)",
)
comment = models.TextField(blank=True, help_text="Any other comments or feedback?")
approved = models.BooleanField(
blank=True,
null=True,
help_text="Approve feedback? It will not be visible to the Event owner before it is approved.",
)
@property
def camp(self):
return self.event.camp
camp_filter = "event__track__camp"
def get_absolute_url(self):
return reverse(
"program:event_feedback_detail",
kwargs={"camp_slug": self.camp.slug, "event_slug": self.event.slug},
)
# classes and functions below here was used by picture handling for speakers before it was removed in May 2018 by tyk
class CustomUrlStorage(FileSystemStorage):
"""
Must exist because it is mentioned in old migrations.
Can be removed when we clean up old migrations at some point
"""
def get_speaker_picture_upload_path():
"""
Must exist because it is mentioned in old migrations.
Can be removed when we clean up old migrations at some point
"""
def get_speakerproposal_picture_upload_path():
"""
Must exist because it is mentioned in old migrations.
Can be removed when we clean up old migrations at some point
"""
def get_speakersubmission_picture_upload_path():
"""
Must exist because it is mentioned in old migrations.
Can be removed when we clean up old migrations at some point
"""