Example #1
0
class SpeakerProfile(LogMixin, models.Model):
    """All :class:`~pretalx.event.models.event.Event` related data concerning
    a.

    :class:`~pretalx.person.models.user.User` is stored here.

    :param has_arrived: Can be set to track speaker arrival. Will be used in
        warnings about missing speakers.
    """

    user = models.ForeignKey(
        to="person.User",
        related_name="profiles",
        on_delete=models.CASCADE,
        null=True,
        blank=True,
    )
    event = models.ForeignKey(
        to="event.Event", related_name="+", on_delete=models.CASCADE
    )
    biography = models.TextField(
        verbose_name=_("Biography"),
        help_text=phrases.base.use_markdown,
        null=True,
        blank=True,
    )
    has_arrived = models.BooleanField(
        default=False, verbose_name=_("The speaker has arrived")
    )

    objects = ScopedManager(event="event")

    class urls(EventUrls):
        public = "{self.event.urls.base}speaker/{self.user.code}/"
        talks_ical = "{self.urls.public}talks.ics"

    class orga_urls(EventUrls):
        base = "{self.event.orga_urls.speakers}{self.user.id}/"
        password_reset = "{self.event.orga_urls.speakers}{self.user.id}/reset"
        toggle_arrived = "{self.event.orga_urls.speakers}{self.user.id}/toggle-arrived"

    def __str__(self):
        """Help when debugging."""
        user = self.user.get_display_name() if self.user else None
        return f"SpeakerProfile(event={self.event.slug}, user={user})"

    @cached_property
    def code(self):
        return self.user.code

    @cached_property
    def submissions(self):
        """All non-deleted.

        :class:`~pretalx.submission.models.submission.Submission` objects by
        this user on this event.
        """
        return self.user.submissions.filter(event=self.event)

    @cached_property
    def talks(self):
        """A queryset of.

        :class:`~pretalx.submission.models.submission.Submission` objects.

        Contains all visible talks by this user on this event.
        """
        return self.event.talks.filter(speakers__in=[self.user])

    @cached_property
    def answers(self):
        """A queryset of :class:`~pretalx.submission.models.question.Answer`
        objects.

        Includes all answers the user has given either for themselves or
        for their talks for this event.
        """
        from pretalx.submission.models import Answer, Submission

        submissions = Submission.objects.filter(
            event=self.event, speakers__in=[self.user]
        )
        return Answer.objects.filter(
            models.Q(submission__in=submissions) | models.Q(person=self.user)
        )

    @property
    def reviewer_answers(self):
        return self.answers.filter(question__is_visible_to_reviewers=True)
Example #2
0
class Invoice(models.Model):
    """
    Represents an invoice that is issued because of an order. Because invoices are legally required
    not to change, this object duplicates a lot of data (e.g. the invoice address).

    :param order: The associated order
    :type order: Order
    :param event: The event this belongs to (for convenience)
    :type event: Event
    :param organizer: The organizer this belongs to (redundant, for enforcing uniqueness)
    :type organizer: Organizer
    :param invoice_no: The human-readable, event-unique invoice number
    :type invoice_no: int
    :param is_cancellation: Whether or not this is a cancellation instead of an invoice
    :type is_cancellation: bool
    :param refers: A link to another invoice this invoice refers to, e.g. the canceled invoice in a cancellation
    :type refers: Invoice
    :param invoice_from: The sender address
    :type invoice_from: str
    :param invoice_to: The receiver address
    :type invoice_to: str
    :param full_invoice_no: The full invoice number (for performance reasons only)
    :type full_invoice_no: str
    :param date: The invoice date
    :type date: date
    :param locale: The locale in which the invoice should be printed
    :type locale: str
    :param introductory_text: Introductory text for the invoice, e.g. for a greeting
    :type introductory_text: str
    :param additional_text: Additional text for the invoice
    :type additional_text: str
    :param payment_provider_text: A payment provider specific text
    :type payment_provider_text: str
    :param footer_text: A footer text, displayed smaller and centered on every page
    :type footer_text: str
    :param foreign_currency_display: A different currency that taxes should also be displayed in.
    :type foreign_currency_display: str
    :param foreign_currency_rate: The rate of a foreign currency that the taxes should be displayed in.
    :type foreign_currency_rate: Decimal
    :param foreign_currency_rate_date: The date of the foreign currency exchange rates.
    :type foreign_currency_rate_date: date
    :param file: The filename of the rendered invoice
    :type file: File
    """
    order = models.ForeignKey('Order',
                              related_name='invoices',
                              db_index=True,
                              on_delete=models.CASCADE)
    organizer = models.ForeignKey('Organizer',
                                  related_name='invoices',
                                  db_index=True,
                                  on_delete=models.PROTECT)
    event = models.ForeignKey('Event',
                              related_name='invoices',
                              db_index=True,
                              on_delete=models.CASCADE)

    prefix = models.CharField(max_length=160, db_index=True)
    invoice_no = models.CharField(max_length=19, db_index=True)
    full_invoice_no = models.CharField(max_length=190, db_index=True)

    is_cancellation = models.BooleanField(default=False)
    refers = models.ForeignKey('Invoice',
                               related_name='refered',
                               null=True,
                               blank=True,
                               on_delete=models.CASCADE)

    invoice_from = models.TextField()
    invoice_from_name = models.CharField(max_length=190, null=True)
    invoice_from_zipcode = models.CharField(max_length=190, null=True)
    invoice_from_city = models.CharField(max_length=190, null=True)
    invoice_from_country = FastCountryField(null=True)
    invoice_from_tax_id = models.CharField(max_length=190, null=True)
    invoice_from_vat_id = models.CharField(max_length=190, null=True)

    invoice_to = models.TextField()
    invoice_to_company = models.TextField(null=True)
    invoice_to_name = models.TextField(null=True)
    invoice_to_street = models.TextField(null=True)
    invoice_to_zipcode = models.CharField(max_length=190, null=True)
    invoice_to_city = models.TextField(null=True)
    invoice_to_state = models.CharField(max_length=190, null=True)
    invoice_to_country = FastCountryField(null=True)
    invoice_to_vat_id = models.TextField(null=True)
    invoice_to_beneficiary = models.TextField(null=True)
    internal_reference = models.TextField(blank=True)
    custom_field = models.CharField(max_length=255, null=True)

    date = models.DateField(default=today)
    locale = models.CharField(max_length=50, default='en')
    introductory_text = models.TextField(blank=True)
    additional_text = models.TextField(blank=True)
    reverse_charge = models.BooleanField(default=False)
    payment_provider_text = models.TextField(blank=True)
    footer_text = models.TextField(blank=True)

    foreign_currency_display = models.CharField(max_length=50,
                                                null=True,
                                                blank=True)
    foreign_currency_rate = models.DecimalField(decimal_places=4,
                                                max_digits=10,
                                                null=True,
                                                blank=True)
    foreign_currency_rate_date = models.DateField(null=True, blank=True)

    shredded = models.BooleanField(default=False)

    # The field sent_to_organizer records whether this invocie was already sent to the organizer by a configured
    # mechanism such as email.
    # NULL: The cronjob that handles sending did not yet run.
    # True: The invoice was sent.
    # False: The invoice wasn't sent and never will, because sending was not configured at the time of the check.
    sent_to_organizer = models.BooleanField(null=True, blank=True)

    file = models.FileField(null=True,
                            blank=True,
                            upload_to=invoice_filename,
                            max_length=255)

    objects = ScopedManager(organizer='event__organizer')

    @staticmethod
    def _to_numeric_invoice_number(number, places):
        return ('{:0%dd}' % places).format(int(number))

    @property
    def full_invoice_from(self):
        taxidrow = ""
        if self.invoice_from_tax_id:
            if str(self.invoice_from_country) == "AU":
                taxidrow = "ABN: %s" % self.invoice_from_tax_id
            else:
                taxidrow = pgettext("invoice",
                                    "Tax ID: %s") % self.invoice_from_tax_id
        parts = [
            self.invoice_from_name,
            self.invoice_from,
            (self.invoice_from_zipcode or "") + " " +
            (self.invoice_from_city or ""),
            self.invoice_from_country.name
            if self.invoice_from_country else "",
            pgettext("invoice", "VAT-ID: %s") %
            self.invoice_from_vat_id if self.invoice_from_vat_id else "",
            taxidrow,
        ]
        return '\n'.join([p.strip() for p in parts if p and p.strip()])

    @property
    def address_invoice_from(self):
        parts = [
            self.invoice_from_name,
            self.invoice_from,
            (self.invoice_from_zipcode or "") + " " +
            (self.invoice_from_city or ""),
            self.invoice_from_country.name
            if self.invoice_from_country else "",
        ]
        return '\n'.join([p.strip() for p in parts if p and p.strip()])

    @property
    def address_invoice_to(self):
        if self.invoice_to and not self.invoice_to_company and not self.invoice_to_name:
            return self.invoice_to

        state_name = ""
        if self.invoice_to_state:
            state_name = self.invoice_to_state
            if str(self.invoice_to_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
                if COUNTRIES_WITH_STATE_IN_ADDRESS[str(
                        self.invoice_to_country)][1] == 'long':
                    try:
                        state_name = pycountry.subdivisions.get(
                            code='{}-{}'.format(self.invoice_to_country,
                                                self.invoice_to_state)).name
                    except:
                        pass

        parts = [
            self.invoice_to_company,
            self.invoice_to_name,
            self.invoice_to_street,
            ((self.invoice_to_zipcode or "") + " " +
             (self.invoice_to_city or "") + " " + (state_name or "")).strip(),
            self.invoice_to_country.name if self.invoice_to_country else "",
        ]
        return '\n'.join([p.strip() for p in parts if p and p.strip()])

    def _get_numeric_invoice_number(self, c_length):
        numeric_invoices = Invoice.objects.filter(
            event__organizer=self.event.organizer,
            prefix=self.prefix,
        ).exclude(invoice_no__contains='-').annotate(numeric_number=Cast(
            'invoice_no', models.IntegerField())).aggregate(
                max=Max('numeric_number'))['max'] or 0
        return self._to_numeric_invoice_number(numeric_invoices + 1, c_length)

    def _get_invoice_number_from_order(self):
        return '{order}-{count}'.format(
            order=self.order.code,
            count=Invoice.objects.filter(event=self.event,
                                         order=self.order).count() + 1,
        )

    def save(self, *args, **kwargs):
        if not self.order:
            raise ValueError('Every invoice needs to be connected to an order')
        if not self.event:
            self.event = self.order.event
        if not self.organizer:
            self.organizer = self.order.event.organizer
        if not self.prefix:
            self.prefix = self.event.settings.invoice_numbers_prefix or (
                self.event.slug.upper() + '-')
            if self.is_cancellation:
                self.prefix = self.event.settings.invoice_numbers_prefix_cancellations or self.prefix
            if '%' in self.prefix:
                self.prefix = self.date.strftime(self.prefix)

        if not self.invoice_no:
            if self.order.testmode:
                self.prefix += 'TEST-'
            for i in range(10):
                if self.event.settings.get('invoice_numbers_consecutive'):
                    self.invoice_no = self._get_numeric_invoice_number(
                        self.event.settings.invoice_numbers_counter_length)
                else:
                    self.invoice_no = self._get_invoice_number_from_order()
                try:
                    with transaction.atomic():
                        return super().save(*args, **kwargs)
                except DatabaseError:
                    # Suppress duplicate key errors and try again
                    if i == 9:
                        raise

        self.full_invoice_no = self.prefix + self.invoice_no
        return super().save(*args, **kwargs)

    def delete(self, *args, **kwargs):
        """
        Deleting an Invoice would allow for the creation of another Invoice object
        with the same invoice_no as the deleted one. For various reasons, invoice_no
        should be reliably unique for an event.
        """
        raise Exception(
            'Invoices cannot be deleted, to guarantee uniqueness of Invoice.invoice_no in any event.'
        )

    @property
    def number(self):
        """
        Returns the invoice number in a human-readable string with the event slug prepended.
        """
        return '{prefix}{code}'.format(prefix=self.prefix,
                                       code=self.invoice_no)

    @cached_property
    def canceled(self):
        return self.refered.filter(is_cancellation=True).exists()

    class Meta:
        unique_together = ('organizer', 'prefix', 'invoice_no')
        ordering = (
            'date',
            'invoice_no',
        )

    def __repr__(self):
        return '<Invoice {} / {}>'.format(self.full_invoice_no, self.pk)

    def __str__(self):
        return self.full_invoice_no
Example #3
0
class Question(LogMixin, models.Model):
    """Questions can be asked per.

    :class:`~pretalx.submission.models.submission.Submission`, per speaker, or
    of reviewers per :class:`~pretalx.submission.models.review.Review`.

    Questions can have many types, which offers a flexible framework to give organisers
    the opportunity to get all the information they need.

    :param variant: Can be any of 'number', 'string', 'text', 'boolean',
        'file', 'choices', or 'multiple_choice'. Defined in the
        ``QuestionVariant`` class.
    :param target: Can be any of 'submission', 'speaker', or 'reviewer'.
        Defined in the ``QuestionTarget`` class.
    :param deadline: Datetime field. This field is required for 'after deadline' and 'freeze after' options of
        question_required field and optional for the other ones. For 'after deadline' it shows that the answer will
        be optional before the deadline and mandatory after that deadline. For 'freeze after' it shows that the
        answer will be allowed before the deadline and frozen after that deadline
    :param question_required: Can be any of 'none', 'require ', 'after deadline', or 'freeze after'.
        Defined in the ``QuestionRequired`` class.
        'required' answering this question will always be required.
        'optional' means that it will never be mandatory.
        'after deadline' the answer will be optional before the deadline and mandatory after the deadline.
    :param freeze_after: Can be a datetime field or null.
        For 'freeze after' the answer will be allowed before the deadline and frozen after the deadline.
    :param position: Position in the question order in this event.
    """

    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,
    )
    target = models.CharField(
        max_length=QuestionTarget.get_max_length(),
        choices=QuestionTarget.get_choices(),
        default=QuestionTarget.SUBMISSION,
        verbose_name=_("question type"),
        help_text=_(
            "Do you require an answer from every speaker or for every session?"
        ),
    )
    deadline = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_("deadline"),
        help_text=
        _("Set a deadline to make this question required after the given date."
          ),
    )
    freeze_after = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_("freeze after"),
        help_text=_(
            "Set a deadline to stop changes to answers after the given date."),
    )
    question_required = models.CharField(
        max_length=QuestionRequired.get_max_length(),
        choices=QuestionRequired.get_choices(),
        default=QuestionRequired.OPTIONAL,
        verbose_name=_("question required"),
    )
    tracks = models.ManyToManyField(
        to="submission.Track",
        related_name="questions",
        help_text=
        _("You can limit this question to some tracks. Leave this field empty to apply to all tracks."
          ),
        verbose_name=_("Tracks"),
        blank=True,
    )
    submission_types = models.ManyToManyField(
        to="submission.SubmissionType",
        related_name="questions",
        help_text=
        _("You can limit this question to some session types. Leave this field empty to apply to all session types."
          ),
        verbose_name=_("Session Types"),
        blank=True,
    )
    question = I18nCharField(max_length=800, verbose_name=_("question"))
    help_text = I18nCharField(
        null=True,
        blank=True,
        max_length=800,
        verbose_name=_("help text"),
        help_text=_(
            "Will appear just like this text below the question input field.")
        + " " + phrases.base.use_markdown,
    )
    default_answer = models.TextField(null=True,
                                      blank=True,
                                      verbose_name=_("default answer"))
    position = models.IntegerField(default=0, verbose_name=_("position"))
    active = models.BooleanField(
        default=True,
        verbose_name=_("active"),
        help_text=_("Inactive questions will no longer be asked."),
    )
    contains_personal_data = models.BooleanField(
        default=True,
        verbose_name=_("Answers contain personal data"),
        help_text=
        _("If a user deletes their account, answers of questions for personal data will be removed, too."
          ),
    )
    min_length = models.PositiveIntegerField(
        null=True,
        blank=True,
        verbose_name=_("Minimum text length"),
        help_text=_(
            "Minimum allowed text in characters or words (set in CfP settings)."
        ),
    )
    max_length = models.PositiveIntegerField(
        null=True,
        blank=True,
        verbose_name=_("Maximum text length"),
        help_text=
        _("Maximum allowed text length in characters or words (set in CfP settings)."
          ),
    )
    is_public = models.BooleanField(
        default=False,
        verbose_name=_("Publish answers"),
        help_text=
        _("Answers will be shown on session or speaker pages as appropriate. Please note that you cannot make a question public after the first answers have been given, to allow speakers explicit consent before publishing information."
          ),
    )
    is_visible_to_reviewers = models.BooleanField(
        default=True,
        verbose_name=_("Show answers to reviewers"),
        help_text=
        _("Should answers to this question be shown to reviewers? This is helpful if you want to collect personal information, but use anonymous reviews."
          ),
    )
    objects = ScopedManager(event="event", _manager_class=QuestionManager)
    all_objects = ScopedManager(event="event",
                                _manager_class=AllQuestionManager)

    @cached_property
    def required(self):
        _now = now()
        # Question should become optional in order to be frozen
        if self.read_only:
            return False
        if self.question_required == QuestionRequired.REQUIRED:
            return True
        if self.question_required == QuestionRequired.AFTER_DEADLINE:
            return self.deadline <= _now
        return False

    @property
    def read_only(self):
        return self.freeze_after and (self.freeze_after <= now())

    class urls(EventUrls):
        base = "{self.event.cfp.urls.questions}{self.pk}/"
        edit = "{base}edit"
        up = "{base}up"
        down = "{base}down"
        delete = "{base}delete"
        toggle = "{base}toggle"

    def __str__(self):
        return str(self.question)

    def missing_answers(self,
                        filter_speakers: list = False,
                        filter_talks: list = False) -> int:
        """Returns how many answers are still missing or this question.

        This method only supports submission questions and speaker questions.
        For missing reviews, please use the Review.find_missing_reviews method.

        :param filter_speakers: Apply only to these speakers.
        :param filter_talks: Apply only to these talks.
        """
        from pretalx.person.models import User
        from pretalx.submission.models import Submission

        answers = self.answers.all()
        filter_talks = filter_talks or Submission.objects.none()
        filter_speakers = filter_speakers or User.objects.none()
        if filter_speakers or filter_talks:
            answers = answers.filter(
                models.Q(person__in=filter_speakers)
                | models.Q(submission__in=filter_talks))
        answer_count = answers.count()
        if self.target == QuestionTarget.SUBMISSION:
            submissions = filter_talks or self.event.submissions.all()
            return max(submissions.count() - answer_count, 0)
        if self.target == QuestionTarget.SPEAKER:
            users = filter_speakers or User.objects.filter(
                submissions__event_id=self.event.pk)
            return max(users.count() - answer_count, 0)
        return 0

    class Meta:
        ordering = ["position"]
