Source code for pretalx.submission.models.review

from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField

from pretalx.common.urls import EventUrls


class ReviewScoreCategory(models.Model):
    event = models.ForeignKey(
        to="event.Event", related_name="score_categories", on_delete=models.CASCADE
    )
    name = I18nCharField()
    weight = models.DecimalField(max_digits=4, decimal_places=1, default=1)
    required = models.BooleanField(default=False)
    active = models.BooleanField(default=True)
    limit_tracks = models.ManyToManyField(
        to="submission.Track",
        verbose_name=_("Limit to tracks"),
        blank=True,
        help_text=_("Leave empty to use this category for all tracks."),
    )
    is_independent = models.BooleanField(
        default=False,
        verbose_name=_("Independent score"),
        help_text=_(
            "Independent scores are not part of the total score. Instead they are shown in a separate column in the review dashboard."
        ),
    )

    objects = ScopedManager(event="event")

    class urls(EventUrls):
        base = "{self.event.orga_urls.review_settings}category/{self.pk}/"
        delete = "{base}delete"

    @classmethod
    def recalculate_scores(cls, event):
        for review in event.reviews.all():
            review.save(update_score=True)

    def save(self, *args, **kwargs):
        if self.is_independent:
            self.weight = 0
        return super().save(*args, **kwargs)


class ReviewScore(models.Model):
    category = models.ForeignKey(
        to=ReviewScoreCategory, related_name="scores", on_delete=models.CASCADE
    )
    value = models.DecimalField(max_digits=3, decimal_places=1)
    label = models.CharField(null=True, blank=True, max_length=100)

    objects = ScopedManager(event="category__event")

    def __str__(self):
        value = self.value
        if int(value) == value:
            value = int(value)
        if self.label and self.label != str(value):
            return f"{self.label} ({value})"
        return str(value)

    class Meta:
        ordering = ("value",)


[docs]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: This score is calculated from all the related ``scores`` and their weights. Do not set it directly, use the ``update_score`` method instead. """ 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.DecimalField( max_digits=10, decimal_places=2, verbose_name=_("Score"), null=True, blank=True ) scores = models.ManyToManyField(to=ReviewScore, related_name="reviews") 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})"
[docs] @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=ignore) # This is not randomised, because order_by("review_count", "?") sets all annotated # review_count values to 1. return queryset.order_by("review_count")
@classmethod def calculate_score(cls, scores): if not scores: return None return sum(score.value * score.category.weight for score in scores) @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.score is None: return "×" if int(self.score) == self.score: return str(int(self.score)) return str(self.score) def update_score(self): scores = ( self.scores.all() .select_related("category") .filter(category__in=self.submission.score_categories) ) self.score = self.calculate_score(scores) def save(self, *args, update_score=True, **kwargs): if self.id and update_score: self.update_score() return super().save(*args, **kwargs) class urls(EventUrls): base = "{self.submission.orga_urls.reviews}" delete = "{base}{self.pk}/delete"
[docs]class ReviewPhase(models.Model): """ReviewPhases determine reviewer access rights during a (potentially open) time frame. :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 proposal")), ), default="after_review", ) can_see_speaker_names = models.BooleanField( verbose_name=_("Reviewers can see speaker names"), default=True, ) can_see_reviewer_names = models.BooleanField( verbose_name=_("Reviewers can see the names of other reviewers"), default=True, ) can_change_submission_state = models.BooleanField( verbose_name=_("Reviewers can accept and reject proposals"), default=False, ) can_tag_submissions = models.CharField( verbose_name=_("Reviewers can tag proposals"), max_length=12, choices=( ("never", _("Never")), ("use_tags", _("Add and remove existing tags")), ("create_tags", _("Add, remove and create tags")), ), default="never", ) speakers_can_change_submissions = models.BooleanField( verbose_name=_("Speakers can modify their proposals before acceptance"), help_text=_( "By default, modification of proposals is locked after the CfP ends, and is re-enabled once the proposal 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"
[docs] 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()
activate.alters_data = True