TabbycatDebate/tabbycat

View on GitHub
tabbycat/draw/views.py

Summary

Maintainability
C
1 day
Test Coverage
F
52%
import datetime
import logging
import unicodedata
from itertools import product

from django.contrib import messages
from django.db.models import OuterRef, Subquery
from django.http import HttpResponseRedirect
from django.utils.functional import cached_property
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy, ngettext
from django.views.generic.base import TemplateView

from actionlog.mixins import LogActionMixin
from actionlog.models import ActionLogEntry
from adjallocation.models import DebateAdjudicator
from adjallocation.utils import adjudicator_conflicts_display
from availability.utils import annotate_availability
from draw.generator.powerpair import BasePowerPairedDrawGenerator
from notifications.models import BulkNotification
from notifications.views import RoundTemplateEmailCreateView
from options.preferences import BPPositionCost
from participants.models import Adjudicator, Speaker, Team
from participants.prefetch import populate_win_counts
from participants.utils import get_side_history
from standings.base import StandingsError
from standings.teams import TeamStandingsGenerator
from standings.views import BaseStandingsView
from tournaments.mixins import (CurrentRoundMixin, DebateDragAndDropMixin,
    OptionalAssistantTournamentPageMixin, PublicTournamentPageMixin, RoundMixin,
    TournamentMixin)
from tournaments.models import Round
from tournaments.utils import get_side_name
from utils.misc import reverse_round, reverse_tournament
from utils.mixins import AdministratorMixin
from utils.tables import TabbycatTableBuilder
from utils.views import PostOnlyRedirectView, VueTableTemplateView
from venues.allocator import allocate_venues
from venues.models import VenueConstraint
from venues.utils import venue_conflicts_display

from .dbutils import delete_round_draw
from .generator import DrawFatalError, DrawUserError
from .manager import DrawManager
from .models import Debate, TeamSideAllocation
from .prefetch import populate_history
from .serializers import EditDebateTeamsDebateSerializer, EditDebateTeamsTeamSerializer
from .tables import (AdminDrawTableBuilder, PositionBalanceReportDrawTableBuilder,
        PositionBalanceReportSummaryTableBuilder, PublicDrawTableBuilder)

logger = logging.getLogger(__name__)


class BaseDisplayDrawTableView(TournamentMixin, VueTableTemplateView):
    """Base class for views showing a draw table to the public in some way.
    Subclasses are *not* necessarily public views; they may be admin/assistant
    views intended to facilitate displaying the draw in the general assembly
    room. Since, whether a public, assistant or admin view, the content on it
    is intended for consumption by the public, the table is always built as if
    it were a public view."""

    template_name = 'draw_display_by.html'
    sort_key = 'venue'
    page_emoji = '👏'
    empty_table_title = gettext_lazy("No debates in this round")

    @property
    def rounds(self):
        raise NotImplementedError  # leave for subclasses

    def get_page_title(self):
        if len(self.rounds) == 1:
            return _("Draw for %(round)s") % {'round': self.rounds[0].name}
        else:
            return _("Draws for Current Rounds")

    def get_page_subtitle(self):
        if len(self.rounds) == 1 and getattr(self.rounds[0], 'starts_at', None):
            return _("debates start at %(time)s (in %(time_zone)s)") % {
                     'time': self.rounds[0].starts_at.strftime('%H:%M'),
                     'time_zone': get_current_timezone_name()}
        elif any(getattr(r, 'starts_at', None) for r in self.rounds):
            return _("start times in time zone: %(time_zone)s") % {'time_zone': get_current_timezone_name()}
        else:
            return ""

    def populate_table(self, debates, table, highlight=[]):
        table.add_debate_venue_columns(debates)
        table.add_debate_team_columns(debates, highlight)
        table.add_debate_adjudicators_column(debates, show_splits=False)

    @classmethod
    def get_debates_for_round(cls, round):
        """Not used if the `get_debates()` method is defined. Overridden by
        `PublicDrawMixin` to blank the table if the round hasn't been
        released."""
        return round.debate_set_with_prefetches()

    def get_tables(self):

        # If the view has debates specified specifically, use those in a single table
        if hasattr(self, 'get_debates'):
            table = PublicDrawTableBuilder(view=self, sort_key=self.sort_key,
                    admin=False, empty_title=self.empty_table_title)
            self.populate_table(self.get_debates(), table)
            return [table]

        # If there's only one round, use that in a single table
        if len(self.rounds) == 1:
            table = PublicDrawTableBuilder(view=self, sort_key=self.sort_key,
                    admin=False, empty_title=self.empty_table_title)
            debates = self.get_debates_for_round(self.rounds[0])
            self.populate_table(debates, table)
            return [table]

        tables = []
        for r in self.tournament.current_rounds:
            debates = self.get_debates_for_round(r)
            if r.starts_at:
                subtitle = ngettext(
                    "debate starts at %(time)s",
                    "debates start at %(time)s",
                    debates.count(),
                ) % {'round_name': r.name, 'time': r.starts_at.strftime('%H:%M')}
            else:
                subtitle = ""
            table = PublicDrawTableBuilder(view=self, sort_key=self.sort_key,
                admin=False, title=r.name, subtitle=subtitle,
                empty_title=self.empty_table_title)
            self.populate_table(debates, table)
            tables.append(table)

        return tables


