diff --git a/tabbycat/options/preferences.py b/tabbycat/options/preferences.py index f63864a2e12..b1c3d1d189a 100644 --- a/tabbycat/options/preferences.py +++ b/tabbycat/options/preferences.py @@ -461,6 +461,21 @@ class BallotsPerDebateElimination(ChoicePreference): default = 'per-adj' +@tournament_preferences_registry.register +class BallotMustConfirmWinner(ChoicePreference): + help_text = _("Whether adjudicator(s) must select the winning team in their ballot, and how it should be treated. Note: Not supported in BP.") + verbose_name = _("Winner Declaration in ballot(s)") + section = debate_rules + name = 'winners_in_ballots' + choices = ( + ('none', _("Do not require separate winner selection")), + ('high-points', _("Require separate winner selection as a check on correct scores")), + ('tied-points', _("Require winner selection to break tied-point debates")), + ('low-points', _("Require winner selection, overriding scores")), + ) + default = 'none' + + @tournament_preferences_registry.register class SubstantiveSpeakers(IntegerPreference): help_text = _("How many substantive speakers on a team") diff --git a/tabbycat/results/forms.py b/tabbycat/results/forms.py index 4154863b748..5629d788a83 100644 --- a/tabbycat/results/forms.py +++ b/tabbycat/results/forms.py @@ -252,7 +252,8 @@ def __init__(self, ballotsub, *args, **kwargs): self.using_vetoes = self.tournament.pref('motion_vetoes_enabled') self.using_forfeits = self.tournament.pref('enable_forfeits') self.using_replies = self.tournament.pref('reply_scores_enabled') - self.using_declared_winner = True + self.using_declared_winner = self.tournament.pref('winners_in_ballots') != 'none' + self.declared_winner_overrides = self.tournament.pref('winners_in_ballots') in ['tied-points', 'low-points'] self.bypassing_checks = self.tournament.pref('disable_ballot_confirms') self.max_margin = self.tournament.pref('maximum_margin') self.choosing_sides = (self.tournament.pref('draw_side_allocations') == 'manual-ballot' and @@ -680,19 +681,30 @@ def list_score_fields(self): def clean_scoresheet(self, cleaned_data): try: - totals = [sum(cleaned_data[self._fieldname_score(side, pos)] - for pos in self.positions) for side in self.sides] + side_totals = {side: sum(cleaned_data[self._fieldname_score(side, pos)] + for pos in self.positions) for side in self.sides} + totals = list(side_totals.values()) except KeyError as e: logger.warning("Field %s not found", str(e)) else: - # Check that no teams had the same total. - if len(totals) == 2 and totals[0] == totals[1] and not hasattr(cleaned_data, self._fieldname_declared_winner()): - self.add_error(None, forms.ValidationError( - _("The total scores for the teams are the same (i.e. a draw)."), - code='draw' - )) + if len(totals) == 2 and not self.declared_winner_overrides: + # Check that no teams had the same total + if totals[0] == totals[1]: + self.add_error(None, forms.ValidationError( + _("The total scores for the teams are the same (i.e. a draw)."), + code='draw' + )) + + # Check that the high-point team is declared the winner + has_declared = hasattr(cleaned_data, self._fieldname_declared_winner()) + highest_score = max(side_totals, key=lambda key: side_totals[key]) + if has_declared and highest_score != cleaned_data[self._fieldname_declared_winner()]: + self.add_error(None, forms.ValidationError( + _("The declared winner does not correspond to the team with the highest score."), + code='wrong_winner' + )) elif len(totals) > 2: for total in set(totals): @@ -794,20 +806,30 @@ def list_score_fields(self): def clean_scoresheet(self, cleaned_data): for adj in self.adjudicators: try: - totals = [sum(cleaned_data[self._fieldname_score(adj, side, pos)] - for pos in self.positions) for side in self.sides] + side_totals = {side: sum(cleaned_data[self._fieldname_score(adj, side, pos)] + for pos in self.positions) for side in self.sides} + totals = list(side_totals.values()) except KeyError as e: logger.warning("Field %s not found", str(e)) else: # Check that it was not a draw. - if totals[0] == totals[1] and not hasattr(cleaned_data, self._fieldname_declared_winner(adj)): + if totals[0] == totals[1] and not self.declared_winner_overrides: self.add_error(None, forms.ValidationError( _("The total scores for the teams are the same (i.e. a draw) for adjudicator %(adj)s."), params={'adj': adj.name}, code='draw' )) + # Check that the high-point team is declared the winner + has_declared = hasattr(cleaned_data, self._fieldname_declared_winner(adj)) + highest_score = max(side_totals, key=lambda key: side_totals[key]) + if has_declared and highest_score != cleaned_data[self._fieldname_declared_winner(adj)]: + self.add_error(None, forms.ValidationError( + _("The declared winner does not correspond to the team with the highest score for adjudicator %(adj)s."), + params={'adj': adj.name}, code='wrong_winner' + )) + # Check that the margin did not exceed the maximum permissible. margin = abs(totals[0] - totals[1]) if self.max_margin and margin > self.max_margin: @@ -826,7 +848,6 @@ def populate_result_with_scores(self, result): if declared_winner is not None: result.set_winner(adj, declared_winner) - # -------------------------------------------------------------------------- # Template access methods # -------------------------------------------------------------------------- @@ -834,7 +855,6 @@ def populate_result_with_scores(self, result): def scoresheets(self): """Generates a sequence of nested dicts that allows for easy iteration through the form. Used in the ballot_set.html template.""" - for adj in self.adjudicators: sheet_dict = { "adjudicator": adj, diff --git a/tabbycat/results/result.py b/tabbycat/results/result.py index 817db42acdc..8bc92928d17 100644 --- a/tabbycat/results/result.py +++ b/tabbycat/results/result.py @@ -45,7 +45,8 @@ from adjallocation.allocation import AdjudicatorAllocation from adjallocation.models import DebateAdjudicator -from .scoresheet import BPEliminationScoresheet, BPScoresheet, HighPointWinsRequiredScoresheet, ResultOnlyScoresheet +from .scoresheet import (BPEliminationScoresheet, BPScoresheet, HighPointWinsRequiredScoresheet, LowPointWinsAllowedScoresheet, + ResultOnlyScoresheet, TiedPointWinsAllowedScoresheet) from .utils import side_and_position_names logger = logging.getLogger(__name__) @@ -160,6 +161,9 @@ def __init__(self, ballotsub, load=True): self.tournament = self.debate.round.tournament self.sides = self.tournament.sides + # Needed here (not in ScoresMixin) as is called by `.get_scoresheet_class()` + self.winners_declared = self.tournament.pref('winners_in_ballots') + self.scoresheet_class = self.get_scoresheet_class() if load: @@ -174,7 +178,7 @@ def __repr__(self): # -------------------------------------------------------------------------- def get_scoresheet_class(self): - raise NotImplementedError + return ResultOnlyScoresheet def full_load(self): self.init_blank_buffer() @@ -358,9 +362,6 @@ def __init__(self, ballotsub, load=True): # Management methods # -------------------------------------------------------------------------- - def get_scoresheet_class(self): - return ResultOnlyScoresheet - def init_blank_buffer(self): super().init_blank_buffer() self.debateadjs = {} @@ -587,7 +588,12 @@ def __init__(self, ballotsub, load=True): # -------------------------------------------------------------------------- def get_scoresheet_class(self): - return HighPointWinsRequiredScoresheet + return { + 'none': HighPointWinsRequiredScoresheet, + 'high-points': HighPointWinsRequiredScoresheet, + 'tied-points': TiedPointWinsAllowedScoresheet, + 'low-points': LowPointWinsAllowedScoresheet, + }[self.winners_declared] def init_blank_buffer(self): super().init_blank_buffer() @@ -740,7 +746,7 @@ def is_valid(self): def get_scoresheet_class(self): if len(self.sides) == 2: - return ResultOnlyScoresheet + return super().get_scoresheet_class() elif len(self.sides) == 4: return BPEliminationScoresheet @@ -789,7 +795,7 @@ class ConsensusDebateResultWithScores(DebateResultWithScoresMixin, ConsensusDeba def get_scoresheet_class(self): if len(self.sides) == 2: - return HighPointWinsRequiredScoresheet + return super().get_scoresheet_class() elif len(self.sides) == 4: return BPScoresheet diff --git a/tabbycat/results/scoresheet.py b/tabbycat/results/scoresheet.py index 10587d03c9e..618700df822 100644 --- a/tabbycat/results/scoresheet.py +++ b/tabbycat/results/scoresheet.py @@ -35,6 +35,13 @@ def winners(self): return [] return self._get_winners() + def set_declared_winners(self, winners): + """Winners may be declared by the form as validation; by default do nothing.""" + pass + + def get_declared_winners(self): + pass + class ScoresMixin: """Provides functionality for speaker scores.