TabbycatDebate/tabbycat

View on GitHub
tabbycat/draw/models.py

Summary

Maintainability
B
5 hrs
Test Coverage
D
62%
import logging

from django.contrib.humanize.templatetags.humanize import ordinal
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db import models
from django.utils.translation import gettext, gettext_lazy as _

from tournaments.utils import get_side_name
from utils.fields import ChoiceArrayField

from .generator import DRAW_FLAG_DESCRIPTIONS

logger = logging.getLogger(__name__)


class DebateManager(models.Manager):
    use_for_related_fields = True

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


class Debate(models.Model):
    STATUS_NONE = 'N'
    STATUS_POSTPONED = 'P'
    STATUS_DRAFT = 'D'
    STATUS_CONFIRMED = 'C'
    STATUS_CHOICES = (
        (STATUS_NONE, _("none")),
        (STATUS_POSTPONED, _("postponed")),
        (STATUS_DRAFT, _("draft")),
        (STATUS_CONFIRMED, _("confirmed")),
    )
    STATUS_CHOICES_RESTRICTED = (  # If postponements are disabled - used in forms
        (STATUS_NONE, _("none")),
        (STATUS_DRAFT, _("draft")),
        (STATUS_CONFIRMED, _("confirmed")),
    )

    objects = DebateManager()

    round = models.ForeignKey('tournaments.Round', models.CASCADE, db_index=True,
        verbose_name=_("round"))
    venue = models.ForeignKey('venues.Venue', models.SET_NULL, blank=True, null=True,
        verbose_name=_("room"))

    bracket = models.FloatField(default=0,
        verbose_name=_("bracket"))
    room_rank = models.IntegerField(default=0,
        verbose_name=_("room rank"))

    flags = ChoiceArrayField(blank=True, default=list,
        base_field=models.CharField(max_length=15, choices=DRAW_FLAG_DESCRIPTIONS))

    importance = models.IntegerField(default=0, choices=[(i, i) for i in range(-2, 3)],
        verbose_name=_("importance"))
    result_status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=STATUS_NONE,
        verbose_name=_("result status"))
    sides_confirmed = models.BooleanField(default=True,
        verbose_name=_("sides confirmed"),
        help_text=_("If unchecked, the sides assigned to teams in this debate are just placeholders."))

    class Meta:
        verbose_name = _("debate")
        verbose_name_plural = _("debates")

    def __str__(self):
        description = "[{}/{}/{}] ".format(self.round.tournament.slug, self.round.abbreviation, self.id)
        try:
            description += self.matchup
        except Exception:
            logger.exception("Error rendering Debate.matchup in Debate.__str__")
            description += "<error showing teams>"
        return description

    @property
    def matchup(self):
        # This method is used by __str__, so it's not allowed to crash (ever)
        if not self.sides_confirmed:
            teams_list = ", ".join([dt.team.short_name for dt in self.debateteam_set.all()])
            # Translators: This is appended to a list of teams, e.g. "Auckland
            # 1, Vic Wellington 1 (sides not confirmed)". Mind the leading
            # space.
            return teams_list + gettext(" (sides not confirmed)")

        try:
            # This can sometimes arise during the call to self.round.tournament.sides
            # if preferences aren't loaded correctly, which happens in `manage.py shell`.
            sides = self.round.tournament.sides
        except IndexError:
            return self._teams_and_sides_display()  # fallback

        try:
            # Translators: This goes between teams in a debate, e.g. "Auckland 1
            # vs Vic Wellington 1". Mind the leading and trailing spaces.
            return gettext(" vs ").join(self.get_team(side).short_name for side in sides)
        except (ObjectDoesNotExist, MultipleObjectsReturned):
            return self._teams_and_sides_display()

    def _teams_and_sides_display(self):
        return ", ".join(["%s (%s)" % (dt.team.short_name, dt.get_side_display())
                for dt in self.debateteam_set.all()])

    @property
    def matchup_codes(self):
        # Like matchup, but uses team codes. It is not as protected.
        if not self.sides_confirmed:
            teams_list = ", ".join([dt.team.code_name for dt in self.debateteam_set.all()])
            return teams_list + gettext(" (sides not confirmed)")

        try:
            sides = self.round.tournament.sides
            return gettext(" vs ").join(self.get_team(side).code_name for side in sides)
        except (IndexError, ObjectDoesNotExist, MultipleObjectsReturned):
            return ", ".join(["%s (%s)" % (dt.team.code_name, dt.get_side_display())
                for dt in self.debateteam_set.all()])

    # --------------------------------------------------------------------------
    # Team properties
    # --------------------------------------------------------------------------
    # Team properties are stored in the dict `self._team_properties`, except for
    # the list of all teams, which is in `self._teams`. These are lazily
    # evaluated: on the first call of any team property,
    # `self._populate_teams()` is run to populate all team properties in a
    # single database query, then the appropriate value is returned.
    #
    # If the team in question doesn't exist or there is more than one, the
    # property in question will raise an ObjectDoesNotExist or
    # MultipleObjectsReturned exception, so that it behaves like a database
    # query. This exception raising is lazy: it does so only when the errant
    # property is called, rather than raising straight away in
    # `self._populate_teams()`.
    #
    # Callers that wish to retrieve the teams of many debates should add
    #   prefetch_related(Prefetch('debateteam_set',
    #       queryset=DebateTeam.objects.select_related('team'))
    # to their query set.

    def _populate_teams(self):
        """Populates the team attributes from self.debateteam_set."""
        dts = self.debateteam_set.all()
        if not dts._prefetch_done:  # uses internal undocumented flag of Django's QuerySet class
            dts = dts.select_related('team')

        self._teams = []
        self._multiple_found = []
        self._team_properties = {}

        for dt in dts:
            self._teams.append(dt.team)
            team_key = '%s_team' % dt.side
            dt_key = '%s_dt' % dt.side
            if team_key in self._team_properties:
                self._multiple_found.extend([team_key, dt_key])
            self._team_properties[team_key] = dt.team
            self._team_properties[dt_key] = dt

    def _team_property(attr):  # noqa: N805
        """Used to construct properties that rely on self._populate_teams()."""
        @property
        def _property(self):
            if not hasattr(self, '_team_properties'):
                self._populate_teams()
            if attr in self._multiple_found:
                raise MultipleDebateTeamsError("Multiple debate teams found for '%s' in debate ID %d. "
                    "Teams in debate are: %s." % (attr, self.id, self._teams_and_sides_display()))
            try:
                return self._team_properties[attr]
            except KeyError:
                raise NoDebateTeamFoundError("No debate team found for '%s' in debate ID %d. "
                    "Teams in debate are: %s." % (attr, self.id, self._teams_and_sides_display()))
        return _property

    @property
    def teams(self):
        # No need for _team_property overhead, this list is guaranteed to exist
        # (it just might be empty).
        if not hasattr(self, '_teams'):
            self._populate_teams()
        return self._teams

    def debateteams_ordered(self):
        for side in self.round.tournament.sides:
            yield self.get_dt(side)

    aff_team = _team_property('aff_team')
    neg_team = _team_property('neg_team')
    bye_team = _team_property('bye_team')
    og_team = _team_property('og_team')
    oo_team = _team_property('oo_team')
    cg_team = _team_property('cg_team')
    co_team = _team_property('co_team')
    aff_dt = _team_property('aff_dt')
    neg_dt = _team_property('neg_dt')
    bye_dt = _team_property('bye_dt')
    og_dt = _team_property('og_dt')
    oo_dt = _team_property('oo_dt')
    cg_dt = _team_property('cg_dt')
    co_dt = _team_property('co_dt')

    def get_team(self, side):
        return getattr(self, '%s_team' % side)

    def get_dt(self, side):
        """dt = DebateTeam"""
        return getattr(self, '%s_dt' % side)

    # --------------------------------------------------------------------------
    # Other properties
    # --------------------------------------------------------------------------

    @property
    def confirmed_ballot(self):
        """Returns the confirmed BallotSubmission for this debate, or None if
        there is no such ballot submission."""
        try:
            return self._confirmed_ballot
        except AttributeError:
            try:
                self._confirmed_ballot = self.ballotsubmission_set.get(confirmed=True)
            except ObjectDoesNotExist:
                self._confirmed_ballot = None
            return self._confirmed_ballot

    @property
    def history(self):
        try:
            return self._history
        except AttributeError:
            self._history = self.aff_team.seen(self.neg_team, before_round=self.round.seq)
            return self._history

    @property
    def related_adjudicator_set(self):
        """Used by objects that work with both Debate and PreformedPanel."""
        return self.debateadjudicator_set

    @property
    def adjudicators(self):
        """Returns an AdjudicatorAllocation containing the adjudicators for this
        debate."""
        try:
            return self._adjudicators
        except AttributeError:
            from adjallocation.allocation import AdjudicatorAllocation
            self._adjudicators = AdjudicatorAllocation(self, from_db=True)
            return self._adjudicators

    @property
    def is_bye(self):
        if not hasattr(self, '_team_properties'):
            self._populate_teams()
        return 'bye_dt' in self._team_properties