class BaseDisplayDrawForSpecificRoundTableView(RoundMixin, BaseDisplayDrawTableView):

    @property
    def rounds(self):
        return [self.round]

    def get_page_subtitle(self):
        # Skip the RoundMixin implementation
        return BaseDisplayDrawTableView.get_page_subtitle(self)

    def get_context_data(self, **kwargs):
        kwargs["round"] = self.round
        return super().get_context_data(**kwargs)


class BaseDisplayDrawForCurrentRoundsTableView(BaseDisplayDrawTableView):

    tables_orientation = 'rows'

    @property
    def rounds(self):
        return self.tournament.current_rounds


# ==============================================================================
# Viewing Draw (Public)
# ==============================================================================


class PublicDrawMixin(PublicTournamentPageMixin):
    """Governs permissions, particularly those relating to draw release."""

    empty_table_title = gettext_lazy("The draw for this round hasn't been released.")

    @cached_property
    def draws_available(self):
        return any(r.draw_status == Round.Status.RELEASED for r in self.rounds)

    @classmethod
    def get_debates_for_round(cls, round):
        if round.draw_status != Round.Status.RELEASED:
            return Debate.objects.none()
        return super().get_debates_for_round(round)

    def get_template_names(self):
        if not self.draws_available:
            return ['draw_not_released.html']
        return super().get_template_names()

    def get_tables(self):
        if not self.draws_available:
            return []
        return super().get_tables()

    def get_page_emoji(self):
        if not self.draws_available:
            return '😴'
        return super().get_page_emoji()

    def get_page_subtitle(self):
        if not self.draws_available:
            return ""
        return super().get_page_subtitle()


class PublicDrawForRoundView(PublicDrawMixin, BaseDisplayDrawForSpecificRoundTableView):

    def is_page_enabled(self, tournament):
        return tournament.pref('public_draw') == 'all-released'


class PublicDrawForCurrentRoundsView(PublicDrawMixin, BaseDisplayDrawForCurrentRoundsTableView):

    def is_page_enabled(self, tournament):
        return tournament.pref('public_draw') == 'current'


class PublicAllDrawsAllTournamentsView(PublicTournamentPageMixin, BaseDisplayDrawTableView):
    public_page_preference = 'enable_mass_draws'

    @property
    def rounds(self):
        return []

    def get_page_title(self):
        return _("All Debates for All Rounds of %(tournament)s") % {'tournament': self.tournament.name}

    def get_page_subtitle(self):
        return None

    def get_page_emoji(self):
        return None

    def populate_table(self, debates, table, highlight=[]):
        table.add_round_column(d.round for d in debates)
        super().populate_table(debates, table, highlight=highlight)

    def get_draw(self):
        all_rounds = Round.objects.filter(tournament=self.tournament,
                                          draw_status=Round.Status.RELEASED)
        draw = []
        for round in all_rounds:
            draw.extend(round.debate_set_with_prefetches())
        return draw


