conference-website/conference_main/models.py

720 lines
26 KiB
Python

import colorsys
import datetime
import hashlib
import uuid
from pathlib import Path
from typing import Dict, Optional, Union
from django import urls
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.db import models
from django_countries.fields import CountryField
from conference_main.model_mixins import CreatedUpdatedMixin
from conference_main.util import get_sha256
from tickets.models import TicketClaim
def get_upload_path(instance: models.Model, filename: str, prefix: str) -> str:
# File will be uploaded to MEDIA_ROOT/bd/2b/bd2b5b1cd81333ed2d8db03971f91200.jpg
extension = Path(filename).suffix
# Combine filename and uuid4 and get a unique string.
unique_name = str(uuid.uuid4()) + filename
hashed_name = hashlib.md5(unique_name.encode('utf-8')).hexdigest()
return str(
(Path(prefix) / hashed_name[:2] / hashed_name[2:4] / hashed_name).with_suffix(extension)
)
def get_edition_media_upload_path(instance: 'Edition', filename: str) -> str:
return get_upload_path(instance, filename, prefix='edition')
class Tag(models.Model):
name = models.CharField(
max_length=100,
unique=True,
)
slug = models.SlugField(
unique=True,
max_length=100,
allow_unicode=True,
)
def __str__(self):
return self.name
class Meta:
ordering = ['name']
class Location(models.Model):
name = models.CharField(
max_length=100,
unique=True,
)
slug = models.SlugField(
unique=True,
max_length=100,
allow_unicode=True,
)
color = models.CharField(max_length=10, default='#DDDDDD')
order = models.IntegerField(blank=True, null=True)
def color_to_rgb(self, hex_value):
h = hex_value.strip("#")
rgb = tuple(int(h[i : i + 2], 16) for i in (0, 2, 4))
return rgb
def color_to_hsv(self):
rgb = self.color_to_rgb(self.color)
# Turn colors into 255 range
rgb = [c / 255.0 for c in rgb]
# Turn RGB to HSV
hsv = colorsys.rgb_to_hsv(*rgb)
# Turn colors in to CSS HSV representation (360, 100, 100)
return hsv[0] * 360, hsv[1] * 100, hsv[2] * 100
def __str__(self):
return self.name
class Meta:
ordering = ['order']
class Edition(models.Model):
"""
An edition of the Blender Conference.
NOTE: Django caches this model aggressively through `Site.objects.get_current`.
So when editing this make sure you handle cache invalidation properly.
See `conference_main.signals.clear_site_cache` and its usages.
In particular, if you add a foreign key you should also invalidate the
`Site.objects.get_current` cache whenever the related object is updated.
"""
class Meta:
indexes = (
models.Index(fields=('year',)),
models.Index(fields=('path',)),
models.Index(fields=('show_in_main_menu',)),
)
ordering = ['-year', '-path']
year = models.PositiveIntegerField()
location = models.CharField(max_length=256, blank=True, null=True)
location_url = models.URLField(blank=True)
location_lat = models.FloatField(
blank=True,
null=True,
help_text='Latitude coordinates for the venue, used by maps (for example in the Location page)',
)
location_lon = models.FloatField(
blank=True,
null=True,
help_text='Longitude coordinates for the venue, used by maps (for example in the Location page)',
)
path = models.CharField(blank=False, null=False, unique=True, max_length=64)
title = models.CharField(blank=False, null=False, max_length=128)
date_start = models.DateField(blank=True, null=True)
date_end = models.DateField(blank=True, null=True)
logo = models.ImageField(upload_to=get_edition_media_upload_path, blank=True)
header = models.ImageField(upload_to=get_edition_media_upload_path, blank=True)
thumbnail = models.ImageField(
upload_to=get_edition_media_upload_path,
blank=True,
help_text='Image for social media/OpenGraph, 1280x720px',
)
flatpage_location_published = models.BooleanField(default=False)
flatpage_call_for_participation_published = models.BooleanField(default=False)
flatpage_festival_published = models.BooleanField(default=False)
flatpage_call_for_sponsors_published = models.BooleanField(default=False)
show_in_main_menu = models.BooleanField(
default=False,
help_text='Display in the "Editions" dropdown selector',
)
presentation_submissions_open = models.BooleanField(default=False)
festival_submissions_open = models.BooleanField(default=False)
festival_voting_open = models.BooleanField(default=False)
festival_final_voting_open = models.BooleanField(default=False)
class TicketSaleStatus(models.TextChoices):
UNAVAILABLE = "unavailable", "Unavailable"
OPEN = "open", "Open"
SOLD_OUT_WAITING_LIST = "sold_out_waiting_list", "Sold Out with Waiting List"
SOLD_OUT = "sold_out", "Sold Out"
ticket_sale_status = models.CharField(
max_length=24,
choices=TicketSaleStatus.choices,
default=TicketSaleStatus.UNAVAILABLE,
help_text="Status changes allow the display of purchase buttons and various alerts.",
)
live_page_open = models.BooleanField(default=False)
live_url = models.URLField(
blank=True, help_text='URL where the event can be followed live, e.g. YouTube'
)
is_archived = models.BooleanField(default=True)
SCHEDULE_UNAVAILABLE = 'unavailable'
SCHEDULE_PROPOSED = 'proposed'
SCHEDULE_FINAL = 'final'
SCHEDULE_STATUS_CHOICES = [
(SCHEDULE_UNAVAILABLE, 'Unavailable'),
(SCHEDULE_PROPOSED, 'Proposed'),
(SCHEDULE_FINAL, 'Final'),
]
schedule_status = models.CharField(
max_length=20, choices=SCHEDULE_STATUS_CHOICES, default=SCHEDULE_UNAVAILABLE
)
speakers_viewable = models.BooleanField(default=False)
attendees_viewable = models.BooleanField(
default=False, help_text='Show attendees list (to attendees only).'
)
social_hashtag = models.CharField(max_length=128, blank=True, null=True)
support_email = models.EmailField(default='conference@blender.org')
# Used to populate the 'location' field when creating an Event
locations = models.ManyToManyField(Location, related_name='locations', blank=True)
def __str__(self):
return self.path
def is_live_page_open(self) -> bool:
return not self.is_archived and self.live_page_open
def get_absolute_url(self) -> str:
return urls.reverse('homepage', kwargs={'edition_path': self.path})
def get_attendees(self) -> models.QuerySet['Profile']:
"""Return a query set of public attendee profiles for given edition."""
return Profile.objects.filter(
is_public=True,
user__ticketclaim__ticket__edition=self,
).distinct()
def get_open_album(self):
"""Return latest album for which upload is open."""
return self.albums.filter(is_upload_open=True).order_by('-id').first()
class Day(models.Model):
edition = models.ForeignKey(Edition, on_delete=models.CASCADE, related_name='days')
date = models.DateField()
# Number of the day is useful to locate an event within the context
# of an event (1st day, 2nd day, etc). We define this explicitly
# once instead of querying all days of an event, sort them and find
# out the number of the day.
number = models.PositiveIntegerField(blank=True, null=True)
class Meta:
ordering = ['date']
unique_together = ['edition', 'number']
def __str__(self):
return str(self.date)
def get_profile_portrait_upload_path(instance: 'Event', filename: str) -> str:
return get_upload_path(instance, filename, prefix='speaker_portraits')
def get_thumbnail_upload_path(instance: 'FestivalEntry', filename: str) -> str:
return get_upload_path(instance, filename, prefix='festival_entry_thumbnails')
class Profile(models.Model):
# User is allowed to be null, since an administrator may create stand-in
# profile to be connected with an Event. See the 'speakers' property in
# the Event model.
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', null=True)
full_name = models.CharField(
max_length=255,
blank=True,
default='',
help_text=(
'Name to be printed on the Conference badge and '
'shown on your speaker profile, if you are presenting.'
),
)
company = models.CharField(
max_length=255,
blank=True,
default='',
help_text=(
'Company or institute you are affiliated with, if any, '
'or the nickname you are known under in the Blender community.'
),
)
title = models.CharField(
max_length=50, blank=True, default='', help_text='3D Artist, CEO, etc.'
)
country = CountryField(blank=True, null=True)
bio = models.TextField(blank=True)
url = models.URLField(blank=True)
portrait = models.ImageField(upload_to=get_profile_portrait_upload_path, blank=True)
is_public = models.BooleanField(
blank=True,
null=False,
default=False,
help_text=(
"<br>If checked, your profile will show up in the conference attendees list. "
'Can be useful for networking.'
),
)
def get_absolute_url(self):
return urls.reverse('profile_detail', kwargs={'pk': self.pk})
def get_ticket_order_admin_url(self, edition: Edition):
claim = TicketClaim.objects.filter(user=self.user, ticket__edition=edition).first()
if not claim:
return ''
return claim.ticket.get_admin_order_url()
def __str__(self):
return self.full_name or (self.user.username if self.user else self.id)
def get_event_picture_upload_path(instance: 'Event', filename: str) -> str:
return get_upload_path(instance, filename, prefix='events')
class Event(CreatedUpdatedMixin, models.Model):
class Statuses(models.TextChoices):
SUBMITTED = 'submitted', 'Submitted'
ACCEPTED = 'accepted', 'Accepted'
REJECTED = 'rejected', 'Rejected'
CANCELLED = 'cancelled', 'Cancelled'
# NOTE: An Event that is 'cancelled' has been 'accepted' before but now
# there is something preventing the talk from taking place. Because we
# need to communicate this to the users 'cancelled' Events *can* be
# viewed by non-owner users.
UNDER_REVIEW = 'under_review', 'Under Review'
CATEGORIES = (
('talk', 'Talk'),
('workshop', 'Workshop'),
('tutorial', 'Live Tutorial'),
('panel', 'Panel'),
('meeting', 'Meeting'),
('other', 'Other'),
)
LOCATIONS = (
('theater', 'Theater'),
('salon', 'Salon'),
('spotlight', 'Spotlight'),
('attic', 'Attic'),
('studio', 'Studio'),
('market', 'Market'),
('hacker_bar', 'Hacker Bar'),
('classroom', 'Classroom'),
('sig', 'SIG'),
('other', 'Other'),
)
class Meta:
ordering = ['-day__date', 'time']
indexes = (models.Index(fields=('status',)),)
user = models.ForeignKey(User, on_delete=models.CASCADE)
# TODO(fsiddi): add validation that edition == day.edition if day is not None.
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
# Submitted but not yet scheduled events don't have a day.
day = models.ForeignKey(Day, on_delete=models.CASCADE, null=True, blank=True)
category = models.CharField(max_length=20, choices=CATEGORIES, blank=True)
name = models.CharField(
max_length=255, help_text='A short (5-10 words) title for your presentation.'
)
description = models.TextField(
blank=True,
help_text='Short (50-100 words) description to help attendees understand what the presentation is about.',
)
proposal = models.TextField(
help_text='Describe the content of your presentation, highlighting its relevance and novelty.'
)
website = models.URLField(blank=True)
status = models.CharField(max_length=20, choices=Statuses.choices, default=Statuses.SUBMITTED)
# location_text = models.CharField(max_length=20, choices=LOCATIONS, blank=True)
location = models.ForeignKey(
Location, on_delete=models.CASCADE, blank=True, null=True, related_name='events'
)
picture = models.ImageField(
help_text='A 16:9 picture representing your presentation. Please avoid text.',
upload_to=get_event_picture_upload_path,
blank=True,
height_field='picture_height',
width_field='picture_width',
)
picture_height = models.PositiveIntegerField(null=True)
picture_width = models.PositiveIntegerField(null=True)
time = models.TimeField(null=True, blank=True)
duration_minutes = models.PositiveIntegerField(null=True, blank=True)
recording = models.CharField(
max_length=255, blank=True, help_text='Recording URL that supports oembed.'
)
slides_url = models.URLField(
verbose_name='Slides URL',
max_length=255,
blank=True,
help_text='A link to a public URL hosting all the files for your presentation. Google Drive, Dropbox, etc.',
)
favorites = models.ManyToManyField(User, related_name='favorites')
attendees = models.ManyToManyField(User, related_name='attending')
# Profiles are connected to Events and referred to as 'speakers'.
# We use Profiles instead of Users so that an administrator may attach
# multiple speakers to an Event, without requiring them to create a Blender ID.
# This is a proven, practical need.
# If one of the speakers added manually by the admin wants to create his/her
# own profile, he/she needs to login with Blender ID and then notify the admin
# which will delete the stand-in profile and connect the newly created one.
speakers = models.ManyToManyField(Profile, related_name='events', blank=True)
messages = GenericRelation('MessageExchange')
tags = models.ManyToManyField(Tag, related_name='events', blank=True)
def get_absolute_url(self):
return urls.reverse(
'presentation_detail', kwargs={'edition_path': self.edition.path, 'pk': self.pk}
)
def get_review_url(self):
return urls.reverse(
'presentation_review', kwargs={'edition_path': self.edition.path, 'pk': self.pk}
)
def __str__(self):
return self.name
@property
def end_time(self) -> Optional[datetime.time]:
if self.day is None or self.time is None or self.duration_minutes is None:
return None
else:
# NOTE: We need to use the whole date here because you cannot just add
# a timedelta to a time instance because of DST.
end_datetime: datetime.datetime = datetime.datetime.combine(
self.day.date, self.time
) + datetime.timedelta(minutes=self.duration_minutes)
return end_datetime.time()
@property
def start_time_in_minutes(self):
"""Start time in minutes since beginning of day."""
if not self.time:
return 0
return (self.time.hour * 60) + self.time.minute
def as_json(self) -> Dict[str, Union[None, int, str]]:
"""Return the event as dictionary intended for JSON-encoding.
Used in the presentation-list-as-JSON endpoint, for generating title
cards for the recorded videos.
"""
return {
'title': self.name,
'day': str(self.day) if self.day else None,
'start_time': self.time.strftime('%H:%M') if self.time else None,
'duration_minutes': self.duration_minutes, # can be None
'speakers': ', '.join([speaker.full_name for speaker in self.speakers.all()]),
'location': self.location.slug if self.location else '',
'category': self.get_category_display(),
'website': self.website or None,
}
def public_attendess(self):
return self.attendees.filter(profile__is_public=True)
@property
def message_form_url(self):
return urls.reverse(
'message_presentation_form', kwargs={'edition_path': self.edition.path, 'pk': self.pk}
)
class FestivalEntry(CreatedUpdatedMixin, models.Model):
class Meta:
verbose_name = "Festival Entry"
verbose_name_plural = "Festival Entries"
indexes = (models.Index(fields=('status',)),)
STATUS_SUBMITTED = 'submitted'
STATUSES = (
(STATUS_SUBMITTED, 'Submitted'),
('accepted', 'Accepted'),
('rejected', 'Rejected'),
('nominated', 'Nominated'),
('winner', 'Winner'),
)
CATEGORIES = (('animation', 'Animation'), ('short', 'Short'), ('design', 'Design'))
user = models.ForeignKey(User, on_delete=models.CASCADE)
edition = models.ForeignKey(Edition, on_delete=models.CASCADE)
category = models.CharField(max_length=20, choices=CATEGORIES, blank=True)
title = models.CharField(max_length=255)
description = models.TextField()
thumbnail = models.ImageField(upload_to=get_thumbnail_upload_path, blank=True, null=True)
credits = models.TextField(null=True)
website = models.URLField(null=True, blank=True)
status = models.CharField(max_length=20, choices=STATUSES, default=STATUS_SUBMITTED)
video_link = models.URLField()
messages = GenericRelation('MessageExchange')
def get_absolute_url(self) -> str:
return urls.reverse(
'festival_entry_detail', kwargs={'edition_path': self.edition.path, 'pk': self.pk}
)
@property
def message_form_url(self):
return urls.reverse(
'message_festival_entry_form', kwargs={'edition_path': self.edition.path, 'pk': self.pk}
)
def __str__(self):
return self.title
class FestivalEntryVotes(models.Model):
class Meta:
constraints = [
models.UniqueConstraint(
fields=('user', 'festival_entry'), name='user_festival_entry_unique'
)
]
STATUS_VALID = 'valid'
STATUSES = ((STATUS_VALID, 'Valid'), ('review', 'Review'), ('invalid', 'Invalid'))
user = models.ForeignKey(User, on_delete=models.CASCADE)
festival_entry = models.ForeignKey(
FestivalEntry, on_delete=models.CASCADE, related_name='votes'
)
rating = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)])
status = models.TextField(max_length=50, choices=STATUSES, default=STATUS_VALID)
class FestivalEntryFinalVotes(models.Model):
class Meta:
constraints = [
models.UniqueConstraint(
fields=('user', 'festival_entry'), name='user_festival_final_unique'
)
]
user = models.ForeignKey(User, on_delete=models.CASCADE)
festival_entry = models.ForeignKey(
FestivalEntry, on_delete=models.CASCADE, related_name='final_votes'
)
points = models.PositiveIntegerField(choices=[(i, str(i)) for i in range(1, 6)])
class Message(CreatedUpdatedMixin, models.Model):
"""Messages exchanged in relations an Event or a Video Submission.
Only Conference managers and the creator of an Event or a Video
creators can exchange messages via the review inferface"""
# TODO(fsiddi) merge Messages with MessageExchange
user = models.ForeignKey(User, on_delete=models.CASCADE)
content = models.TextField()
def __str__(self):
return self.user.username
class MessageExchange(models.Model):
limit = models.Q(app_label='conference_main', model='Event') | models.Q(
app_label='conference_main', model='FestivalEntry'
)
message = models.ForeignKey(Message, on_delete=models.CASCADE)
content_type = models.ForeignKey(ContentType, limit_choices_to=limit, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey()
class Meta:
ordering = ['message__created_at']
class SiteSettings(models.Model):
"""
Contains per site settings.
NOTE: Django caches this model aggressively through `Site.objects.get_current`.
So when editing this make sure you handle cache invalidation properly.
See `conference_main.signals.clear_site_cache` and its usages.
In particular, if you add a foreign key you should also invalidate the
`Site.objects.get_current` cache whenever the related object is updated.
"""
class Meta:
verbose_name = 'Site Settings'
verbose_name_plural = 'Site Settings'
site = models.OneToOneField(Site, related_name='settings', on_delete=models.CASCADE)
current_edition = models.ForeignKey(Edition, on_delete=models.SET_NULL, null=True, default=None)
popularity_weight = models.FloatField(
default=1.0,
help_text='Controls the importance of the popularity term (i.e. total amount of stars) '
'when ordering the festival entries for voting. Increasing this will make more '
'popular entries float to the top of the list.',
)
recency_weight = models.FloatField(
default=1.0,
help_text='Controls the importance of the recency term (i.e. the age of the entry) when '
'ordering the festival entries for voting. Increasing this will make more recent '
'entries float to the top of the list.',
)
per_user_random_weight = models.FloatField(
default=0.2,
help_text='Controls the importance of the randomness term when ordering the festival '
'entries for voting. Increasing this will make the ordering behave more like a '
'random ordering. The random term is fixed per user to avoid reshuffling the '
'list when the user refreshes.',
)
def __str__(self):
return f'Site Settings: {self.site.name}'
def get_flatfile_upload_path(instance: 'FlatFile', filename: str) -> str:
return str(Path('flatfiles') / filename)
class FlatFile(models.Model):
file = models.FileField(upload_to=get_flatfile_upload_path)
IMAGE = 'image'
BINARY = 'binary'
TYPES = ((IMAGE, 'Image'), (BINARY, 'Binary'))
type = models.CharField(max_length=25, choices=TYPES, default=BINARY)
def __str__(self) -> str:
return str(self.file.name)
def get_photo_upload_path(instance: 'Photo', filename: str) -> str:
return str(Path('photos') / instance.edition.path / filename)
class Photo(CreatedUpdatedMixin, models.Model):
edition = models.ForeignKey(Edition, on_delete=models.CASCADE, related_name='photos')
file = models.ImageField(upload_to=get_photo_upload_path)
user = models.ForeignKey(User, null=True, related_name='photos', on_delete=models.SET_NULL)
hash = models.CharField(max_length=255, null=True, blank=True, unique=True)
class Meta:
ordering = ['-id']
@classmethod
def generate_hash(self, source):
"""Generate a hash for the File"""
return f'sha256:{get_sha256(source)}'
def save(self, *args, **kwargs):
"""Set calculated values, e.g. file hash."""
if not self.hash:
self.hash = self.generate_hash(self.file)
self.full_clean()
return super().save(*args, **kwargs)
def __str__(self) -> str:
return str(self.file.name)
def get_album_cover_image_upload_path(instance: 'Album', filename: str) -> str:
return str(
(Path('album_cover_images') / f'{instance.edition.path}_{instance.slug}').with_suffix(
Path(filename).suffix
)
)
class Album(CreatedUpdatedMixin, models.Model):
class Meta:
unique_together = ['slug', 'edition']
edition = models.ForeignKey(Edition, on_delete=models.CASCADE, related_name='albums')
photos = models.ManyToManyField(Photo, blank=True, related_name='albums')
title = models.CharField(max_length=255)
slug = models.SlugField()
order = models.IntegerField(blank=True, null=True)
license = models.CharField(max_length=255)
cover_image = models.ImageField(upload_to=get_album_cover_image_upload_path)
is_upload_open = models.BooleanField(
default=False,
blank=True,
help_text=(
"If set, a link to this album will be shown in the edition's header "
"and attendees will be able to upload photos to this album."
),
)
def __str__(self):
return f'{self.edition.year}/{self.slug}'
def get_absolute_url(self) -> str:
return urls.reverse(
'album',
kwargs={'edition_path': self.edition.path, 'album': self.slug},
)
def get_upload_url(self) -> str:
return urls.reverse(
'album_upload',
kwargs={'edition_path': self.edition.path, 'album': self.slug},
)
class SponsorLevel(models.Model):
name = models.CharField(max_length=64, unique=True)
order = models.PositiveIntegerField(help_text='Small number means top of the list.')
class Meta:
ordering = ['order']
def __str__(self):
return self.name
class Sponsor(CreatedUpdatedMixin, models.Model):
edition = models.ForeignKey(Edition, on_delete=models.CASCADE, related_name='sponsors')
name = models.CharField(max_length=255)
level = models.ForeignKey(SponsorLevel, on_delete=models.CASCADE)
logo = models.ImageField(upload_to=get_edition_media_upload_path, blank=True)
url = models.URLField(blank=True)
class Meta:
ordering = ['level__order']
def __str__(self):
return self.name