class DebateTeamManager(models.Manager):
    use_for_related_fields = True

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


class DebateTeam(models.Model):

    class Side(models.TextChoices):
        AFF = 'aff', _("affirmative")
        NEG = 'neg', _("negative")
        OG = 'og', _("opening government")
        OO = 'oo', _("opening opposition")
        CG = 'cg', _("closing government")
        CO = 'co', _("closing opposition")
        BYE = 'bye', _("bye")

    objects = DebateTeamManager()

    debate = models.ForeignKey(Debate, models.CASCADE, db_index=True,
        verbose_name=_("debate"))
    team = models.ForeignKey('participants.Team', models.PROTECT,
        verbose_name=_("team"))
    side = models.CharField(max_length=3, choices=Side.choices,
        verbose_name=_("side"))

    flags = ChoiceArrayField(base_field=models.CharField(max_length=15, choices=DRAW_FLAG_DESCRIPTIONS), blank=True, default=list)

    class Meta:
        verbose_name = _("debate team")
        verbose_name_plural = _("debate teams")

    def __str__(self):
        return '{} in {}'.format(self.team.short_name, self.debate)

    @property
    def opponent(self):
        try:
            return self._opponent
        except AttributeError:
            if self.side == self.Side.BYE:
                self._opponent = None
                return self._opponent

            try:
                self._opponent = DebateTeam.objects.exclude(side=self.side).select_related(
                        'team', 'team__institution').get(debate=self.debate)
            except (DebateTeam.DoesNotExist, DebateTeam.MultipleObjectsReturned):
                logger.warning("No opponent found for %s", str(self))
                self._opponent = None
            return self._opponent

    def get_result_display(self):
        if self.team.tournament.pref('teams_in_debate') == 'bp':
            if self.points is not None:
                return gettext("placed %(place)s") % {'place': ordinal(4 - self.points)}
            else:
                return gettext("result unknown")
        else:
            if self.win is True:
                return gettext("won")
            elif self.win is False: # not None
                return gettext("lost")
            else:
                return gettext("result unknown")

    @property
    def win(self):
        """Convenience function. Returns True if this team won, False if this
        team lost, or None if there isn't a confirmed result.

        This result is stored for the lifetime of the instance -- it won't
        update on the same instance if a result is entered."""
        try:
            return self._win
        except AttributeError:
            try:
                self._win = self.teamscore_set.get(ballot_submission__confirmed=True).win
            except ObjectDoesNotExist:
                self._win = None
            return self._win

    @property
    def points(self):
        """Convenience function. Returns the number of points this team received
        or None if there isn't a confirmed result.

        This result is stored for the lifetime of the instance -- it won't
        update on the same instance if a result is entered."""
        try:
            return self._points
        except AttributeError:
            try:
                self._points = self.teamscore_set.get(ballot_submission__confirmed=True).points
            except ObjectDoesNotExist:
                self._points = None
            return self._points

    def get_side_name(self, tournament=None, name_type='full'):
        """Should be used instead of get_side_display() on views.
        `tournament` can be passed in if known, for performance."""
        try:
            return get_side_name(tournament or self.debate.round.tournament,
                                 self.side, name_type)
        except KeyError:
            return self.get_side_display()  # fallback

    def get_side_abbr(self, tournament=None):
        """Convenience function, mainly for use in templates."""
        return self.get_side_name(tournament, 'abbr')


class MultipleDebateTeamsError(DebateTeam.MultipleObjectsReturned):
    pass


class NoDebateTeamFoundError(DebateTeam.DoesNotExist):
    pass


class TeamSideAllocation(models.Model):
    """Model to store team side allocations for tournaments like Joynt
    Scroll (New Zealand). Each team-round combination should have one of these.
    In tournaments without team side allocations, just don't use this
    model."""

    round = models.ForeignKey('tournaments.Round', models.CASCADE,
        verbose_name=_("round"))
    team = models.ForeignKey('participants.Team', models.CASCADE,
        verbose_name=_("team"))
    side = models.CharField(max_length=3, choices=DebateTeam.Side.choices,
        verbose_name=_("side"))

    class Meta:
        unique_together = [('round', 'team')]
        verbose_name = _("team side allocation")
        verbose_name_plural = _("team side allocations")