TabbycatDebate/tabbycat

View on GitHub
tabbycat/results/models.py

Summary

Maintainability
A
3 hrs
Test Coverage
C
71%
import logging
from threading import Lock

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from motions.models import RoundMotion
from utils.misc import badge_datetime_format, reverse_tournament

from .result import DebateResult
from .utils import readable_ballotsub_result

logger = logging.getLogger(__name__)


class ScoreField(models.FloatField):
    pass


class Submission(models.Model):
    """Abstract base class to provide functionality common to different
    types of submissions.

    The unique_together class attribute of the Meta class MUST be set in
    all subclasses."""

    class Submitter(models.TextChoices):
        TABROOM = 'T', _("Tab room")
        PUBLIC = 'P', _("Public")
        AUTOMATION = 'A', _("Automation")

    timestamp = models.DateTimeField(auto_now_add=True,
        verbose_name=_("timestamp"))
    version = models.PositiveIntegerField(
        verbose_name=_("version"))
    submitter_type = models.CharField(max_length=1, choices=Submitter.choices,
        verbose_name=_("submitter type"))
    confirmed = models.BooleanField(default=False,
        verbose_name=_("confirmed"))

    # relevant for private URL submissions
    private_url = models.BooleanField(default=False,
        verbose_name=_("from private URL"))
    participant_submitter = models.ForeignKey('participants.Person', models.PROTECT,
        blank=True, null=True, related_name="%(app_label)s_%(class)s_participant_submitted",
        verbose_name=_("from participant"))

    # only relevant if submitter was in tab room
    submitter = models.ForeignKey(settings.AUTH_USER_MODEL, models.PROTECT,
        blank=True, null=True, related_name="%(app_label)s_%(class)s_submitted",
        verbose_name=_("submitter"))
    confirmer = models.ForeignKey(settings.AUTH_USER_MODEL, models.PROTECT,
        blank=True, null=True, related_name="%(app_label)s_%(class)s_confirmed",
        verbose_name=_("confirmer"))
    confirm_timestamp = models.DateTimeField(blank=True, null=True,
        verbose_name=_("confirm timestamp"))
    ip_address = models.GenericIPAddressField(blank=True, null=True,
        verbose_name=_("IP address"))

    save_lock = Lock()

    class Meta:
        abstract = True

    @property
    def _unique_filter_args(self):
        return dict((arg, getattr(self, arg)) for arg in self._meta.unique_together[0]
                    if arg != 'version')

    def _unique_unconfirm_args(self):
        return self._unique_filter_args

    def save(self, *args, **kwargs):
        # Use a lock to protect against the possibility that two submissions do this
        # at the same time and get the same version number or both be confirmed.
        with self.save_lock:

            # Assign the version field to one more than the current maximum version.
            if self.pk is None:
                existing = self.__class__.objects.filter(**self._unique_filter_args)
                if existing.exists():
                    self.version = existing.aggregate(models.Max('version'))['version__max'] + 1
                else:
                    self.version = 1

            # Check for uniqueness.
            if self.confirmed:
                unconfirmed = self.__class__.objects.filter(confirmed=True,
                        **self._unique_unconfirm_args()).exclude(pk=self.pk).update(confirmed=False)
                if unconfirmed > 0:
                    logger.info("Unconfirmed %d %s so that %s could be confirmed", unconfirmed, self._meta.verbose_name_plural, self)

            super(Submission, self).save(*args, **kwargs)

    def clean(self):
        super().clean()
        if self.submitter_type == self.Submitter.TABROOM and self.submitter is None:
            raise ValidationError(_("A tab room ballot must have a user associated."))