# ==============================================================================
# Viewing Draw (Briefing Room)
# ==============================================================================

class BriefingRoomDrawTableMixin:
    """Mixin for views that get projected in the briefing room, to be accessed
    only by admins and assistants."""

    def get_context_data(self, **kwargs):
        kwargs['no_popovers'] = True
        return super().get_context_data(**kwargs)


class BriefingRoomDrawByVenueTableMixin(BriefingRoomDrawTableMixin):
    # inherit everything, this class is kept in code for ease of reading
    pass


class BriefingRoomDrawByTeamTableMixin(BriefingRoomDrawTableMixin):

    sort_key = '' # Leave with default sort order

    def populate_table(self, debates, table):
        # unicodedata.normalize gets accented characters (e.g. "Éothéod") to sort correctly
        byes = [d for d in debates if d.is_bye]
        debates = [d for d in debates if not d.is_bye]

        draw_by_team = [(debate, debate.get_team(side)) for debate, side in product(debates, self.tournament.sides)]
        draw_by_team.extend([(debate, debate.get_team('bye')) for debate in byes])
        draw_by_team.sort(key=lambda x: unicodedata.normalize('NFKD', table._team_short_name(x[1])))

        if len(draw_by_team) == 0:
            debates, teams = [], []  # next line can't unpack if draw_by_team is empty
        else:
            debates, teams = zip(*draw_by_team)
        super().populate_table(debates, table, highlight=teams)


class AdminDrawDisplayForSpecificRoundByVenueView(AdministratorMixin,
        BriefingRoomDrawByVenueTableMixin, BaseDisplayDrawForSpecificRoundTableView):
    pass


class AdminDrawDisplayForSpecificRoundByTeamView(AdministratorMixin,
        BriefingRoomDrawByTeamTableMixin, BaseDisplayDrawForSpecificRoundTableView):
    pass


class AdminDrawDisplayForCurrentRoundsByVenueView(AdministratorMixin,
        BriefingRoomDrawByVenueTableMixin, BaseDisplayDrawForCurrentRoundsTableView):
    pass


class AdminDrawDisplayForCurrentRoundsByTeamView(AdministratorMixin,
        BriefingRoomDrawByTeamTableMixin, BaseDisplayDrawForCurrentRoundsTableView):
    pass


class AssistantDrawDisplayForSpecificRoundByVenueView(OptionalAssistantTournamentPageMixin,
        BriefingRoomDrawByVenueTableMixin, BaseDisplayDrawForSpecificRoundTableView):
    assistant_page_permissions = ['all_areas', 'results_draw']

    def is_page_enabled(self, tournament):
        return self.round.is_current and super().is_page_enabled(tournament)


class AssistantDrawDisplayForSpecificRoundByTeamView(OptionalAssistantTournamentPageMixin,
        BriefingRoomDrawByTeamTableMixin, BaseDisplayDrawForSpecificRoundTableView):
    assistant_page_permissions = ['all_areas', 'results_draw']

    def is_page_enabled(self, tournament):
        return self.round.is_current and super().is_page_enabled(tournament)


class AssistantDrawDisplayForCurrentRoundsByVenueView(OptionalAssistantTournamentPageMixin,
        BriefingRoomDrawByVenueTableMixin, BaseDisplayDrawForCurrentRoundsTableView):
    assistant_page_permissions = ['all_areas', 'results_draw']


class AssistantDrawDisplayForCurrentRoundsByTeamView(OptionalAssistantTournamentPageMixin,
        BriefingRoomDrawByTeamTableMixin, BaseDisplayDrawForCurrentRoundsTableView):
    assistant_page_permissions = ['all_areas', 'results_draw']


# ==============================================================================
# Draw Alerts Utilities (Admin)
# ==============================================================================

