class Room(LogMixin, models.Model): """A Room is an actual place where talks will be scheduled. The Room object stores some meta information. Most, like capacity, are not in use right now. """ event = models.ForeignKey(to="event.Event", on_delete=models.PROTECT, related_name="rooms") name = I18nCharField(max_length=100, verbose_name=_("Name")) description = I18nCharField( max_length=1000, null=True, blank=True, verbose_name=_("Description"), help_text=_("A description for attendees, for example directions."), ) speaker_info = I18nCharField( max_length=1000, null=True, blank=True, verbose_name=_("Speaker Information"), help_text= _("Information relevant for speakers scheduled in this room, for example room size, special directions, available adaptors for video input …" ), ) capacity = models.PositiveIntegerField( null=True, blank=True, verbose_name=_("Capacity"), help_text=_("How many people can fit in the room?"), ) position = models.PositiveIntegerField( null=True, blank=True, verbose_name=_("Position"), help_text= _("This is the order that rooms are displayed in in the schedule (lower = left)." ), ) objects = ScopedManager(event="event") class Meta: ordering = ("position", ) class urls(EventUrls): settings_base = edit = "{self.event.orga_urls.room_settings}{self.pk}/" delete = "{settings_base}delete" up = "{settings_base}up" down = "{settings_base}down" def __str__(self) -> str: return str(self.name)
class Room(LogMixin, models.Model): """A Room is an actual place where talks will be scheduled. The Room object stores some meta information. Most, like capacity, are not in use right now. """ event = models.ForeignKey(to='event.Event', on_delete=models.PROTECT, related_name='rooms') name = I18nCharField(max_length=100, verbose_name=_('Name')) description = I18nCharField( max_length=1000, null=True, blank=True, verbose_name=_('Description'), help_text=_('A description for attendees, for example directions.'), ) speaker_info = I18nCharField( max_length=1000, null=True, blank=True, verbose_name=_('Speaker Information'), help_text= _('Information relevant for speakers scheduled in this room, for example room size, special directions, available adapters for video input …' ), ) capacity = models.PositiveIntegerField( null=True, blank=True, verbose_name=_('Capacity'), help_text=_('How many people can fit in the room?'), ) position = models.PositiveIntegerField( null=True, blank=True, verbose_name=_('Position'), help_text= _('This is the order that rooms are displayed in in the schedule (lower = left).' ), ) class Meta: ordering = ('position', ) class urls(EventUrls): settings_base = edit = '{self.event.orga_urls.room_settings}{self.pk}/' delete = '{settings_base}delete' up = '{settings_base}up' down = '{settings_base}down' def __str__(self) -> str: return str(self.name)
class Room(LogMixin, models.Model): event = models.ForeignKey( to='event.Event', on_delete=models.PROTECT, related_name='rooms', ) name = I18nCharField( max_length=100, verbose_name=_('Name'), ) description = I18nCharField( max_length=1000, null=True, blank=True, verbose_name=_('Description'), help_text=_('A description for attendees, for example directions.'), ) speaker_info = I18nCharField( max_length=1000, null=True, blank=True, verbose_name=_('Speaker Information'), help_text= _('Information relevant for speakers scheduled in this room, for example room size, special directions, available adapters for video input …' ), ) capacity = models.PositiveIntegerField( null=True, blank=True, verbose_name=_('Capacity'), help_text=_('How many people can fit in the room?')) position = models.PositiveIntegerField( null=True, blank=True, verbose_name=_('Position'), help_text= _('This is the order that rooms are displayed in in the schedule (lower = left).' ), ) class Meta: ordering = ('position', ) class urls(EventUrls): settings_base = edit = '{self.event.orga_urls.room_settings}/{self.pk}' delete = '{settings_base}/delete' def __str__(self) -> str: return f'Room(event={self.event.slug}, name={self.name})'
class SpeakerInformation(LogMixin, FileCleanupMixin, models.Model): """Represents any information organisers want to show all or some submitters or speakers.""" event = models.ForeignKey( to="event.Event", related_name="information", on_delete=models.CASCADE ) include_submitters = models.BooleanField( verbose_name=_("Include all submitters"), help_text=_("Show to every submitter regardless of their proposals' status"), default=False, ) exclude_unconfirmed = models.BooleanField( verbose_name=_("Exclude unconfirmed speakers"), help_text=_("Show to speakers only once they have confirmed attendance"), default=False, ) title = I18nCharField(verbose_name=_("Subject"), max_length=200) text = I18nTextField(verbose_name=_("Text"), help_text=phrases.base.use_markdown) resource = models.FileField( verbose_name=_("file"), null=True, blank=True, help_text=_("Please try to keep your upload small, preferably below 16 MB."), upload_to=resource_path, ) objects = ScopedManager(event="event") class orga_urls(EventUrls): base = edit = "{self.event.orga_urls.information}{self.pk}/" delete = "{base}delete"
class CfP(LogMixin, models.Model): event = models.OneToOneField( to='event.Event', on_delete=models.PROTECT, ) headline = I18nCharField( max_length=300, null=True, blank=True, ) text = I18nTextField(null=True, blank=True) default_type = models.ForeignKey( to='submission.SubmissionType', on_delete=models.PROTECT, related_name='+', ) deadline = models.DateTimeField(null=True, blank=True) class urls(Urls): base = '{self.event.orga_urls.cfp}' questions = '{base}/questions' new_question = '{questions}/new' text = '{base}/text' edit_text = '{text}/edit' types = '{base}/types' new_type = '{types}/new' @property def is_open(self): if self.deadline is not None: return now() <= self.deadline return True def __str__(self) -> str: return str(self.headline)
class ReviewScoreCategory(models.Model): event = models.ForeignKey( to="event.Event", related_name="score_categories", on_delete=models.CASCADE ) name = I18nCharField() weight = models.DecimalField(max_digits=4, decimal_places=1, default=1) required = models.BooleanField(default=False) active = models.BooleanField(default=True) limit_tracks = models.ManyToManyField( to="submission.Track", verbose_name=_("Limit to tracks"), blank=True, help_text=_("Leave empty to use this category for all tracks."), ) objects = ScopedManager(event="event") class urls(EventUrls): base = "{self.event.orga_urls.review_settings}category/{self.pk}/" delete = "{base}delete" @classmethod def recalculate_scores(cls, event): for review in event.reviews.all(): review.save(update_score=True)
class Question(LogMixin, models.Model): event = models.ForeignKey( to='event.Event', on_delete=models.PROTECT, related_name='questions', ) variant = models.CharField( max_length=QuestionVariant.get_max_length(), choices=QuestionVariant.get_choices(), default=QuestionVariant.STRING, ) question = I18nCharField(max_length=200, ) default_answer = models.TextField( null=True, blank=True, ) required = models.BooleanField(default=False, ) position = models.IntegerField(default=0, ) class urls(Urls): base = '{self.event.cfp.urls.questions}/{self.pk}' edit = '{base}/edit' delete = '{base}/delete' def __str__(self): return str(self.question) class Meta: ordering = ['position']
class QuestionOption(models.Model): question = models.ForeignKey('Question', related_name='options', on_delete=models.CASCADE) identifier = models.CharField(max_length=190) answer = I18nCharField(verbose_name=_('Answer')) position = models.IntegerField(default=0) def __str__(self): return str(self.answer) def save(self, *args, **kwargs): if not self.identifier: charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') while True: code = get_random_string(length=8, allowed_chars=charset) if not QuestionOption.objects.filter(question__event=self.question.event, identifier=code).exists(): self.identifier = code break super().save(*args, **kwargs) @staticmethod def clean_identifier(event, code, instance=None, known=[]): qs = QuestionOption.objects.filter(question__event=event, identifier=code) if instance: qs = qs.exclude(pk=instance.pk) if qs.exists() or code in known: raise ValidationError(_('The identifier "{}" is already used for a different option.').format(code)) class Meta: verbose_name = _("Question option") verbose_name_plural = _("Question options") ordering = ('position', 'id')
class Page(models.Model): # TODO: find the table for the foreign key event = models.ForeignKey(Event, on_delete=models.CASCADE) slug = models.CharField( max_length=150, db_index=True, verbose_name=_('URL to static page'), validators=[ RegexValidator( regex="^[a-zA-Z0-9.-]+$", message= _("The slug may only contain letters, numbers, dots and dashes." )), ], help_text=_( "This will be used to generate the URL of the page. Please only use latin letters, " "numbers, dots and dashes. You cannot change this afterwards.")) position = models.IntegerField(default=0) title = I18nCharField(verbose_name=_('Page title')) text = I18nTextField(verbose_name=_('Page content')) link_on_frontpage = models.BooleanField( default=False, verbose_name=_('Show link on the event start page')) link_in_footer = models.BooleanField( default=False, verbose_name=_('Show link in the event footer')) require_confirmation = models.BooleanField( default=False, verbose_name=_('Require the user to acknowledge this page before the ' 'user action (e.g. for code of conduct).')) class Meta: ordering = ['position', 'title']
class Track(LogMixin, models.Model): """A track groups :class:`~pretalx.submission.models.submission.Submission` objects within an :class:`~pretalx.event.models.event.Event`, e.g. by topic. """ event = models.ForeignKey(to='event.Event', on_delete=models.PROTECT, related_name='tracks') name = I18nCharField( max_length=200, verbose_name=_('Name'), ) color = models.CharField( max_length=7, verbose_name=_('Color'), validators=[ RegexValidator(r'#([0-9A-Fa-f]{3}){1,2}'), ], ) class urls(EventUrls): base = edit = '{self.event.cfp.urls.tracks}{self.pk}/' delete = '{base}delete' prefilled_cfp = '{self.event.cfp.urls.public}?track={self.slug}' def __str__(self) -> str: return str(self.name) @property def slug(self) -> str: """The slug makes tracks more readable in URLs. It consists of the ID, followed by a slugified (and, in lookups, optional) form of the track name.""" return f'{self.id}-{slugify(self.name)}'
class FAQ(models.Model): category = models.ForeignKey( to=FAQCategory, on_delete=models.CASCADE, related_name='questions', verbose_name=_('Category'), ) question = I18nCharField(verbose_name=_('Question')) answer = I18nTextField(verbose_name=_('Answer')) tags = models.CharField( null=True, blank=True, max_length=180, verbose_name=_('Tags'), help_text=( 'Tags can help people find related questions. Please enter the tags separated by commas.' ), ) position = models.PositiveIntegerField(verbose_name=_('Position')) objects = ScopedManager(event='category__event') def __str__(self): return str(self.question) class Meta: ordering = ('category__position', 'position', 'id')
class SubmissionType(LogMixin, models.Model): event = models.ForeignKey( to='event.Event', related_name='submission_types', on_delete=models.CASCADE, ) name = I18nCharField(max_length=100, ) default_duration = models.PositiveIntegerField( default=30, help_text='Default duration in minutes', ) max_duration = models.PositiveIntegerField( default=60, help_text='Maximum duration in minutes', ) class urls(Urls): base = '{self.event.cfp.urls.types}/{self.pk}' default = '{base}/default' edit = '{base}/edit' delete = '{base}/delete' def __str__(self) -> str: return _('{name} ({duration} minutes)').format( name=self.name, duration=self.default_duration, )
class Page(LogMixin, models.Model): event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="pages") slug = models.CharField( max_length=150, db_index=True, verbose_name=_("URL to static page"), validators=[ RegexValidator( regex="^[a-zA-Z0-9.-]+$", message=_( "The slug may only contain letters, numbers, dots and dashes." ), ) ], help_text=_( "This will be used to generate the URL of the page. Please only use latin letters, " "numbers, dots and dashes. You cannot change this afterwards." ), ) position = models.IntegerField(default=0) title = I18nCharField(verbose_name=_("Page title")) text = I18nTextField( verbose_name=_("Page content"), help_text=phrases.base.use_markdown ) link_in_footer = models.BooleanField( default=False, verbose_name=_("Show link in the event footer") ) class Meta: ordering = ["position", "title"]
class Book(models.Model): title = I18nCharField(verbose_name='Book title', max_length=190) abstract = I18nTextField(verbose_name='Abstract') author = models.ForeignKey(Author, verbose_name='Author') def __str__(self): return str(self.title)
class SpeakerInformation(LogMixin, models.Model): event = models.ForeignKey( to='event.Event', related_name='information', on_delete=models.CASCADE ) include_submitters = models.BooleanField( verbose_name=_('Include all submitters'), help_text=_('Show to every submitter regardless of their submissions\' status'), default=False, ) exclude_unconfirmed = models.BooleanField( verbose_name=_('Exclude unconfirmed speakers'), help_text=_('Show to speakers only once they have confirmed attendance'), default=False, ) title = I18nCharField(verbose_name=_('Subject'), max_length=200) text = I18nTextField(verbose_name=_('Text'), help_text=phrases.base.use_markdown) resource = models.FileField( verbose_name=_('file'), null=True, blank=True, help_text=_('Please try to keep your upload small, preferably below 16 MB.'), ) class orga_urls(EventUrls): base = edit = '{self.event.orga_urls.information}/{self.pk}' delete = '{base}/delete/'
class Organiser(LogMixin, models.Model): """The Organiser model represents the entity responsible for one or several events.""" name = I18nCharField( max_length=190, verbose_name=_('Name'), ) slug = models.SlugField( max_length=50, db_index=True, unique=True, validators=[ RegexValidator( regex=f"^[{SLUG_CHARS}]+$", message= _('The slug may only contain letters, numbers, dots and dashes.' ), ), ], verbose_name=_('Short form'), help_text= _('Should be short, only contain lowercase letters and numbers, and must be unique, as it is used in URLs.' ), ) def __str__(self) -> str: """Used in generated forms.""" return str(self.name) class orga_urls(EventUrls): base = '/orga/organiser/{self.slug}' teams = '{base}/teams' new_team = '{teams}/new'
class ItemCategory(LoggedModel): """ Items can be sorted into these categories. :param event: The event this category belongs to :type event: Event :param name: The name of this category :type name: str :param position: An integer, used for sorting :type position: int """ event = models.ForeignKey( Event, on_delete=models.CASCADE, related_name='categories', ) name = I18nCharField( max_length=255, verbose_name=_("Category name"), ) description = I18nTextField( blank=True, verbose_name=_("Category description") ) position = models.IntegerField( default=0 ) is_addon = models.BooleanField( default=False, verbose_name=_('Products in this category are add-on products'), help_text=_('If selected, the products belonging to this category are not for sale on their own. They can ' 'only be bought in combination with a product that has this category configured as a possible ' 'source for add-ons.') ) class Meta: verbose_name = _("Product category") verbose_name_plural = _("Product categories") ordering = ('position', 'id') def __str__(self): if self.is_addon: return _('{category} (Add-On products)').format(category=str(self.name)) return str(self.name) def delete(self, *args, **kwargs): super().delete(*args, **kwargs) if self.event: self.event.get_cache().clear() def save(self, *args, **kwargs): super().save(*args, **kwargs) if self.event: self.event.get_cache().clear() @property def sortkey(self): return self.position, self.id def __lt__(self, other) -> bool: return self.sortkey < other.sortkey
class MembershipType(LoggedModel): id = models.BigAutoField(primary_key=True) organizer = models.ForeignKey(Organizer, related_name='membership_types', on_delete=models.CASCADE) name = I18nCharField( verbose_name=_('Name'), ) transferable = models.BooleanField( verbose_name=_('Membership is transferable'), help_text=_('If this is selected, the membership can be used to purchase tickets for multiple persons. If not, ' 'the attendee name always needs to stay the same.'), default=False ) allow_parallel_usage = models.BooleanField( verbose_name=_('Parallel usage is allowed'), help_text=_('If this is selected, the membership can be used to purchase tickets for events happening at the same time. Note ' 'that this will only check for an identical start time of the events, not for any overlap between events.'), default=False ) max_usages = models.PositiveIntegerField( verbose_name=_("Maximum usages"), help_text=_("Number of times this membership can be used in a purchase."), null=True, blank=True, ) class Meta: ordering = ('id',) def __str__(self): return str(self.name) def allow_delete(self): return not self.memberships.exists() and not self.granted_by.exists()
class CfP(LogMixin, models.Model): event = models.OneToOneField( to='event.Event', on_delete=models.PROTECT, ) headline = I18nCharField( max_length=300, null=True, blank=True, ) text = I18nTextField(null=True, blank=True) default_type = models.ForeignKey( to='submission.SubmissionType', on_delete=models.PROTECT, related_name='+', ) deadline = models.DateTimeField(null=True, blank=True) @property def is_open(self): if self.deadline is not None: return now() <= self.deadline return True def __str__(self) -> str: return str(self.headline)
class CfP(LogMixin, models.Model): event = models.OneToOneField( to='event.Event', on_delete=models.PROTECT, ) headline = I18nCharField( max_length=300, null=True, blank=True, verbose_name=_('headline'), ) text = I18nTextField( null=True, blank=True, verbose_name=_('text'), help_text=_('You can use markdown here.'), ) default_type = models.ForeignKey( to='submission.SubmissionType', on_delete=models.PROTECT, related_name='+', verbose_name=_('Default submission type'), ) deadline = models.DateTimeField( null=True, blank=True, verbose_name=_('deadline'), help_text= _('Please put in the last date you want to accept submissions from users.' ), ) class urls(EventUrls): base = '{self.event.orga_urls.cfp}' questions = '{base}/questions' new_question = '{questions}/new' remind_questions = '{questions}/remind' text = edit_text = '{base}/text' types = '{base}/types' new_type = '{types}/new' public = '{self.event.urls.base}/cfp' def __str__(self) -> str: return f'CfP(event={self.event.slug})' @cached_property def is_open(self): if self.deadline is None: return True return self.max_deadline >= now() @cached_property def max_deadline(self): deadlines = list( self.event.submission_types.filter( deadline__isnull=False).values_list('deadline', flat=True)) if self.deadline: deadlines += [self.deadline] return max(deadlines)
class MailTemplate(Auditable, models.Model): subject = I18nCharField( max_length=200, verbose_name=_('Subject'), ) text = I18nTextField( verbose_name=_('Text'), ) reply_to = models.EmailField( max_length=200, blank=True, null=True, verbose_name=_('Reply-To'), help_text=_('Change the Reply-To address if you do not want to use the default orga address'), ) bcc = models.CharField( max_length=1000, blank=True, null=True, verbose_name=_('BCC'), help_text=_('Enter comma separated addresses. Will receive a blind copy of every mail sent from this template. This may be a LOT!'), ) def __str__(self): return '{self.subject}'.format(self=self) def to_mail(self, email, locale=None, context=None, skip_queue=False, attachments=None, save=True): from byro.common.models import Configuration config = Configuration.get_solo() locale = locale or config.language with override(locale): context = context or dict() try: subject = str(self.subject).format(**context) text = str(self.text).format(**context) except KeyError as e: raise SendMailException('Experienced KeyError when rendering Text: {e}'.format(e=e)) mail = EMail( to=email, reply_to=self.reply_to, bcc=self.bcc, subject=subject, text=text, template=self, ) if save: mail.save() if attachments: for a in attachments: mail.attachments.add(a) if skip_queue: mail.send() return mail def get_absolute_url(self): return reverse('office:mails.templates.view', kwargs={'pk': self.pk}) def get_object_icon(self): return mark_safe('<i class="fa fa-envelope-o"></i> ')
class CfP(LogMixin, models.Model): event = models.OneToOneField(to='event.Event', on_delete=models.PROTECT) headline = I18nCharField(max_length=300, null=True, blank=True, verbose_name=_('headline')) text = I18nTextField( null=True, blank=True, verbose_name=_('text'), help_text=phrases.base.use_markdown, ) default_type = models.ForeignKey( to='submission.SubmissionType', on_delete=models.PROTECT, related_name='+', verbose_name=_('Default submission type'), ) deadline = models.DateTimeField( null=True, blank=True, verbose_name=_('deadline'), help_text= _('Please put in the last date you want to accept submissions from users.' ), ) class urls(EventUrls): base = '{self.event.orga_urls.cfp}' questions = '{base}questions/' new_question = '{questions}new' remind_questions = '{questions}remind' text = edit_text = '{base}text' types = '{base}types/' new_type = '{types}new' tracks = '{base}tracks/' new_track = '{tracks}new' public = '{self.event.urls.base}cfp' submit = '{self.event.urls.base}submit/' def __str__(self) -> str: """Help with debugging.""" return f'CfP(event={self.event.slug})' @cached_property def is_open(self): if self.deadline is None: return True return self.max_deadline >= now() if self.max_deadline else True @cached_property def max_deadline(self): deadlines = list( self.event.submission_types.filter( deadline__isnull=False).values_list('deadline', flat=True)) if self.deadline: deadlines.append(self.deadline) return max(deadlines) if deadlines else None
class RadioOption(django_models.Model): name = I18nCharField(max_length=60) related_filter = django_models.ForeignKey(RadioFilter, related_name='options', on_delete=django_models.CASCADE) def __str__(self): return f'{self.name} option id: {self.id}'
class EmailTemplate(models.Model): title = I18nCharField(max_length=255) subject = I18nCharField(max_length=255) plain_text = I18nTextField() html_body = I18nRichTextField(blank=True) panels = [ FieldPanel('title', heading=_('Title')), FieldPanel('subject', heading=_('Subject')), FieldPanel('plain_text', heading=_('Plain Text Body')), FieldPanel('html_body', heading=_('HTML Body')), ] def __str__(self): return str(self.title) class Meta: verbose_name = _('Email Template') verbose_name_plural = _('Email Templates')
class Track(LogMixin, models.Model): event = models.ForeignKey( to='event.Event', on_delete=models.PROTECT, related_name='tracks', ) name = I18nCharField(max_length=200, ) color = models.CharField(max_length=7, ) def __str__(self) -> str: return str(self.name)
class MailTemplate(LogMixin, models.Model): event = models.ForeignKey( to='event.Event', on_delete=models.PROTECT, related_name='mail_templates', ) subject = I18nCharField(max_length=200) text = I18nTextField() reply_to = models.EmailField( max_length=200, blank=True, null=True, verbose_name=_('Reply-To'), help_text= _('Change the Reply-To address if you do not want to use the default orga address' ), ) bcc = models.CharField( max_length=1000, blank=True, null=True, verbose_name=_('BCC'), help_text= _('Enter comma separated addresses. Will receive a blind copy of every mail sent from this template. This may be a LOT!' )) class urls(Urls): base = '{self.event.orga_urls.mail_templates}/{self.pk}' edit = '{base}/edit' delete = '{base}/delete' def bulk_mail(self): # TODO: call to_mail pass def to_mail(self, user, event, locale=None, context=None, skip_queue=False): with override(locale): context = TolerantDict(context or dict()) mail = QueuedMail(event=self.event, to=user.email, reply_to=self.reply_to or event.email, bcc=self.bcc, subject=str(self.subject).format(**context), text=str(self.text).format(**context)) if skip_queue: mail.send() else: mail.save() return mail
class Filter(PolymorphicModel): name = I18nCharField(max_length=60) order = django_models.PositiveIntegerField(default=0) objects = PolymorphicManager.from_queryset(FilterQuerySet)() def __str__(self): return f'{self.name} filter' class Meta: ordering = ['order']
class Track(LogMixin, models.Model): event = models.ForeignKey( to='event.Event', on_delete=models.PROTECT, related_name='tracks', ) name = I18nCharField(max_length=200, ) color = models.CharField(max_length=7, ) def __str__(self) -> str: """Help when debugging.""" return f'Track(event={self.event.slug}, name={self.name})'
class QuestionOption(models.Model): question = models.ForeignKey('Question', related_name='options') answer = I18nCharField(verbose_name=_('Answer')) position = models.IntegerField(default=0) def __str__(self): return str(self.answer) class Meta: verbose_name = _("Question option") verbose_name_plural = _("Question options") ordering = ('position', 'id')
class Track(LogMixin, models.Model): """A track groups :class:`~pretalx.submission.models.submission.Submission` objects within an :class:`~pretalx.event.models.event.Event`, e.g. by topic. :param color: The track colour, in the format #012345. """ event = models.ForeignKey( to="event.Event", on_delete=models.PROTECT, related_name="tracks" ) name = I18nCharField( max_length=200, verbose_name=_("Name"), ) description = I18nTextField( verbose_name=_("Description"), blank=True, ) color = models.CharField( max_length=7, verbose_name=_("Color"), validators=[ RegexValidator(r"#([0-9A-Fa-f]{3}){1,2}"), ], ) requires_access_code = models.BooleanField( verbose_name=_("Requires access code"), help_text=_( "This track will only be shown to submitters with a matching access code." ), default=False, ) objects = ScopedManager(event="event") class urls(EventUrls): base = edit = "{self.event.cfp.urls.tracks}{self.pk}/" delete = "{base}delete" prefilled_cfp = "{self.event.cfp.urls.public}?track={self.slug}" def __str__(self) -> str: return str(self.name) @property def slug(self) -> str: """The slug makes tracks more readable in URLs. It consists of the ID, followed by a slugified (and, in lookups, optional) form of the track name. """ return f"{self.id}-{slugify(self.name)}"