Example #4
0
class Customer(LoggedModel):
    """
    Represents a registered customer of an organizer.
    """
    id = models.BigAutoField(primary_key=True)
    organizer = models.ForeignKey(Organizer,
                                  related_name='customers',
                                  on_delete=models.CASCADE)
    identifier = models.CharField(max_length=190, db_index=True, unique=True)
    email = models.EmailField(db_index=True,
                              null=True,
                              blank=False,
                              verbose_name=_('E-mail'),
                              max_length=190)
    phone = PhoneNumberField(null=True,
                             blank=True,
                             verbose_name=_('Phone number'))
    password = models.CharField(verbose_name=_('Password'), max_length=128)
    name_cached = models.CharField(max_length=255,
                                   verbose_name=_('Full name'),
                                   blank=True)
    name_parts = models.JSONField(default=dict)
    is_active = models.BooleanField(default=True,
                                    verbose_name=_('Account active'))
    is_verified = models.BooleanField(default=True,
                                      verbose_name=_('Verified email address'))
    last_login = models.DateTimeField(verbose_name=_('Last login'),
                                      blank=True,
                                      null=True)
    date_joined = models.DateTimeField(auto_now_add=True,
                                       verbose_name=_('Registration date'))
    locale = models.CharField(max_length=50,
                              choices=settings.LANGUAGES,
                              default=settings.LANGUAGE_CODE,
                              verbose_name=_('Language'))
    last_modified = models.DateTimeField(auto_now=True)

    objects = ScopedManager(organizer='organizer')

    class Meta:
        unique_together = [['organizer', 'email']]
        ordering = ('email', )

    def get_email_field_name(self):
        return 'email'

    def save(self, **kwargs):
        if self.email:
            self.email = self.email.lower()
        if 'update_fields' in kwargs and 'last_modified' not in kwargs[
                'update_fields']:
            kwargs['update_fields'] = list(
                kwargs['update_fields']) + ['last_modified']
        if not self.identifier:
            self.assign_identifier()
        if self.name_parts:
            self.name_cached = self.name
        else:
            self.name_cached = ""
            self.name_parts = {}
        super().save(**kwargs)

    def anonymize(self):
        self.is_active = False
        self.is_verified = False
        self.name_parts = {}
        self.name_cached = ''
        self.email = None
        self.phone = None
        self.save()
        self.all_logentries().update(data={}, shredded=True)
        self.orders.all().update(customer=None)
        self.memberships.all().update(attendee_name_parts=None)
        self.attendee_profiles.all().delete()
        self.invoice_addresses.all().delete()

    @scopes_disabled()
    def assign_identifier(self):
        charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
        iteration = 0
        length = settings.ENTROPY['customer_identifier']
        while True:
            code = get_random_string(length=length, allowed_chars=charset)
            iteration += 1

            if banned(code):
                continue

            if not Customer.objects.filter(identifier=code).exists():
                self.identifier = code
                return

            if iteration > 20:
                # Safeguard: If we don't find an unused and non-banlisted code within 20 iterations, we increase
                # the length.
                length += 1
                iteration = 0

    @property
    def name(self):
        if not self.name_parts:
            return ""
        if '_legacy' in self.name_parts:
            return self.name_parts['_legacy']
        if '_scheme' in self.name_parts:
            scheme = PERSON_NAME_SCHEMES[self.name_parts['_scheme']]
        else:
            raise TypeError("Invalid name given.")
        return scheme['concatenation'](self.name_parts).strip()

    def __str__(self):
        s = f'#{self.identifier}'
        if self.name or self.email:
            s += f' – {self.name or self.email}'
        if not self.is_active:
            s += f' ({_("disabled")})'
        return s

    def set_password(self, raw_password):
        self.password = make_password(raw_password)

    def check_password(self, raw_password):
        """
        Return a boolean of whether the raw_password was correct. Handles
        hashing formats behind the scenes.
        """
        def setter(raw_password):
            self.set_password(raw_password)
            self.save(update_fields=["password"])

        return check_password(raw_password, self.password, setter)

    def set_unusable_password(self):
        # Set a value that will never be a valid hash
        self.password = make_password(None)

    def has_usable_password(self):
        """
        Return False if set_unusable_password() has been called for this user.
        """
        return is_password_usable(self.password)

    def get_session_auth_hash(self):
        """
        Return an HMAC of the password field.
        """
        key_salt = "pretix.base.models.customers.Customer.get_session_auth_hash"
        payload = self.password
        payload += self.email
        return salted_hmac(key_salt, payload).hexdigest()

    def get_email_context(self):
        from pretix.base.email import get_name_parts_localized
        ctx = {
            'name': self.name,
            'organizer': self.organizer.name,
        }
        name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
        for f, l, w in name_scheme['fields']:
            if f == 'full_name':
                continue
            ctx['name_%s' % f] = get_name_parts_localized(self.name_parts, f)

        if "concatenation_for_salutation" in name_scheme:
            ctx['name_for_salutation'] = name_scheme[
                "concatenation_for_salutation"](self.name_parts)
        else:
            ctx['name_for_salutation'] = name_scheme["concatenation"](
                self.name_parts)

        return ctx

    @property
    def stored_addresses(self):
        return self.invoice_addresses(manager='profiles')

    def usable_memberships(self, for_event, testmode=False):
        return self.memberships.active(for_event).with_usages().filter(
            Q(membership_type__max_usages__isnull=True)
            | Q(usages__lt=F('membership_type__max_usages')),
            testmode=testmode,
        )