class AdminDrawUtilitiesMixin:
    """Shared between the admin draw and admin display pages."""

    def get_draw(self):
        if not hasattr(self, '_draw'):
            self._draw = self.round.debate_set_with_prefetches(ordering=('room_rank',),
                    institutions=True, venues=True)
        return self._draw

    @cached_property
    def adjudicator_conflicts(self):
        return adjudicator_conflicts_display(self.get_draw())

    @cached_property
    def venue_conflicts(self):
        return venue_conflicts_display(self.get_draw())

    def get_context_data(self, **kwargs):
        # Need to call super() first, so that get_table() can populate
        # self.venue_conflicts and self.adjudicator_conflicts.
        data = super().get_context_data(**kwargs)

        def _count(conflicts):
            return [len([x for x in c if x[0] != 'success']) > 0 for c in conflicts.values()].count(True)

        data['debates_with_adj_conflicts'] = _count(self.adjudicator_conflicts)
        data['debates_with_venue_conflicts'] = _count(self.venue_conflicts)
        data['active_adjs'] = self.round.active_adjudicators.count()
        data['debates_in_round'] = self.round.debate_set.count()
        data['preformed_panels_in_round'] = self.round.preformedpanel_set.count()
        if hasattr(self, 'highlighted_cells_exist'):
            data['highlighted_cells_exist'] = self.highlighted_cells_exist
        data['expected_nmotions'] = 3 if self.tournament.pref('enable_motions') else (self.round.motion_set.count() or 1)
        return data


# ==============================================================================
# Draw Display Index (Admin)
# ==============================================================================

class BaseDrawDisplayIndexView(AdminDrawUtilitiesMixin, RoundMixin, TemplateView):
    pass


class AdminDrawDisplayView(AdministratorMixin, BaseDrawDisplayIndexView):
    template_name = 'draw_display_admin.html'


class AssistantDrawDisplayView(CurrentRoundMixin, OptionalAssistantTournamentPageMixin, BaseDrawDisplayIndexView):
    template_name = 'draw_display_assistant.html'
    assistant_page_permissions = ['all_areas', 'results_draw']


class EmailAdjudicatorAssignmentsView(RoundTemplateEmailCreateView):
    page_subtitle = _("Adjudicator Assignments")

    event = BulkNotification.EventType.ADJ_DRAW
    subject_template = 'adj_email_subject'
    message_template = 'adj_email_message'

    round_redirect_pattern_name = 'draw-display'

    dadj_type_display = dict(DebateAdjudicator.TYPE_CHOICES)

    def get_extra(self):
        extra = super().get_extra()
        extra['url'] = self.request.build_absolute_uri(
            reverse_tournament('privateurls-person-index', self.tournament, kwargs={'url_key': '0'}))[:-2]
        return extra

    def get_person_type(self, person, **kwargs):
        return person.position

    def get_table(self):
        table = super().get_table()

        table.add_column({'key': 'pos', 'title': _("Position")}, [{
            'text': self.dadj_type_display[p.position],
        } for p in self.get_queryset()])

        return table

    def get_queryset(self):
        return Adjudicator.objects.filter(debateadjudicator__debate__round=self.round).annotate(
            position=Subquery(DebateAdjudicator.objects.filter(adjudicator_id=OuterRef('pk'), debate__round=self.round).values('type')[:1]),
        )

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['categories'] = [
            {'id': pos, 'name': d} for pos, d in DebateAdjudicator.TYPE_CHOICES
        ]
        return context


class EmailTeamAssignmentsView(RoundTemplateEmailCreateView):
    page_subtitle = _("Team Pairings")

    event = BulkNotification.EventType.TEAM_DRAW
    subject_template = 'team_draw_email_subject'
    message_template = 'team_draw_email_message'

    round_redirect_pattern_name = 'draw-display'

    def get_queryset(self):
        return Speaker.objects.filter(team__debateteam__debate__round=self.round).select_related('team')


# ==============================================================================
# Draw Creation (Admin)
# ==============================================================================

