Source code for pretalx.submission.models.submission

# SPDX-FileCopyrightText: 2017-present Tobias Kunze
# SPDX-License-Identifier: AGPL-3.0-only WITH LicenseRef-Pretalx-AGPL-3.0-Terms
#
# This file contains Apache-2.0 licensed contributions copyrighted by the following contributors:
# SPDX-FileContributor: Florian Moesch
# SPDX-FileContributor: Raphael Michel
# SPDX-FileContributor: luto

import copy
import datetime as dt
import json
import statistics
from enum import nonmember
from itertools import repeat

from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Prefetch, Q
from django.db.models.fields.files import FieldFile
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override, pgettext_lazy
from django_scopes import ScopedManager

from pretalx.agenda.rules import (
    event_uses_feedback,
    is_agenda_submission_visible,
    is_agenda_visible,
    is_submission_visible_via_schedule,
)
from pretalx.common.exceptions import SubmissionError
from pretalx.common.models.fields import MarkdownField
from pretalx.common.models.mixins import GenerateCode, PretalxModel
from pretalx.common.text.path import hashed_path
from pretalx.common.text.phrases import phrases
from pretalx.common.text.serialize import serialize_duration
from pretalx.common.urls import EventUrls
from pretalx.mail.models import get_prefixed_subject
from pretalx.person.rules import is_reviewer
from pretalx.submission.rules import (
    are_featured_submissions_visible,
    can_be_confirmed,
    can_be_edited,
    can_be_removed,
    can_be_reviewed,
    can_be_withdrawn,
    can_request_speakers,
    can_view_reviews,
    has_reviewer_access,
    is_feedback_ready,
    is_speaker,
    orga_can_change_submissions,
    orga_or_reviewer_can_change_submission,
)
from pretalx.submission.signals import (
    before_submission_state_change,
    submission_state_change,
)


def generate_invite_code(length=32):
    return get_random_string(length=length, allowed_chars=Submission.code_charset)


def submission_image_path(instance, filename):
    return hashed_path(
        filename,
        target_name="image",
        upload_dir=f"{instance.event.slug}/submissions/{instance.code}/",
    )


class SubmissionStates(models.TextChoices):
    SUBMITTED = "submitted", pgettext_lazy("proposal status", "submitted")
    ACCEPTED = "accepted", _("accepted")
    CONFIRMED = "confirmed", _("confirmed")
    REJECTED = "rejected", _("rejected")
    CANCELED = "canceled", _("canceled")
    WITHDRAWN = "withdrawn", _("withdrawn")
    DRAFT = "draft", _("draft")

    method_names = nonmember(
        {
            "submitted": "make_submitted",
            "rejected": "reject",
            "accepted": "accept",
            "confirmed": "confirm",
            "canceled": "cancel",
            "withdrawn": "withdraw",
        }
    )

    accepted_states = nonmember(("accepted", "confirmed"))

    @classmethod
    def get_max_length(cls):
        return max(len(val) for val in cls.values)

    @staticmethod
    def get_color(state):
        return {
            "submitted": "--color-info",
            "accepted": "--color-success",
            "confirmed": "--color-success",
            "rejected": "--color-danger",
        }.get(state, "--color-grey")


class SubmissionQuerySet(models.QuerySet):
    def with_sorted_speakers(self):
        return self.prefetch_related(sorted_speakers_prefetch())


class SubmissionManager(models.Manager.from_queryset(SubmissionQuerySet)):
    def get_queryset(self):
        return super().get_queryset().exclude(state=SubmissionStates.DRAFT)


class AllSubmissionManager(models.Manager.from_queryset(SubmissionQuerySet)):
    pass


class SpeakerRole(models.Model):
    """Through model connecting speaker and submission."""

    submission = models.ForeignKey(
        to="submission.Submission",
        on_delete=models.CASCADE,
        related_name="speaker_roles",
    )
    speaker = models.ForeignKey(
        to="person.SpeakerProfile",
        on_delete=models.CASCADE,
        related_name="speaker_roles",
    )
    position = models.PositiveIntegerField(default=0)

    objects = ScopedManager(event="submission__event")

    class Meta:
        ordering = ("position",)
        unique_together = (("submission", "speaker"),)

    def __str__(self):
        return f"SpeakerRole(submission={self.submission.code}, speaker={self.speaker})"


