720 lines
26 KiB
Python
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
|