class AdminDrawView(RoundMixin, AdministratorMixin, AdminDrawUtilitiesMixin, VueTableTemplateView):
    detailed = False

    def get_page_title(self):
        round = self.round
        self.page_emoji = '👀'
        if round.draw_status == Round.Status.NONE:
            title = _("No Draw")
        elif round.draw_status == Round.Status.DRAFT:
            title = _("Draft Draw")
        elif round.draw_status in [Round.Status.CONFIRMED, Round.Status.RELEASED]:
            self.page_emoji = '👏'
            title = _("Draw")
        else:
            logger.error("Unrecognised draw status: %s", round.draw_status)
            title = _("Draw")
        return title % {'round': round.name}

    def get_bp_position_balance_table(self):
        draw = self.get_draw()
        teams = Team.objects.filter(debateteam__debate__round=self.round)
        side_histories_before = get_side_history(teams, self.tournament.sides, self.round.prev.seq)
        side_histories_now = get_side_history(teams, self.tournament.sides, self.round.seq)
        metrics = self.tournament.pref('team_standings_precedence')
        generator = TeamStandingsGenerator(metrics[0:1], ())
        standings = generator.generate(teams, round=self.round.prev)
        draw_table = PositionBalanceReportDrawTableBuilder(view=self)
        draw_table.build(draw, teams, side_histories_before, side_histories_now, standings)
        self.highlighted_cells_exist = any(draw_table.get_imbalance_category(team) is not None for team in teams)
        return draw_table

    def get_standard_table(self):
        r = self.round

        if r.is_break_round:
            sort_key = "room-rank"
            sort_order = 'asc'
        else:
            sort_key = "bracket"
            sort_order = 'desc'

        table = AdminDrawTableBuilder(view=self, sort_key=sort_key,
                                      sort_order=sort_order,
                                      empty_title=_("No debates in this round"))

        draw = self.get_draw()
        populate_history(draw)

        if r.is_break_round:
            table.add_room_rank_columns(draw)
        else:
            table.add_debate_bracket_columns(draw)

        table.add_debate_venue_columns(draw, for_admin=True)
        table.add_debate_team_columns(draw)

        # For draw details and draw draft pages
        if (r.draw_status == Round.Status.DRAFT or self.detailed) and r.prev:
            teams = Team.objects.filter(debateteam__debate__round=r)
            metrics = self.tournament.pref('team_standings_precedence')

            if self.tournament.pref('teams_in_debate') == 'two':
                pullup_metric = BasePowerPairedDrawGenerator.PULLUP_RESTRICTION_METRICS[self.tournament.pref('draw_pullup_restriction')]
            else:
                pullup_metric = None

            # subrank only makes sense if there's a second metric to rank on
            rankings = ('rank', 'subrank') if len(metrics) > 1 else ('rank',)
            generator = TeamStandingsGenerator(metrics, rankings,
                extra_metrics=(pullup_metric,) if pullup_metric and pullup_metric not in metrics else ())
            standings = generator.generate(teams, round=r.prev)
            if not r.is_break_round:
                table.add_debate_ranking_columns(draw, standings)
            else:
                self._add_break_rank_columns(table, draw, r.break_category)
            table.add_debate_metric_columns(draw, standings)
            table.add_debate_side_history_columns(draw, r.prev)
        elif not (r.draw_status == Round.Status.DRAFT or self.detailed):
            table.add_debate_adjudicators_column(draw, show_splits=False, for_admin=True)

        table.add_draw_conflicts_columns(draw, self.venue_conflicts, self.adjudicator_conflicts)

        if not r.is_break_round:
            table.highlight_rows_by_column_value(column=0) # highlight first row of a new bracket

        return table

    def get_table(self):
        r = self.round
        if r.draw_status == Round.Status.NONE:
            return TabbycatTableBuilder(view=self)  # blank
        elif self.tournament.pref('teams_in_debate') == 'bp' and \
                r.draw_status == Round.Status.DRAFT and r.prev is not None and \
                not r.is_break_round:
            return self.get_bp_position_balance_table()
        else:
            return self.get_standard_table()

    def _add_break_rank_columns(self, table, draw, category):
        for side in self.tournament.sides:
            # Translators: e.g. "Affirmative: Break rank"
            tooltip = _("%(side)s: Break rank") % {
                'side': get_side_name(self.tournament, side, 'full'),
            }
            tooltip = tooltip.capitalize()
            # Translators: "BR" stands for "Break rank"
            key = format_html("{}<br>{}", get_side_name(self.tournament, side, 'abbr'), _("BR"))

            table.add_column(
                {'tooltip': tooltip, 'key': key, 'text': key},
                [d.get_team(side).break_rank_for_category(category) for d in draw],
            )

    def get_template_names(self):
        if self.round.draw_status == Round.Status.NONE:
            return ["draw_status_none.html"]
        elif self.round.draw_status == Round.Status.DRAFT:
            return ["draw_status_draft.html"]
        elif self.round.draw_status in [Round.Status.CONFIRMED, Round.Status.RELEASED]:
            return ["draw_status_confirmed.html"]
        else:
            logger.error("Unrecognised draw status: %s", self.round.draw_status)
            return ["base.html"]