class BallotSubmission(Submission):
    """Represents a single submission of ballots for a debate.
    (Not a single motion, but a single submission of all ballots for a debate.)"""

    debate = models.ForeignKey('draw.Debate', models.CASCADE, db_index=True,
        verbose_name=_("debate"))
    motion = models.ForeignKey('motions.Motion', models.SET_NULL, blank=True, null=True,
        verbose_name=_("motion"))
    discarded = models.BooleanField(default=False,
        verbose_name=_("discarded"))
    single_adj = models.BooleanField(default=False,
        verbose_name=_("single adjudicator"),
        help_text=_("Whether this submission represents only the submitting adjudicator on a panel, "
                    "when individual adjudicator ballots are enabled."))

    class Meta:
        unique_together = [('debate', 'version')]
        verbose_name = _("ballot submission")
        verbose_name_plural = _("ballot submissions")

    def __str__(self):
        if self.timestamp is None:
            return "[{0.id}] Ballot for {0.debate!s}, no submission time (v{0.version})".format(self)
        else:
            return ("[{0.id}] Ballot for {0.debate!s}, submitted at "
                "{0.timestamp:%Y-%m-%dT%H:%M:%S} (v{0.version})").format(self)

    @property
    def result(self):
        if not hasattr(self, "_result"):
            self._result = DebateResult(self)
        return self._result

    def clean(self):
        # The motion must be from the relevant round
        super().clean()
        if self.motion is not None and self.debate.round not in self.motion.rounds.all():
            raise ValidationError(_("Debate is in round %(round)d but motion (%(motion)s) is not in round") % {
                    'round': self.debate.round,
                    'motion': self.motion.reference})

        if self.confirmed and self.discarded:
            raise ValidationError(_("A ballot can't be both confirmed and discarded!"))

    @property
    def serialize_like_actionlog(self):
        if hasattr(self, '_result'):
            dr = self._result
        else:
            from results.result import DebateResult
            dr = DebateResult(self)
        result_winner, result = readable_ballotsub_result(dr)
        return {
            'user': result_winner,
            'id': self.id,
            'type': result,
            'param': '',
            'timestamp': badge_datetime_format(self.timestamp),
            'confirmed': self.confirmed,
            'debate': self.debate.id,
            'result_status': self.debate.result_status,
        }

    def serialize(self, tournament=None):
        if not tournament:
            tournament = self.debate.round.tournament

        # Shown in the results page on a per-ballot; always measured in tab TZ
        created_short = timezone.localtime(self.timestamp).strftime("%H:%M")
        # These are used by the status graph
        created = timezone.localtime(self.timestamp).isoformat()
        confirmed = None
        if self.confirm_timestamp and self.confirmed:
            confirmed = timezone.localtime(self.confirm_timestamp).isoformat()

        if tournament.pref('enable_blind_checks') and tournament.pref('teams_in_debate') == 'bp':
            admin_url = 'results-ballotset-edit'
            assistant_url = 'results-assistant-ballotset-edit'
        else:
            admin_url = 'old-results-ballotset-edit'
            assistant_url = 'old-results-assistant-ballotset-edit'

        submitter = self.ip_address
        private_url = False
        if self.submitter:
            submitter = self.submitter.username
        elif self.participant_submitter:
            submitter = self.participant_submitter.name
            private_url = True

        return {
            'ballot_id': self.id,
            'debate_id': self.debate.id,
            'submitter': submitter,
            'private_url': private_url,
            'admin_link': reverse_tournament(admin_url, tournament, kwargs={'pk': self.id}),
            'assistant_link': reverse_tournament(assistant_url, tournament, kwargs={'pk': self.id}),
            'short_time': created_short,
            'created_timestamp': created,
            'confirmed_timestamp': confirmed,
            'version': self.version,
            'confirmed': self.confirmed,
            'discarded': self.discarded,
            'single_adj': self.single_adj,
        }

    @property
    def roundmotion(self):
        if not hasattr(self, "_roundmotion"):
            if self.motion is not None:
                self._roundmotion = RoundMotion.objects.get(motion=self.motion, round_id=self.debate.round_id)
            else:
                self._roundmotion = None
        return self._roundmotion


class TeamScoreByAdj(models.Model):
    """Holds team result given by a particular adjudicator in a debate.
    Mostly redundant; is necessary however for voting elimination ballots."""
    ballot_submission = models.ForeignKey(BallotSubmission, models.CASCADE,
        verbose_name=_("ballot submission"))
    debate_adjudicator = models.ForeignKey('adjallocation.DebateAdjudicator', models.CASCADE,
        verbose_name=_("debate adjudicator"))
    debate_team = models.ForeignKey('draw.DebateTeam', models.CASCADE,
        verbose_name=_("debate team"))

    win = models.BooleanField(null=True, blank=True,
        verbose_name=_("win"))
    margin = ScoreField(null=True, blank=True,
        verbose_name=_("margin"))
    score = ScoreField(null=True, blank=True,
        verbose_name=_("score"))

    class Meta:
        unique_together = [('debate_adjudicator', 'debate_team', 'ballot_submission')]
        index_together = ['ballot_submission', 'debate_adjudicator']
        verbose_name = _("team score by adjudicator")
        verbose_name_plural = _("team scores by adjudicator")

    def __str__(self):
        has_won = "Win" if self.win else "Loss"
        return ("[{0.ballot_submission_id}/{0.id}] {1} for "
            "{0.debate_team!s} from {0.debate_adjudicator!s}").format(self, has_won)

    @property
    def debate(self):
        return self.debate_team.debate

    def clean(self):
        super().clean()
        if (self.debate_team.debate != self.debate_adjudicator.debate or
                self.debate_team.debate != self.ballot_submission.debate):
            raise ValidationError(_("The debate team, debate adjudicator and ballot "
                    "submission must all relate to the same debate."))