Example #5
0
class Review(models.Model):
    """Reviews model the opinion of reviewers of a :class:`~pretalx.submission.models.submission.Submission`.

    They can, but don't have to, include a score and a text.

    :param text: The review itself. May be empty.
    :param score: The upper and lower bounds of this value are defined in an
        event's settings.
    :param override_vote: If this field is ``True`` or ``False``, it indicates
        that the reviewer has spent one of their override votes to emphasize
        their opinion of the review. It is ``None`` otherwise.
    """
    submission = models.ForeignKey(to='submission.Submission',
                                   related_name='reviews',
                                   on_delete=models.CASCADE)
    user = models.ForeignKey(to='person.User',
                             related_name='reviews',
                             on_delete=models.CASCADE)
    text = models.TextField(verbose_name=_('What do you think?'),
                            null=True,
                            blank=True)
    score = models.IntegerField(verbose_name=_('Score'), null=True, blank=True)
    override_vote = models.BooleanField(default=None, null=True, blank=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    objects = ScopedManager(event='submission__event')

    def __str__(self):
        return f'Review(event={self.submission.event.slug}, submission={self.submission.title}, user={self.user.get_display_name}, score={self.score})'

    @classmethod
    def find_missing_reviews(cls, event, user, ignore=None):
        """
        Returns all :class:`~pretalx.submission.models.submission.Submission`
        objects this :class:`~pretalx.person.models.user.User` still has to
        review for the given :class:`~pretalx.event.models.event.Event`.

        Excludes submissions this user has submitted, and takes track
        :class:`~pretalx.event.models.organiser.Team` permissions into account.
        The result is ordered by review count.

        :type event: :class:`~pretalx.event.models.event.Event`
        :type user: :class:`~pretalx.person.models.user.User`
        :rtype: Queryset of :class:`~pretalx.submission.models.submission.Submission` objects
        """
        from pretalx.submission.models import SubmissionStates

        queryset = (event.submissions.filter(
            state=SubmissionStates.SUBMITTED).exclude(
                reviews__user=user).exclude(speakers__in=[user]).annotate(
                    review_count=models.Count('reviews')))
        limit_tracks = user.teams.filter(
            models.Q(all_events=True)
            | models.Q(
                models.Q(all_events=False)
                & models.Q(limit_events__in=[event])),
            limit_tracks__isnull=False,
        )
        if limit_tracks.exists():
            tracks = set()
            for team in limit_tracks:
                tracks.update(team.limit_tracks.filter(event=event))
            queryset = queryset.filter(track__in=tracks)
        if ignore:
            queryset = queryset.exclude(
                pk__in=[submission.pk for submission in ignore])
        return queryset.order_by('review_count', '?')

    @cached_property
    def event(self):
        return self.submission.event

    @cached_property
    def display_score(self) -> str:
        """Helper method to get a display string of the review's score."""
        if self.override_vote is True:
            return _('Positive override')
        if self.override_vote is False:
            return _('Negative override (Veto)')
        if self.score is None:
            return '×'
        return self.submission.event.settings.get(
            f'review_score_name_{self.score}') or str(self.score)

    class urls(EventUrls):
        base = '{self.submission.orga_urls.reviews}'
        delete = '{base}{self.pk}/delete'
Example #6
0
class Bookmark(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    userid = models.IntegerField()

    objects = ScopedManager(site='post__site', user_id='userid')
Example #7
0
class TalkSlot(LogMixin, models.Model):
    """The TalkSlot object is the scheduled version of a.

    :class:`~pretalx.submission.models.submission.Submission`.

    TalkSlots always belong to one submission and one :class:`~pretalx.schedule.models.schedule.Schedule`.

    :param is_visible: This parameter is set on schedule release. Only confirmed talks will be visible.
    """

    submission = models.ForeignKey(
        to="submission.Submission",
        on_delete=models.PROTECT,
        related_name="slots",
        null=True,
        blank=
        True,  # If the submission is empty, this is a break or similar event
    )
    room = models.ForeignKey(
        to="schedule.Room",
        on_delete=models.PROTECT,
        related_name="talks",
        null=True,
        blank=True,
    )
    schedule = models.ForeignKey(to="schedule.Schedule",
                                 on_delete=models.PROTECT,
                                 related_name="talks")
    is_visible = models.BooleanField(default=False)
    start = models.DateTimeField(null=True)
    end = models.DateTimeField(null=True)
    description = I18nCharField(null=True)

    objects = ScopedManager(event="schedule__event")

    def __str__(self):
        """Help when debugging."""
        return f'TalkSlot(event={self.schedule.event.slug}, submission={getattr(self.submission, "title", None)}, schedule={self.schedule.version})'

    @cached_property
    def event(self):
        return self.submission.event

    @property
    def duration(self) -> int:
        """Returns the actual duration in minutes if the talk is scheduled, and
        the planned duration in minutes otherwise."""
        if self.start and self.end:
            return int((self.end - self.start).total_seconds() / 60)
        if not self.submission:
            return None
        return self.submission.get_duration()

    @cached_property
    def export_duration(self):
        from pretalx.common.serialize import serialize_duration

        return serialize_duration(minutes=self.duration)

    @cached_property
    def pentabarf_export_duration(self):
        duration = dt.timedelta(minutes=self.duration)
        days = duration.days
        hours = duration.total_seconds() // 3600 - days * 24
        minutes = duration.seconds // 60 % 60
        return f"{hours:02}{minutes:02}00"

    @cached_property
    def real_end(self):
        """Guaranteed to provide a useful end datetime if ``start`` is set,
        even if ``end`` is empty."""
        return self.end or (self.start + dt.timedelta(minutes=self.duration)
                            if self.start else None)

    @cached_property
    def as_availability(self):
        """'Casts' a slot as.

        :class:`~pretalx.schedule.models.availability.Availability`, useful for
        availability arithmetics.
        """
        from pretalx.schedule.models import Availability

        return Availability(start=self.start,
                            end=self.real_end,
                            event=self.submission.event)

    @cached_property
    def warnings(self) -> list:
        """A list of warnings that apply to this slot.

        Warnings are dictionaries with a ``type`` (``room`` or
        ``speaker``, for now) and a ``message`` fit for public display.
        This property only shows availability based warnings.
        """
        if not self.start:
            return []
        warnings = []
        availability = self.as_availability
        if self.room:
            if not any(
                    room_availability.contains(availability)
                    for room_availability in self.room.availabilities.all()):
                warnings.append({
                    "type":
                    "room",
                    "message":
                    _("The room is not available at the scheduled time."),
                })
        for speaker in self.submission.speakers.all():
            profile = speaker.event_profile(event=self.submission.event)
            if profile.availabilities.exists() and not any(
                    speaker_availability.contains(availability)
                    for speaker_availability in profile.availabilities.all()):
                warnings.append({
                    "type":
                    "speaker",
                    "speaker": {
                        "name": speaker.get_display_name(),
                        "id": speaker.pk,
                    },
                    "message":
                    _("A speaker is not available at the scheduled time."),
                })
            overlaps = (TalkSlot.objects.filter(
                schedule=self.schedule,
                submission__speakers__in=[speaker]).filter(
                    models.Q(start__lt=self.start, end__gt=self.start)
                    | models.Q(start__lt=self.end, end__gt=self.end)
                    | models.Q(start__gt=self.start, end__lt=self.end)).exists(
                    ))
            if overlaps:
                warnings.append({
                    "type":
                    "speaker",
                    "speaker": {
                        "name": speaker.get_display_name(),
                        "id": speaker.pk,
                    },
                    "message":
                    _("A speaker is giving another talk at the scheduled time."
                      ),
                })

        return warnings

    def copy_to_schedule(self, new_schedule, save=True):
        """Create a new slot for the given.

        :class:`~pretalx.schedule.models.schedule.Schedule` with all other
        fields identical to this one.
        """
        new_slot = TalkSlot(schedule=new_schedule)

        for field in [
                f for f in self._meta.fields
                if f.name not in ("id", "schedule")
        ]:
            setattr(new_slot, field.name, getattr(self, field.name))

        if save:
            new_slot.save()
        return new_slot

    copy_to_schedule.alters_data = True

    def is_same_slot(self, other_slot) -> bool:
        """Checks if both slots have the same room and start time."""
        return self.room == other_slot.room and self.start == other_slot.start

    @cached_property
    def id_suffix(self):
        if not self.event.settings.present_multiple_times:
            return ""
        all_slots = list(
            TalkSlot.objects.filter(submission_id=self.submission_id,
                                    schedule_id=self.schedule_id))
        if len(all_slots) == 1:
            return ""
        return "-" + str(all_slots.index(self))

    @cached_property
    def frab_slug(self):
        title = re.sub(r"\W+", "-", self.submission.title)
        legal_chars = string.ascii_letters + string.digits + "-"
        pattern = f"[^{legal_chars}]+"
        title = re.sub(pattern, "", title)
        title = title.lower()
        title = title.strip("_")
        return f"{self.event.slug}-{self.submission.pk}{self.id_suffix}-{title}"

    @cached_property
    def uuid(self):
        """A UUID5, calculated from the submission code and the instance
        identifier."""
        global INSTANCE_IDENTIFIER
        if not INSTANCE_IDENTIFIER:
            from pretalx.common.models.settings import GlobalSettings

            INSTANCE_IDENTIFIER = GlobalSettings().get_instance_identifier()
        return uuid.uuid5(INSTANCE_IDENTIFIER,
                          self.submission.code + self.id_suffix)

    def build_ical(self, calendar, creation_time=None, netloc=None):
        if not self.start or not self.end or not self.room:
            return
        creation_time = creation_time or dt.datetime.now(pytz.utc)
        netloc = netloc or urlparse(get_base_url(self.event)).netloc
        tz = pytz.timezone(self.submission.event.timezone)

        vevent = calendar.add("vevent")
        vevent.add(
            "summary"
        ).value = f"{self.submission.title} - {self.submission.display_speaker_names}"
        vevent.add("dtstamp").value = creation_time
        vevent.add("location").value = str(self.room.name)
        vevent.add("uid").value = "pretalx-{}-{}{}@{}".format(
            self.submission.event.slug, self.submission.code, self.id_suffix,
            netloc)

        vevent.add("dtstart").value = self.start.astimezone(tz)
        vevent.add("dtend").value = self.end.astimezone(tz)
        vevent.add("description").value = self.submission.abstract or ""
        vevent.add("url").value = self.submission.urls.public.full()
Example #8
0
class Submission(LogMixin, GenerateCode, FileCleanupMixin, models.Model):
    """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.
    """

    created = models.DateTimeField(null=True, auto_now_add=True)
    code = models.CharField(max_length=16, unique=True)
    speakers = models.ManyToManyField(
        to="person.User", related_name="submissions", blank=True
    )
    event = models.ForeignKey(
        to="event.Event", on_delete=models.PROTECT, related_name="submissions"
    )
    title = models.CharField(max_length=200, verbose_name=_("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=_("Submission type"),
    )
    track = models.ForeignKey(
        to="submission.Track",
        related_name="submissions",
        on_delete=models.PROTECT,
        verbose_name=_("Track"),
        null=True,
        blank=True,
    )
    state = models.CharField(
        max_length=SubmissionStates.get_max_length(),
        choices=SubmissionStates.get_choices(),
        default=SubmissionStates.SUBMITTED,
        verbose_name=_("Submission state"),
    )
    abstract = models.TextField(
        null=True,
        blank=True,
        verbose_name=_("Abstract"),
        help_text=phrases.base.use_markdown,
    )
    description = models.TextField(
        null=True,
        blank=True,
        verbose_name=_("Description"),
        help_text=phrases.base.use_markdown,
    )
    notes = models.TextField(
        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. Leave empty for default duration for this submission type."
        ),
    )
    slot_count = models.PositiveIntegerField(
        default=1,
        verbose_name=_("Slot Count"),
        help_text=_("How many times this talk will be held."),
    )
    content_locale = models.CharField(
        max_length=32,
        default=settings.LANGUAGE_CODE,
        choices=settings.LANGUAGES,
        verbose_name=_("Language"),
    )
    is_featured = models.BooleanField(
        default=False,
        verbose_name=_(
            "Show this talk on the public sneak peek page, if the sneak peek page is enabled and the talk was accepted."
        ),
    )
    do_not_record = models.BooleanField(
        default=False, verbose_name=_("Don't record this talk.")
    )
    image = models.ImageField(
        null=True,
        blank=True,
        upload_to=submission_image_path,
        verbose_name=_("Talk image"),
        help_text=_("Use this if you want an illustration to go with your submission."),
    )
    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="{}")

    objects = ScopedManager(event="event", _manager_class=SubmissionManager)
    deleted_objects = ScopedManager(
        event="event", _manager_class=DeletedSubmissionManager
    )
    all_objects = ScopedManager(event="event", _manager_class=AllSubmissionManager)

    class urls(EventUrls):
        user_base = "{self.event.urls.user_submissions}{self.code}/"
        withdraw = "{user_base}withdraw"
        confirm = "{user_base}confirm"
        public_base = "{self.event.urls.base}talk/{self.code}"
        public = "{public_base}/"
        feedback = "{public}feedback/"
        ical = "{public_base}.ics"
        image = "{self.image_url}"
        invite = "{user_base}invite"
        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/"
        new_speaker = "{speakers}add"
        delete_speaker = "{speakers}delete"
        reviews = "{base}reviews/"
        feedback = "{base}feedback/"
        toggle_featured = "{base}toggle_featured"
        anonymise = "{base}anonymise/"
        quick_schedule = "{self.event.orga_urls.schedule}quick/{self.code}/"

    @property
    def image_url(self):
        return self.image.url if self.image else ""

    @property
    def editable(self):
        if self.state == SubmissionStates.SUBMITTED:
            return self.event.cfp.is_open or (
                self.event.active_review_phase
                and self.event.active_review_phase.speakers_can_change_submissions
            )
        return self.state in (SubmissionStates.ACCEPTED, SubmissionStates.CONFIRMED)

    @property
    def anonymised(self):
        try:
            result = json.loads(self.anonymised_data)
        except Exception:
            result = None
        if not result or not isinstance(result, dict):
            return {}
        return result

    @property
    def is_anonymised(self):
        if self.anonymised:
            return self.anonymised.get("_anonymised", False)
        return False

    @property
    def reviewer_answers(self):
        return self.answers.filter(question__is_visible_to_reviewers=True)

    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

    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 _set_state(self, new_state, force=False, person=None):
        """Check if the new state is valid for this Submission (based on
        SubmissionStates.valid_next_states).

        If yes, set it and save the object. if no, raise a
        SubmissionError with a helpful message.
        """
        valid_next_states = SubmissionStates.valid_next_states.get(self.state, [])

        if self.state == new_state:
            self.update_talk_slots()
            return
        if force or new_state in valid_next_states:
            old_state = self.state
            self.state = new_state
            self.save(update_fields=["state"])
            self.update_talk_slots()
            submission_state_change.send_robust(
                self.event, submission=self, old_state=old_state, user=person
            )
        else:
            source_states = (
                src
                for src, dsts in SubmissionStates.valid_next_states.items()
                if new_state in dsts
            )

            # build an error message mentioning all states, which are valid source states for the desired new state.
            trans_or = pgettext(
                'used in talk confirm/accept/reject/...-errors, like "... must be accepted OR foo OR bar ..."',
                " or ",
            )
            state_names = dict(SubmissionStates.get_choices())
            source_states = trans_or.join(
                str(state_names[state]) for state in source_states
            )
            raise SubmissionError(
                _(
                    "Submission must be {src_states} not {state} to be {new_state}."
                ).format(
                    src_states=source_states, state=self.state, new_state=new_state
                )
            )

    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

        if self.state not in [SubmissionStates.ACCEPTED, SubmissionStates.CONFIRMED]:
            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,
                )

    update_talk_slots.alters_data = True

    def make_submitted(self, person=None, force: bool = False, orga: bool = False):
        """Sets the submission's state to 'submitted'."""
        self._set_state(SubmissionStates.SUBMITTED, force, person=person)

    make_submitted.alters_data = True

    def confirm(self, person=None, force: bool = False, orga: bool = False):
        """Sets the submission's state to 'confirmed'."""
        self._set_state(SubmissionStates.CONFIRMED, force, person=person)
        self.log_action("pretalx.submission.confirm", person=person, orga=orga)

    confirm.alters_data = True

    def accept(self, person=None, force: bool = False, orga: bool = True):
        """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, force, person=person)
        self.log_action("pretalx.submission.accept", person=person, orga=True)

        if previous != SubmissionStates.CONFIRMED:
            self.send_state_mail()

    accept.alters_data = True

    def reject(self, person=None, force: bool = False, orga: bool = True):
        """Sets the submission's state to 'rejected' and creates a rejection.

        :class:`~pretalx.mail.models.QueuedMail`.
        """
        self._set_state(SubmissionStates.REJECTED, force, person=person)
        self.log_action("pretalx.submission.reject", person=person, orga=True)
        self.send_state_mail()

    reject.alters_data = True

    def send_state_mail(self):
        if self.state == SubmissionStates.ACCEPTED:
            template = self.event.accept_template
        elif self.state == SubmissionStates.REJECTED:
            template = self.event.reject_template
        else:
            return

        kwargs = {
            "event": self.event,
            "context": template_context_from_submission(self),
            "locale": self.content_locale,
        }
        for speaker in self.speakers.all():
            template.to_mail(user=speaker, **kwargs)

    send_state_mail.alters_data = True

    def cancel(self, person=None, force: bool = False, orga: bool = True):
        """Sets the submission's state to 'canceled'."""
        self._set_state(SubmissionStates.CANCELED, force, person=person)
        self.log_action("pretalx.submission.cancel", person=person, orga=True)

    cancel.alters_data = True

    def withdraw(self, person=None, force: bool = False, orga: bool = False):
        """Sets the submission's state to 'withdrawn'."""
        self._set_state(SubmissionStates.WITHDRAWN, force, person=person)
        self.log_action("pretalx.submission.withdraw", person=person, orga=orga)

    withdraw.alters_data = True

    def remove(self, person=None, force: bool = False, orga: bool = True):
        """Sets the submission's state to 'deleted'."""
        self._set_state(SubmissionStates.DELETED, force, person=person)
        for answer in self.answers.all():
            answer.remove(person=person, force=force)
        self.log_action("pretalx.submission.deleted", person=person, orga=True)

    remove.alters_data = True

    @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: i for i, 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).first()
            if self.event.current_schedule
            else None
        )

    @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`.
        """
        from pretalx.agenda.permissions import is_agenda_visible

        if not is_agenda_visible(None, self.event):
            return []
        return self.event.current_schedule.talks.filter(
            submission=self, is_visible=True
        )

    @cached_property
    def display_speaker_names(self):
        """Helper method for a consistent speaker name display."""
        return ", ".join(speaker.get_display_name() for speaker in self.speakers.all())

    @cached_property
    def does_accept_feedback(self):
        slot = self.slot
        if slot and slot.start:
            end = slot.end or slot.start + slot.submission.get_duration()
            return end < now()
        return False

    @cached_property
    def median_score(self):
        scores = [r.score for r in self.reviews.all() if r.score is not None]
        return statistics.median(scores) if scores else None

    @cached_property
    def active_resources(self):
        return self.resources.exclude(resource=None).exclude(resource="")

    @property
    def is_deleted(self):
        return self.state == SubmissionStates.DELETED

    def __str__(self):
        """Help when debugging."""
        return f"Submission(event={self.event.slug}, code={self.code}, title={self.title}, state={self.state})"

    @cached_property
    def export_duration(self):
        from pretalx.common.serialize import serialize_duration

        return serialize_duration(minutes=self.get_duration())

    @cached_property
    def speaker_profiles(self):
        from pretalx.person.models.profile import SpeakerProfile

        return SpeakerProfile.objects.filter(
            event=self.event, user__in=self.speakers.all()
        )

    @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

        all_availabilities = self.event.availabilities.filter(
            person__in=self.speaker_profiles
        )
        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.settings.custom_domain or settings.SITE_URL
                ) + field_content.url
            result += f"**{field_name}**: {field_content}\n\n"
        return result

    def send_invite(self, to, _from=None, subject=None, text=None):
        if not _from and (not subject or not text):
            raise Exception("Please enter a sender for this invitation.")

        subject = subject or _("{speaker} invites you to join their talk!").format(
            speaker=_from.get_display_name()
        )
        subject = f"[{self.event.slug}] {subject}"
        text = (
            text
            or _(
                """Hi!

I'd like to invite you to be a speaker in the talk

  “{title}”

at {event}. Please follow this link to join:

  {url}

I'm looking forward to it!
{speaker}"""
            ).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,).send()

    send_invite.alters_data = True
Example #9
0
class TalkSlot(LogMixin, models.Model):
    """The TalkSlot object is the scheduled version of a.

    :class:`~pretalx.submission.models.submission.Submission`.

    TalkSlots always belong to one submission and one :class:`~pretalx.schedule.models.schedule.Schedule`.

    :param is_visible: This parameter is set on schedule release. Only confirmed talks will be visible.
    """
    submission = models.ForeignKey(to='submission.Submission',
                                   on_delete=models.PROTECT,
                                   related_name='slots')
    room = models.ForeignKey(
        to='schedule.Room',
        on_delete=models.PROTECT,
        related_name='talks',
        null=True,
        blank=True,
    )
    schedule = models.ForeignKey(to='schedule.Schedule',
                                 on_delete=models.PROTECT,
                                 related_name='talks')
    is_visible = models.BooleanField(default=False)
    start = models.DateTimeField(null=True)
    end = models.DateTimeField(null=True)

    objects = ScopedManager(event='submission__event')

    def __str__(self):
        """Help when debugging."""
        return f'TalkSlot(event={self.submission.event.slug}, submission={self.submission.title}, schedule={self.schedule.version})'

    @cached_property
    def event(self):
        return self.submission.event

    @cached_property
    def duration(self) -> int:
        """Returns the actual duration in minutes if the talk is scheduled, and
        the planned duration in minutes otherwise."""
        if self.start and self.end:
            return int((self.end - self.start).total_seconds() / 60)
        return self.submission.get_duration()

    @cached_property
    def export_duration(self):
        from pretalx.common.serialize import serialize_duration

        return serialize_duration(minutes=self.duration)

    @cached_property
    def pentabarf_export_duration(self):
        duration = timedelta(minutes=self.duration)
        days = duration.days
        hours = duration.total_seconds() // 3600 - days * 24
        minutes = duration.seconds // 60 % 60
        return f'{hours:02}{minutes:02}00'

    @cached_property
    def real_end(self):
        """Guaranteed to provide a useful end datetime if ``start`` is set,
        even if ``end`` is empty."""
        return self.end or (self.start + timedelta(minutes=self.duration)
                            if self.start else None)

    @cached_property
    def as_availability(self):
        """'Casts' a slot as.

        :class:`~pretalx.schedule.models.availability.Availability`, useful for
        availability arithmetics.
        """
        from pretalx.schedule.models import Availability

        return Availability(start=self.start,
                            end=self.real_end,
                            event=self.submission.event)

    @cached_property
    def warnings(self) -> list:
        """A list of warnings that apply to this slot.

        Warnings are dictionaries with a ``type`` (``room`` or
        ``speaker``, for now) and a ``message`` fit for public display.
        This property only shows availability based warnings.
        """
        if not self.start:
            return []
        warnings = []
        availability = self.as_availability
        if self.room:
            if not any(
                    room_availability.contains(availability)
                    for room_availability in self.room.availabilities.all()):
                warnings.append({
                    'type':
                    'room',
                    'message':
                    _('The room is not available at the scheduled time.'),
                })
        for speaker in self.submission.speakers.all():
            profile = speaker.event_profile(event=self.submission.event)
            if profile.availabilities.exists() and not any(
                    speaker_availability.contains(availability)
                    for speaker_availability in profile.availabilities.all()):
                warnings.append({
                    'type':
                    'speaker',
                    'speaker': {
                        'name': speaker.get_display_name(),
                        'id': speaker.pk,
                    },
                    'message':
                    _('A speaker is not available at the scheduled time.'),
                })
            overlaps = TalkSlot.objects.filter(
                schedule=self.schedule,
                submission__speakers__in=[speaker]).filter(
                    models.Q(start__lt=self.start, end__gt=self.start)
                    | models.Q(start__lt=self.end, end__gt=self.end)).exists()
            if overlaps:
                warnings.append({
                    'type':
                    'speaker',
                    'speaker': {
                        'name': speaker.get_display_name(),
                        'id': speaker.pk,
                    },
                    'message':
                    _('A speaker is giving another talk at the scheduled time.'
                      ),
                })

        return warnings

    def copy_to_schedule(self, new_schedule, save=True):
        """Create a new slot for the given.

        :class:`~pretalx.schedule.models.schedule.Schedule` with all other
        fields identical to this one.
        """
        new_slot = TalkSlot(schedule=new_schedule)

        for field in [
                f for f in self._meta.fields
                if f.name not in ('id', 'schedule')
        ]:
            setattr(new_slot, field.name, getattr(self, field.name))

        if save:
            new_slot.save()
        return new_slot

    def is_same_slot(self, other_slot) -> bool:
        """Checks if both slots have the same room and start time."""
        return self.room == other_slot.room and self.start == other_slot.start

    def build_ical(self, calendar, creation_time=None, netloc=None):
        if not self.start or not self.end or not self.room:
            return
        creation_time = creation_time or datetime.now(pytz.utc)
        netloc = netloc or urlparse(get_base_url(self.event)).netloc
        tz = pytz.timezone(self.submission.event.timezone)

        vevent = calendar.add('vevent')
        vevent.add(
            'summary'
        ).value = f'{self.submission.title} - {self.submission.display_speaker_names}'
        vevent.add('dtstamp').value = creation_time
        vevent.add('location').value = str(self.room.name)
        vevent.add('uid').value = 'pretalx-{}-{}@{}'.format(
            self.submission.event.slug, self.submission.code, netloc)

        vevent.add('dtstart').value = self.start.astimezone(tz)
        vevent.add('dtend').value = self.end.astimezone(tz)
        vevent.add('description').value = self.submission.abstract or ""
        vevent.add('url').value = self.submission.urls.public.full()
Example #10
0
class Answer(LogMixin, models.Model):
    """Answers are connected to a.

    :class:`~pretalx.submission.models.question.Question`, and, depending on
    type, a :class:`~pretalx.person.models.user.User`, a
    :class:`~pretalx.submission.models.submission.Submission`, or a
    :class:`~pretalx.submission.models.review.Review`.
    """

    question = models.ForeignKey(
        to="submission.Question", on_delete=models.PROTECT, related_name="answers"
    )
    submission = models.ForeignKey(
        to="submission.Submission",
        on_delete=models.PROTECT,
        related_name="answers",
        null=True,
        blank=True,
    )
    person = models.ForeignKey(
        to="person.User",
        on_delete=models.PROTECT,
        related_name="answers",
        null=True,
        blank=True,
    )
    review = models.ForeignKey(
        to="submission.Review",
        on_delete=models.PROTECT,
        related_name="answers",
        null=True,
        blank=True,
    )
    answer = models.TextField()
    answer_file = models.FileField(upload_to=answer_file_path, null=True, blank=True)
    options = models.ManyToManyField(
        to="submission.AnswerOption", related_name="answers"
    )

    objects = ScopedManager(event="question__event")

    @cached_property
    def event(self):
        return self.question.event

    def __str__(self):
        """Help when debugging."""
        return f"Answer(question={self.question.question}, answer={self.answer})"

    def remove(self, person=None, force=False):
        """Deletes an answer."""
        for option in self.options.all():
            option.answers.remove(self)
        self.delete()

    remove.alters_data = True

    @cached_property
    def boolean_answer(self):
        if self.answer == "True":
            return True
        if self.answer == "False":
            return False

    @property
    def answer_string(self):
        if self.question.variant in ("number", "string", "text"):
            return self.answer or ""
        if self.question.variant == "boolean":
            if self.boolean_answer is True:
                return _("Yes")
            if self.boolean_answer is False:
                return _("No")
            return ""
        if self.question.variant == "file":
            return self.answer_file.url if self.answer_file else ""
        if self.question.variant in ("choices", "multiple_choice"):
            return ", ".join(str(option.answer) for option in self.options.all())
Example #11
0
class Schedule(LogMixin, models.Model):
    """The Schedule model contains all scheduled.

    :class:`~pretalx.schedule.models.slot.TalkSlot` objects (visible or not)
    for a schedule release for an :class:`~pretalx.event.models.event.Event`.

    :param published: ``None`` if the schedule has not been published yet.
    """
    event = models.ForeignKey(to='event.Event',
                              on_delete=models.PROTECT,
                              related_name='schedules')
    version = models.CharField(max_length=190,
                               null=True,
                               blank=True,
                               verbose_name=_('version'))
    published = models.DateTimeField(null=True, blank=True)

    objects = ScopedManager(event='event')

    class Meta:
        ordering = ('-published', )
        unique_together = (('event', 'version'), )

    class urls(EventUrls):
        public = '{self.event.urls.schedule}v/{self.url_version}/'

    @transaction.atomic
    def freeze(self, name: str, user=None, notify_speakers: bool = True):
        """Releases the current WIP schedule as a fixed schedule version.

        :param name: The new schedule name. May not be in use in this event,
            and cannot be 'wip' or 'latest'.
        :param user: The :class:`~pretalx.person.models.user.User` initiating
            the freeze.
        :param notify_speakers: Should notification emails for speakers with
            changed slots be generated?
        :rtype: Schedule
        """
        from pretalx.schedule.models import TalkSlot

        if name in ['wip', 'latest']:
            raise Exception(
                f'Cannot use reserved name "{name}" for schedule version.')
        if self.version:
            raise Exception(
                f'Cannot freeze schedule version: already versioned as "{self.version}".'
            )
        if not name:
            raise Exception(
                'Cannot create schedule version without a version name.')

        self.version = name
        self.published = now()
        self.save(update_fields=['published', 'version'])
        self.log_action('pretalx.schedule.release', person=user, orga=True)

        wip_schedule = Schedule.objects.create(event=self.event)

        # Set visibility
        self.talks.filter(
            start__isnull=False,
            submission__state=SubmissionStates.CONFIRMED,
            is_visible=False,
        ).update(is_visible=True)
        self.talks.filter(is_visible=True).exclude(
            start__isnull=False,
            submission__state=SubmissionStates.CONFIRMED).update(
                is_visible=False)

        talks = []
        for talk in self.talks.select_related('submission', 'room').all():
            talks.append(talk.copy_to_schedule(wip_schedule, save=False))
        TalkSlot.objects.bulk_create(talks)

        if notify_speakers:
            self.notify_speakers()

        with suppress(AttributeError):
            del wip_schedule.event.wip_schedule
        with suppress(AttributeError):
            del wip_schedule.event.current_schedule

        if self.event.settings.export_html_on_schedule_release:
            if settings.HAS_CELERY:
                export_schedule_html.apply_async(
                    kwargs={'event_id': self.event.id})
            else:
                self.event.cache.set('rebuild_schedule_export', True, None)
        return self, wip_schedule

    @transaction.atomic
    def unfreeze(self, user=None):
        """Resets the current WIP schedule to an older schedule version."""
        from pretalx.schedule.models import TalkSlot

        if not self.version:
            raise Exception(
                'Cannot unfreeze schedule version: not released yet.')

        # collect all talks, which have been added since this schedule (#72)
        submission_ids = self.talks.all().values_list('submission_id',
                                                      flat=True)
        talks = self.event.wip_schedule.talks.exclude(
            submission_id__in=submission_ids).union(self.talks.all())

        wip_schedule = Schedule.objects.create(event=self.event)
        new_talks = []
        for talk in talks:
            new_talks.append(talk.copy_to_schedule(wip_schedule, save=False))
        TalkSlot.objects.bulk_create(new_talks)

        self.event.wip_schedule.talks.all().delete()
        self.event.wip_schedule.delete()

        with suppress(AttributeError):
            del wip_schedule.event.wip_schedule

        return self, wip_schedule

    @cached_property
    def scheduled_talks(self):
        """Returns all :class:`~pretalx.schedule.models.slot.TalkSlot` objects
        that have been scheduled."""
        return self.talks.select_related(
            'submission',
            'submission__event',
            'room',
        ).filter(room__isnull=False, start__isnull=False,
                 is_visible=True).exclude(
                     submission__state=SubmissionStates.DELETED)

    @cached_property
    def slots(self):
        """Returns all.

        :class:`~pretalx.submission.models.submission.Submission` objects with
        :class:`~pretalx.schedule.models.slot.TalkSlot` objects in this
        schedule.
        """
        from pretalx.submission.models import Submission

        return Submission.objects.filter(
            id__in=self.scheduled_talks.values_list('submission', flat=True))

    @cached_property
    def previous_schedule(self):
        """Returns the schedule released before this one, if any."""
        queryset = self.event.schedules.exclude(pk=self.pk)
        if self.published:
            queryset = queryset.filter(published__lt=self.published)
        return queryset.order_by('-published').first()

    def _handle_submission_move(self, submission_pk, old_slots, new_slots):
        new = []
        canceled = []
        moved = []
        all_old_slots = list(old_slots.filter(submission__pk=submission_pk))
        all_new_slots = list(new_slots.filter(submission__pk=submission_pk))
        old_slots = [
            slot for slot in all_old_slots if not any(
                slot.is_same_slot(other_slot) for other_slot in all_new_slots)
        ]
        new_slots = [
            slot for slot in all_new_slots if not any(
                slot.is_same_slot(other_slot) for other_slot in all_old_slots)
        ]
        diff = len(old_slots) - len(new_slots)
        if diff > 0:
            canceled = old_slots[:diff]
            old_slots = old_slots[diff:]
        elif diff < 0:
            diff = -diff
            new = new_slots[:diff]
            new_slots = new_slots[diff:]
        for move in zip(old_slots, new_slots):
            old_slot = move[0]
            new_slot = move[1]
            moved.append({
                'submission': new_slot.submission,
                'old_start': old_slot.start.astimezone(self.tz),
                'new_start': new_slot.start.astimezone(self.tz),
                'old_room': old_slot.room.name,
                'new_room': new_slot.room.name,
                'new_info': new_slot.room.speaker_info,
            })
        return new, canceled, moved

    @cached_property
    def tz(self):
        return pytz.timezone(self.event.timezone)

    @cached_property
    def changes(self) -> dict:
        """Returns a dictionary of changes when compared to the previous
        version.

        The ``action`` field is either ``create`` or ``update``. If it's
        an update, the ``count`` integer, and the ``new_talks``,
        ``canceled_talks`` and ``moved_talks`` lists are also present.
        """
        result = {
            'count': 0,
            'action': 'update',
            'new_talks': [],
            'canceled_talks': [],
            'moved_talks': [],
        }
        if not self.previous_schedule:
            result['action'] = 'create'
            return result

        old_slots = self.previous_schedule.scheduled_talks
        new_slots = self.scheduled_talks
        old_slot_set = set(
            old_slots.values_list('submission', 'room', 'start', named=True))
        new_slot_set = set(
            new_slots.values_list('submission', 'room', 'start', named=True))
        old_submissions = set(
            old_slots.values_list('submission__id', flat=True))
        new_submissions = set(
            new_slots.values_list('submission__id', flat=True))
        handled_submissions = set()

        moved_or_missing = old_slot_set - new_slot_set
        moved_or_new = new_slot_set - old_slot_set

        for entry in moved_or_missing:
            if entry.submission in handled_submissions:
                continue
            if entry.submission not in new_submissions:
                result['canceled_talks'] += list(
                    old_slots.filter(submission__pk=entry.submission))
            else:
                new, canceled, moved = self._handle_submission_move(
                    entry.submission, old_slots, new_slots)
                result['new_talks'] += new
                result['canceled_talks'] += canceled
                result['moved_talks'] += moved
            handled_submissions.add(entry.submission)
        for entry in moved_or_new:
            if entry.submission in handled_submissions:
                continue
            if entry.submission not in old_submissions:
                result['new_talks'] += list(
                    new_slots.filter(submission__pk=entry.submission))
            else:
                new, canceled, moved = self._handle_submission_move(
                    entry.submission, old_slots, new_slots)
                result['new_talks'] += new
                result['canceled_talks'] += canceled
                result['moved_talks'] += moved
            handled_submissions.add(entry.submission)

        result['count'] = (len(result['new_talks']) +
                           len(result['canceled_talks']) +
                           len(result['moved_talks']))
        return result

    @cached_property
    def warnings(self) -> dict:
        """A dictionary of warnings to be acknowledged pre-release.

        ``talk_warnings`` contains a list of talk-related warnings.
        ``unscheduled`` is the list of talks without a scheduled slot,
        ``unconfirmed`` is the list of submissions that will not be
        visible due to their unconfirmed status, and ``no_track`` are
        submissions without a track in a conference that uses tracks.
        """
        warnings = {
            'talk_warnings': [],
            'unscheduled': [],
            'unconfirmed': [],
            'no_track': [],
        }
        for talk in self.talks.all():
            if not talk.start:
                warnings['unscheduled'].append(talk)
            elif talk.warnings:
                warnings['talk_warnings'].append(talk)
            if talk.submission.state != SubmissionStates.CONFIRMED:
                warnings['unconfirmed'].append(talk)
            if talk.submission.event.settings.use_tracks and not talk.submission.track:
                warnings['no_track'].append(talk)
        return warnings

    @cached_property
    def speakers_concerned(self):
        """Returns a dictionary of speakers with their new and changed talks in
        this schedule.

        Each speaker is assigned a dictionary with ``create`` and
        ``update`` fields, each containing a list of submissions.
        """
        if self.changes['action'] == 'create':
            return {
                speaker: {
                    'create': self.talks.filter(submission__speakers=speaker),
                    'update': [],
                }
                for speaker in User.objects.filter(
                    submissions__slots__schedule=self)
            }

        if self.changes['count'] == len(self.changes['canceled_talks']):
            return []

        speakers = defaultdict(lambda: {'create': [], 'update': []})
        for new_talk in self.changes['new_talks']:
            for speaker in new_talk.submission.speakers.all():
                speakers[speaker]['create'].append(new_talk)
        for moved_talk in self.changes['moved_talks']:
            for speaker in moved_talk['submission'].speakers.all():
                speakers[speaker]['update'].append(moved_talk)
        return speakers

    @cached_property
    def notifications(self):
        """A list of unsaved :class:`~pretalx.mail.models.QueuedMail` objects
        to be sent on schedule release."""
        mails = []
        for speaker in self.speakers_concerned:
            with override(speaker.locale), tzoverride(self.tz):
                notifications = get_template(
                    'schedule/speaker_notification.txt').render({
                        'speaker':
                        speaker,
                        **self.speakers_concerned[speaker]
                    })
            context = template_context_from_event(self.event)
            context['notifications'] = notifications
            mails.append(
                self.event.update_template.to_mail(user=speaker,
                                                   event=self.event,
                                                   context=context,
                                                   commit=False))
        return mails

    def notify_speakers(self):
        """Save the ``notifications`` :class:`~pretalx.mail.models.QueuedMail`
        objects to the outbox."""
        for notification in self.notifications:
            notification.save()

    @cached_property
    def url_version(self):
        return quote(self.version) if self.version else 'wip'

    @cached_property
    def is_archived(self):
        if not self.version:
            return False

        return self != self.event.current_schedule

    def __str__(self) -> str:
        """Help when debugging."""
        return f'Schedule(event={self.event.slug}, version={self.version})'
Example #12
0
class Availability(LogMixin, models.Model):
    """The Availability class models when people or rooms are available for.

    :class:`~pretalx.schedule.models.slot.TalkSlot` objects.

    The power of this class is not within its rather simple data model,
    but with the operations available on it. An availability object can
    span multiple days, but due to our choice of input widget, it will
    usually only span a single day at most.
    """
    event = models.ForeignKey(to='event.Event',
                              related_name='availabilities',
                              on_delete=models.CASCADE)
    person = models.ForeignKey(
        to='person.SpeakerProfile',
        related_name='availabilities',
        on_delete=models.CASCADE,
        null=True,
        blank=True,
    )
    room = models.ForeignKey(
        to='schedule.Room',
        related_name='availabilities',
        on_delete=models.CASCADE,
        null=True,
        blank=True,
    )
    start = models.DateTimeField()
    end = models.DateTimeField()

    objects = ScopedManager(event='event')

    def __str__(self) -> str:
        person = self.person.user.get_display_name() if self.person else None
        room = getattr(self.room, 'name', None)
        event = getattr(getattr(self, 'event', None), 'slug', None)
        return f'Availability(event={event}, person={person}, room={room})'

    def __hash__(self):
        return hash(
            (getattr(self, 'event',
                     None), self.person, self.room, self.start, self.end))

    def __eq__(self, other: 'Availability') -> bool:
        """Comparisons like ``availability1 == availability2``.

        Checks if ``event``, ``person``, ``room``, ``start`` and ``end``
        are the same.
        """
        return all([
            getattr(self, attribute, None) == getattr(other, attribute, None)
            for attribute in ['event', 'person', 'room', 'start', 'end']
        ])

    @cached_property
    def all_day(self) -> bool:
        """Checks if the Availability spans one (or, technically: multiple)
        complete day."""
        return self.start.time() == zerotime and self.end.time() == zerotime

    def serialize(self) -> dict:
        from pretalx.api.serializers.room import AvailabilitySerializer

        return AvailabilitySerializer(self).data

    def overlaps(self, other: 'Availability', strict: bool) -> bool:
        """Test if two Availabilities overlap.

        :param strict: Only count a real overlap as overlap, not direct adjacency.
        """

        if not isinstance(other, Availability):
            raise Exception('Please provide an Availability object')

        if strict:
            return ((self.start <= other.start < self.end)
                    or (self.start < other.end <= self.end)
                    or (other.start <= self.start < other.end)
                    or (other.start < self.end <= other.end))
        return ((self.start <= other.start <= self.end)
                or (self.start <= other.end <= self.end)
                or (other.start <= self.start <= other.end)
                or (other.start <= self.end <= other.end))

    def contains(self, other: 'Availability') -> bool:
        """Tests if this availability starts before and ends after the
        other."""
        return self.start <= other.start and self.end >= other.end

    def merge_with(self, other: 'Availability') -> 'Availability':
        """Return a new Availability which spans the range of this one and the
        given one."""

        if not isinstance(other, Availability):
            raise Exception('Please provide an Availability object.')
        if not other.overlaps(self, strict=False):
            raise Exception('Only overlapping Availabilities can be merged.')

        return Availability(start=min(self.start, other.start),
                            end=max(self.end, other.end))

    def __or__(self, other: 'Availability') -> 'Availability':
        """Performs the merge operation: ``availability1 | availability2``"""
        return self.merge_with(other)

    def intersect_with(self, other: 'Availability') -> 'Availability':
        """Return a new Availability which spans the range covered both by this
        one and the given one."""

        if not isinstance(other, Availability):
            raise Exception('Please provide an Availability object.')
        if not other.overlaps(self, False):
            raise Exception(
                'Only overlapping Availabilities can be intersected.')

        return Availability(start=max(self.start, other.start),
                            end=min(self.end, other.end))

    def __and__(self, other: 'Availability') -> 'Availability':
        """Performs the intersect operation: ``availability1 &
        availability2``"""
        return self.intersect_with(other)

    @classmethod
    def union(cls,
              availabilities: List['Availability']) -> List['Availability']:
        """Return the minimal list of Availability objects which are covered by
        at least one given Availability."""
        if not availabilities:
            return []

        availabilities = sorted(availabilities, key=lambda a: a.start)
        result = [availabilities[0]]
        availabilities = availabilities[1:]

        for avail in availabilities:
            if avail.overlaps(result[-1], False):
                result[-1] = result[-1].merge_with(avail)
            else:
                result.append(avail)

        return result

    @classmethod
    def _pair_intersection(
        cls,
        availabilities_a: List['Availability'],
        availabilities_b: List['Availability'],
    ) -> List['Availability']:
        """return the list of Availabilities, which are covered by each of the
        given sets."""
        result = []

        # yay for O(b*a) time! I am sure there is some fancy trick to make this faster,
        # but we're dealing with less than 100 items in total, sooo.. ¯\_(ツ)_/¯
        for a in availabilities_a:
            for b in availabilities_b:
                if a.overlaps(b, True):
                    result.append(a.intersect_with(b))

        return result

    @classmethod
    def intersection(
            cls,
            *availabilitysets: List['Availability']) -> List['Availability']:
        """Return the list of Availabilities which are covered by all of the
        given sets."""

        # get rid of any overlaps and unmerged ranges in each set
        availabilitysets = [
            cls.union(avialset) for avialset in availabilitysets
        ]
        # bail out for obvious cases (there are no sets given, one of the sets is empty)
        if not availabilitysets:
            return []
        if not all(availabilitysets):
            return []
        # start with the very first set ...
        result = availabilitysets[0]
        for availset in availabilitysets[1:]:
            # ... subtract each of the other sets
            result = cls._pair_intersection(result, availset)
        return result
class QuestionPlaceholder(models.Model):
    question = models.ForeignKey(
        Question,
        on_delete=models.CASCADE,
        verbose_name=_("Question"),
        related_name="plugin_question_placeholders",
    )
    slug = models.SlugField(
        null=True,
        blank=True,
        verbose_name=_("Placeholder name"),
        help_text=_(
            "By default, the placeholder will look like {question_123}, but you can change it to {question_something_else}"
        ),
    )
    fallback_content = I18nTextField(
        null=True,
        blank=True,
        verbose_name=_("Fallback"),
        help_text=_("Will be used when no other condition matches. Can be empty."),
    )
    use_fallback_when_unanswered = models.BooleanField(
        default=False,
        verbose_name=_("Use fallback when the question was not answered"),
        help_text=_(
            "Usually, the fallback will only be used when the question has been answered, but in a way that none "
            "of your rules cover. Turn on if you always want to use the fallback, even when the question has not been "
            "answered at all."
        ),
    )

    objects = ScopedManager(organizer="question__event__organizer")

    @property
    def placeholder_name(self):
        return self.slug or self.question_id

    def render(self, order):
        from pretix.base.models.orders import QuestionAnswer

        matches = {}  # We use the fact that dicts are ordered now
        any_unanswered = False

        for position in order.positions.all():
            answer = QuestionAnswer.objects.filter(
                orderposition=position, question=self.question
            ).first()
            if answer:
                match = None
                for rule in self.rules.all():
                    if rule.matches(answer):
                        match = rule
                        break  # Only the first matching rule is used, for each orderposition.
                matches[match or "fallback"] = True
            else:
                any_unanswered = True

        use_fallback = matches.pop("fallback", False) or (
            any_unanswered and self.use_fallback_when_unanswered
        )
        matches = sorted(list(matches.keys()), key=lambda r: r.position)
        content = [match.content for match in matches]
        if use_fallback:
            content.append(self.fallback_content)
        return "\n\n".join([str(c) for c in content])
Example #14
0
class SubmissionType(LogMixin, models.Model):
    """Each :class:`~pretalx.submission.models.submission.Submission` has one SubmissionType.

    SubmissionTypes are used to group submissions by default duration (which
    can be overridden on a per-submission basis), and to be able to offer
    different deadlines for some parts of the
    :class:`~pretalx.event.models.event.Event`.
    """
    event = models.ForeignKey(to='event.Event',
                              related_name='submission_types',
                              on_delete=models.CASCADE)
    name = I18nCharField(max_length=100, verbose_name=_('name'))
    default_duration = models.PositiveIntegerField(
        default=30,
        verbose_name=_('default duration'),
        help_text=_('Default duration in minutes'),
    )
    deadline = models.DateTimeField(
        null=True,
        blank=True,
        verbose_name=_('deadline'),
        help_text=
        _('If you want a different deadline than the global deadline for this submission type, enter it here.'
          ),
    )

    objects = ScopedManager(event='event')

    class urls(EventUrls):
        base = edit = '{self.event.cfp.urls.types}{self.pk}/'
        default = '{base}default'
        delete = '{base}delete'
        prefilled_cfp = '{self.event.cfp.urls.public}?submission_type={self.slug}'

    def __str__(self) -> str:
        """Used in choice drop downs."""
        if self.default_duration > 60 * 24:
            return _('{name} ({duration} days)').format(
                name=self.name,
                duration=pleasing_number(
                    round(self.default_duration / 60 / 24, 1)),
            )
        if self.default_duration > 90:
            return _('{name} ({duration} hours)').format(
                name=self.name,
                duration=pleasing_number(round(self.default_duration / 60, 1)),
            )
        return _('{name} ({duration} minutes)').format(
            name=self.name, duration=self.default_duration)

    @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 submission type name.
        """
        return f'{self.id}-{slugify(self.name)}'

    def update_duration(self):
        """Updates the duration of all :class:`~pretalx.schedule.models.slot.TalkSlot` objects of :class:`~pretalx.submission.models.submission.Submission` objects of this type.

        Runs only for submissions that do not override their default duration.
        Should be called whenever ``duration`` changes."""
        for submission in self.submissions.filter(duration__isnull=True):
            submission.update_duration()
Example #15
0
class Post(models.Model):
    site = models.ForeignKey(Site, on_delete=models.CASCADE)
    title = models.CharField(max_length=200)

    objects = ScopedManager(site='site', _manager_class=PostManager)
Example #16
0
class Device(LoggedModel):
    organizer = models.ForeignKey(
        'pretixbase.Organizer',
        on_delete=models.PROTECT,
        related_name='devices'
    )
    device_id = models.PositiveIntegerField()
    unique_serial = models.CharField(max_length=190, default=generate_serial, unique=True)
    initialization_token = models.CharField(max_length=190, default=generate_initialization_token, unique=True)
    api_token = models.CharField(max_length=190, unique=True, null=True)
    all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
    limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
    revoked = models.BooleanField(default=False)
    name = models.CharField(
        max_length=190,
        verbose_name=_('Name')
    )
    created = models.DateTimeField(
        auto_now_add=True,
        verbose_name=_('Setup date')
    )
    initialized = models.DateTimeField(
        verbose_name=_('Initialization date'),
        null=True,
    )
    hardware_brand = models.CharField(
        max_length=190,
        null=True, blank=True
    )
    hardware_model = models.CharField(
        max_length=190,
        null=True, blank=True
    )
    software_brand = models.CharField(
        max_length=190,
        null=True, blank=True
    )
    software_version = models.CharField(
        max_length=190,
        null=True, blank=True
    )

    objects = ScopedManager(organizer='organizer')

    class Meta:
        unique_together = (('organizer', 'device_id'),)

    def __str__(self):
        return '#{}: {} ({} {})'.format(
            self.device_id, self.name, self.hardware_brand, self.hardware_model
        )

    def save(self, *args, **kwargs):
        if not self.device_id:
            self.device_id = (self.organizer.devices.aggregate(m=Max('device_id'))['m'] or 0) + 1
        super().save(*args, **kwargs)

    def permission_set(self) -> set:
        return {
            'can_view_orders',
            'can_change_orders',
            'can_manage_gift_cards'
        }

    def get_event_permission_set(self, organizer, event) -> set:
        """
        Gets a set of permissions (as strings) that a token holds for a particular event

        :param organizer: The organizer of the event
        :param event: The event to check
        :return: set of permissions
        """
        has_event_access = (self.all_events and organizer == self.organizer) or (
            event in self.limit_events.all()
        )
        return self.permission_set() if has_event_access else set()

    def get_organizer_permission_set(self, organizer) -> set:
        """
        Gets a set of permissions (as strings) that a token holds for a particular organizer

        :param organizer: The organizer of the event
        :return: set of permissions
        """
        return self.permission_set() if self.organizer == organizer else set()

    def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
        """
        Checks if this token is part of a team that grants access of type ``perm_name``
        to the event ``event``.

        :param organizer: The organizer of the event
        :param event: The event to check
        :param perm_name: The permission, e.g. ``can_change_teams``
        :param request: This parameter is ignored and only defined for compatibility reasons.
        :return: bool
        """
        has_event_access = (self.all_events and organizer == self.organizer) or (
            event in self.limit_events.all()
        )
        if isinstance(perm_name, (tuple, list)):
            return has_event_access and any(p in self.permission_set() for p in perm_name)
        return has_event_access and (not perm_name or perm_name in self.permission_set())

    def has_organizer_permission(self, organizer, perm_name=None, request=None):
        """
        Checks if this token is part of a team that grants access of type ``perm_name``
        to the organizer ``organizer``.

        :param organizer: The organizer to check
        :param perm_name: The permission, e.g. ``can_change_teams``
        :param request: This parameter is ignored and only defined for compatibility reasons.
        :return: bool
        """
        if isinstance(perm_name, (tuple, list)):
            return organizer == self.organizer and any(p in self.permission_set() for p in perm_name)
        return organizer == self.organizer and (not perm_name or perm_name in self.permission_set())

    def get_events_with_any_permission(self):
        """
        Returns a queryset of events the token has any permissions to.

        :return: Iterable of Events
        """
        if self.all_events:
            return self.organizer.events.all()
        else:
            return self.limit_events.all()
Example #17
0
class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    text = models.TextField()

    objects = ScopedManager(site='post__site')
Example #18
0
class Answer(LogMixin, models.Model):
    """Answers are connected to a.

    :class:`~pretalx.submission.models.question.Question`, and, depending on
    type, a :class:`~pretalx.person.models.user.User`, a
    :class:`~pretalx.submission.models.submission.Submission`, or a
    :class:`~pretalx.submission.models.review.Review`.
    """
    question = models.ForeignKey(
        to='submission.Question', on_delete=models.PROTECT, related_name='answers'
    )
    submission = models.ForeignKey(
        to='submission.Submission',
        on_delete=models.PROTECT,
        related_name='answers',
        null=True,
        blank=True,
    )
    person = models.ForeignKey(
        to='person.User',
        on_delete=models.PROTECT,
        related_name='answers',
        null=True,
        blank=True,
    )
    review = models.ForeignKey(
        to='submission.Review',
        on_delete=models.PROTECT,
        related_name='answers',
        null=True,
        blank=True,
    )
    answer = models.TextField()
    answer_file = models.FileField(upload_to=answer_file_path, null=True, blank=True)
    options = models.ManyToManyField(
        to='submission.AnswerOption', related_name='answers'
    )

    objects = ScopedManager(event='question__event')

    @cached_property
    def event(self):
        return self.question.event

    def __str__(self):
        """Help when debugging."""
        return f'Answer(question={self.question.question}, answer={self.answer})'

    def remove(self, person=None, force=False):
        """Deletes an answer."""
        for option in self.options.all():
            option.answers.remove(self)
        self.delete()
    remove.alters_data = True

    @cached_property
    def boolean_answer(self):
        if self.answer == 'True':
            return True
        if self.answer == 'False':
            return False
Example #19
0
class Like(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE)

    objects = ScopedManager(site='post__site', ignore_missing_scopes=True)
Example #20
0
class Question(LogMixin, models.Model):
    """Questions can be asked per.

    :class:`~pretalx.submission.models.submission.Submission`, per speaker, or
    of reviewers per :class:`~pretalx.submission.models.review.Review`.

    Questions can have many types, which offers a flexible framework to give organisers
    the opportunity to get all the information they need.

    :param variant: Can be any of 'number', 'string', 'text', 'boolean',
        'file', 'choices', or 'multiple_choice'. Defined in the
        ``QuestionVariant`` class.
    :param target: Can be any of 'submission', 'speaker', or 'reviewer'.
        Defined in the ``QuestionTarget`` class.
    :param required: If this is ``True``, the answer must be given at
        submission time. On boolean questions: must check box.
    :param position: Position in the question order in this event.
    """
    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,
    )
    target = models.CharField(
        max_length=QuestionTarget.get_max_length(),
        choices=QuestionTarget.get_choices(),
        default=QuestionTarget.SUBMISSION,
        verbose_name=_('question type'),
        help_text=_('Do you require an answer from every speaker or for every talk?'),
    )
    tracks = models.ManyToManyField(
        to='submission.Track',
        related_name='questions',
        help_text=_('You can limit this question to some tracks. Leave this field empty to apply to all tracks.'),
        verbose_name=_('Tracks'),
        blank=True,
    )
    question = I18nCharField(max_length=800, verbose_name=_('question'))
    help_text = I18nCharField(
        null=True,
        blank=True,
        max_length=200,
        verbose_name=_('help text'),
        help_text=_('Will appear just like this text below the question input field.')
        + ' '
        + phrases.base.use_markdown,
    )
    default_answer = models.TextField(
        null=True, blank=True, verbose_name=_('default answer')
    )
    required = models.BooleanField(default=False, verbose_name=_('required'))
    position = models.IntegerField(default=0, verbose_name=_('position'))
    active = models.BooleanField(
        default=True,
        verbose_name=_('active'),
        help_text=_('Inactive questions will no longer be asked.'),
    )
    contains_personal_data = models.BooleanField(
        default=True,
        verbose_name=_('Answers contain personal data'),
        help_text=_(
            'If a user deletes their account, answers of questions for personal data will be removed, too.'
        ),
    )
    min_length = models.PositiveIntegerField(
        null=True,
        blank=True,
        verbose_name=_('Minimum text length'),
        help_text=_('Minimum allowed text in characters or words (set in CfP settings).'),
    )
    max_length = models.PositiveIntegerField(
        null=True,
        blank=True,
        verbose_name=_('Maximum text length'),
        help_text=_('Maximum allowed text lenght in characters or words (set in CfP settings).'),
    )
    is_public = models.BooleanField(
        default=False,
        verbose_name=_('Publish answers'),
        help_text=_('Answers will be shown on talk or speaker pages as appropriate. Please note that you cannot make a question public after the first answers have been given, to allow speakers explicit consent before publishing information.'),
    )
    is_visible_to_reviewers = models.BooleanField(
        default=True,
        verbose_name=_('Show answers to reviewers'),
        help_text=_('Should answers to this question be shown to reviewers? This is helpful if you want to collect personal information, but use anonymous reviews.'),
    )
    objects = ScopedManager(event='event', _manager_class=QuestionManager)
    all_objects = ScopedManager(event='event', _manager_class=AllQuestionManager)

    class urls(EventUrls):
        base = '{self.event.cfp.urls.questions}{self.pk}/'
        edit = '{base}edit'
        up = '{base}up'
        down = '{base}down'
        delete = '{base}delete'
        toggle = '{base}toggle'

    def __str__(self):
        """Help when debugging."""
        return f'Question(event={self.event.slug}, variant={self.variant}, target={self.target}, question={self.question})'

    @cached_property
    def grouped_answers(self):
        if self.variant == QuestionVariant.FILE:
            return [{'answer': answer, 'count': 1} for answer in self.answers.all()]
        if self.variant in [QuestionVariant.CHOICES, QuestionVariant.MULTIPLE]:
            return (
                self.answers.order_by('options')
                .values('options', 'options__answer')
                .annotate(count=models.Count('id'))
                .order_by('-count')
            )
        return list(
            self.answers.order_by('answer')
            .values('answer')
            .annotate(count=models.Count('id'))
            .order_by('-count')
        )

    @cached_property
    def grouped_answers_json(self):
        return json.dumps(list(self.grouped_answers), cls=I18nStrJSONEncoder)

    def missing_answers(self, filter_speakers: list=False, filter_talks: list=False) -> int:
        """Returns how many answers are still missing or this question.

        This method only supports submission questions and speaker questions.
        For missing reviews, please use the Review.find_missing_reviews method.

        :param filter_speakers: Apply only to these speakers.
        :param filter_talks: Apply only to these talks.
        """
        from pretalx.person.models import User

        answers = self.answers.all()
        if filter_speakers or filter_talks:
            answers = answers.filter(
                models.Q(person__in=filter_speakers)
                | models.Q(submission__in=filter_talks)
            )
        answer_count = answers.count()
        if self.target == QuestionTarget.SUBMISSION:
            submissions = filter_talks or self.event.submissions.all()
            return max(submissions.count() - answer_count, 0)
        if self.target == QuestionTarget.SPEAKER:
            users = filter_speakers or User.objects.filter(
                submissions__event_id=self.event.pk
            )
            return max(users.count() - answer_count, 0)
        return 0

    class Meta:
        ordering = ['position']
Example #21
0
class AttendeeProfile(models.Model):
    customer = models.ForeignKey(Customer,
                                 related_name='attendee_profiles',
                                 on_delete=models.CASCADE)
    attendee_name_cached = models.CharField(
        max_length=255,
        verbose_name=_("Attendee name"),
        blank=True,
        null=True,
    )
    attendee_name_parts = models.JSONField(blank=True, default=dict)
    attendee_email = models.EmailField(
        verbose_name=_("Attendee email"),
        blank=True,
        null=True,
    )
    company = models.CharField(max_length=255,
                               blank=True,
                               verbose_name=_('Company name'),
                               null=True)
    street = models.TextField(verbose_name=_('Address'), blank=True, null=True)
    zipcode = models.CharField(max_length=30,
                               verbose_name=_('ZIP code'),
                               blank=True,
                               null=True)
    city = models.CharField(max_length=255,
                            verbose_name=_('City'),
                            blank=True,
                            null=True)
    country = FastCountryField(verbose_name=_('Country'),
                               blank=True,
                               blank_label=_('Select country'),
                               null=True)
    state = models.CharField(max_length=255,
                             verbose_name=pgettext_lazy('address', 'State'),
                             blank=True,
                             null=True)
    answers = models.JSONField(default=list)

    objects = ScopedManager(organizer='customer__organizer')

    @property
    def attendee_name(self):
        if not self.attendee_name_parts:
            return None
        if '_legacy' in self.attendee_name_parts:
            return self.attendee_name_parts['_legacy']
        if '_scheme' in self.attendee_name_parts:
            scheme = PERSON_NAME_SCHEMES[self.attendee_name_parts['_scheme']]
        else:
            scheme = PERSON_NAME_SCHEMES[
                self.customer.organizer.settings.name_scheme]
        return scheme['concatenation'](self.attendee_name_parts).strip()

    @property
    def state_name(self):
        sd = pycountry.subdivisions.get(
            code='{}-{}'.format(self.country, self.state))
        if sd:
            return sd.name
        return self.state

    @property
    def state_for_address(self):
        from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
        if not self.state or str(
                self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS:
            return ""
        if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long':
            return self.state_name
        return self.state

    def describe(self):
        from .items import Question
        from .orders import QuestionAnswer

        parts = [
            self.attendee_name,
            self.attendee_email,
            self.company,
            self.street,
            (self.zipcode or '') + ' ' + (self.city or '') + ' ' +
            (self.state_for_address or ''),
            self.country.name,
        ]
        for a in self.answers:
            value = a.get('value')
            try:
                value = ", ".join(value.values())
            except AttributeError:
                value = str(value)
            answer = QuestionAnswer(
                question=Question(type=a.get('question_type')), answer=value)
            val = str(answer)
            parts.append(f'{a["field_label"]}: {val}')

        return '\n'.join(
            [str(p).strip() for p in parts if p and str(p).strip()])
Example #22
0
class QueuedMail(LogMixin, models.Model):
    """Emails in pretalx are rarely sent directly, hence the name QueuedMail.

    This mechanism allows organisers to make sure they send out the right
    content, and to include personal changes in emails.

    :param sent_at: ``None`` if the mail has not been sent yet.
    :param to_users: All known users to whom this email is addressed.
    :param to: A comma-separated list of email addresses to whom this email
        is addressed. Does not contain any email addresses known to belong
        to users.
    """

    event = models.ForeignKey(
        to="event.Event",
        on_delete=models.PROTECT,
        related_name="queued_mails",
        null=True,
        blank=True,
    )
    to = models.CharField(
        max_length=1000,
        verbose_name=_("To"),
        help_text=_(
            "One email address or several addresses separated by commas."),
        null=True,
        blank=True,
    )
    to_users = models.ManyToManyField(
        to="person.User",
        related_name="mails",
    )
    reply_to = models.CharField(
        max_length=1000,
        null=True,
        blank=True,
        verbose_name=_("Reply-To"),
        help_text=_("By default, the organiser address is used as Reply-To."),
    )
    cc = models.CharField(
        max_length=1000,
        null=True,
        blank=True,
        verbose_name=_("CC"),
        help_text=_(
            "One email address or several addresses separated by commas."),
    )
    bcc = models.CharField(
        max_length=1000,
        null=True,
        blank=True,
        verbose_name=_("BCC"),
        help_text=_(
            "One email address or several addresses separated by commas."),
    )
    subject = models.CharField(max_length=200, verbose_name=_("Subject"))
    text = models.TextField(verbose_name=_("Text"))
    sent = models.DateTimeField(null=True,
                                blank=True,
                                verbose_name=_("Sent at"))

    objects = ScopedManager(event="event")

    class urls(EventUrls):
        base = edit = "{self.event.orga_urls.mail}{self.pk}/"
        delete = "{base}delete"
        send = "{base}send"
        copy = "{base}copy"

    def __str__(self):
        """Help with debugging."""
        sent = self.sent.isoformat() if self.sent else None
        return f"OutboxMail(to={self.to}, subject={self.subject}, sent={sent})"

    @classmethod
    def make_html(cls, text, event=None):
        body_md = bleach.linkify(
            bleach.clean(markdown.markdown(text), tags=ALLOWED_TAGS),
            parse_email=True,
        )
        html_context = {
            "body": body_md,
            "event": event,
            "color": (event.primary_color if event else "") or "#3aa57c",
        }
        return get_template("mail/mailwrapper.html").render(html_context)

    @classmethod
    def make_text(cls, text, event=None):
        if not event or not event.settings.mail_signature:
            return text
        sig = event.settings.mail_signature
        if not sig.strip().startswith("-- "):
            sig = f"-- \n{sig}"
        return f"{text}\n{sig}"

    @classmethod
    def make_subject(cls, text, event=None):
        if not event or not event.settings.mail_subject_prefix:
            return text
        prefix = event.settings.mail_subject_prefix
        if not (prefix.startswith("[") and prefix.endswith("]")):
            prefix = f"[{prefix}]"
        return f"{prefix} {text}"

    @transaction.atomic
    def send(self, requestor=None, orga: bool = True):
        """Sends an email.

        :param requestor: The user issuing the command. Used for logging.
        :type requestor: :class:`~pretalx.person.models.user.User`
        :param orga: Was this email sent as by a privileged user?
        """
        if self.sent:
            raise Exception(
                _("This mail has been sent already. It cannot be sent again."))

        has_event = getattr(self, "event", None)
        text = self.make_text(self.text, event=has_event)
        body_html = self.make_html(text)

        from pretalx.common.mail import mail_send_task

        to = self.to.split(",") if self.to else []
        if self.id:
            to += [user.email for user in self.to_users.all()]
        mail_send_task.apply_async(
            kwargs={
                "to": to,
                "subject": self.make_subject(self.subject, event=has_event),
                "body": text,
                "html": body_html,
                "reply_to": (self.reply_to or "").split(","),
                "event": self.event.pk if has_event else None,
                "cc": (self.cc or "").split(","),
                "bcc": (self.bcc or "").split(","),
            })

        self.sent = now()
        if self.pk:
            self.log_action(
                "pretalx.mail.sent",
                person=requestor,
                orga=orga,
                data={
                    "to_users":
                    [(user.pk, user.email) for user in self.to_users.all()]
                },
            )
            self.save()

    send.alters_data = True

    def copy_to_draft(self):
        """Copies an already sent email to a new object and adds it to the
        outbox."""
        new_mail = deepcopy(self)
        new_mail.pk = None
        new_mail.sent = None
        new_mail.save()
        for user in self.to_users.all():
            new_mail.to_users.add(user)
        return new_mail

    copy_to_draft.alters_data = True
Example #23
0
class Voucher(LoggedModel):
    """
    A Voucher can reserve ticket quota or allow special prices.

    :param event: The event this voucher is valid for
    :type event: Event
    :param subevent: The date in the event series, if event series are enabled
    :type subevent: SubEvent
    :param code: The secret voucher code
    :type code: str
    :param max_usages: The number of times this voucher can be redeemed
    :type max_usages: int
    :param redeemed: The number of times this voucher already has been redeemed
    :type redeemed: int
    :param valid_until: The expiration date of this voucher (optional)
    :type valid_until: datetime
    :param block_quota: If set to true, this voucher will reserve quota for its holder
    :type block_quota: bool
    :param allow_ignore_quota: If set to true, this voucher can be redeemed even if the event is sold out
    :type allow_ignore_quota: bool
    :param price_mode: Sets how this voucher affects a product's price. Can be ``none``, ``set``, ``subtract``
                       or ``percent``.
    :type price_mode: str
    :param value: The value by which the price should be modified in the way specified by ``price_mode``.
    :type value: decimal.Decimal
    :param item: If set, the item to sell
    :type item: Item
    :param variation: If set, the variation to sell
    :type variation: ItemVariation
    :param quota: If set, the quota to choose an item from
    :type quota: Quota
    :param comment: An internal comment that will only be visible to staff, and never displayed to the user
    :type comment: str
    :param tag: Use this field to group multiple vouchers together. If you enter the same value for multiple
                vouchers, you can get statistics on how many of them have been redeemed etc.
    :type tag: str

    Various constraints apply:

    * You need to either select a quota or an item
    * If you select an item that has variations but do not select a variation, you cannot set block_quota
    """
    PRICE_MODES = (
        ('none', _('No effect')),
        ('set', _('Set product price to')),
        ('subtract', _('Subtract from product price')),
        ('percent', _('Reduce product price by (%)')),
    )

    event = models.ForeignKey(
        Event,
        on_delete=models.CASCADE,
        related_name="vouchers",
        verbose_name=_("Event"),
    )
    subevent = models.ForeignKey(
        SubEvent,
        null=True,
        blank=True,
        on_delete=models.CASCADE,
        verbose_name=pgettext_lazy("subevent", "Date"),
    )
    code = models.CharField(verbose_name=_("Voucher code"),
                            max_length=255,
                            default=generate_code,
                            db_index=True,
                            validators=[MinLengthValidator(5)])
    max_usages = models.PositiveIntegerField(
        verbose_name=_("Maximum usages"),
        help_text=_("Number of times this voucher can be redeemed."),
        default=1)
    redeemed = models.PositiveIntegerField(verbose_name=_("Redeemed"),
                                           default=0)
    budget = models.DecimalField(
        verbose_name=_("Maximum discount budget"),
        help_text=_(
            "This is the maximum monetary amount that will be discounted using this voucher across all usages. "
            "If this is sum reached, the voucher can no longer be used."),
        decimal_places=2,
        max_digits=10,
        null=True,
        blank=True)
    valid_until = models.DateTimeField(blank=True,
                                       null=True,
                                       db_index=True,
                                       verbose_name=_("Valid until"))
    block_quota = models.BooleanField(
        default=False,
        verbose_name=_("Reserve ticket from quota"),
        help_text=
        _("If activated, this voucher will be substracted from the affected product\'s quotas, such that it is "
          "guaranteed that anyone with this voucher code does receive a ticket."
          ))
    allow_ignore_quota = models.BooleanField(
        default=False,
        verbose_name=_("Allow to bypass quota"),
        help_text=
        _("If activated, a holder of this voucher code can buy tickets, even if there are none left."
          ))
    price_mode = models.CharField(verbose_name=_("Price mode"),
                                  max_length=100,
                                  choices=PRICE_MODES,
                                  default='none')
    value = models.DecimalField(
        verbose_name=_("Voucher value"),
        decimal_places=2,
        max_digits=10,
        null=True,
        blank=True,
    )
    item = models.ForeignKey(
        Item,
        related_name='vouchers',
        verbose_name=_("Product"),
        null=True,
        blank=True,
        on_delete=models.
        PROTECT,  # We use a fake version of SET_NULL in Item.delete()
        help_text=
        _("This product is added to the user's cart if the voucher is redeemed."
          ))
    variation = models.ForeignKey(
        ItemVariation,
        related_name='vouchers',
        null=True,
        blank=True,
        on_delete=models.
        PROTECT,  # We use a fake version of SET_NULL in ItemVariation.delete() to avoid the semantic change
        # that would happen if we just set variation to None
        verbose_name=_("Product variation"),
        help_text=_(
            "This variation of the product select above is being used."))
    quota = models.ForeignKey(
        Quota,
        related_name='vouchers',
        null=True,
        blank=True,
        on_delete=models.
        PROTECT,  # We use a fake version of SET_NULL in Quota.delete()
        verbose_name=_("Quota"),
        help_text=
        _("If enabled, the voucher is valid for any product affected by this quota."
          ))
    seat = models.ForeignKey(
        Seat,
        related_name='vouchers',
        null=True,
        blank=True,
        on_delete=models.PROTECT,
        verbose_name=_("Specific seat"),
    )
    tag = models.CharField(
        max_length=255,
        verbose_name=_("Tag"),
        blank=True,
        db_index=True,
        help_text=
        _("You can use this field to group multiple vouchers together. If you enter the same value for "
          "multiple vouchers, you can get statistics on how many of them have been redeemed etc."
          ))
    comment = models.TextField(
        blank=True,
        verbose_name=_("Comment"),
        help_text=_(
            "The text entered in this field will not be visible to the user and is available for your "
            "convenience."))
    show_hidden_items = models.BooleanField(
        verbose_name=_("Shows hidden products that match this voucher"),
        default=True)

    objects = ScopedManager(organizer='event__organizer')

    class Meta:
        verbose_name = _("Voucher")
        verbose_name_plural = _("Vouchers")
        unique_together = (("event", "code"), )
        ordering = ('code', )

    def __str__(self):
        return self.code

    def allow_delete(self):
        return self.redeemed == 0 and not self.orderposition_set.exists()

    def clean(self):
        Voucher.clean_item_properties({
            'block_quota': self.block_quota,
        },
                                      self.event,
                                      self.quota,
                                      self.item,
                                      self.variation,
                                      seats_given=bool(self.seat))

    @staticmethod
    def clean_item_properties(data,
                              event,
                              quota,
                              item,
                              variation,
                              block_quota=False,
                              seats_given=False):
        if quota:
            if quota.event != event:
                raise ValidationError(
                    _('You cannot select a quota that belongs to a different event.'
                      ))
            if item:
                raise ValidationError(
                    _('You cannot select a quota and a specific product at the same time.'
                      ))
        elif item:
            if item.event != event:
                raise ValidationError(
                    _('You cannot select an item that belongs to a different event.'
                      ))
            if variation and (not item or not item.has_variations):
                raise ValidationError(
                    _('You cannot select a variation without having selected a product that provides '
                      'variations.'))
            if variation and not item.variations.filter(
                    pk=variation.pk).exists():
                raise ValidationError(
                    _('This variation does not belong to this product.'))
            if item.has_variations and not variation and data.get(
                    'block_quota'):
                raise ValidationError(
                    _('You can only block quota if you specify a specific product variation. '
                      'Otherwise it might be unclear which quotas to block.'))
            if item.category and item.category.is_addon:
                raise ValidationError(
                    _('It is currently not possible to create vouchers for add-on products.'
                      ))
        elif block_quota:
            raise ValidationError(
                _('You need to select a specific product or quota if this voucher should reserve '
                  'tickets.'))
        elif variation:
            raise ValidationError(
                _('You cannot select a variation without having selected a product that provides '
                  'variations.'))

    @staticmethod
    def clean_max_usages(data, redeemed):
        if data.get('max_usages', 1) < redeemed:
            raise ValidationError(_(
                'This voucher has already been redeemed %(redeemed)s times. You cannot reduce the maximum number of '
                'usages below this number.'),
                                  params={'redeemed': redeemed})

    @staticmethod
    def clean_subevent(data, event):
        if event.has_subevents and data.get(
                'block_quota') and not data.get('subevent'):
            raise ValidationError(
                _('If you want this voucher to block quota, you need to select a specific date.'
                  ))
        elif data.get('subevent') and not event.has_subevents:
            raise ValidationError(
                _('You can not select a subevent if your event is not an event series.'
                  ))

    @staticmethod
    def clean_quota_needs_checking(data, old_instance, item_changed, creating):
        # We only need to check for quota on vouchers that are now blocking quota and haven't
        # before (or have blocked a different quota before)
        if data.get('allow_ignore_quota', False):
            return False
        if data.get('block_quota', False):
            is_valid = data.get('valid_until') is None or data.get(
                'valid_until') >= now()
            if not is_valid:
                # If the voucher is not valid, it won't block any quota
                return False

            if creating:
                # This is a new voucher
                return True

            if not old_instance.block_quota:
                # Change from nonblocking to blocking
                return True

            if old_instance.valid_until is not None and old_instance.valid_until < now(
            ):
                # This voucher has been expired and is now valid again and therefore blocks quota again
                return True

            if item_changed:
                # The voucher has been reassigned to a different item, variation or quota
                return True

            if data.get('subevent') != old_instance.subevent:
                # The voucher has been reassigned to a different subevent
                return True

        return False

    @staticmethod
    def clean_quota_get_ignored(old_instance):
        quotas = set()
        was_valid = old_instance and (old_instance.valid_until is None
                                      or old_instance.valid_until >= now())
        if old_instance and old_instance.block_quota and was_valid:
            if old_instance.quota:
                quotas.add(old_instance.quota)
            elif old_instance.variation:
                quotas |= set(
                    old_instance.variation.quotas.filter(
                        subevent=old_instance.subevent))
            elif old_instance.item:
                quotas |= set(
                    old_instance.item.quotas.filter(
                        subevent=old_instance.subevent))
        return quotas

    @staticmethod
    def clean_quota_check(data, cnt, old_instance, event, quota, item,
                          variation):
        old_quotas = Voucher.clean_quota_get_ignored(old_instance)

        if event.has_subevents and data.get(
                'block_quota') and not data.get('subevent'):
            raise ValidationError(
                _('If you want this voucher to block quota, you need to select a specific date.'
                  ))

        if quota:
            if quota in old_quotas:
                return
            else:
                avail = quota.availability(count_waitinglist=False)
        elif item and item.has_variations and not variation:
            raise ValidationError(
                _('You can only block quota if you specify a specific product variation. '
                  'Otherwise it might be unclear which quotas to block.'))
        elif item and variation:
            avail = variation.check_quotas(ignored_quotas=old_quotas,
                                           subevent=data.get('subevent'))
        elif item and not item.has_variations:
            avail = item.check_quotas(ignored_quotas=old_quotas,
                                      subevent=data.get('subevent'))
        else:
            raise ValidationError(
                _('You need to select a specific product or quota if this voucher should reserve '
                  'tickets.'))

        if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None
                                                 and avail[1] < cnt):
            raise ValidationError(
                _('You cannot create a voucher that blocks quota as the selected product or '
                  'quota is currently sold out or completely reserved.'))

    @staticmethod
    def clean_voucher_code(data, event, pk):
        if 'code' in data and Voucher.objects.filter(
                Q(code__iexact=data['code']) & Q(event=event)
                & ~Q(pk=pk)).exists():
            raise ValidationError(
                _('A voucher with this code already exists.'))

    @staticmethod
    def clean_seat_id(data, item, quota, event, pk):
        try:
            if event.has_subevents:
                if not data.get('subevent'):
                    raise ValidationError(
                        _('You need to choose a date if you select a seat.'))
                seat = event.seats.select_related('product').get(
                    seat_guid=data.get('seat'), subevent=data.get('subevent'))
            else:
                seat = event.seats.select_related('product').get(
                    seat_guid=data.get('seat'))
        except Seat.DoesNotExist:
            raise ValidationError(
                _('The specified seat ID "{id}" does not exist for this event.'
                  ).format(id=data.get('seat')))

        if not seat.is_available(ignore_voucher_id=pk, ignore_cart=True):
            raise ValidationError(
                _('The seat "{id}" is currently unavailable (blocked, already sold or a '
                  'different voucher).').format(id=seat.seat_guid))

        if quota:
            raise ValidationError(
                _('You need to choose a specific product if you select a seat.'
                  ))

        if data.get('max_usages', 1) > 1:
            raise ValidationError(
                _('Seat-specific vouchers can only be used once.'))

        if item and seat.product != item:
            raise ValidationError(
                _('You need to choose the product "{prod}" for this seat.').
                format(prod=seat.product))

        if not seat.is_available(ignore_voucher_id=pk):
            raise ValidationError(
                _('The seat "{id}" is already sold or currently blocked.').
                format(id=seat.seat_guid))

        return seat

    def save(self, *args, **kwargs):
        self.code = self.code.upper()
        super().save(*args, **kwargs)
        self.event.cache.set('vouchers_exist', True)

    def delete(self, using=None, keep_parents=False):
        super().delete(using, keep_parents)
        self.event.cache.delete('vouchers_exist')

    def is_in_cart(self) -> bool:
        """
        Returns whether a cart position exists that uses this voucher.
        """
        return self.cartposition_set.exists()

    def is_ordered(self) -> bool:
        """
        Returns whether an order position exists that uses this voucher.
        """
        return self.orderposition_set.exists()

    def applies_to(self, item: Item, variation: ItemVariation = None) -> bool:
        """
        Returns whether this voucher applies to a given item (and optionally
        a variation).
        """
        if self.quota_id:
            if variation:
                return variation.quotas.filter(pk=self.quota_id).exists()
            return item.quotas.filter(pk=self.quota_id).exists()
        if self.item_id and not self.variation_id:
            return self.item_id == item.pk
        if self.item_id:
            return (self.item_id
                    == item.pk) and (variation
                                     and self.variation_id == variation.pk)
        return True

    def is_active(self):
        """
        Returns True if a voucher has not yet been redeemed, but is still
        within its validity (if valid_until is set).
        """
        if self.redeemed >= self.max_usages:
            return False
        if self.valid_until and self.valid_until < now():
            return False
        return True

    def calculate_price(self,
                        original_price: Decimal,
                        max_discount: Decimal = None) -> Decimal:
        """
        Returns how the price given in original_price would be modified if this
        voucher is applied, i.e. replaced by a different price or reduced by a
        certain percentage. If the voucher does not modify the price, the
        original price will be returned.
        """
        if self.value is not None:
            if self.price_mode == 'set':
                p = self.value
            elif self.price_mode == 'subtract':
                p = max(original_price - self.value, Decimal('0.00'))
            elif self.price_mode == 'percent':
                p = round_decimal(original_price *
                                  (Decimal('100.00') - self.value) /
                                  Decimal('100.00'))
            else:
                p = original_price
            places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
            if places < 2:
                p = p.quantize(Decimal('1') / 10**places, ROUND_HALF_UP)
            if max_discount is not None:
                p = max(p, original_price - max_discount)
            return p
        return original_price

    def distinct_orders(self):
        """
        Return the list of orders where this voucher has been used.
        Each order will appear at most once.
        """

        return Order.objects.filter(
            all_positions__voucher__in=[self]).distinct()

    def seating_available(self, subevent):
        kwargs = {}
        if self.subevent:
            kwargs['subevent'] = self.subevent
        if self.quota_id:
            return SeatCategoryMapping.objects.filter(
                product__quotas__pk=self.quota_id, **kwargs).exists()
        elif self.item_id:
            return self.item.seat_category_mappings.filter(**kwargs).exists()
        else:
            return bool(
                subevent.seating_plan) if subevent else self.event.seating_plan

    @classmethod
    def annotate_budget_used_orders(cls, qs):
        opq = OrderPosition.objects.filter(
            voucher_id=OuterRef('pk'),
            price_before_voucher__isnull=False,
            order__status__in=[
                Order.STATUS_PAID, Order.STATUS_PENDING
            ]).order_by().values('voucher_id').annotate(
                s=Sum(F('price_before_voucher') - F('price'))).values('s')
        return qs.annotate(budget_used_orders=Coalesce(
            Subquery(opq,
                     output_field=models.DecimalField(
                         max_digits=10, decimal_places=2)), Decimal('0.00')))

    def budget_used(self):
        ops = OrderPosition.objects.filter(
            voucher=self,
            price_before_voucher__isnull=False,
            order__status__in=[
                Order.STATUS_PAID, Order.STATUS_PENDING
            ]).aggregate(s=Sum(F('price_before_voucher') -
                               F('price')))['s'] or Decimal('0.00')
        return ops
Example #24
0
class QueuedMail(LogMixin, models.Model):
    """Emails in pretalx are rarely sent directly, hence the name QueuedMail.

    This mechanism allows organisers to make sure they send out the right
    content, and to include personal changes in emails.

    :param sent_at: ``None`` if the mail has not been sent yet.
    :param to_users: All known users to whom this email is addressed.
    :param to: A comma-separated list of email addresses to whom this email
        is addressed. Does not contain any email addresses known to belong
        to users.
    """
    event = models.ForeignKey(
        to='event.Event',
        on_delete=models.PROTECT,
        related_name='queued_mails',
        null=True,
        blank=True,
    )
    to = models.CharField(
        max_length=1000,
        verbose_name=_('To'),
        help_text=_(
            'One email address or several addresses separated by commas.'),
        null=True,
        blank=True,
    )
    to_users = models.ManyToManyField(
        to='person.User',
        related_name='mails',
    )
    reply_to = models.CharField(
        max_length=1000,
        null=True,
        blank=True,
        verbose_name=_('Reply-To'),
        help_text=_('By default, the organiser address is used as Reply-To.'),
    )
    cc = models.CharField(
        max_length=1000,
        null=True,
        blank=True,
        verbose_name=_('CC'),
        help_text=_(
            'One email address or several addresses separated by commas.'),
    )
    bcc = models.CharField(
        max_length=1000,
        null=True,
        blank=True,
        verbose_name=_('BCC'),
        help_text=_(
            'One email address or several addresses separated by commas.'),
    )
    subject = models.CharField(max_length=200, verbose_name=_('Subject'))
    text = models.TextField(verbose_name=_('Text'))
    sent = models.DateTimeField(null=True,
                                blank=True,
                                verbose_name=_('Sent at'))

    objects = ScopedManager(event='event')

    class urls(EventUrls):
        base = edit = '{self.event.orga_urls.mail}{self.pk}/'
        delete = '{base}delete'
        send = '{base}send'
        copy = '{base}copy'

    def __str__(self):
        """Help with debugging."""
        sent = self.sent.isoformat() if self.sent else None
        return f'OutboxMail(to={self.to}, subject={self.subject}, sent={sent})'

    @classmethod
    def make_html(cls, text, event=None):
        body_md = bleach.linkify(
            bleach.clean(markdown.markdown(text), tags=ALLOWED_TAGS),
            parse_email=True,
        )
        html_context = {
            'body': body_md,
            'event': event,
            'color': (event.primary_color if event else '') or '#1c4a3b',
        }
        return get_template('mail/mailwrapper.html').render(html_context)

    @classmethod
    def make_text(cls, text, event=None):
        if not event or not event.settings.mail_signature:
            return text
        sig = event.settings.mail_signature
        if not sig.strip().startswith('-- '):
            sig = f'-- \n{sig}'
        return f'{text}\n{sig}'

    @classmethod
    def make_subject(cls, text, event=None):
        if not event or not event.settings.mail_subject_prefix:
            return text
        prefix = event.settings.mail_subject_prefix
        if not (prefix.startswith('[') and prefix.endswith(']')):
            prefix = f'[{prefix}]'
        return f'{prefix} {text}'

    @transaction.atomic
    def send(self, requestor=None, orga: bool = True):
        """Sends an email.

        :param requestor: The user issuing the command. Used for logging.
        :type requestor: :class:`~pretalx.person.models.user.User`
        :param orga: Was this email sent as by a privileged user?
        """
        if self.sent:
            raise Exception(
                _('This mail has been sent already. It cannot be sent again.'))

        has_event = getattr(self, 'event', None)
        text = self.make_text(self.text, event=has_event)
        body_html = self.make_html(text)

        from pretalx.common.mail import mail_send_task

        to = (self.to or '').split(',')
        if self.id:
            to += [user.email for user in self.to_users.all()]
        mail_send_task.apply_async(
            kwargs={
                'to': to,
                'subject': self.make_subject(self.subject, event=has_event),
                'body': text,
                'html': body_html,
                'reply_to': (self.reply_to or '').split(','),
                'event': self.event.pk if has_event else None,
                'cc': (self.cc or '').split(','),
                'bcc': (self.bcc or '').split(','),
            })

        self.sent = now()
        if self.pk:
            self.log_action(
                'pretalx.mail.sent',
                person=requestor,
                orga=orga,
                data={
                    'to_users':
                    [(user.pk, user.email) for user in self.to_users.all()]
                },
            )
            self.save()

    def copy_to_draft(self):
        """Copies an already sent email to a new object and adds it to the
        outbox."""
        new_mail = deepcopy(self)
        new_mail.pk = None
        new_mail.sent = None
        new_mail.save()
        for user in self.to_users.all():
            new_mail.to_users.add(user)
        return new_mail
Example #25
0
class ReviewPhase(models.Model):
    """ReviewPhases determine reviewer access rights during a (potentially open) timeframe.

    :param is_active: Is this phase currently active? There can be only one
        active phase per event. Use the ``activate`` method to activate a
        review phase, as it will take care of this limitation.
    :param position: Helper field to deal with relative positioning of review
        phases next to each other.
    """
    event = models.ForeignKey(to='event.Event',
                              related_name='review_phases',
                              on_delete=models.CASCADE)
    name = models.CharField(verbose_name=_('Name'), max_length=100)
    start = models.DateTimeField(verbose_name=_('Phase start'),
                                 null=True,
                                 blank=True)
    end = models.DateTimeField(verbose_name=_('Phase end'),
                               null=True,
                               blank=True)
    position = models.PositiveIntegerField(default=0)
    is_active = models.BooleanField(default=False)

    can_review = models.BooleanField(
        verbose_name=_('Reviewers can write and edit reviews'),
        default=True,
    )
    can_see_other_reviews = models.CharField(
        verbose_name=_('Reviewers can see other reviews'),
        max_length=12,
        choices=(('always', _('Always')), ('never', _('Never')),
                 ('after_review', _('After reviewing the submission'))),
        default='after_review',
    )
    can_see_speaker_names = models.BooleanField(
        verbose_name=_('Reviewers can see speaker names'),
        default=True,
    )
    can_change_submission_state = models.BooleanField(
        verbose_name=_('Reviewers can accept and reject submissions'),
        default=False,
    )
    speakers_can_change_submissions = models.BooleanField(
        verbose_name=_(
            'Speakers can modify their submissions before acceptance'),
        help_text=
        _('By default, modification of submissions is locked after the CfP ends, and is re-enabled once the submission was accepted.'
          ),
        default=False,
    )

    objects = ScopedManager(event='event')

    class Meta:
        ordering = ('position', )

    class urls(EventUrls):
        base = '{self.event.orga_urls.review_settings}phase/{self.pk}/'
        delete = '{base}delete'
        up = '{base}up'
        down = '{base}down'
        activate = '{base}activate'

    def activate(self) -> None:
        """Activates this review phase and deactivates all others in this event."""
        self.event.review_phases.all().update(is_active=False)
        self.is_active = True
        self.save()
Example #26
0
class MailTemplate(LogMixin, models.Model):
    """MailTemplates can be used to create.

    :class:`~pretalx.mail.models.QueuedMail` objects.

    The process does not come with variable substitution except for
    hardcoded cases, for now.
    """
    event = models.ForeignKey(
        to='event.Event',
        on_delete=models.PROTECT,
        related_name='mail_templates',
    )
    subject = I18nCharField(
        max_length=200,
        verbose_name=_('Subject'),
    )
    text = I18nTextField(verbose_name=_('Text'), )
    reply_to = models.CharField(
        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 organiser 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!'
          ),
    )

    objects = ScopedManager(event='event')

    class urls(EventUrls):
        base = edit = '{self.event.orga_urls.mail_templates}{self.pk}/'
        delete = '{base}delete'

    def __str__(self):
        """Help with debugging."""
        return f'MailTemplate(event={self.event.slug}, subject={self.subject})'

    def to_mail(
        self,
        user,
        event,
        locale: str = None,
        context: dict = None,
        skip_queue: bool = False,
        commit: bool = True,
        submission=None,
        full_submission_content: bool = False,
    ):
        """Creates a :class:`~pretalx.mail.models.QueuedMail` object from a
        MailTemplate.

        :param user: Either a :class:`~pretalx.person.models.user.User` or an
            email address as a string.
        :param event: The event to which this email belongs. May be ``None``.
        :param locale: The locale will be set via the event and the recipient,
            but can be overridden with this parameter.
        :param context: Context to be used when rendering the template.
        :param skip_queue: Send directly without saving. Use with caution, as
            it removes any logging and traces.
        :param commit: Set ``False`` to return an unsaved object.
        :param submission: Pass a submission if one is related to the mail.
            Will be used to generate context.
        :param full_submission_content: Attach the complete submission with
            all its fields to the email.
        """
        from pretalx.person.models import User
        if isinstance(user, str):
            address = user
            users = None
        elif isinstance(user, User):
            address = None
            users = [user]
        else:
            raise Exception(
                'First argument to to_mail must be a string or a User, not ' +
                str(type(user)))
        if users and (not commit or skip_queue):
            address = ','.join(user.email for user in users)
            users = None

        with override(locale):
            context = context or dict()
            try:
                subject = str(self.subject).format(**context)
                text = str(self.text).format(**context)
                if submission and full_submission_content:
                    text += '\n\n\n***********\n\n' + str(
                        _('Full submission content:\n\n'))
                    text += submission.get_content_for_mail()
            except KeyError as e:
                raise SendMailException(
                    f'Experienced KeyError when rendering Text: {str(e)}')

            if len(subject) > 200:
                subject = subject[:198] + '…'

            mail = QueuedMail(
                event=self.event,
                to=address,
                reply_to=self.reply_to,
                bcc=self.bcc,
                subject=subject,
                text=text,
            )
            if skip_queue:
                mail.send()
            elif commit:
                mail.save()
                mail.to_users.set(users)
        return mail
Example #27
0
class ActivityLog(models.Model):
    """This model logs actions within an event.

    It is **not** designed to provide a complete or reliable audit trail."""
    event = models.ForeignKey(
        to='event.Event',
        on_delete=models.PROTECT,
        related_name='log_entries',
        null=True,
        blank=True,
    )
    person = models.ForeignKey(
        to='person.User',
        on_delete=models.PROTECT,
        related_name='log_entries',
        null=True,
        blank=True,
    )
    content_type = models.ForeignKey(to=ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField(db_index=True)
    content_object = GenericForeignKey('content_type', 'object_id')
    timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
    action_type = models.CharField(max_length=200)
    data = models.TextField(null=True, blank=True)
    is_orga_action = models.BooleanField(default=False)

    objects = ScopedManager(event='event')

    class Meta:
        ordering = ('-timestamp', )

    def __str__(self):
        """Custom __str__ to help with debugging."""
        event = getattr(self.event, 'slug', 'None')
        person = getattr(self.person, 'name', 'None')
        return f'ActivityLog(event={event}, person={person}, content_object={self.content_object}, action_type={self.action_type})'

    def display(self):
        response = LOG_NAMES.get(self.action_type)
        if response is None:
            import logging

            logger = logging.getLogger(__name__)
            logger.warning(f'Unknown log action "{self.action_type}".')
            return self.action_type
        return response

    def get_public_url(self) -> str:
        """Returns a public URL to the object in question (if any)."""
        if isinstance(self.content_object, Submission):
            return self.content_object.urls.public
        if isinstance(self.content_object, CfP):
            return self.content_object.urls.public
        return ''

    def get_orga_url(self) -> str:
        """Returns an organiser backend URL to the object in question (if any)."""
        if isinstance(self.content_object, Submission):
            return self.content_object.orga_urls.base
        if isinstance(self.content_object, Question):
            return self.content_object.urls.base
        if isinstance(self.content_object, AnswerOption):
            return self.content_object.question.urls.base
        if isinstance(self.content_object, Answer):
            if self.content_object.submission:
                return self.content_object.submission.orga_urls.base
            return self.content_object.question.urls.base
        if isinstance(self.content_object, CfP):
            return self.content_object.urls.text
        if isinstance(self.content_object, (MailTemplate, QueuedMail)):
            return self.content_object.urls.base
        return ''
Example #28
0
class WaitingListEntry(LoggedModel):
    event = models.ForeignKey(
        Event,
        on_delete=models.CASCADE,
        related_name="waitinglistentries",
        verbose_name=_("Event"),
    )
    subevent = models.ForeignKey(
        SubEvent,
        null=True, blank=True,
        on_delete=models.CASCADE,
        verbose_name=pgettext_lazy("subevent", "Date"),
    )
    created = models.DateTimeField(
        verbose_name=_("On waiting list since"),
        auto_now_add=True
    )
    email = models.EmailField(
        verbose_name=_("E-mail address")
    )
    voucher = models.ForeignKey(
        'Voucher',
        verbose_name=_("Assigned voucher"),
        null=True, blank=True,
        related_name='waitinglistentries',
        on_delete=models.CASCADE
    )
    item = models.ForeignKey(
        Item, related_name='waitinglistentries', on_delete=models.CASCADE,
        verbose_name=_("Product"),
        help_text=_(
            "The product the user waits for."
        )
    )
    variation = models.ForeignKey(
        ItemVariation, related_name='waitinglistentries',
        null=True, blank=True, on_delete=models.CASCADE,
        verbose_name=_("Product variation"),
        help_text=_(
            "The variation of the product selected above."
        )
    )
    locale = models.CharField(
        max_length=190,
        default='en'
    )
    priority = models.IntegerField(default=0)

    objects = ScopedManager(organizer='event__organizer')

    class Meta:
        verbose_name = _("Waiting list entry")
        verbose_name_plural = _("Waiting list entries")
        ordering = ('-priority', 'created')

    def __str__(self):
        return '%s waits for %s' % (str(self.email), str(self.item))

    def clean(self):
        WaitingListEntry.clean_duplicate(self.email, self.item, self.variation, self.subevent, self.pk)
        WaitingListEntry.clean_itemvar(self.event, self.item, self.variation)
        WaitingListEntry.clean_subevent(self.event, self.subevent)

    def send_voucher(self, quota_cache=None, user=None, auth=None):
        availability = (
            self.variation.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)
            if self.variation
            else self.item.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache)
        )
        if availability[1] is None or availability[1] < 1:
            raise WaitingListException(_('This product is currently not available.'))
        if self.voucher:
            raise WaitingListException(_('A voucher has already been sent to this person.'))
        if '@' not in self.email:
            raise WaitingListException(_('This entry is anonymized and can no longer be used.'))

        with transaction.atomic():
            v = Voucher.objects.create(
                event=self.event,
                max_usages=1,
                valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours),
                item=self.item,
                variation=self.variation,
                tag='waiting-list',
                comment=_('Automatically created from waiting list entry for {email}').format(
                    email=self.email
                ),
                block_quota=True,
                subevent=self.subevent,
            )
            v.log_action('pretix.voucher.added.waitinglist', {
                'item': self.item.pk,
                'variation': self.variation.pk if self.variation else None,
                'tag': 'waiting-list',
                'block_quota': True,
                'valid_until': v.valid_until.isoformat(),
                'max_usages': 1,
                'email': self.email,
                'waitinglistentry': self.pk,
                'subevent': self.subevent.pk if self.subevent else None,
            }, user=user, auth=auth)
            self.log_action('pretix.waitinglist.voucher', user=user, auth=auth)
            self.voucher = v
            self.save()

        with language(self.locale):
            mail(
                self.email,
                _('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
                self.event.settings.mail_text_waiting_list,
                get_email_context(event=self.event, waiting_list_entry=self),
                self.event,
                locale=self.locale
            )

    @staticmethod
    def clean_itemvar(event, item, variation):
        if event != item.event:
            raise ValidationError(_('The selected item does not belong to this event.'))
        if item.has_variations and (not variation or variation.item != item):
            raise ValidationError(_('Please select a specific variation of this product.'))

    @staticmethod
    def clean_subevent(event, subevent):
        if event.has_subevents:
            if not subevent:
                raise ValidationError(_('Subevent cannot be null for event series.'))
            if event != subevent.event:
                raise ValidationError(_('The subevent does not belong to this event.'))
        else:
            if subevent:
                raise ValidationError(_('The subevent does not belong to this event.'))

    @staticmethod
    def clean_duplicate(email, item, variation, subevent, pk):
        if WaitingListEntry.objects.filter(
                item=item, variation=variation, email__iexact=email, voucher__isnull=True, subevent=subevent
        ).exclude(pk=pk).exists():
            raise ValidationError(_('You are already on this waiting list! We will notify '
                                    'you as soon as we have a ticket available for you.'))