class AdminDrawWithDetailsView(AdminDrawView):
    detailed = True
    page_emoji = '👀'
    use_template_subtitle = False  # Use the "for Round n" subtitle

    def get_page_title(self):
        return _("Draw with Details")

    def get_template_names(self):
        return ["draw_subpage.html"]


class PositionBalanceReportView(RoundMixin, AdministratorMixin, VueTableTemplateView):
    page_emoji = "⚖"
    page_title = _("Position Balance Report")
    tables_orientation = 'rows'

    def get_context_data(self, **kwargs):
        kwargs['cost_func'] = self.get_position_cost_function_str()
        return super().get_context_data(**kwargs)

    def get_position_cost_function_str(self):
        cost_func = self.tournament.pref('bp_position_cost')
        if cost_func == 'entropy':
            renyi_order = self.tournament.pref('bp_renyi_order')
            cost_func_str = _("Rényi entropy of order %(order)s" % {'order': renyi_order})
            if renyi_order == 1:
                # Translators: This is appended to the string "Rényi entropy of order 1.0"
                cost_func_str += _(" (<i>i.e.</i>, Shannon entropy)")
            return mark_safe(cost_func_str)
        else:
            for k, v in BPPositionCost.choices:
                if k == cost_func:
                    return v
            else:
                logger.error("Unknown position cost function option: %s", cost_func)
                return "Unknown"  # don't translate, should never happen

    def get_tables(self):
        if self.tournament.pref('teams_in_debate') != 'bp':
            logger.warning("Tried to access position balance report for a non-BP tournament")
            return []
        if self.round.prev is None:
            logger.warning("Tried to access position balance report for first round")
            return []
        if self.round.is_break_round:
            logger.warning("Tried to access position balance report for a break round")
            return []

        draw = self.round.debate_set_with_prefetches(ordering=('room_rank',), institutions=True)
        teams = Team.objects.filter(debateteam__debate__round=self.round)
        side_histories_before = get_side_history(teams, self.tournament.sides, self.round.prev.seq)
        side_histories_now = get_side_history(teams, self.tournament.sides, self.round.seq)
        metrics = self.tournament.pref('team_standings_precedence')
        generator = TeamStandingsGenerator(metrics[0:1], ())
        standings = generator.generate(teams, round=self.round.prev)

        summary_table = PositionBalanceReportSummaryTableBuilder(view=self,
                title=_("Teams with position imbalances"),
                empty_title=_("No teams with position imbalances! Hooray!") + " 😊")
        summary_table.build(draw, teams, side_histories_before, side_histories_now, standings)

        draw_table = PositionBalanceReportDrawTableBuilder(view=self, title=_("Annotated draw"))
        draw_table.build(draw, teams, side_histories_before, side_histories_now, standings)

        return [summary_table, draw_table]

    def get_template_names(self):
        # Show an error page if this isn't a BP tournament or if it's the first round
        if self.tournament.pref('teams_in_debate') != 'bp':
            return ['position_balance_nonbp.html']
        elif self.round.prev is None:
            return ['position_balance_round1.html']
        elif self.round.is_break_round:
            return ['position_balance_break.html']
        else:
            return ['position_balance.html']