def sorted_speakers_prefetch(prefix=""):
    """Prefetch for speakers ordered by their speaking position.

    Use prefix="submission__" when prefetching from slot querysets.
    """
    from pretalx.person.models import SpeakerProfile  # noqa: PLC0415

    lookup = f"{prefix}speakers" if prefix else "speakers"
    return Prefetch(
        lookup,
        queryset=SpeakerProfile.objects.select_related(
            "profile_picture", "event", "user"
        ).order_by("speaker_roles__position"),
    )


[docs] class Submission(GenerateCode, PretalxModel): """Submissions are, next to :class:`~pretalx.event.models.event.Event`, the central model in pretalx. A submission, which belongs to exactly one event, can have multiple speakers and a lot of other related data, such as a :class:`~pretalx.submission.models.type.SubmissionType`, a :class:`~pretalx.submission.models.track.Track`, multiple :class:`~pretalx.submission.models.question.Answer` objects, and so on. :param code: The unique alphanumeric identifier used to refer to a submission. :param state: The submission can be 'submitted', 'accepted', 'confirmed', 'rejected', 'withdrawn', or 'canceled'. State changes should be done via the corresponding methods, like ``accept()``. The ``SubmissionStates`` class comes with a ``method_names`` dictionary for method lookup. :param image: An image illustrating the talk or topic. :param review_code: A token used in secret URLs giving read-access to the submission. """ code = models.CharField(max_length=16, unique=True) speakers = models.ManyToManyField( to="person.SpeakerProfile", related_name="submissions", through=SpeakerRole, blank=True, verbose_name=_("Speakers"), ) event = models.ForeignKey( to="event.Event", on_delete=models.PROTECT, related_name="submissions" ) title = models.CharField(max_length=200, verbose_name=_("Proposal title")) submission_type = models.ForeignKey( # Reasonable default must be set in form/view to="submission.SubmissionType", related_name="submissions", on_delete=models.PROTECT, verbose_name=_("Session type"), ) track = models.ForeignKey( to="submission.Track", related_name="submissions", on_delete=models.PROTECT, verbose_name=_("Track"), null=True, blank=True, ) tags = models.ManyToManyField( to="submission.Tag", related_name="submissions", verbose_name=_("Tags") ) state = models.CharField( max_length=SubmissionStates.get_max_length(), choices=SubmissionStates.choices, default=SubmissionStates.SUBMITTED, verbose_name=_("Proposal state"), ) pending_state = models.CharField( null=True, blank=True, max_length=SubmissionStates.get_max_length(), choices=SubmissionStates.choices, default=None, verbose_name=_("Pending proposal state"), ) abstract = MarkdownField(null=True, blank=True, verbose_name=_("Abstract")) description = MarkdownField(null=True, blank=True, verbose_name=_("Description")) notes = MarkdownField( null=True, blank=True, verbose_name=_("Notes"), help_text=_( "These notes are meant for the organiser and won’t be made public." ), ) internal_notes = models.TextField( null=True, blank=True, verbose_name=_("Internal notes"), help_text=_( "Internal notes for other organisers/reviewers. Not visible to the speakers or the public." ), ) duration = models.PositiveIntegerField( null=True, blank=True, verbose_name=_("Duration"), help_text=_("The duration in minutes."), ) slot_count = models.IntegerField( default=1, verbose_name=_("Slot Count"), help_text=_("How many times this session will take place."), validators=[MinValueValidator(1)], ) content_locale = models.CharField( max_length=32, default=settings.LANGUAGE_CODE, verbose_name=_("Language") ) is_featured = models.BooleanField( default=False, verbose_name=_("Show this session in public list of featured sessions."), ) do_not_record = models.BooleanField( default=False, verbose_name=_("Don’t record this session.") ) image = models.ImageField( null=True, blank=True, upload_to=submission_image_path, verbose_name=_("Session image"), help_text=_("Use this if you want an illustration to go with your proposal."), ) invitation_token = models.CharField(max_length=32, default=generate_invite_code) access_code = models.ForeignKey( to="submission.SubmitterAccessCode", related_name="submissions", on_delete=models.PROTECT, null=True, blank=True, ) review_code = models.CharField( max_length=32, unique=True, null=True, blank=True, default=generate_invite_code ) anonymised_data = models.TextField(null=True, blank=True, default="{}") assigned_reviewers = models.ManyToManyField( verbose_name=_("Assigned reviewers"), to="person.User", related_name="assigned_reviews", blank=True, ) objects = ScopedManager(event="event", _manager_class=SubmissionManager) all_objects = ScopedManager(event="event", _manager_class=AllSubmissionManager) log_prefix = "pretalx.submission" @property def log_parent(self): return self.event class Meta: rules_permissions = { "list": is_agenda_visible | orga_can_change_submissions | is_reviewer, "list_featured": are_featured_submissions_visible | orga_can_change_submissions, "view": is_agenda_submission_visible | is_speaker | orga_can_change_submissions | has_reviewer_access, "view_public": is_agenda_submission_visible | orga_can_change_submissions, "orga_list": orga_can_change_submissions | is_reviewer, "orga_update": orga_can_change_submissions, "review": has_reviewer_access & can_be_reviewed, "view_reviews": has_reviewer_access | orga_can_change_submissions, "view_all_reviews": (has_reviewer_access & can_view_reviews) | orga_can_change_submissions, "create": orga_can_change_submissions, "update": (can_be_edited & is_speaker) | orga_can_change_submissions, "delete": orga_can_change_submissions, "state_change": orga_or_reviewer_can_change_submission, "accept_or_reject": orga_or_reviewer_can_change_submission, "withdraw": can_be_withdrawn & is_speaker, "confirm": can_be_confirmed & (is_speaker | orga_can_change_submissions), "remove": can_be_removed & orga_can_change_submissions, "view_feedback_page": event_uses_feedback & is_agenda_submission_visible, "view_scheduling_details": is_submission_visible_via_schedule, "view_feedback": is_speaker | has_reviewer_access | orga_can_change_submissions, "give_feedback": is_agenda_submission_visible & is_feedback_ready, "is_speaker": is_speaker, "add_speaker": can_be_edited & can_request_speakers, } class urls(EventUrls): user_base = "{self.event.urls.user_submissions}{self.code}/" withdraw = "{user_base}withdraw" discard = "{user_base}discard" confirm = "{user_base}confirm" public_base = "{self.event.urls.base}talk/{self.code}" public = "{public_base}/" feedback = "{public}feedback/" social_image = "{public}og-image" ical = "{public_base}.ics" image = "{self.image_url}" invite = "{user_base}invite" retract_invitation = "{user_base}retract-invitation" accept_invitation = ( "{self.event.urls.base}invitation/{self.code}/{self.invitation_token}" ) review = "{self.event.urls.base}talk/review/{self.review_code}" class orga_urls(EventUrls): base = edit = "{self.event.orga_urls.submissions}{self.code}/" make_submitted = "{base}submit" accept = "{base}accept" reject = "{base}reject" confirm = "{base}confirm" delete = "{base}delete" withdraw = "{base}withdraw" cancel = "{base}cancel" speakers = "{base}speakers/" delete_speaker = "{speakers}delete" reorder_speakers = "{speakers}reorder" retract_invitation = "{speakers}invitation/retract" reviews = "{base}reviews/" feedback = "{base}feedback/" toggle_featured = "{base}toggle_featured" apply_pending = "{base}apply_pending" anonymise = "{base}anonymise/" comments = "{base}comments/" quick_schedule = "{self.event.orga_urls.schedule}quick/{self.code}/" history = "{base}history/" @property def image_url(self): return self.image.url if self.image else "" @cached_property def editable(self) -> bool: """ Checks if the speaker is currently allowed to edit the submission. """ try: event = self.event except ObjectDoesNotExist: # Unsaved submissions can always be edited return True deadline = self.submission_type.deadline or event.cfp.deadline deadline_open = (not deadline) or now() <= deadline if self.state == SubmissionStates.DRAFT: # We have to check if we comply with the standard submission requirements if # we are in a draft state, as drafts should only be editable when they could # also be submitted. # For existing drafts with access codes, we ignore the redemption count # since the code was already redeemed when creating the draft. access_code = ( self.access_code if (self.access_code and self.access_code.time_valid) else None ) if (self.track and self.track.requires_access_code) and not access_code: return False if self.submission_type.requires_access_code and not access_code: return False # We are not missing an access code, so we can just check if we hit the # deadline or can ignore it safely return bool(deadline_open or access_code) if not event.get_feature_flag("speakers_can_edit_submissions"): return False if self.state == SubmissionStates.SUBMITTED: return deadline_open or ( event.active_review_phase and event.active_review_phase.speakers_can_change_submissions ) return self.state in SubmissionStates.accepted_states @property def anonymised(self): try: result = json.loads(self.anonymised_data) except (ValueError, TypeError): result = None if not result or not isinstance(result, dict): return {} return result @property def is_anonymised(self) -> bool: """ Has this submission been anonymised by the organisers? """ if self.anonymised: return bool(self.anonymised.get("_anonymised", False)) return False @cached_property def reviewer_answers(self): return self.answers.filter(question__is_visible_to_reviewers=True).order_by( "question__position" ) @cached_property def public_answers(self): from pretalx.submission.models.question import QuestionTarget # noqa: PLC0415 qs = ( self.answers.filter( Q(question__submission_types__in=[self.submission_type]) | Q(question__submission_types__isnull=True), question__is_public=True, question__event=self.event, question__target=QuestionTarget.SUBMISSION, ) .select_related("question") .order_by("question__position") ) if self.track: qs = qs.filter( Q(question__tracks__in=[self.track]) | Q(question__tracks__isnull=True) ) return qs
[docs] def get_duration(self) -> int: """Returns this submission's duration in minutes. Falls back to the :class:`~pretalx.submission.models.type.SubmissionType`'s default duration if none is set on the submission. """ if self.duration is None: return self.submission_type.default_duration return self.duration
[docs] def update_duration(self): """Apply the submission's duration to its currently scheduled. :class:`~pretalx.schedule.models.slot.TalkSlot`. Should be called whenever the duration changes. """ for slot in self.event.wip_schedule.talks.filter( submission=self, start__isnull=False ): slot.end = slot.start + dt.timedelta(minutes=self.get_duration()) slot.save()
update_duration.alters_data = True def save(self, *args, **kwargs): is_creating = not self.pk super().save(*args, **kwargs) if is_creating and self.state != SubmissionStates.DRAFT: submission_state_change.send_robust( self.event, submission=self, old_state=None, user=None ) def get_instance_data(self): data = super().get_instance_data() if self.pk: resources = list(self.resources.all()) or [] lines = [] for resource in resources: label = resource.description if resource.link and label: lines.append(f"- [{label}]({resource.link})") elif resource.link: lines.append(f"- {resource.link}") elif label: file_str = _("File") lines.append(f"- {file_str}: {label}") elif resource.filename: file_str = _("File") lines.append(f"- {file_str}: {resource.filename}") if lines: data["resources"] = "\n".join(lines) tags = list(self.tags.all().values_list("tag", flat=True)) or [] data["tags"] = "\n".join(f"- {tag}" for tag in tags) return data
[docs] def update_review_scores(self): """Apply the submission's calculated review scores. Should be called whenever the tracks of a submission change. """ for review in self.reviews.all(): review.save(update_score=True)
def set_state(self, new_state, person=None): """Set the submission's state and save.""" if self.state == new_state: self.pending_state = None self.save(update_fields=["state", "pending_state"]) self.update_talk_slots() return is_initial = new_state == SubmissionStates.SUBMITTED and self.state in ( None, SubmissionStates.DRAFT, ) if not is_initial: responses = before_submission_state_change.send_robust( self.event, submission=self, new_state=new_state, user=person ) exceptions = [r[1] for r in responses if isinstance(r[1], SubmissionError)] if exceptions: raise exceptions[0] old_state = self.state self.state = new_state self.pending_state = None if new_state in ( SubmissionStates.REJECTED, SubmissionStates.CANCELED, SubmissionStates.WITHDRAWN, ): self.is_featured = False self.save(update_fields=["state", "pending_state"]) self.update_talk_slots() submission_state_change.send_robust( self.event, submission=self, old_state=old_state if old_state != SubmissionStates.DRAFT else None, user=person, )
[docs] def update_talk_slots(self): """Makes sure the correct amount of. :class:`~pretalx.schedule.models.slot.TalkSlot` objects exists. After an update or state change, talk slots should either be all deleted, or all created, or the number of talk slots might need to be adjusted. """ from pretalx.schedule.models import TalkSlot # noqa: PLC0415 scheduling_allowed = ( self.state in SubmissionStates.accepted_states or self.pending_state in SubmissionStates.accepted_states ) if not scheduling_allowed: TalkSlot.objects.filter( submission=self, schedule=self.event.wip_schedule ).delete() return slot_count_current = TalkSlot.objects.filter( submission=self, schedule=self.event.wip_schedule ).count() diff = slot_count_current - self.slot_count if diff > 0: # We build a list of all IDs to delete as .delete() doesn't work on sliced querysets. # We delete unscheduled talks first. talks_to_delete = ( TalkSlot.objects.filter( submission=self, schedule=self.event.wip_schedule ) .order_by("start", "room", "is_visible")[:diff] .values_list("id", flat=True) ) TalkSlot.objects.filter(pk__in=list(talks_to_delete)).delete() elif diff < 0: for __ in repeat(None, abs(diff)): TalkSlot.objects.create( submission=self, schedule=self.event.wip_schedule ) TalkSlot.objects.filter( submission=self, schedule=self.event.wip_schedule ).update(is_visible=self.state == SubmissionStates.CONFIRMED)
update_talk_slots.alters_data = True def send_initial_mails(self, person): from pretalx.mail.models import MailTemplateRoles # noqa: PLC0415 template = self.event.get_mail_template(MailTemplateRoles.NEW_SUBMISSION) template_text = copy.deepcopy(template.text) locale = self.get_email_locale(person.locale) with override(locale): if "{full_submission_content}" not in str(template.text): template.text = ( str(template.text) + "\n\n\n***********\n\n" + str(_("Full proposal content:\n\n") + "{full_submission_content}") ) template.to_mail( user=person, event=self.event, context_kwargs={"user": person, "submission": self}, context={"full_submission_content": self.get_content_for_mail()}, skip_queue=True, commit=True, # Send immediately, but save a record locale=self.get_email_locale(person.locale), ) template.refresh_from_db() if template.text != template_text: template.text = template_text template.save() if self.event.mail_settings["mail_on_new_submission"]: self.event.get_mail_template( MailTemplateRoles.NEW_SUBMISSION_INTERNAL ).to_mail( user=self.event.email, event=self.event, context_kwargs={"user": person, "submission": self}, context={"orga_url": self.orga_urls.base.full()}, skip_queue=True, commit=False, # Send immediately, don't save a record locale=self.event.locale, )
[docs] def make_submitted( self, person=None, orga: bool = False, from_pending: bool = False ): """Sets the submission's state to 'submitted'.""" previous = self.state self.set_state(SubmissionStates.SUBMITTED, person=person) if previous != SubmissionStates.DRAFT: self.log_action( "pretalx.submission.make_submitted", person=person, orga=orga, data={"previous": previous, "from_pending": from_pending}, )
make_submitted.alters_data = True
[docs] def confirm(self, person=None, orga: bool = False, from_pending: bool = False): """Sets the submission's state to 'confirmed'.""" previous = self.state self.set_state(SubmissionStates.CONFIRMED, person=person) self.log_action( "pretalx.submission.confirm", person=person, orga=orga, data={"previous": previous, "from_pending": from_pending}, )
confirm.alters_data = True
[docs] def accept(self, person=None, orga: bool = True, from_pending: bool = False): """Sets the submission's state to 'accepted'. Creates an acceptance :class:`~pretalx.mail.models.QueuedMail` unless the submission was previously confirmed. """ previous = self.state self.set_state(SubmissionStates.ACCEPTED, person=person) self.log_action( "pretalx.submission.accept", person=person, orga=True, data={"previous": previous, "from_pending": from_pending}, ) if previous not in (SubmissionStates.ACCEPTED, SubmissionStates.CONFIRMED): self.send_state_mail()
accept.alters_data = True
[docs] def reject(self, person=None, orga: bool = True, from_pending: bool = False): """Sets the submission's state to 'rejected' and creates a rejection. :class:`~pretalx.mail.models.QueuedMail`. """ previous = self.state self.set_state(SubmissionStates.REJECTED, person=person) self.log_action( "pretalx.submission.reject", person=person, orga=True, data={"previous": previous, "from_pending": from_pending}, ) if previous != SubmissionStates.REJECTED: self.send_state_mail()
reject.alters_data = True def apply_pending_state(self, person=None): if not self.pending_state: return if self.pending_state == self.state: self.pending_state = None self.save() return getattr(self, SubmissionStates.method_names[self.pending_state])( person=person, from_pending=True ) apply_pending_state.alters_data = True def get_email_locale(self, fallback=None): if self.content_locale in self.event.locales: return self.content_locale if fallback and fallback in self.event.locales: return fallback return self.event.locale def get_content_locale_display(self): return str(dict(self.event.named_content_locales)[self.content_locale]) def send_state_mail(self): from pretalx.mail.models import MailTemplateRoles # noqa: PLC0415 if self.state == SubmissionStates.ACCEPTED: template = self.event.get_mail_template(MailTemplateRoles.SUBMISSION_ACCEPT) elif self.state == SubmissionStates.REJECTED: template = self.event.get_mail_template(MailTemplateRoles.SUBMISSION_REJECT) else: return for speaker in self.sorted_speakers: template.to_mail( user=speaker.user, locale=self.get_email_locale(speaker.user.locale), context_kwargs={"submission": self, "user": speaker.user}, event=self.event, ) send_state_mail.alters_data = True
[docs] def cancel(self, person=None, orga: bool = True, from_pending: bool = False): """Sets the submission's state to 'canceled'.""" previous = self.state self.set_state(SubmissionStates.CANCELED, person=person) self.log_action( "pretalx.submission.cancel", person=person, orga=True, data={"previous": previous, "from_pending": from_pending}, )
cancel.alters_data = True
[docs] def withdraw(self, person=None, orga: bool = False, from_pending: bool = False): """Sets the submission's state to 'withdrawn'.""" previous = self.state self.set_state(SubmissionStates.WITHDRAWN, person=person) self.log_action( "pretalx.submission.withdraw", person=person, orga=orga, data={"previous": previous, "from_pending": from_pending}, )
withdraw.alters_data = True def delete(self, person=None, orga: bool = True, **kwargs): self.slots.all().delete() self.answers.all().delete() for resource in self.resources.all(): resource.delete() super().delete( log_kwargs={ "person": person, "orga": orga, "data": {"title": self.title, "code": self.code, "state": self.state}, }, **kwargs, ) @cached_property def integer_uuid(self): # For import into Engelsystem, we need to somehow convert our submission code into an unique integer. Luckily, # codes can contain 34 different characters (including compatibility with frab imported data) and normally have # 6 charactes. Since log2(34 **6) == 30.52, that just fits in to a positive 32-bit signed integer (that # Engelsystem expects), if we do it correctly. charset = [ *self.code_charset, "1", "2", "4", "5", "6", "0", ] # compatibility with imported frab data base = len(charset) table = {char: cp for cp, char in enumerate(charset)} intval = 0 for char in self.code: intval *= base intval += table[char] return intval @cached_property def slot(self): """The first scheduled :class:`~pretalx.schedule.models.slot.TalkSlot` of this submission in the current. :class:`~pretalx.schedule.models.schedule.Schedule`. Note that this slot is not guaranteed to be visible. """ return ( self.event.current_schedule.talks.filter(submission=self) .select_related("room", "submission", "submission__event") .first() if self.event.current_schedule else None ) @cached_property def current_slots(self): if not self.event.current_schedule: return None return self.event.current_schedule.talks.filter( submission=self, is_visible=True ).select_related("room") @cached_property def public_slots(self): """All publicly visible :class:`~pretalx.schedule.models.slot.TalkSlot` objects of this submission in the current. :class:`~pretalx.schedule.models.schedule.Schedule`. """ if not is_agenda_visible(None, self.event): return [] return self.current_slots @cached_property def sorted_speakers(self): if "speakers" in getattr(self, "_prefetched_objects_cache", {}): return self.speakers.all() return self.speakers.all().order_by("speaker_roles__position") @cached_property def display_speaker_names(self): """Helper method for a consistent speaker name display.""" return ", ".join(s.get_display_name() for s in self.sorted_speakers) @cached_property def display_title_with_speakers(self): title = ( f"{phrases.base.quotation_open}{self.title}{phrases.base.quotation_close}" ) if not self.sorted_speakers: return title return _("{title_in_quotes} by {list_of_speakers}").format( title_in_quotes=title, list_of_speakers=self.display_speaker_names ) @cached_property def does_accept_feedback(self): slot = self.slot if slot and slot.start: return slot.start < now() return False @cached_property def median_score(self) -> float | None: scores = [ review.score for review in self.reviews.all() if review.score is not None ] return statistics.median(scores) if scores else None @cached_property def mean_score(self) -> float | None: scores = [ review.score for review in self.reviews.all() if review.score is not None ] return round(statistics.fmean(scores), 1) if scores else None @cached_property def score_categories(self): track = self.track track_filter = models.Q(limit_tracks__isnull=True) if track: track_filter |= models.Q(limit_tracks__in=[track]) return self.event.score_categories.filter(track_filter, active=True).order_by( "id" ) @cached_property def active_resources(self): return self.resources.filter( models.Q( # either the resource exists ~models.Q(resource="") & models.Q(resource__isnull=False) & ~models.Q(resource="None") ) | models.Q( # or the link exists models.Q(link__isnull=False) & ~models.Q(link="") ) ).order_by("link") @cached_property def private_resources(self): return self.active_resources.filter(is_public=False) @cached_property def public_resources(self): return self.active_resources.filter(is_public=True) @property def user_state(self): deadline = self.submission_type.deadline or self.event.cfp.deadline cfp_open = (not deadline) or now() <= deadline if self.state == SubmissionStates.SUBMITTED and not cfp_open: return "review" return self.state def __str__(self): """Help when debugging.""" if self.pk: return f"Submission(event={self.event.slug}, code={self.code}, title={self.title}, state={self.state})" return f"Submission(code={self.code}, title={self.title}, state={self.state})" @cached_property def export_duration(self): return serialize_duration(minutes=self.get_duration()) @property def availabilities(self): """The intersection of all. :class:`~pretalx.schedule.models.availability.Availability` objects of all speakers of this submission. """ from pretalx.schedule.models.availability import Availability # noqa: PLC0415 all_availabilities = self.event.valid_availabilities.filter( person__in=self.speakers.all() ) return Availability.intersection(all_availabilities) def get_content_for_mail(self): order = [ "title", "abstract", "description", "notes", "duration", "content_locale", "do_not_record", "image", ] data = [] result = "" for field in order: field_content = getattr(self, field, None) if field_content: _field = self._meta.get_field(field) field_name = _field.verbose_name or _field.name data.append({"name": field_name, "value": field_content}) for answer in self.answers.all().order_by("question__position"): if answer.question.variant == "boolean": data.append( {"name": answer.question.question, "value": answer.boolean_answer} ) elif answer.answer_file: data.append( {"name": answer.question.question, "value": answer.answer_file} ) else: data.append( {"name": answer.question.question, "value": answer.answer or "-"} ) for content in data: field_name = content["name"] field_content = content["value"] if isinstance(field_content, bool): field_content = _("Yes") if field_content else _("No") elif isinstance(field_content, FieldFile): field_content = ( self.event.custom_domain or settings.SITE_URL ) + field_content.url result += f"**{field_name}**: {field_content}\n\n" return result def invite_speaker(self, email, name=None, locale=None, user=None): from pretalx.common.urls import build_absolute_uri # noqa: PLC0415 from pretalx.mail.models import MailTemplateRoles # noqa: PLC0415 from pretalx.person.models import User # noqa: PLC0415 from pretalx.person.services import create_user # noqa: PLC0415 user_created = False context = {} try: speaker_user = User.objects.get(email__iexact=email) except User.DoesNotExist: speaker_user = create_user(email=email, name=name, event=self.event) user_created = True context["invitation_link"] = build_absolute_uri( "cfp:event.new_recover", kwargs={"event": self.event.slug, "token": speaker_user.pw_reset_token}, ) speaker = self.add_speaker(user=speaker_user, name=name, log_user=user) context["user"] = speaker_user template = self.event.get_mail_template( MailTemplateRoles.EXISTING_SPEAKER_INVITE if not user_created else MailTemplateRoles.NEW_SPEAKER_INVITE ) template.to_mail( user=speaker_user, event=self.event, context=context, context_kwargs={ "user": speaker_user, "submission": self, "event": self.event, }, locale=locale or self.event.locale, ) return speaker
[docs] def add_speaker(self, user=None, speaker=None, name=None, log_user=None): """ Add a speaker to this submission. Pass a speaker object if it exists, or a user object with optional additional information to be used in the new speaker object. """ from pretalx.person.models import SpeakerProfile # noqa: PLC0415 if not speaker: speaker, _ = SpeakerProfile.objects.get_or_create( user=user, event=self.event, defaults={"name": name or user.name} ) self.speakers.add(speaker) max_position = ( SpeakerRole.objects.filter(submission=self) .exclude(speaker=speaker) .aggregate(max_pos=models.Max("position")) .get("max_pos") ) SpeakerRole.objects.filter(submission=self, speaker=speaker).update( position=(max_position or 0) + 1 ) if log_user: self.log_action( "pretalx.submission.speakers.add", person=log_user, orga=True, data={ "code": speaker.code, "name": speaker.get_display_name(), "email": user.email, }, ) return speaker
def remove_speaker(self, speaker, orga=True, user=None): if self.speakers.filter(code=speaker.code).exists(): self.speakers.remove(speaker) self.log_action( "pretalx.submission.speakers.remove", person=user or speaker.user, orga=orga, data={ "code": speaker.code, "email": speaker.user.email, "name": speaker.get_display_name(), }, ) def send_invite(self, to, _from=None, subject=None, text=None): from pretalx.mail.models import QueuedMail # noqa: PLC0415 if not _from and (not subject or not text): raise ValueError("Please enter a sender for this invitation.") subject = subject or phrases.cfp.invite_subject.format( speaker=_from.get_display_name() ) subject = get_prefixed_subject(self.event, subject) text = text or phrases.cfp.invite_text.format( event=self.event.name, title=self.title, url=self.urls.accept_invitation.full(), speaker=_from.get_display_name(), ) to = to.split(",") if isinstance(to, str) else to for invite in to: QueuedMail( event=self.event, to=invite, subject=subject, text=text, locale=self.get_email_locale(), ).send() send_invite.alters_data = True def add_favourite(self, user): SubmissionFavourite.objects.get_or_create(user=user, submission=self) def remove_favourite(self, user): SubmissionFavourite.objects.filter(user=user, submission=self).delete() def log_action(self, action, data=None, **kwargs): if self.state != SubmissionStates.DRAFT: return super().log_action(action=action, data=data, **kwargs)
class SubmissionFavourite(PretalxModel): user = models.ForeignKey( to="person.User", on_delete=models.CASCADE, related_name="submission_favourites" ) submission = models.ForeignKey( to="submission.Submission", on_delete=models.CASCADE, related_name="favourites" ) objects = ScopedManager(event="submission__event") class Meta: unique_together = (("user", "submission"),) class SubmissionInvitation(PretalxModel): """Track pending speaker invitations for submissions. When a speaker is invited to a submission, a SubmissionInvitation is created with a unique token. The invitation is deleted when the invited person accepts it, or can be retracted by organisers or the submitter. """ submission = models.ForeignKey( to="submission.Submission", related_name="invitations", on_delete=models.CASCADE ) email = models.EmailField(verbose_name=_("Email")) token = models.CharField(default=generate_invite_code, max_length=64, unique=True) objects = ScopedManager(event="submission__event") class Meta: unique_together = (("submission", "email"),) class urls(EventUrls): base = "{self.submission.event.urls.base}invitation/{self.submission.code}/{self.token}" @property def event(self): return self.submission.event def __str__(self): return _("Invite to {submission} for {email}").format( submission=self.submission.title, email=self.email ) def send(self, _from=None, subject=None, text=None): from pretalx.mail.models import QueuedMail # noqa: PLC0415 if not _from: raise ValueError("Please enter a sender for this invitation.") subject = subject or phrases.cfp.invite_subject.format( speaker=_from.get_display_name() ) subject = get_prefixed_subject(self.submission.event, subject) text = text or phrases.cfp.invite_text.format( event=self.submission.event.name, title=self.submission.title, url=self.urls.base.full(), speaker=_from.get_display_name(), ) mail = QueuedMail( event=self.submission.event, to=self.email, subject=subject, text=text, locale=self.submission.get_email_locale(), ) mail.send() return mail send.alters_data = True def retract(self, person=None, orga=False): email = self.email submission = self.submission self.delete() submission.log_action( "pretalx.submission.invitation.retract", person=person, orga=orga, data={"email": email}, ) retract.alters_data = True