class SpeakerScoreByAdj(models.Model):
    """Holds score given by a particular adjudicator in a debate."""
    ballot_submission = models.ForeignKey(BallotSubmission, models.CASCADE,
        verbose_name=_("ballot submission"))
    debate_adjudicator = models.ForeignKey('adjallocation.DebateAdjudicator', models.CASCADE,
        verbose_name=_("debate adjudicator"))
    debate_team = models.ForeignKey('draw.DebateTeam', models.CASCADE,
        verbose_name=_("debate team"))
    score = ScoreField(verbose_name=_("score"))
    position = models.IntegerField(verbose_name=_("position"))

    class Meta:
        unique_together = [('debate_adjudicator', 'debate_team', 'position',
                            'ballot_submission')]
        index_together = ['ballot_submission', 'debate_adjudicator']
        verbose_name = _("speaker score by adjudicator")
        verbose_name_plural = _("speaker scores by adjudicator")

    def __str__(self):
        return ("[{0.ballot_submission_id}/{0.id}] {0.score} at {0.position} for "
            "{0.debate_team!s} from {0.debate_adjudicator!s}").format(self)

    @property
    def debate(self):
        return self.debate_team.debate

    def clean(self):
        super().clean()
        if (self.debate_team.debate != self.debate_adjudicator.debate or
                self.debate_team.debate != self.ballot_submission.debate):
            raise ValidationError(_("The debate team, debate adjudicator and ballot "
                    "submission must all relate to the same debate."))


class TeamScore(models.Model):
    """Stores information about a team's result in a debate. This is all
    redundant information — it can all be derived from indirectly-related
    SpeakerScore objects. We use a separate model for it for performance
    reasons."""

    ballot_submission = models.ForeignKey(BallotSubmission, models.CASCADE,
        verbose_name=_("ballot submission"))
    debate_team = models.ForeignKey('draw.DebateTeam', models.CASCADE, db_index=True,
        verbose_name=_("debate team"))

    points = models.PositiveSmallIntegerField(null=True, blank=True,
        verbose_name=_("points"))
    win = models.BooleanField(null=True, blank=True,
        verbose_name=_("win"))
    margin = ScoreField(null=True, blank=True,
        verbose_name=_("margin"))
    score = ScoreField(null=True, blank=True,
        verbose_name=_("score"))
    votes_given = models.PositiveSmallIntegerField(null=True, blank=True,
        verbose_name=_("votes given"))
    votes_possible = models.PositiveSmallIntegerField(null=True, blank=True,
        verbose_name=_("votes possible"))
    has_ghost = models.BooleanField(null=True, blank=True, verbose_name=_("has ghost score"))

    class Meta:
        unique_together = [('debate_team', 'ballot_submission')]
        verbose_name = _("team score")
        verbose_name_plural = _("team scores")

    def __str__(self):
        return ("[{0.ballot_submission_id}/{0.id}] {0.points}, {0.score} for "
            "{0.debate_team!s}").format(self)


class SpeakerScoreManager(models.Manager):
    use_for_related_fields = True

    def get_queryset(self):
        return super().get_queryset().select_related('speaker')


class SpeakerScore(models.Model):
    """Represents a speaker's (overall) score in a debate.

    The 'speaker' field is canonical. The 'score' field, however, is a
    performance enhancement; raw scores are stored in SpeakerScoreByAdj. The
    result classes in result.py calculates this when it saves a result.
    """
    ballot_submission = models.ForeignKey(BallotSubmission, models.CASCADE,
        verbose_name=_("ballot submission"))
    debate_team = models.ForeignKey('draw.DebateTeam', models.CASCADE,
        verbose_name=_("debate team"))
    speaker = models.ForeignKey('participants.Speaker', models.CASCADE, db_index=True,
        verbose_name=_("speaker"))
    rank = models.PositiveSmallIntegerField(null=True, blank=True,
        verbose_name=_("rank"))
    score = ScoreField(verbose_name=_("score"))
    position = models.IntegerField(verbose_name=_("position"))
    ghost = models.BooleanField(default=False,
        verbose_name=_("ghost"),
        help_text=_("If checked, this score does not count towards the speaker tab. "
            "This is typically checked for speeches where someone spoke twice to "
            "make up for an absent teammate (sometimes known as \"iron-person\" or "
            "\"iron-man\" speeches)."))

    objects = SpeakerScoreManager()

    class Meta:
        unique_together = [('debate_team', 'position', 'ballot_submission')]
        verbose_name = _("speaker score")
        verbose_name_plural = _("speaker scores")

    def __str__(self):
        return ("[{0.ballot_submission_id}/{0.id}] {0.score} at {0.position} for "
            "{0.speaker.name} in {0.debate_team!s}").format(self)

    def clean(self):
        super().clean()
        if self.debate_team.team != self.speaker.team:
            raise ValidationError(_("The debate team and speaker must be from the "
                    "same team."))
        if self.ballot_submission.debate != self.debate_team.debate:
            raise ValidationError(_("The ballot submission and debate team must "
                    "relate to the same debate."))