# ==============================================================================
# Draw Status POSTS
# ==============================================================================

class DrawStatusEdit(LogActionMixin, AdministratorMixin, RoundMixin, PostOnlyRedirectView):
    round_redirect_pattern_name = 'draw'


class CreateDrawView(DrawStatusEdit):

    action_log_type = ActionLogEntry.ActionType.DRAW_CREATE

    def post(self, request, *args, **kwargs):
        if self.round.draw_status != Round.Status.NONE:
            messages.error(request, _("Could not create draw for %(round)s, there was already a draw!") % {'round': self.round.name})
            return super().post(request, *args, **kwargs)

        try:
            manager = DrawManager(self.round)
            manager.create()
        except DrawUserError as e:
            messages.error(request, mark_safe(_(
                "<p>The draw could not be created, for the following reason: "
                "<em>%(message)s</em></p>\n"
                "<p>Please fix this issue before attempting to create the draw.</p>",
            ) % {'message': str(e)}))
            logger.warning("User error creating draw: " + str(e), exc_info=True)
            return HttpResponseRedirect(reverse_round('availability-index', self.round))
        except DrawFatalError as e:
            messages.error(request, mark_safe(_(
                "<p>The draw could not be created, because the following error occurred: "
                "<em>%(message)s</em></p>\n"
                "<p>If this issue persists and you're not sure how to resolve it, please "
                "contact the developers.</p>",
            ) % {'message': str(e)}))
            logger.exception("Fatal error creating draw: " + str(e))
            return HttpResponseRedirect(reverse_round('availability-index', self.round))
        except StandingsError as e:
            message = _(
                "<p>The team standings could not be generated, because the following error occurred: "
                "<em>%(message)s</em></p>\n"
                "<p>Because generating the draw uses the current team standings, this "
                "prevents the draw from being generated.</p>",
            ) % {'message': str(e)}
            standings_options_url = reverse_tournament('options-tournament-section', self.tournament, kwargs={'section': 'standings'})
            instructions = BaseStandingsView.admin_standings_error_instructions % {'standings_options_url': standings_options_url}
            messages.error(request, mark_safe(message + instructions))
            logger.exception("Error generating standings for draw: " + str(e))
            return HttpResponseRedirect(reverse_round('availability-index', self.round))

        relevant_adj_venue_constraints = VenueConstraint.objects.filter(
                adjudicator__in=self.tournament.relevant_adjudicators)
        if not relevant_adj_venue_constraints.exists():
            allocate_venues(self.round)
        else:
            messages.warning(request, _("Rooms were not auto-allocated because there are one or more adjudicator room constraints. "
                "You should run room allocations after allocating adjudicators."))

        self.log_action()
        return super().post(request, *args, **kwargs)


class ConfirmDrawCreationView(DrawStatusEdit):
    action_log_type = ActionLogEntry.ActionType.DRAW_CONFIRM

    def post(self, request, *args, **kwargs):
        if self.round.draw_status != Round.Status.DRAFT:
            if self.round.draw_status == Round.Status.NONE:
                messages.error(request, _("There is no draw."))
            else:
                messages.info(request, _("The draw had already been confirmed."))
            return HttpResponseRedirect(reverse_round('draw', self.round))

        self.round.draw_status = Round.Status.CONFIRMED
        self.round.save()
        self.log_action()
        return super().post(request, *args, **kwargs)


class DrawRegenerateView(DrawStatusEdit):
    action_log_type = ActionLogEntry.ActionType.DRAW_REGENERATE
    round_redirect_pattern_name = 'availability-index'

    def post(self, request, *args, **kwargs):
        delete_round_draw(self.round)
        self.log_action()
        messages.success(request, _("Deleted the draw. You can now recreate it as normal."))
        return super().post(request, *args, **kwargs)


class ConfirmDrawRegenerationView(AdministratorMixin, TemplateView):
    template_name = "draw_confirm_regeneration.html"


class DrawReleaseView(DrawStatusEdit):
    action_log_type = ActionLogEntry.ActionType.DRAW_RELEASE
    round_redirect_pattern_name = 'draw-display'

    def post(self, request, *args, **kwargs):
        if self.round.draw_status != Round.Status.CONFIRMED:
            if self.round.draw_status == Round.Status.RELEASED:
                messages.info(request, _("The draw has already been released."))
            else:
                messages.error(request, _("The draw must be confirmed before being released."))
            return HttpResponseRedirect(reverse_round('draw', self.round))

        self.round.draw_status = Round.Status.RELEASED
        self.round.save()
        self.log_action()

        messages.success(request, _("Released the draw."))
        return super().post(request, *args, **kwargs)


class DrawUnreleaseView(DrawStatusEdit):
    action_log_type = ActionLogEntry.ActionType.DRAW_UNRELEASE
    round_redirect_pattern_name = 'draw-display'

    def post(self, request, *args, **kwargs):
        if self.round.draw_status != Round.Status.RELEASED:
            messages.info(request, _("The draw had been unreleased."))
            return HttpResponseRedirect(reverse_round('draw', self.round))

        self.round.draw_status = Round.Status.CONFIRMED
        self.round.save()
        self.log_action()
        messages.success(request, _("Unreleased the draw."))
        return super().post(request, *args, **kwargs)


class SetRoundStartTimeView(DrawStatusEdit):
    action_log_type = ActionLogEntry.ActionType.ROUND_START_TIME_SET
    round_redirect_pattern_name = 'draw-display'

    def post(self, request, *args, **kwargs):
        time_text = request.POST["start_time"]
        try:
            time = datetime.datetime.strptime(time_text, "%H:%M").time()
        except ValueError:
            messages.error(request, _("Sorry, \"%(input)s\" isn't a valid time. It must "
                           "be in 24-hour format, with a colon, for "
                           "example: \"13:57\".") % {'input': time_text})
            return super().post(request, *args, **kwargs)

        self.round.starts_at = time
        self.round.save()

        self.log_action()

        return super().post(request, *args, **kwargs)


# ==============================================================================
# Sides Editing and Viewing
# ==============================================================================

class BaseSideAllocationsView(TournamentMixin, VueTableTemplateView):

    page_title = gettext_lazy("Side Pre-Allocations")

    def get_table(self):
        teams = self.tournament.team_set.all()
        rounds = self.tournament.prelim_rounds()

        tsas = dict()
        for tsa in TeamSideAllocation.objects.filter(round__in=rounds):
            try:
                tsas[(tsa.team.id, tsa.round.seq)] = get_side_name(self.tournament, tsa.side, 'abbr')
            except ValueError:
                pass

        table = TabbycatTableBuilder(view=self)
        table.add_team_columns(teams)

        headers = [escape(round.abbreviation) for round in rounds]
        data = [[tsas.get((team.id, round.seq), "—") for round in rounds] for team in teams]
        table.add_columns(headers, data)

        return table


class SideAllocationsView(AdministratorMixin, BaseSideAllocationsView):
    pass


class PublicSideAllocationsView(PublicTournamentPageMixin, BaseSideAllocationsView):
    public_page_preference = 'public_side_allocations'


class EditDebateTeamsView(DebateDragAndDropMixin, AdministratorMixin, TemplateView):
    template_name = "edit_debate_teams.html"
    page_title = gettext_lazy("Edit Matchups")
    prefetch_teams = False # Fetched in full as get_serialised

    def get_serialised_allocatable_items(self):
        # TODO: account for shared teams
        teams = Team.objects.filter(tournament=self.tournament).prefetch_related('speaker_set')
        teams = annotate_availability(teams, self.round)
        populate_win_counts(teams)
        serialized_teams = EditDebateTeamsTeamSerializer(teams, many=True)
        return self.json_render(serialized_teams.data)

    def debates_or_panels_factory(self, debates):
        return EditDebateTeamsDebateSerializer(
            debates, many=True, context={'sides': self.tournament.sides})