diff --git a/assets/js/outround.js b/assets/js/outround.js index 2082eeade..b03351205 100644 --- a/assets/js/outround.js +++ b/assets/js/outround.js @@ -90,10 +90,10 @@ function assignTeam(e) { $container.find(".outround-tabcard").attr("team-id", result.team.id); populateTabCards(); } else { - window.alert(alertMsg); + window.alert(result.message || alertMsg); } }, - failure() { + error() { window.alert(alertMsg); }, }); @@ -125,6 +125,7 @@ function assignJudge(e) { const url = `/outround/${roundId}/assign_judge/${judgeId}/${ curJudgeId || "" }`; + const alertMsg = "Unable to assign that judge. Refresh and try again."; let $buttonWrapper; if (curJudgeId) { @@ -139,6 +140,10 @@ function assignJudge(e) { url, success(result) { $button.removeClass("disabled"); + if (!result.success) { + window.alert(result.message || alertMsg); + return; + } $buttonWrapper.removeClass("unassigned"); $buttonWrapper.attr("judge-id", result.judge_id); @@ -148,6 +153,10 @@ function assignJudge(e) { $(`.judges span[round-id=${roundId}][judge-id=${result.chair_id}] .judge-toggle`).addClass("chair"); }, + error() { + $button.removeClass("disabled"); + window.alert(alertMsg); + }, }); } diff --git a/assets/js/pairing.js b/assets/js/pairing.js index 6342ad7d9..9894b8959 100644 --- a/assets/js/pairing.js +++ b/assets/js/pairing.js @@ -56,10 +56,10 @@ function assignTeam(e) { populateTabCards(); } else { - window.alert(alertMsg); + window.alert(result.message || alertMsg); } }, - failure() { + error() { window.alert(alertMsg); }, }); @@ -191,6 +191,7 @@ function assignJudge(e) { const judgeId = $(e.target).attr("judge-id"); const curJudgeId = $(e.target).attr("current-judge-id"); const url = `/round/${roundId}/assign_judge/${judgeId}/${curJudgeId || ""}`; + const alertMsg = "Unable to assign that judge. Refresh and try again."; let $buttonWrapper; if (curJudgeId) { @@ -205,6 +206,10 @@ function assignJudge(e) { url, success(result) { $button.removeClass("disabled"); + if (!result.success) { + window.alert(result.message || alertMsg); + return; + } $buttonWrapper.removeClass("unassigned"); $buttonWrapper.addClass("judge-assignment manual-lay"); $buttonWrapper.attr("judge-id", result.judge_id); @@ -217,6 +222,10 @@ function assignJudge(e) { $(`.judges span[round-id=${roundId}][judge-id=${result.chair_id}] .judge-toggle`).addClass("chair"); }, + error() { + $button.removeClass("disabled"); + window.alert(alertMsg); + }, }); } diff --git a/mittab/apps/tab/admin.py b/mittab/apps/tab/admin.py index b72719493..522f5e0b7 100644 --- a/mittab/apps/tab/admin.py +++ b/mittab/apps/tab/admin.py @@ -35,13 +35,21 @@ class TeamAdmin(admin.ModelAdmin): class MotionAdmin(admin.ModelAdmin): - list_display = ("round_display", "motion_text_truncated", "is_published", "updated_at") + list_display = ( + "round_display", + "motion_text_truncated", + "is_published", + "updated_at", + ) list_filter = ("is_published", "outround_type") search_fields = ("motion_text", "info_slide") ordering = ("round_number", "outround_type", "-num_teams") def motion_text_truncated(self, obj): - return obj.motion_text[:100] + "..." if len(obj.motion_text) > 100 else obj.motion_text + if len(obj.motion_text) > 100: + return obj.motion_text[:100] + "..." + return obj.motion_text + motion_text_truncated.short_description = "Motion Text" @@ -62,4 +70,6 @@ def motion_text_truncated(self, obj): admin.site.register(models.NoShow) admin.site.register(models.BreakingTeam) admin.site.register(models.Outround, OutroundAdmin) +admin.site.register(models.JudgeJudgeScratch) +admin.site.register(models.TeamTeamScratch) admin.site.register(models.Motion, MotionAdmin) diff --git a/mittab/apps/tab/forms.py b/mittab/apps/tab/forms.py index 07774b613..c7c7dd9af 100644 --- a/mittab/apps/tab/forms.py +++ b/mittab/apps/tab/forms.py @@ -240,6 +240,46 @@ class Meta: exclude = ["team", "judge"] +class JudgeJudgeScratchForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + judge_queryset = kwargs.pop("judge_queryset", Judge.objects.all()) + super().__init__(*args, **kwargs) + self.fields["judge_one"].queryset = judge_queryset + self.fields["judge_two"].queryset = judge_queryset + + def clean(self): + cleaned_data = super().clean() + judge_one = cleaned_data.get("judge_one") + judge_two = cleaned_data.get("judge_two") + if judge_one and judge_two and judge_one == judge_two: + raise forms.ValidationError("Pick two different judges") + return cleaned_data + + class Meta: + model = JudgeJudgeScratch + fields = "__all__" + + +class TeamTeamScratchForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + team_queryset = kwargs.pop("team_queryset", Team.objects.all()) + super().__init__(*args, **kwargs) + self.fields["team_one"].queryset = team_queryset + self.fields["team_two"].queryset = team_queryset + + def clean(self): + cleaned_data = super().clean() + team_one = cleaned_data.get("team_one") + team_two = cleaned_data.get("team_two") + if team_one and team_two and team_one == team_two: + raise forms.ValidationError("Pick two different teams") + return cleaned_data + + class Meta: + model = TeamTeamScratch + fields = "__all__" + + class DebaterForm(forms.ModelForm): class Meta: model = Debater diff --git a/mittab/apps/tab/migrations/0032_judgejudgescratch_teamteamscratch.py b/mittab/apps/tab/migrations/0032_judgejudgescratch_teamteamscratch.py new file mode 100644 index 000000000..b598ffcff --- /dev/null +++ b/mittab/apps/tab/migrations/0032_judgejudgescratch_teamteamscratch.py @@ -0,0 +1,39 @@ +# pylint: disable=invalid-name,line-too-long +# Generated by Django 3.2.25 on 2025-11-05 23:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tab', '0031_remove_noshow_lenient_late'), + ] + + operations = [ + migrations.CreateModel( + name='TeamTeamScratch', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('team_one', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_scratch_primary', to='tab.team')), + ('team_two', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_scratch_secondary', to='tab.team')), + ], + options={ + 'verbose_name_plural': 'team scratches', + 'unique_together': {('team_one', 'team_two')}, + }, + ), + migrations.CreateModel( + name='JudgeJudgeScratch', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('judge_one', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='judge_scratch_primary', to='tab.judge')), + ('judge_two', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='judge_scratch_secondary', to='tab.judge')), + ], + options={ + 'verbose_name_plural': 'judge scratches', + 'unique_together': {('judge_one', 'judge_two')}, + }, + ), + ] diff --git a/mittab/apps/tab/migrations/0037_merge_20260326_0343.py b/mittab/apps/tab/migrations/0037_merge_20260326_0343.py new file mode 100644 index 000000000..6f80b2357 --- /dev/null +++ b/mittab/apps/tab/migrations/0037_merge_20260326_0343.py @@ -0,0 +1,15 @@ +# pylint: disable=invalid-name +# Generated by Django 3.2.25 on 2026-03-26 03:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tab', '0032_judgejudgescratch_teamteamscratch'), + ('tab', '0036_alter_outround_room_nullable'), + ] + + operations = [ + ] diff --git a/mittab/apps/tab/models.py b/mittab/apps/tab/models.py index 2f20db86e..647b16a8a 100644 --- a/mittab/apps/tab/models.py +++ b/mittab/apps/tab/models.py @@ -434,6 +434,70 @@ def __str__(self): s_type = ("Team", "Tab")[self.scratch_type] return f"{self.team} <={s_type}=> {self.judge}" +class JudgeJudgeScratch(models.Model): + judge_one = models.ForeignKey( + "Judge", + related_name="judge_scratch_primary", + on_delete=models.CASCADE, + ) + judge_two = models.ForeignKey( + "Judge", + related_name="judge_scratch_secondary", + on_delete=models.CASCADE, + ) + + class Meta: + unique_together = ("judge_one", "judge_two") + verbose_name_plural = "judge scratches" + + def __str__(self): + return f"{self.judge_one} <=> {self.judge_two}" + + def clean(self): + if self.judge_one and self.judge_two and self.judge_one == self.judge_two: + raise ValidationError("Judge scratches must involve two distinct judges") + + def save(self, *args, **kwargs): + if self.judge_one and self.judge_two: + if self.judge_one == self.judge_two: + raise ValidationError( + "Judge scratches must involve two distinct judges") + if self.judge_one.id > self.judge_two.id: + self.judge_one, self.judge_two = self.judge_two, self.judge_one + super().save(*args, **kwargs) + + +class TeamTeamScratch(models.Model): + team_one = models.ForeignKey( + "Team", + related_name="team_scratch_primary", + on_delete=models.CASCADE, + ) + team_two = models.ForeignKey( + "Team", + related_name="team_scratch_secondary", + on_delete=models.CASCADE, + ) + + class Meta: + unique_together = ("team_one", "team_two") + verbose_name_plural = "team scratches" + + def __str__(self): + return f"{self.team_one} <=> {self.team_two}" + + def clean(self): + if self.team_one and self.team_two and self.team_one == self.team_two: + raise ValidationError("Team scratches must involve two distinct teams") + + def save(self, *args, **kwargs): + if self.team_one and self.team_two: + if self.team_one == self.team_two: + raise ValidationError("Team scratches must involve two distinct teams") + if self.team_one.id > self.team_two.id: + self.team_one, self.team_two = self.team_two, self.team_one + super().save(*args, **kwargs) + class Room(models.Model): name = models.CharField(max_length=30, unique=True) @@ -729,10 +793,11 @@ def __str__(self): class Motion(models.Model): """ Represents a debate motion (topic) for a specific round. - + Round identification: - For inrounds: round_number is set (1, 2, 3, etc.) - - For outrounds: round_number is None, and outround_type + num_teams identifies the round + - For outrounds: round_number is None, and outround_type + num_teams + identifies the round (e.g., Varsity Quarterfinals = outround_type=0, num_teams=8) """ VARSITY = 0 @@ -742,22 +807,37 @@ class Motion(models.Model): (NOVICE, "Novice"), ) - # Round identification - either round_number for inrounds, or outround fields for outrounds - round_number = models.IntegerField(null=True, blank=True, - help_text="Round number for inrounds (1, 2, 3, etc.)") - outround_type = models.IntegerField(null=True, blank=True, choices=OUTROUND_TYPE_CHOICES, - help_text="Type of outround (Varsity/Novice)") - num_teams = models.IntegerField(null=True, blank=True, - help_text="Number of teams in outround (e.g., 8 for quarterfinals)") + # Round identification for inrounds or outrounds. + round_number = models.IntegerField( + null=True, + blank=True, + help_text="Round number for inrounds (1, 2, 3, etc.)", + ) + outround_type = models.IntegerField( + null=True, + blank=True, + choices=OUTROUND_TYPE_CHOICES, + help_text="Type of outround (Varsity/Novice)", + ) + num_teams = models.IntegerField( + null=True, + blank=True, + help_text="Number of teams in outround (e.g., 8 for quarterfinals)", + ) # Motion content - info_slide = models.TextField(blank=True, default="", - help_text="Optional context/info slide text shown before the motion") + info_slide = models.TextField( + blank=True, + default="", + help_text="Optional context/info slide text shown before the motion", + ) motion_text = models.TextField(help_text="The debate motion/resolution text") # Publication status - is_published = models.BooleanField(default=False, - help_text="Whether this motion is visible to the public") + is_published = models.BooleanField( + default=False, + help_text="Whether this motion is visible to the public", + ) # Timestamps created_at = models.DateTimeField(auto_now_add=True) @@ -782,7 +862,9 @@ def round_display(self): if self.round_number is not None: return f"Round {self.round_number}" elif self.num_teams is not None: - type_name = "Varsity" if self.outround_type == self.VARSITY else "Novice" + type_name = ( + "Varsity" if self.outround_type == self.VARSITY else "Novice" + ) round_names = { 2: "Finals", 4: "Semifinals", @@ -791,18 +873,20 @@ def round_display(self): 32: "Double Octofinals", 64: "Triple Octofinals", } - round_name = round_names.get(self.num_teams, f"Round of {self.num_teams}") + round_name = round_names.get( + self.num_teams, f"Round of {self.num_teams}" + ) return f"{type_name} {round_name}" return "Unknown Round" @property def sort_key(self): - """Returns a sort key for ordering motions (inrounds first, then outrounds by size).""" + """Returns a sort key for ordering motions.""" if self.round_number is not None: # Inrounds: sort by round number return (0, self.round_number, 0) else: - # Outrounds: sort by type (varsity first), then by num_teams (descending) + # Outrounds: sort by type first, then by bracket size descending. return (1, self.outround_type or 0, -(self.num_teams or 0)) @property @@ -815,18 +899,20 @@ def round_selection_value(self): return "" def clean(self): - """Validate that either round_number or outround fields are set, but not both.""" + """Validate that either inround or outround fields are set.""" super().clean() has_round_number = self.round_number is not None has_outround = self.num_teams is not None if has_round_number and has_outround: raise ValidationError( - "A motion cannot have both a round number (inround) and outround fields set." + "A motion cannot have both a round number (inround) " + "and outround fields set." ) if not has_round_number and not has_outround: raise ValidationError( - "A motion must have either a round number (inround) or outround fields set." + "A motion must have either a round number (inround) " + "or outround fields set." ) if has_outround and self.outround_type is None: raise ValidationError( diff --git a/mittab/apps/tab/scratch_views.py b/mittab/apps/tab/scratch_views.py new file mode 100644 index 000000000..da8d20c39 --- /dev/null +++ b/mittab/apps/tab/scratch_views.py @@ -0,0 +1,401 @@ +from django.contrib.auth.decorators import permission_required +from django.db import IntegrityError, transaction +from django.db.models import Q +from django.shortcuts import get_object_or_404, render +from django.urls import reverse + +from mittab.apps.tab.forms import ( + ScratchForm, + JudgeJudgeScratchForm, + TeamTeamScratchForm, +) +from mittab.apps.tab.helpers import ( + redirect_and_flash_error, + redirect_and_flash_success, +) +from mittab.apps.tab.models import ( + Judge, + Team, + Scratch, + JudgeJudgeScratch, + TeamTeamScratch, +) + +SCRATCH_OBJECTS = { + "judge-team": Scratch, + "judge-judge": JudgeJudgeScratch, + "team-team": TeamTeamScratch, +} + +SCRATCH_FORMS = { + "judge-team": ScratchForm, + "judge-judge": JudgeJudgeScratchForm, + "team-team": TeamTeamScratchForm, +} + +SCRATCH_TAB_TYPES = {"judge_team", "judge_judge", "team_team"} + + +def _get_form_kwargs(scratch_type): + if scratch_type == "judge-team": + return { + "judge_queryset": Judge.objects.order_by("name"), + "team_queryset": Team.objects.order_by("name"), + } + if scratch_type == "judge-judge": + return {"judge_queryset": Judge.objects.order_by("name")} + if scratch_type == "team-team": + return {"team_queryset": Team.objects.order_by("name")} + return {} + + +def _build_form_for_instance(scratch_type, instance, data=None, prefix=None): + form_class = SCRATCH_FORMS[scratch_type] + return form_class( + data, + instance=instance, + prefix=prefix, + **_get_form_kwargs(scratch_type), + ) + + +def _serialize_entry(scratch_type, instance): + return { + "scratch_type": scratch_type, + "instance": instance, + } + + +def _get_scratch_entries_for_judge(judge_id): + entries = [ + _serialize_entry("judge-team", scratch) + for scratch in Scratch.objects.filter(judge_id=judge_id).select_related( + "judge", "team" + ).order_by("team__name", "judge__name") + ] + entries.extend( + _serialize_entry("judge-judge", scratch) + for scratch in JudgeJudgeScratch.objects.filter( + Q(judge_one_id=judge_id) | Q(judge_two_id=judge_id) + ).select_related("judge_one", "judge_two").order_by( + "judge_one__name", "judge_two__name" + ) + ) + return entries + + +def _get_scratch_entries_for_team(team_id): + entries = [ + _serialize_entry("judge-team", scratch) + for scratch in Scratch.objects.filter(team_id=team_id).select_related( + "judge", "team" + ).order_by("team__name", "judge__name") + ] + entries.extend( + _serialize_entry("team-team", scratch) + for scratch in TeamTeamScratch.objects.filter( + Q(team_one_id=team_id) | Q(team_two_id=team_id) + ).select_related("team_one", "team_two").order_by( + "team_one__name", "team_two__name" + ) + ) + return entries + + +def _build_object_forms(entries, data=None): + forms = [] + for index, entry in enumerate(entries, start=1): + form = _build_form_for_instance( + entry["scratch_type"], + entry["instance"], + data=data, + prefix=str(index), + ) + forms.append( + ( + form, + reverse( + "scratch_delete", + args=(entry["scratch_type"], entry["instance"].id), + ), + ) + ) + return forms + + +def _parse_scratch_count(raw_count): + try: + return max(1, int(raw_count)) + except (TypeError, ValueError): + return 1 + + +def add_scratch(request): + judges = Judge.objects.order_by("name") + teams = Team.objects.order_by("name") + + judge_id, team_id = request.GET.get("judge_id"), request.GET.get("team_id") + active_tab = request.POST.get("form_type") or request.GET.get("tab") or "judge_team" + if active_tab not in SCRATCH_TAB_TYPES: + active_tab = "judge_team" + form_count = _parse_scratch_count( + request.POST.get("count", request.GET.get("count")) + ) + + is_post = request.method == "POST" + + # shared initial data + scratch_initial = { + "scratch_type": Scratch.TEAM_SCRATCH, + "judge": judge_id, + "team": team_id, + } + judge_pair_initial = {"judge_one": judge_id} if judge_id else {} + team_pair_initial = {"team_one": team_id} if team_id else {} + + def make_form(form_cls, prefix, queryset_args, initial): + data = ( + request.POST if (is_post and active_tab == prefix) else None + ) + return [ + form_cls( + data, + prefix=f"{prefix}_{index}", + **queryset_args, + initial=None if data else initial, + ) + for index in range(form_count) + ] + + forms_by_type = { + "judge_team": make_form( + ScratchForm, + "judge_team", + {"judge_queryset": judges, "team_queryset": teams}, + scratch_initial, + ), + "judge_judge": make_form( + JudgeJudgeScratchForm, + "judge_judge", + {"judge_queryset": judges}, + judge_pair_initial, + ), + "team_team": make_form( + TeamTeamScratchForm, + "team_team", + {"team_queryset": teams}, + team_pair_initial, + ), + } + + if is_post: + forms = forms_by_type[active_tab] + if all(f.is_valid() for f in forms): + try: + with transaction.atomic(): + for f in forms: + f.save() + except IntegrityError: + for f in forms: + f.add_error(None, "This scratch already exists.") + else: + return redirect_and_flash_success( + request, + "Scratches created successfully", + path=request.get_full_path(), + ) + + tab_labels = { + "judge_team": "Judge ↔ Team", + "judge_judge": "Judge ↔ Judge", + "team_team": "Team ↔ Team", + } + + return render( + request, + "scratches/add_scratches.html", + { + "forms_by_type": forms_by_type, + "tabs": list(tab_labels.items()), + "forms_context": [ + {"key": k, "label": v, "forms": forms_by_type[k], "count": form_count} + for k, v in tab_labels.items() + ], + "active_tab": active_tab, + }, + ) + + +SCRATCH_FILTER_DEFS = { + "judge-team": {"bit": 1, "label": "Judge ↔ Team"}, + "judge-judge": {"bit": 2, "label": "Judge ↔ Judge"}, + "team-team": {"bit": 4, "label": "Team ↔ Team"}, +} + + +def view_scratches(request): + def build_items(qs, type_key, labels): + """Build (id, name, bitmask, symbols) tuples for each scratch type.""" + bit = SCRATCH_FILTER_DEFS[type_key]["bit"] + items = [] + for s in qs: + left_obj = getattr(s, labels[0]) + right_obj = getattr(s, labels[1]) + left_name = ( + left_obj.display_backend + if isinstance(left_obj, Team) or "team" in labels[0] + else left_obj.name + ) + right_name = ( + right_obj.display_backend + if isinstance(right_obj, Team) or "team" in labels[1] + else right_obj.name + ) + item_id = f"{type_key}/{s.id}" + item_label = left_name + " ↔ " + right_name + items.append((item_id, item_label, bit, "")) + return items + + configs = [ + ( + "judge-team", + Scratch.objects.select_related("team", "judge").order_by( + "team__name", "judge__name" + ), + ("team", "judge"), + ), + ( + "judge-judge", + JudgeJudgeScratch.objects.select_related("judge_one", "judge_two").order_by( + "judge_one__name", "judge_two__name" + ), + ("judge_one", "judge_two"), + ), + ( + "team-team", + TeamTeamScratch.objects.select_related("team_one", "team_two").order_by( + "team_one__name", "team_two__name" + ), + ("team_one", "team_two"), + ), + ] + + # Flatten all items + item_list = [ + item for key, qs, labels in configs for item in build_items(qs, key, labels) + ] + + # Each filter group entry should be (bit, label) + filters_group = [(v["bit"], v["label"]) for v in SCRATCH_FILTER_DEFS.values()] + + return render( + request, + "common/list_data.html", + { + "title": "Scratches", + "item_type": "scratch", + "item_list": item_list, + "filters": [filters_group], + }, + ) + + +def view_scratches_for_object(request, object_id, object_type): + try: + object_id = int(object_id) + except ValueError: + return redirect_and_flash_error(request, "Received invalid data") + + if object_type == "judge": + obj = get_object_or_404(Judge, pk=object_id) + entries = _get_scratch_entries_for_judge(object_id) + add_query = f"?judge_id={object_id}&tab=judge_team" + elif object_type == "team": + obj = get_object_or_404(Team, pk=object_id) + entries = _get_scratch_entries_for_team(object_id) + add_query = f"?team_id={object_id}&tab=judge_team" + else: + return redirect_and_flash_error(request, "Unknown object type") + + forms = _build_object_forms( + entries, + data=request.POST if request.method == "POST" else None, + ) + if request.method == "POST" and all(form.is_valid() for form, _ in forms): + try: + with transaction.atomic(): + for form, _ in forms: + form.save() + except IntegrityError: + for form, _ in forms: + form.add_error(None, "This scratch already exists.") + else: + return redirect_and_flash_success( + request, + "Scratches successfully modified", + path=request.get_full_path(), + ) + + return render( + request, + "common/data_entry_multiple.html", + { + "title": f"Viewing Scratch Information for {obj}", + "data_type": "Scratch", + "forms": forms, + "links": [(reverse("add_scratch") + add_query, "Add Scratch")], + }, + ) +def scratch_detail(request, scratch_type, scratch_id): + model = SCRATCH_OBJECTS.get(scratch_type) + if not model: + return redirect_and_flash_error( + request, "Unknown scratch type", path=reverse("view_scratches") + ) + scratch_obj = get_object_or_404(model, pk=scratch_id) + form_obj = _build_form_for_instance( + scratch_type, + scratch_obj, + data=request.POST if request.method == "POST" else None, + ) + if request.method == "POST" and form_obj.is_valid(): + try: + form_obj.save() + except IntegrityError: + form_obj.add_error(None, "This scratch already exists.") + else: + return redirect_and_flash_success( + request, + "Scratch updated successfully", + path=request.get_full_path(), + ) + return render( + request, + "common/data_entry.html", + { + "title": f"Viewing Scratch: {scratch_obj}", + "data_type": "Scratch", + "form": form_obj, + "delete_link": reverse("scratch_delete", args=(scratch_type, scratch_id)), + }, + ) + + +@permission_required("tab.scratch.can_delete", login_url="/403/") +def scratch_delete(request, scratch_type, scratch_id): + model = SCRATCH_OBJECTS.get(scratch_type) + if not model: + return redirect_and_flash_error( + request, "Unknown scratch type", path=reverse("view_scratches") + ) + try: + scratch = model.objects.get(pk=scratch_id) + except model.DoesNotExist: + return redirect_and_flash_error( + request, "Scratch not found", path=reverse("view_scratches") + ) + + scratch.delete() + return redirect_and_flash_success( + request, "Scratch deleted successfully", path=reverse("view_scratches") + ) diff --git a/mittab/apps/tab/views/judge_views.py b/mittab/apps/tab/views/judge_views.py index 22e38d0a1..82a9f16aa 100644 --- a/mittab/apps/tab/views/judge_views.py +++ b/mittab/apps/tab/views/judge_views.py @@ -1,7 +1,8 @@ from django.http import HttpResponse from django.shortcuts import render +from django.urls import reverse -from mittab.apps.tab.forms import JudgeForm, ScratchForm +from mittab.apps.tab.forms import JudgeForm from mittab.apps.tab.helpers import redirect_and_flash_error, redirect_and_flash_success from mittab.apps.tab.models import * from mittab.libs.errors import * @@ -96,8 +97,7 @@ def view_judge(request, judge_id): form = JudgeForm(instance=judge) judging_rounds = list(Round.objects.filter(judges=judge).select_related( "gov_team", "opp_team", "room")) - base_url = f"/judge/{judge_id}/" - scratch_url = f"{base_url}scratches/view/" + scratch_url = reverse("view_scratches_judge", args=[judge_id]) links = [(scratch_url, f"Scratches for {judge.name}")] return render( request, "tab/judge_detail.html", { @@ -130,105 +130,6 @@ def enter_judge(request): }) -def add_scratches(request, judge_id, number_scratches): - try: - judge_id, number_scratches = int(judge_id), int(number_scratches) - except ValueError: - return redirect_and_flash_error(request, "Got invalid data") - try: - judge = Judge.objects.get(pk=judge_id) - except Judge.DoesNotExist: - return redirect_and_flash_error(request, "No such judge") - - if request.method == "POST": - forms = [ - ScratchForm(request.POST, prefix=str(i)) - for i in range(1, number_scratches + 1) - ] - all_good = True - for form in forms: - all_good = all_good and form.is_valid() - if all_good: - for form in forms: - form.save() - return redirect_and_flash_success( - request, "Scratches created successfully") - else: - forms = [ - ScratchForm( - prefix=str(i), - initial={ - "judge": judge_id, - "scratch_type": 0 - } - ) - for i in range(1, number_scratches + 1) - ] - return render( - request, "common/data_entry_multiple.html", { - "forms": list(zip(forms, [None] * len(forms))), - "data_type": "Scratch", - "title": f"Adding Scratch(es) for {judge.name}" - }) - - -def view_scratches(request, judge_id): - try: - judge_id = int(judge_id) - except ValueError: - return redirect_and_flash_error(request, "Received invalid data") - - judge = Judge.objects.prefetch_related( - "scratches", "scratches__judge", "scratches__team" - ).get(pk=judge_id) - scratches = judge.scratches.all() - - all_teams = Team.objects.all() - all_judges = Judge.objects.all() - - if request.method == "POST": - forms = [ - ScratchForm( - request.POST, - prefix=str(i + 1), - instance=scratches[i], - team_queryset=all_teams, - judge_queryset=all_judges - ) - for i in range(len(scratches)) - ] - all_good = True - for form in forms: - all_good = all_good and form.is_valid() - if all_good: - for form in forms: - form.save() - return redirect_and_flash_success( - request, "Scratches created successfully") - else: - forms = [ - ScratchForm( - prefix=str(i + 1), - instance=scratches[i], - team_queryset=all_teams, - judge_queryset=all_judges - ) - for i in range(len(scratches)) - ] - delete_links = [ - f"/judge/{judge_id}/scratches/delete/{scratches[i].id}" - for i in range(len(scratches)) - ] - links = [(f"/judge/{judge_id}/scratches/add/1/", "Add Scratch")] - - return render( - request, "common/data_entry_multiple.html", { - "forms": list(zip(forms, delete_links)), - "data_type": "Scratch", - "links": links, - "title": f"Viewing Scratch Information for {judge.name}" - }) - def download_judge_codes(request): codes = [ f"{getattr(judge, 'name', 'Unknown')}: {getattr(judge, 'ballot_code', 'N/A')}" diff --git a/mittab/apps/tab/views/outround_pairing_views.py b/mittab/apps/tab/views/outround_pairing_views.py index 758f147d3..b03ed8bef 100644 --- a/mittab/apps/tab/views/outround_pairing_views.py +++ b/mittab/apps/tab/views/outround_pairing_views.py @@ -1,4 +1,6 @@ -import random +"""Outround pairing views.""" +# pylint: disable=too-many-lines + import math from urllib.parse import urlencode @@ -23,6 +25,26 @@ ) +def _scratched_team_ids(team): + if not team: + return set() + scratched_team_ids = set() + for team_one_id, team_two_id in TeamTeamScratch.objects.filter( + Q(team_one_id=team.id) | Q(team_two_id=team.id) + ).values_list("team_one_id", "team_two_id"): + if team_one_id == team.id: + scratched_team_ids.add(team_two_id) + else: + scratched_team_ids.add(team_one_id) + return scratched_team_ids + + +def _team_pair_is_scratched(team_one, team_two): + if not team_one or not team_two: + return False + return team_two.id in _scratched_team_ids(team_one) + + @permission_required("tab.tab_settings.can_change", login_url="/403/") def pair_next_outround(request, num_teams, type_of_round): if request.method == "POST": @@ -415,7 +437,10 @@ def outround_pairing_view(request, else TabSettings.get("nov_panel_size", 3) ) judge_slots = [i for i in range(1, judges_per_panel + 1)] - section_label = f"[{'V' if selected_type == BreakingTeam.VARSITY else 'N'}] Ro{selected_num}" + section_label = ( + f"[{'V' if selected_type == BreakingTeam.VARSITY else 'N'}] " + f"Ro{selected_num}" + ) pairing_released = ( TabSettings.get("var_teams_visible", 256) <= selected_num if selected_type == BreakingTeam.VARSITY @@ -459,8 +484,21 @@ def outround_pairing_view(request, if len(outround_sections) == 1: page_label = outround_sections[0]["label"] control_section = outround_sections[0] if len(outround_sections) == 1 else None - control_round_type = control_section["type_of_round"] if control_section else None - control_num_teams = control_section["num_teams"] if control_section else None + control_round_type = None + control_num_teams = None + control_pairing_released = False + control_outrounds = [] + control_judges_per_panel = 0 + control_judge_slots = [] + control_pairing_exists = False + if control_section is not None: + control_round_type = control_section["type_of_round"] + control_num_teams = control_section["num_teams"] + control_pairing_released = control_section["pairing_released"] + control_outrounds = control_section["outrounds"] + control_judges_per_panel = control_section["judges_per_panel"] + control_judge_slots = control_section["judge_slots"] + control_pairing_exists = control_section["pairing_exists"] warnings = [] for section in outround_sections: @@ -509,11 +547,13 @@ def outround_pairing_view(request, available_rooms = available_rooms.exclude(selected_room_scope) available_rooms = available_rooms.distinct() - size = max(1, max(list( - map( + size = max( + 1, + *map( len, - [excluded_teams, excluded_judges, non_checkins, available_rooms] - )))) + [excluded_teams, excluded_judges, non_checkins, available_rooms], + ), + ) # The minimum rank you want to warn on warning = 5 excluded_people = list( @@ -530,13 +570,13 @@ def outround_pairing_view(request, "choice": choice, "type_of_round": type_of_round, "num_teams": num_teams, - "pairing_released": control_section["pairing_released"] if control_section else False, + "pairing_released": control_pairing_released, "label": page_label, "outround_options": outround_options, - "outrounds": control_section["outrounds"] if control_section else [], - "judges_per_panel": control_section["judges_per_panel"] if control_section else 0, - "judge_slots": control_section["judge_slots"] if control_section else [], - "pairing_exists": control_section["pairing_exists"] if control_section else False, + "outrounds": control_outrounds, + "judges_per_panel": control_judges_per_panel, + "judge_slots": control_judge_slots, + "pairing_exists": control_pairing_exists, "outround_sections": outround_sections, "control_section": control_section, "control_round_type": control_round_type, @@ -562,7 +602,9 @@ def outround_pairing_view(request, key=lambda x: x["num_teams"], reverse=True, ), - "stats_round_numbers": list(dict.fromkeys([spec[1] for spec in selected_specs])), + "stats_round_numbers": list( + dict.fromkeys([spec[1] for spec in selected_specs]) + ), "return_path": request.get_full_path(), } @@ -576,15 +618,20 @@ def outround_pairing_view(request, def alternative_judges(request, round_id, judge_id=None): round_obj = Outround.objects.get(id=int(round_id)) round_gov, round_opp = round_obj.gov_team, round_obj.opp_team + current_judge_id = None + if judge_id is not None: + current_judge_id = int(judge_id) + panel_judge_ids = set(round_obj.judges.values_list("id", flat=True)) + if current_judge_id is not None: + panel_judge_ids.discard(current_judge_id) # All of these variables are for the convenience of the template try: - current_judge_id = int(judge_id) current_judge_obj = Judge.objects.prefetch_related("scratches", "schools").get( id=current_judge_id ) current_judge_name = current_judge_obj.name current_judge_rank = current_judge_obj.rank - except TypeError: + except (TypeError, ValueError, Judge.DoesNotExist): current_judge_id, current_judge_obj, current_judge_rank = "", "", "" current_judge_name = "No judge" @@ -611,21 +658,30 @@ def alternative_judges(request, round_id, judge_id=None): ) else: excluded_judges = checked_in_judges - included_judges = checked_in_judges.filter(judges_outrounds=round_obj).distinct() + included_judges = checked_in_judges.filter( + judges_outrounds=round_obj + ).distinct() excluded_judges = excluded_judges.exclude(judges_outrounds=round_obj).distinct() + excluded_judges = list(excluded_judges) + included_judges = list(included_judges) + judge_pair_blocks = assign_judges.build_judge_pair_blocks() + assign_judges.populate_panel_scratch_ids(excluded_judges, judge_pair_blocks) + assign_judges.populate_panel_scratch_ids(included_judges, judge_pair_blocks) eligible_excluded = assign_judges.can_judge_teams( excluded_judges, round_gov, round_opp, allow_rejudges=True, + panel_judge_ids=panel_judge_ids, ) eligible_included = assign_judges.can_judge_teams( included_judges, round_gov, round_opp, allow_rejudges=True, + panel_judge_ids=panel_judge_ids, ) excluded_judges = [ @@ -659,6 +715,8 @@ def alternative_judges(request, round_id, judge_id=None): def alternative_teams(request, round_id, current_team_id, position): round_obj = Outround.objects.get(pk=round_id) current_team = Team.objects.get(pk=current_team_id) + paired_team = round_obj.opp_team if position == "gov" else round_obj.gov_team + scratched_team_ids = _scratched_team_ids(paired_team) breaking_teams_by_type = [t.team.id for t in BreakingTeam.objects.filter( @@ -671,13 +729,13 @@ def alternative_teams(request, round_id, current_team_id, position): gov_team_outround__num_teams=round_obj.num_teams ).exclude( opp_team_outround__num_teams=round_obj.num_teams - ).exclude(pk=current_team_id) + ).exclude(pk=current_team_id).exclude(pk__in=scratched_team_ids) included_teams = Team.objects.filter( id__in=breaking_teams_by_type ).exclude( pk__in=excluded_teams - ) + ).exclude(pk__in=scratched_team_ids) context = { "round_obj": round_obj, @@ -699,6 +757,14 @@ def assign_team(request, round_id, position, team_id): try: round_obj = Outround.objects.get(id=int(round_id)) team_obj = Team.objects.get(id=int(team_id)) + opposing_team = ( + round_obj.opp_team if position.lower() == "gov" else round_obj.gov_team + ) + if _team_pair_is_scratched(team_obj, opposing_team): + return JsonResponse({ + "success": False, + "message": "Teams cannot be paired because of a scratch.", + }) if position.lower() == "gov": round_obj.gov_team = team_obj @@ -757,10 +823,25 @@ def assign_judge(request, round_id, judge_id, remove_id=None): try: round_obj = Outround.objects.get(id=int(round_id)) judge_obj = Judge.objects.get(id=int(judge_id)) + removed_judge_id = int(remove_id) if remove_id is not None else None + panel_judge_ids = set( + round_obj.judges.exclude(id=removed_judge_id).values_list("id", flat=True) + ) + if not assign_judges.can_assign_judge( + judge_obj, + round_obj.gov_team, + round_obj.opp_team, + allow_rejudges=True, + panel_judge_ids=panel_judge_ids, + ): + return JsonResponse({ + "success": False, + "message": "Judge conflicts with the current round or panel.", + }) round_obj.judges.add(judge_obj) if remove_id is not None: - remove_obj = Judge.objects.get(id=int(remove_id)) + remove_obj = Judge.objects.get(id=removed_judge_id) round_obj.judges.remove(remove_obj) if remove_obj == round_obj.chair: diff --git a/mittab/apps/tab/views/pairing_views.py b/mittab/apps/tab/views/pairing_views.py index 8e6ea2990..e126e2428 100644 --- a/mittab/apps/tab/views/pairing_views.py +++ b/mittab/apps/tab/views/pairing_views.py @@ -8,7 +8,7 @@ from django.contrib.auth.decorators import permission_required from django.contrib import messages from django.db import transaction -from django.db.models import Exists, OuterRef +from django.db.models import Exists, OuterRef, Q from django.shortcuts import redirect from mittab.apps.tab.helpers import redirect_and_flash_error, \ @@ -33,6 +33,26 @@ ) +def _scratched_team_ids(team): + if not team: + return set() + scratched_team_ids = set() + for team_one_id, team_two_id in TeamTeamScratch.objects.filter( + Q(team_one_id=team.id) | Q(team_two_id=team.id) + ).values_list("team_one_id", "team_two_id"): + if team_one_id == team.id: + scratched_team_ids.add(team_two_id) + else: + scratched_team_ids.add(team_one_id) + return scratched_team_ids + + +def _team_pair_is_scratched(team_one, team_two): + if not team_one or not team_two: + return False + return team_two.id in _scratched_team_ids(team_one) + + def invalidate_public_ballot_cache(): tot_rounds = int(TabSettings.get("tot_rounds", 0) or 0) for round_number in range(1, tot_rounds + 1): @@ -571,26 +591,44 @@ def alternative_judges(request, round_id, judge_id=None): round_obj = Round.objects.get(id=int(round_id)) round_number = round_obj.round_number round_gov, round_opp = round_obj.gov_team, round_obj.opp_team + current_judge_id = None + if judge_id is not None: + current_judge_id = int(judge_id) + panel_judge_ids = set(round_obj.judges.values_list("id", flat=True)) + if current_judge_id is not None: + panel_judge_ids.discard(current_judge_id) excluded_judges = Judge.objects.exclude(judges__round_number=round_number) \ .filter(checkin__round_number=round_number) \ .prefetch_related("judges", "scratches", "schools") included_judges = Judge.objects.filter(judges__round_number=round_number) \ .filter(checkin__round_number=round_number) \ .prefetch_related("judges", "scratches", "schools") + excluded_judges = list(excluded_judges) + included_judges = list(included_judges) + judge_pair_blocks = assign_judges.build_judge_pair_blocks() + assign_judges.populate_panel_scratch_ids(excluded_judges, judge_pair_blocks) + assign_judges.populate_panel_scratch_ids(included_judges, judge_pair_blocks) excluded_judges_list = assign_judges.can_judge_teams( - excluded_judges, round_gov, round_opp) + excluded_judges, + round_gov, + round_opp, + panel_judge_ids=panel_judge_ids, + ) included_judges_list = assign_judges.can_judge_teams( - included_judges, round_gov, round_opp) + included_judges, + round_gov, + round_opp, + panel_judge_ids=panel_judge_ids, + ) current_judge_obj = None try: - current_judge_id = int(judge_id) current_judge_obj = Judge.objects.prefetch_related( "judges", "scratches", "schools").get(id=current_judge_id) current_judge_name = current_judge_obj.name current_judge_rank = current_judge_obj.rank - except TypeError: + except (TypeError, ValueError, Judge.DoesNotExist): current_judge_id, current_judge_rank = "", "" current_judge_name = "No judge" @@ -650,11 +688,15 @@ def alternative_teams(request, round_id, current_team_id, position): round_obj = Round.objects.get(pk=round_id) current_team = Team.objects.get(pk=current_team_id) round_number = round_obj.round_number + paired_team = round_obj.opp_team if position == "gov" else round_obj.gov_team + scratched_team_ids = _scratched_team_ids(paired_team) excluded_teams = Team.objects.exclude(gov_team__round_number=round_number) \ .exclude(opp_team__round_number=round_number) \ - .exclude(pk=current_team_id) + .exclude(pk=current_team_id) \ + .exclude(pk__in=scratched_team_ids) included_teams = Team.objects.exclude(pk__in=excluded_teams) \ - .exclude(pk=current_team_id) + .exclude(pk=current_team_id) \ + .exclude(pk__in=scratched_team_ids) context = { "round_obj": round_obj, "current_team": current_team, @@ -712,6 +754,14 @@ def assign_team(request, round_id, position, team_id): try: round_obj = Round.objects.get(id=int(round_id)) team_obj = Team.objects.get(id=int(team_id)) + opposing_team = ( + round_obj.opp_team if position.lower() == "gov" else round_obj.gov_team + ) + if _team_pair_is_scratched(team_obj, opposing_team): + return JsonResponse({ + "success": False, + "message": "Teams cannot be paired because of a scratch.", + }) if position.lower() == "gov": round_obj.gov_team = team_obj @@ -770,10 +820,24 @@ def assign_judge(request, round_id, judge_id, remove_id=None): try: round_obj = Round.objects.get(id=int(round_id)) judge_obj = Judge.objects.get(id=int(judge_id)) + removed_judge_id = int(remove_id) if remove_id is not None else None + panel_judge_ids = set( + round_obj.judges.exclude(id=removed_judge_id).values_list("id", flat=True) + ) + if not assign_judges.can_assign_judge( + judge_obj, + round_obj.gov_team, + round_obj.opp_team, + panel_judge_ids=panel_judge_ids, + ): + return JsonResponse({ + "success": False, + "message": "Judge conflicts with the current round or panel.", + }) round_obj.judges.add(judge_obj) if remove_id is not None: - remove_obj = Judge.objects.get(id=int(remove_id)) + remove_obj = Judge.objects.get(id=removed_judge_id) round_obj.judges.remove(remove_obj) if remove_obj == round_obj.chair: diff --git a/mittab/apps/tab/views/team_views.py b/mittab/apps/tab/views/team_views.py index 869c0cf3a..e9a20a64c 100644 --- a/mittab/apps/tab/views/team_views.py +++ b/mittab/apps/tab/views/team_views.py @@ -1,9 +1,12 @@ +from urllib.parse import urlencode + from django.http import HttpResponseRedirect from django.contrib.auth.decorators import permission_required from django.shortcuts import render +from django.urls import reverse from django.utils.text import slugify -from mittab.apps.tab.forms import TeamForm, TeamEntryForm, ScratchForm +from mittab.apps.tab.forms import TeamForm, TeamEntryForm from mittab.libs.cacheing import cache_logic from mittab.libs.errors import * from mittab.apps.tab.helpers import redirect_and_flash_error, \ @@ -78,7 +81,7 @@ def view_team(request, team_id): form = TeamForm(instance=team) links = [ ( - f"/team/{team_id}/scratches/view/", + reverse("view_scratches_team", args=[team_id]), f"Scratches for {team.display_backend}", ) ] @@ -107,9 +110,12 @@ def enter_team(request): ) num_forms = form.cleaned_data["number_scratches"] if num_forms > 0: - return HttpResponseRedirect( - f"/team/{team.pk}/scratches/add/{num_forms}" - ) + query = urlencode({ + "team_id": team.pk, + "tab": "judge_team", + "count": num_forms, + }) + return HttpResponseRedirect(f"{reverse('add_scratch')}?{query}") else: team_name = team.display_backend return redirect_and_flash_success( @@ -124,109 +130,6 @@ def enter_team(request): }) -def add_scratches(request, team_id, number_scratches): - try: - team_id, number_scratches = int(team_id), int(number_scratches) - except ValueError: - return redirect_and_flash_error(request, "Received invalid data") - try: - team = Team.objects.get(pk=team_id) - except Team.DoesNotExist: - return redirect_and_flash_error(request, - "The selected team does not exist") - all_teams = Team.objects.all() - all_judges = Judge.objects.all() - if request.method == "POST": - forms = [ - ScratchForm( - request.POST, - prefix=str( - i + 1), - team_queryset=all_teams, - judge_queryset=all_judges) - for i in range(number_scratches) - ] - all_good = True - for form in forms: - all_good = all_good and form.is_valid() - if all_good: - for form in forms: - form.save() - return redirect_and_flash_success( - request, "Scratches created successfully") - else: - forms = [ - ScratchForm( - prefix=str(i), - initial={ - "team": team_id, - "scratch_type": 0 - }, - team_queryset=all_teams, - judge_queryset=all_judges - ) for i in range(1, number_scratches + 1) - ] - return render( - request, "common/data_entry_multiple.html", { - "forms": list(zip(forms, [None] * len(forms))), - "data_type": "Scratch", - "title": f"Adding Scratch(es) for {team.display_backend}" - }) - - -def view_scratches(request, team_id): - try: - team_id = int(team_id) - except ValueError: - return redirect_and_flash_error(request, "Received invalid data") - scratches = Scratch.objects.filter(team=team_id) - number_scratches = len(scratches) - team = Team.objects.get(pk=team_id) - all_teams = Team.objects.all() - all_judges = Judge.objects.all() - if request.method == "POST": - forms = [ - ScratchForm( - request.POST, - prefix=str(i + 1), - instance=scratches[i], - team_queryset=all_teams, - judge_queryset=all_judges - ) - for i in range(number_scratches) - ] - all_good = True - for form in forms: - all_good = all_good and form.is_valid() - if all_good: - for form in forms: - form.save() - return redirect_and_flash_success( - request, "Scratches successfully modified") - else: - forms = [ - ScratchForm( - prefix=str(i + 1), - instance=scratches[i], - team_queryset=all_teams, - judge_queryset=all_judges - ) - for i in range(len(scratches)) - ] - delete_links = [ - f"/team/{team_id}/scratches/delete/{scratches[i].id}" - for i in range(len(scratches)) - ] - links = [(f"/team/{team_id}/scratches/add/1/", "Add Scratch")] - return render( - request, "common/data_entry_multiple.html", { - "forms": list(zip(forms, delete_links)), - "data_type": "Scratch", - "links": links, - "title": f"Viewing Scratch Information for {team.display_backend}" - }) - - @permission_required("tab.tab_settings.can_change", login_url="/403/") def all_tab_cards(request): all_teams = Team.objects.all() diff --git a/mittab/apps/tab/views/views.py b/mittab/apps/tab/views/views.py index 8b466c30d..ec77c0733 100644 --- a/mittab/apps/tab/views/views.py +++ b/mittab/apps/tab/views/views.py @@ -1,5 +1,4 @@ import os -from django.db import IntegrityError from django.db.models import Q from django.contrib import messages from django.contrib.auth.decorators import permission_required @@ -24,7 +23,6 @@ SchoolForm, RoomForm, UploadDataForm, - ScratchForm, SettingsForm, ) from mittab.apps.tab.helpers import redirect_and_flash_error, \ @@ -171,7 +169,9 @@ def apda_board_school_detail(request, school_id): form = SchoolApdaIdForm(instance=school) teams = Team.objects.filter(school=school).prefetch_related("debaters") - hybrid_teams = Team.objects.filter(hybrid_school=school).prefetch_related("debaters") + hybrid_teams = Team.objects.filter(hybrid_school=school).prefetch_related( + "debaters" + ) return render( request, @@ -206,7 +206,9 @@ def apda_board_debater_detail(request, debater_id): else: form = DebaterApdaIdForm(instance=debater) - teams = Team.objects.filter(debaters=debater).select_related("school", "hybrid_school") + teams = Team.objects.filter(debaters=debater).select_related( + "school", "hybrid_school" + ) return render( request, "apda_board/debater_detail.html", @@ -244,26 +246,6 @@ def render_500(request, *args, **kwargs): return response -# View for manually adding scratches -def add_scratch(request): - if request.method == "POST": - form = ScratchForm(request.POST) - if form.is_valid(): - try: - form.save() - except IntegrityError: - return redirect_and_flash_error(request, - "This scratch already exists.") - return redirect_and_flash_success(request, - "Scratch created successfully") - else: - form = ScratchForm(initial={"scratch_type": 0}) - return render(request, "common/data_entry.html", { - "title": "Adding Scratch", - "form": form - }) - - #### BEGIN SCHOOL ### # Three views for entering, viewing, and editing schools def view_schools(request): @@ -491,31 +473,6 @@ def bulk_check_in(request): return JsonResponse({"success": True}) -@permission_required("tab.scratch.can_delete", login_url="/403/") -def delete_scratch(request, _item_id, scratch_id): - try: - scratch_id = int(scratch_id) - scratch = Scratch.objects.get(pk=scratch_id) - scratch.delete() - except Scratch.DoesNotExist: - return redirect_and_flash_error( - request, - "This scratch does not exist, please try again with a valid id.") - return redirect_and_flash_success(request, - "Scratch deleted successfully", - path="/") - - -def view_scratches(request): - # Get a list of (id,school_name) tuples - c_scratches = [(s.team.pk, str(s), 0, "") for s in Scratch.objects.all()] - return render( - request, "common/list_data.html", { - "item_type": "team", - "title": "Viewing All Scratches for Teams", - "item_list": c_scratches - }) - def get_settings_from_yaml(): settings_dir = os.path.join(settings.BASE_DIR, "settings") @@ -776,7 +733,10 @@ def ranking_group(request, group_id=None): { "form": form, "links": [], - "title": f"Viewing Ranking Group: {group.name}" if group else "Create Ranking Group", + "title": ( + f"Viewing Ranking Group: {group.name}" + if group else "Create Ranking Group" + ), }, ) diff --git a/mittab/libs/assign_judges.py b/mittab/libs/assign_judges.py index 75b4bcdce..36421d4fe 100644 --- a/mittab/libs/assign_judges.py +++ b/mittab/libs/assign_judges.py @@ -1,6 +1,7 @@ import random +from itertools import combinations from types import SimpleNamespace -from django.db.models import Min +from django.db.models import Min, Q from mittab.libs import tab_logic, mwmatching, errors from mittab.apps.tab.models import * @@ -96,6 +97,24 @@ def construct_judge_scores(judges, mode=JudgePairingMode.DEFAULT): return judge_scores +def build_judge_pair_blocks(): + judge_pair_blocks = {} + for judge_one_id, judge_two_id in JudgeJudgeScratch.objects.values_list( + "judge_one_id", "judge_two_id" + ): + judge_pair_blocks.setdefault(judge_one_id, set()).add(judge_two_id) + judge_pair_blocks.setdefault(judge_two_id, set()).add(judge_one_id) + return judge_pair_blocks + + +def populate_panel_scratch_ids(judges, judge_pair_blocks=None): + if judge_pair_blocks is None: + judge_pair_blocks = build_judge_pair_blocks() + for judge in judges: + judge.panel_scratch_ids = judge_pair_blocks.get(judge.id, set()) + return judges + + def add_judges(): round_number = TabSettings.get("cur_round") - 1 settings = get_inround_settings() @@ -116,6 +135,7 @@ def add_judges(): "schools", ) ) + populate_panel_scratch_ids(all_judges) # Sort all_judges once before creating filtered subsets random.seed(1337) @@ -155,6 +175,7 @@ def pairing_sort_key(pairing): if settings.allow_rejudges: rejudge_counts = judge_team_rejudge_counts(chairs, all_teams) + panel_members_by_pair = [set() for _ in range(num_rounds)] graph_edges = [] for chair_i, chair in enumerate(chairs): chair_score = chair_scores[chair_i] @@ -227,6 +248,7 @@ def pairing_sort_key(pairing): round_obj.chair = chair chair_by_pairing[pairing_i] = chair_i assigned_judge_objects.add(chair.id) # Track by judge ID + panel_members_by_pair[pairing_i].add(chair.id) judge_round_joins.append( Round.judges.through(judge=chair, round=round_obj) ) @@ -253,6 +275,11 @@ def pairing_sort_key(pairing): judge_score = wing_judge_scores[wing_judge_i] for pairing_i in pairing_indices: pairing = pairings[pairing_i] + if judge.panel_scratch_ids and any( + blocked_id in panel_members_by_pair[pairing_i] + for blocked_id in judge.panel_scratch_ids + ): + continue has_conflict = judge_conflict( judge, pairing.gov_team, @@ -296,6 +323,11 @@ def pairing_sort_key(pairing): judge = wing_judges[wing_judge_i] if judge.id in assigned_judge_objects: continue + if judge.panel_scratch_ids and any( + blocked_id in panel_members_by_pair[pairing_i] + for blocked_id in judge.panel_scratch_ids + ): + continue judge_round_joins.append( Round.judges.through( judge=judge, @@ -303,6 +335,7 @@ def pairing_sort_key(pairing): ) ) assigned_judge_objects.add(judge.id) + panel_members_by_pair[pairing_i].add(judge.id) Round.judges.through.objects.bulk_create(judge_round_joins) @@ -374,7 +407,56 @@ def _build_active_pairings( return active_pairings -def _assign_outround_judges_for_specs( +def _panel_is_compatible(panel_indices, judges, existing_panel_ids=None): + existing_panel_ids = set(existing_panel_ids or ()) + for judge_i in panel_indices: + if judge_panel_conflict(judges[judge_i], existing_panel_ids): + return False + for judge_one_i, judge_two_i in combinations(panel_indices, 2): + judge_one = judges[judge_one_i] + judge_two = judges[judge_two_i] + if judge_two.id in _panel_scratch_ids_for_judge(judge_one): + return False + return True + + +def _can_complete_future_panel( + round_obj, + candidate_judge_i, + judges, + available_indices, + current_panel_ids, + remaining_slots): + if remaining_slots <= 0: + return True + + next_panel_ids = set(current_panel_ids) + next_panel_ids.add(judges[candidate_judge_i].id) + + eligible_future_indices = [] + for future_judge_i in available_indices: + if future_judge_i == candidate_judge_i: + continue + future_judge = judges[future_judge_i] + if judge_conflict(future_judge, round_obj.gov_team, round_obj.opp_team, True): + continue + if judge_panel_conflict(future_judge, next_panel_ids): + continue + eligible_future_indices.append(future_judge_i) + + if len(eligible_future_indices) < remaining_slots: + return False + + if remaining_slots == 1: + return True + + return any( + _panel_is_compatible(panel_indices, judges, next_panel_ids) + for panel_indices in combinations(eligible_future_indices, remaining_slots) + ) + + +def _assign_outround_judges_with_slot_matching( round_specs, pairings_by_spec, panel_size_by_spec, @@ -388,6 +470,10 @@ def _assign_outround_judges_for_specs( max_panel_size = max(panel_size_by_spec.values()) if panel_size_by_spec else 0 snake_draft_mode = settings.draft_mode == OutroundJudgePairingMode.SNAKE_DRAFT + panel_members_by_round_id = {} # round_id -> set of assigned judge IDs + chairs_by_round_id = {} + used_indices = set() + local_available_indices = list(available_indices) for panel_member in range(max_panel_size): active_pairings = _build_active_pairings( @@ -403,9 +489,20 @@ def _assign_outround_judges_for_specs( continue graph_edges = [] - for judge_i in available_indices: + for judge_i in local_available_indices: judge = judges[judge_i] for pairing_i, pairing in enumerate(active_pairings): + panel_size = panel_size_by_spec.get( + (pairing.type_of_round, pairing.num_teams), + 0, + ) + remaining_slots = panel_size - panel_member - 1 + current_panel_ids = panel_members_by_round_id.get(pairing.id, set()) + if getattr(judge, "panel_scratch_ids", None) and any( + blocked_id in current_panel_ids + for blocked_id in judge.panel_scratch_ids + ): + continue has_conflict = judge_conflict( judge, pairing.gov_team, @@ -414,6 +511,15 @@ def _assign_outround_judges_for_specs( ) if has_conflict: continue + if not _can_complete_future_panel( + round_obj=pairing, + candidate_judge_i=judge_i, + judges=judges, + available_indices=local_available_indices, + current_panel_ids=current_panel_ids, + remaining_slots=remaining_slots, + ): + continue weight = calc_weight( judge_scores[judge_i], pairing_i, @@ -432,18 +538,59 @@ def _assign_outround_judges_for_specs( f"Could not find a judge for: {bad_pairing}" ) + assigned_this_slot = [] for pairing_i, padded_judge_i in enumerate(panel_matches[:num_rounds]): judge_i = padded_judge_i - num_rounds round_obj = active_pairings[pairing_i] judge = judges[judge_i] if panel_member == 0: - round_obj.chair = judge + chairs_by_round_id[round_obj.id] = judge + panel_members_by_round_id.setdefault(round_obj.id, set()).add(judge.id) judge_round_joins.append(link_outround(judge=judge, outround=round_obj)) - available_indices.remove(judge_i) + assigned_this_slot.append(judge_i) - return judge_round_joins + for judge_i in assigned_this_slot: + local_available_indices.remove(judge_i) + used_indices.add(judge_i) + + return SimpleNamespace( + chairs_by_round_id=chairs_by_round_id, + judge_round_joins=judge_round_joins, + used_indices=used_indices, + ) + + +def _assign_outround_judges_for_specs( + round_specs, + pairings_by_spec, + panel_size_by_spec, + judges, + judge_scores, + settings, + available_indices, + force_novice_last=False): + assignment = _assign_outround_judges_with_slot_matching( + round_specs=round_specs, + pairings_by_spec=pairings_by_spec, + panel_size_by_spec=panel_size_by_spec, + judges=judges, + judge_scores=judge_scores, + settings=settings, + available_indices=available_indices, + force_novice_last=force_novice_last, + ) + + for pairings in pairings_by_spec.values(): + for round_obj in pairings: + if round_obj.id in assignment.chairs_by_round_id: + round_obj.chair = assignment.chairs_by_round_id[round_obj.id] + + for judge_i in assignment.used_indices: + available_indices.remove(judge_i) + + return assignment.judge_round_joins def _collect_outround_pairing_data(round_specs, round_priority): @@ -501,6 +648,7 @@ def add_outround_judges(round_type=Outround.VARSITY, round_specs=None): "schools", ) ) + populate_panel_scratch_ids(judges) random.seed(1337) random.shuffle(judges) judges = sorted(judges, key=lambda j: j.rank, reverse=True) @@ -638,13 +786,58 @@ def had_judge(judge, team): return False -def can_judge_teams(list_of_judges, team1, team2, allow_rejudges=None): +def can_judge_teams( + list_of_judges, + team1, + team2, + allow_rejudges=None, + panel_judge_ids=None): result = [] for judge in list_of_judges: - if not judge_conflict(judge, team1, team2, allow_rejudges=allow_rejudges): + if can_assign_judge( + judge, + team1, + team2, + allow_rejudges=allow_rejudges, + panel_judge_ids=panel_judge_ids, + ): result.append(judge) return result + +def _panel_scratch_ids_for_judge(judge): + panel_scratch_ids = getattr(judge, "panel_scratch_ids", None) + if panel_scratch_ids is not None: + return set(panel_scratch_ids) + + panel_scratch_ids = set() + for judge_one_id, judge_two_id in JudgeJudgeScratch.objects.filter( + Q(judge_one_id=judge.id) | Q(judge_two_id=judge.id) + ).values_list("judge_one_id", "judge_two_id"): + if judge_one_id == judge.id: + panel_scratch_ids.add(judge_two_id) + else: + panel_scratch_ids.add(judge_one_id) + return panel_scratch_ids + + +def judge_panel_conflict(judge, panel_judge_ids): + if not panel_judge_ids: + return False + return bool(_panel_scratch_ids_for_judge(judge) & set(panel_judge_ids)) + + +def can_assign_judge( + judge, + team1, + team2, + allow_rejudges=None, + panel_judge_ids=None): + return ( + not judge_conflict(judge, team1, team2, allow_rejudges=allow_rejudges) + and not judge_panel_conflict(judge, panel_judge_ids) + ) + def judge_team_rejudge_counts(judges, teams, exclude_round_id=None): """Judges must have prefetch_related('judges') to prevent N+1 before calling this function""" diff --git a/mittab/libs/backup/handlers.py b/mittab/libs/backup/handlers.py index 16c1da470..650dc140e 100644 --- a/mittab/libs/backup/handlers.py +++ b/mittab/libs/backup/handlers.py @@ -84,6 +84,10 @@ def _dump_cmd(self, include_scratches=True): cmd.extend(_ssl_cmd_args()) if not include_scratches: - cmd.append(f"--ignore-table={DB_NAME}.tab_scratch") + cmd.extend([ + f"--ignore-table={DB_NAME}.tab_scratch", + f"--ignore-table={DB_NAME}.tab_judgejudgescratch", + f"--ignore-table={DB_NAME}.tab_teamteamscratch", + ]) return cmd diff --git a/mittab/libs/tab_logic/__init__.py b/mittab/libs/tab_logic/__init__.py index 6d583d81a..27736c101 100644 --- a/mittab/libs/tab_logic/__init__.py +++ b/mittab/libs/tab_logic/__init__.py @@ -11,6 +11,126 @@ from mittab.libs.tab_logic.rankings import * +def _re_rank_bracket_after_pull_up( + brackets, bracket, all_checked_in_teams, middle_of_bracket): + removed_teams = [] + for team in all_checked_in_teams: + if ( + team in middle_of_bracket + and tot_wins(team) == bracket + and team in brackets[bracket] + ): + removed_teams.append(team) + brackets[bracket].remove(team) + + brackets[bracket] = rank_teams_except_record(brackets[bracket]) + for team in removed_teams: + brackets[bracket].insert(len(brackets[bracket]) // 2, team) + + +def _ordered_pullup_candidates(source_bracket, historical_pullups): + not_pulled_up = [team for team in source_bracket if team not in historical_pullups] + already_pulled_up = [team for team in source_bracket if team in historical_pullups] + return list(reversed(not_pulled_up)) + list(reversed(already_pulled_up)) + + +def _build_pairings_for_brackets(brackets, current_round): + pairings = [] + for bracket in range(current_round): + temp = perfect_pairing(brackets[bracket]) + if current_round != 1: + print(f"Pairing bracket {bracket} of size {len(temp)}") + for pair in temp: + pairings.append([pair[0], pair[1]]) + return pairings + + +def _pair_brackets_with_pullup_search( + brackets, + current_round, + all_checked_in_teams, + middle_of_bracket, + historical_pullups): + def search(working_brackets, bracket_index, selected_pullups, bye_team): + while bracket_index >= 0 and len(working_brackets[bracket_index]) % 2 == 0: + bracket_index -= 1 + + if bracket_index < 0: + try: + pairings = _build_pairings_for_brackets(working_brackets, current_round) + except errors.NotEnoughTeamsError: + return None + return pairings, selected_pullups, bye_team + + if bracket_index == 0: + candidate_byes = working_brackets[0][-1:] + for candidate_bye in candidate_byes: + next_brackets = [list(bracket) for bracket in working_brackets] + next_brackets[0].remove(candidate_bye) + result = search( + next_brackets, + bracket_index - 1, + list(selected_pullups), + candidate_bye, + ) + if result is not None: + return result + return None + + if bracket_index == 1 and not working_brackets[0]: + candidate_byes = [ + team for team in reversed(working_brackets[1]) if not had_bye(team) + ] + for candidate_bye in candidate_byes: + next_brackets = [list(bracket) for bracket in working_brackets] + next_brackets[1].remove(candidate_bye) + result = search( + next_brackets, + bracket_index - 1, + list(selected_pullups), + candidate_bye, + ) + if result is not None: + return result + return None + + for pull_up in _ordered_pullup_candidates( + working_brackets[bracket_index - 1], + historical_pullups): + next_brackets = [list(bracket) for bracket in working_brackets] + next_selected_pullups = list(selected_pullups) + next_selected_pullups.append(pull_up) + next_brackets[bracket_index].append(pull_up) + next_brackets[bracket_index - 1].remove(pull_up) + _re_rank_bracket_after_pull_up( + next_brackets, + bracket_index, + all_checked_in_teams, + middle_of_bracket, + ) + result = search( + next_brackets, + bracket_index - 1, + next_selected_pullups, + bye_team, + ) + if result is not None: + return result + return None + + result = search( + [list(bracket) for bracket in brackets], + current_round - 1, + [], + None, + ) + if result is None: + raise errors.NotEnoughTeamsError( + "Could not satisfy all scratch constraints while pairing teams." + ) + return result + + def pair_round(): """ Pair the next round of debate. @@ -95,94 +215,27 @@ def pair_round(): bracket_middle = bracket_size // 2 list_of_teams[wins].insert(bracket_middle, team) - # Correct for brackets with odd numbers of teams - # 1) If we are in the bottom bracket, give someone a bye - # 2) If we are in 1-up bracket and there are no all down - # teams, give someone a bye - # 3) Otherwise, find a pull up from the next bracket - - for bracket in reversed(list(range(current_round))): - if len(list_of_teams[bracket]) % 2 != 0: - # If there are no teams all down, give the bye to a one down team. - if bracket == 0: - byeint = len(list_of_teams[bracket]) - 1 - bye = Bye( - bye_team=list_of_teams[bracket][byeint], - round_number=current_round, - ) - bye.save() - list_of_teams[bracket].remove(list_of_teams[bracket][byeint]) - elif bracket == 1 and not list_of_teams[0]: - # in 1 up and no all down teams - found_bye = False - for byeint in range(len(list_of_teams[1]) - 1, -1, -1): - if had_bye(list_of_teams[1][byeint]): - pass - elif not found_bye: - bye = Bye( - bye_team=list_of_teams[1][byeint], - round_number=current_round, - ) - bye.save() - list_of_teams[1].remove(list_of_teams[1][byeint]) - found_bye = True - if not found_bye: - raise errors.NotEnoughTeamsError() - else: - pull_up = None - pullup_rounds = Round.objects.exclude(pullup=Round.NONE) - teams_been_pulled_up = [ - r.gov_team for r in pullup_rounds if r.pullup == Round.GOV - ] - teams_been_pulled_up.extend( - [r.opp_team for r in pullup_rounds if r.pullup == Round.OPP] - ) - - # try to pull-up the lowest-ranked team that hasn't been - # pulled-up. Fall-back to the lowest-ranked team if all have - # been pulled-up - not_pulled_up_teams = [ - t - for t in list_of_teams[bracket - 1] - if t not in teams_been_pulled_up - ] - if not_pulled_up_teams: - pull_up = not_pulled_up_teams[-1] - else: - pull_up = list_of_teams[bracket - 1][-1] - - all_pull_ups.append(pull_up) - list_of_teams[bracket].append(pull_up) - list_of_teams[bracket - 1].remove(pull_up) - - # after adding pull-up to new bracket and deleting from old, - # sort again by speaks making sure to leave any first - # round bye in the correct spot - removed_teams = [] - for team in all_checked_in_teams: - # They have all wins and they haven't forfeited so - # they need to get paired in - if team in middle_of_bracket and tot_wins(team) == bracket: - removed_teams += [team] - list_of_teams[bracket].remove(team) - list_of_teams[bracket] = rank_teams_except_record( - list_of_teams[bracket] - ) - for team in removed_teams: - list_of_teams[bracket].insert( - len(list_of_teams[bracket]) // 2, team - ) - - # Pass in the prepared nodes to the perfect pairing logic - # to get a pairing for the round - pairings = [] - for bracket in range(current_round): - if current_round == 1: - temp = perfect_pairing(list_of_teams) - else: - temp = perfect_pairing(list_of_teams[bracket]) - print(f"Pairing bracket {bracket} of size {len(temp)}") - for pair in temp: + pullup_rounds = Round.objects.exclude(pullup=Round.NONE) + teams_been_pulled_up = [ + r.gov_team for r in pullup_rounds if r.pullup == Round.GOV + ] + teams_been_pulled_up.extend( + [r.opp_team for r in pullup_rounds if r.pullup == Round.OPP] + ) + + pairings, all_pull_ups, bye_team = _pair_brackets_with_pullup_search( + list_of_teams, + current_round, + all_checked_in_teams, + middle_of_bracket, + teams_been_pulled_up, + ) + if bye_team is not None: + Bye.objects.create(bye_team=bye_team, round_number=current_round) + + if current_round == 1: + pairings = [] + for pair in perfect_pairing(list_of_teams): pairings.append([pair[0], pair[1]]) if current_round == 1: @@ -487,9 +540,19 @@ def perfect_pairing(list_of_teams): """Uses the mwmatching library to assign teams in a pairing""" graph_edges = [] weights = get_weights() + scratched_pairs = { + (team_one, team_two) + for team_one, team_two in TeamTeamScratch.objects.values_list( + "team_one_id", "team_two_id" + ) + } for i, team1 in enumerate(list_of_teams): for j, team2 in enumerate(list_of_teams): if i > j: + if team1.id and team2.id: + pair_key = (min(team1.id, team2.id), max(team1.id, team2.id)) + if pair_key in scratched_pairs: + continue weight = calc_weight( team1, team2, @@ -505,6 +568,13 @@ def perfect_pairing(list_of_teams): ) graph_edges += [(i, j, weight)] pairings_num = mwmatching.maxWeightMatching(graph_edges, maxcardinality=True) + if ( + len(pairings_num) < len(list_of_teams) + or -1 in pairings_num[:len(list_of_teams)] + ): + raise errors.NotEnoughTeamsError( + "Could not satisfy all scratch constraints while pairing teams." + ) all_pairs = [] for pair in pairings_num: if pair < len(list_of_teams): diff --git a/mittab/libs/tests/helpers.py b/mittab/libs/tests/helpers.py index a1e8609bd..b5d3cdda9 100644 --- a/mittab/libs/tests/helpers.py +++ b/mittab/libs/tests/helpers.py @@ -5,11 +5,13 @@ from mittab.apps.tab.models import ( CheckIn, Judge, + JudgeJudgeScratch, Room, RoomCheckIn, Round, RoundStats, Scratch, + TeamTeamScratch, ) # Speaks every quarter point @@ -146,6 +148,8 @@ def generate_results(round_number, def clear_all_scratches(): """Remove any pre-existing scratch records so tests start from a clean slate.""" Scratch.objects.all().delete() + JudgeJudgeScratch.objects.all().delete() + TeamTeamScratch.objects.all().delete() def build_judge_pool(num_judges, diff --git a/mittab/libs/tests/tab_logic/test_judge_school_conflicts.py b/mittab/libs/tests/tab_logic/test_judge_school_conflicts.py index 05756a857..6aa892db2 100644 --- a/mittab/libs/tests/tab_logic/test_judge_school_conflicts.py +++ b/mittab/libs/tests/tab_logic/test_judge_school_conflicts.py @@ -1,3 +1,4 @@ +# pylint: disable=line-too-long from decimal import Decimal from django.contrib.auth import get_user_model @@ -9,6 +10,7 @@ BreakingTeam, CheckIn, Judge, + JudgeJudgeScratch, Outround, Room, RoomCheckIn, @@ -17,6 +19,7 @@ Scratch, TabSettings, Team, + TeamTeamScratch, ) from mittab.libs import assign_judges, tab_logic from mittab.libs.assign_judges import judge_conflict @@ -257,7 +260,10 @@ def count_hybrid_comparisons(self, rounds): ) def test_judge_assignment_honors_primary_school_conflicts(self): - teams, judges, schools, _ = self.prepare_tournament_round(num_teams=20, num_judges=15) + teams, judges, schools, _ = self.prepare_tournament_round( + num_teams=20, + num_judges=15, + ) # Set up school affiliations for judges # First 3 judges affiliated with school 0 @@ -299,7 +305,10 @@ def test_judge_assignment_honors_primary_school_conflicts(self): ) def test_hybrid_school_conflicts_are_respected(self): - teams, judges, schools, _ = self.prepare_tournament_round(num_teams=16, num_judges=12) + _teams, judges, schools, _ = self.prepare_tournament_round( + num_teams=16, + num_judges=12, + ) # Create a fourth school for hybrid affiliations school_hybrid = School.objects.create(name="Hybrid Test School") @@ -343,7 +352,10 @@ def assign_hybrids_to_paired_teams(): ) def test_explicit_scratches_still_enforced_alongside_school_conflicts(self): - teams, judges, schools, _ = self.prepare_tournament_round(num_teams=16, num_judges=12) + teams, judges, schools, _ = self.prepare_tournament_round( + num_teams=16, + num_judges=12, + ) # Set up school affiliations judges[0].schools.add(schools[0]) @@ -375,7 +387,9 @@ def test_explicit_scratches_still_enforced_alongside_school_conflicts(self): initial_scratch_count = Scratch.objects.count() # Reload judges with prefetched relations - judges = list(Judge.objects.prefetch_related("scratches", "schools", "judges").all()) + judges = list( + Judge.objects.prefetch_related("scratches", "schools", "judges").all() + ) # Verify judge_conflict detects both types of conflicts # School conflict @@ -418,7 +432,10 @@ def test_explicit_scratches_still_enforced_alongside_school_conflicts(self): def test_allow_rejudges_setting_with_school_conflicts(self): # Set up a smaller tournament for multi-round testing - teams, judges, schools, _ = self.prepare_tournament_round(num_teams=12, num_judges=10) + teams, judges, schools, _ = self.prepare_tournament_round( + num_teams=12, + num_judges=10, + ) # Set up school affiliations judges[0].schools.add(schools[0]) @@ -438,7 +455,9 @@ def test_allow_rejudges_setting_with_school_conflicts(self): TabSettings.set("allow_rejudges", False) # Reload judges with prefetched relations - judges_prefetched = list(Judge.objects.prefetch_related("scratches", "schools", "judges").all()) + judges_prefetched = list( + Judge.objects.prefetch_related("scratches", "schools", "judges").all() + ) # School conflicts should be detected self.assertTrue( @@ -465,7 +484,9 @@ def test_allow_rejudges_setting_with_school_conflicts(self): ).judges.add(judges_prefetched[2]) # Reload judges to pick up the round relation - judges_prefetched = list(Judge.objects.prefetch_related("scratches", "schools", "judges").all()) + judges_prefetched = list( + Judge.objects.prefetch_related("scratches", "schools", "judges").all() + ) # With allow_rejudges=False, previous judging should cause conflict self.assertTrue( @@ -481,7 +502,9 @@ def test_allow_rejudges_setting_with_school_conflicts(self): # But if there IS a school conflict, it should still be enforced judges_prefetched[2].schools.add(schools[2]) - judges_prefetched = list(Judge.objects.prefetch_related("scratches", "schools", "judges").all()) + judges_prefetched = list( + Judge.objects.prefetch_related("scratches", "schools", "judges").all() + ) self.assertTrue( judge_conflict(judges_prefetched[2], teams[2], teams[3], allow_rejudges=True), @@ -630,6 +653,62 @@ def test_outround_alternative_judges_allows_rejudges(self): included_names = {judge[0] for judge in response.context["included_judges"]} self.assertIn(rejudge.name, excluded_names | included_names) + def test_inround_alternative_judges_filter_panel_scratches(self): + gov_team = self.make_team("Panel Gov", self.school_primary) + opp_team = self.make_team("Panel Opp", self.school_other) + round_obj = Round.objects.create( + round_number=1, + gov_team=gov_team, + opp_team=opp_team, + ) + + chair = self.make_judge("Panel Chair") + blocked = self.make_judge("Blocked Wing") + neutral = self.make_judge("Eligible Wing") + round_obj.judges.add(chair) + round_obj.chair = chair + round_obj.save() + + JudgeJudgeScratch.objects.create(judge_one=chair, judge_two=blocked) + CheckIn.objects.create(judge=chair, round_number=1) + CheckIn.objects.create(judge=blocked, round_number=1) + CheckIn.objects.create(judge=neutral, round_number=1) + + response = self.client.get( + reverse("alternative_judges", args=[round_obj.id]) + ) + self.assertEqual(response.status_code, 200) + + excluded_names = {judge[0] for judge in response.context["excluded_judges"]} + included_names = {judge[0] for judge in response.context["included_judges"]} + + self.assertIn(neutral.name, excluded_names | included_names) + self.assertNotIn(blocked.name, excluded_names) + self.assertNotIn(blocked.name, included_names) + + def test_inround_assign_judge_rejects_panel_scratches(self): + gov_team = self.make_team("Assign Gov", self.school_primary) + opp_team = self.make_team("Assign Opp", self.school_other) + round_obj = Round.objects.create( + round_number=1, + gov_team=gov_team, + opp_team=opp_team, + ) + + chair = self.make_judge("Assign Chair") + blocked = self.make_judge("Assign Blocked") + round_obj.judges.add(chair) + round_obj.chair = chair + round_obj.save() + JudgeJudgeScratch.objects.create(judge_one=chair, judge_two=blocked) + + response = self.client.get( + reverse("assign_judge", args=[round_obj.id, blocked.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["success"], False) + self.assertEqual(list(round_obj.judges.values_list("id", flat=True)), [chair.id]) + def test_outround_alternative_judges_within_pairing_uses_selected_scope(self): varsity_gov = self.make_team("Varsity Gov", self.school_primary) varsity_opp = self.make_team("Varsity Opp", self.school_other) @@ -677,3 +756,179 @@ def test_outround_alternative_judges_within_pairing_uses_selected_scope(self): self.assertIn(concurrent_scope_judge.name, included_names) self.assertIn(available_judge.name, excluded_names) self.assertNotIn(concurrent_scope_judge.name, excluded_names) + + def test_outround_alternative_judges_filter_panel_scratches(self): + gov_team = self.make_team("Out Panel Gov", self.school_primary) + opp_team = self.make_team("Out Panel Opp", self.school_other) + outround = Outround.objects.create( + gov_team=gov_team, + opp_team=opp_team, + num_teams=2, + type_of_round=Outround.VARSITY, + ) + + chair = self.make_judge("Out Panel Chair") + blocked = self.make_judge("Out Panel Blocked") + neutral = self.make_judge("Out Panel Neutral") + outround.judges.add(chair) + outround.chair = chair + outround.save() + + JudgeJudgeScratch.objects.create(judge_one=chair, judge_two=blocked) + CheckIn.objects.create(judge=chair, round_number=0) + CheckIn.objects.create(judge=blocked, round_number=0) + CheckIn.objects.create(judge=neutral, round_number=0) + + response = self.client.get( + reverse("outround_alternative_judges", args=[outround.id]) + ) + self.assertEqual(response.status_code, 200) + + excluded_names = {judge[0] for judge in response.context["excluded_judges"]} + included_names = {judge[0] for judge in response.context["included_judges"]} + + self.assertIn(neutral.name, excluded_names | included_names) + self.assertNotIn(blocked.name, excluded_names) + self.assertNotIn(blocked.name, included_names) + + def test_outround_assign_judge_rejects_panel_scratches(self): + gov_team = self.make_team("Out Assign Gov", self.school_primary) + opp_team = self.make_team("Out Assign Opp", self.school_other) + outround = Outround.objects.create( + gov_team=gov_team, + opp_team=opp_team, + num_teams=2, + type_of_round=Outround.VARSITY, + ) + + chair = self.make_judge("Out Assign Chair") + blocked = self.make_judge("Out Assign Blocked") + outround.judges.add(chair) + outround.chair = chair + outround.save() + JudgeJudgeScratch.objects.create(judge_one=chair, judge_two=blocked) + + response = self.client.get( + reverse("outround_assign_judge", args=[outround.id, blocked.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["success"], False) + self.assertEqual(list(outround.judges.values_list("id", flat=True)), [chair.id]) + + def test_inround_alternative_teams_filter_team_scratches(self): + gov_team = self.make_team("Team Filter Gov", self.school_primary) + opp_team = self.make_team("Team Filter Opp", self.school_other) + blocked_team = self.make_team("Team Filter Blocked", self.school_primary) + allowed_team = self.make_team("Team Filter Allowed", self.school_other) + round_obj = Round.objects.create( + round_number=1, + gov_team=gov_team, + opp_team=opp_team, + ) + + TeamTeamScratch.objects.create(team_one=opp_team, team_two=blocked_team) + + response = self.client.get( + reverse("round_alternative_teams", args=[round_obj.id, gov_team.id, "gov"]) + ) + self.assertEqual(response.status_code, 200) + + excluded_ids = {team.id for team in response.context["excluded_teams"]} + included_ids = {team.id for team in response.context["included_teams"]} + + self.assertIn(allowed_team.id, excluded_ids | included_ids) + self.assertNotIn(blocked_team.id, excluded_ids) + self.assertNotIn(blocked_team.id, included_ids) + + def test_inround_assign_team_rejects_team_scratches(self): + gov_team = self.make_team("Assign Team Gov", self.school_primary) + opp_team = self.make_team("Assign Team Opp", self.school_other) + blocked_team = self.make_team("Assign Team Blocked", self.school_primary) + round_obj = Round.objects.create( + round_number=1, + gov_team=gov_team, + opp_team=opp_team, + ) + + TeamTeamScratch.objects.create(team_one=opp_team, team_two=blocked_team) + + response = self.client.get( + reverse("assign_team", args=[round_obj.id, "gov", blocked_team.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["success"], False) + + round_obj.refresh_from_db() + self.assertEqual(round_obj.gov_team_id, gov_team.id) + + def test_outround_alternative_teams_filter_team_scratches(self): + gov_team = self.make_team("Out Team Filter Gov", self.school_primary) + opp_team = self.make_team("Out Team Filter Opp", self.school_other) + blocked_team = self.make_team("Out Team Filter Blocked", self.school_primary) + allowed_team = self.make_team("Out Team Filter Allowed", self.school_other) + + for idx, team in enumerate( + [gov_team, opp_team, blocked_team, allowed_team], + start=1, + ): + BreakingTeam.objects.create( + team=team, + seed=idx, + effective_seed=idx, + type_of_team=BreakingTeam.VARSITY, + ) + + outround = Outround.objects.create( + gov_team=gov_team, + opp_team=opp_team, + num_teams=4, + type_of_round=Outround.VARSITY, + ) + + TeamTeamScratch.objects.create(team_one=opp_team, team_two=blocked_team) + + response = self.client.get( + reverse( + "outround_alternative_teams", + args=[outround.id, gov_team.id, "gov"], + ) + ) + self.assertEqual(response.status_code, 200) + + excluded_ids = {team.id for team in response.context["excluded_teams"]} + included_ids = {team.id for team in response.context["included_teams"]} + + self.assertIn(allowed_team.id, excluded_ids | included_ids) + self.assertNotIn(blocked_team.id, excluded_ids) + self.assertNotIn(blocked_team.id, included_ids) + + def test_outround_assign_team_rejects_team_scratches(self): + gov_team = self.make_team("Out Assign Team Gov", self.school_primary) + opp_team = self.make_team("Out Assign Team Opp", self.school_other) + blocked_team = self.make_team("Out Assign Team Blocked", self.school_primary) + + for idx, team in enumerate([gov_team, opp_team, blocked_team], start=1): + BreakingTeam.objects.create( + team=team, + seed=idx, + effective_seed=idx, + type_of_team=BreakingTeam.VARSITY, + ) + + outround = Outround.objects.create( + gov_team=gov_team, + opp_team=opp_team, + num_teams=4, + type_of_round=Outround.VARSITY, + ) + + TeamTeamScratch.objects.create(team_one=opp_team, team_two=blocked_team) + + response = self.client.get( + reverse("outround_assign_team", args=[outround.id, "gov", blocked_team.id]) + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["success"], False) + + outround.refresh_from_db() + self.assertEqual(outround.gov_team_id, gov_team.id) diff --git a/mittab/libs/tests/tab_logic/test_outround_assignment_scope.py b/mittab/libs/tests/tab_logic/test_outround_assignment_scope.py index aa076f16e..650c50b93 100644 --- a/mittab/libs/tests/tab_logic/test_outround_assignment_scope.py +++ b/mittab/libs/tests/tab_logic/test_outround_assignment_scope.py @@ -8,6 +8,7 @@ BreakingTeam, CheckIn, Judge, + JudgeJudgeScratch, Outround, Room, RoomCheckIn, @@ -187,3 +188,99 @@ def test_assign_outround_rooms_ignores_decided_rounds(self): pending_round.refresh_from_db() self.assertEqual(decided_round.room_id, sentinel_room.id) self.assertIsNotNone(pending_round.room_id) + + +@pytest.mark.django_db +class TestOutroundPanelScratchViability(TestCase): + pytestmark = pytest.mark.django_db + + def setUp(self): + super().setUp() + school = School.objects.create(name="Fallback School") + teams = [ + Team.objects.create( + name=f"Fallback Team {idx}", + school=school, + seed=Team.FULL_SEED, + ) + for idx in range(1, 5) + ] + for idx, team in enumerate(teams, start=1): + BreakingTeam.objects.create( + team=team, + seed=idx, + effective_seed=idx, + type_of_team=BreakingTeam.VARSITY, + ) + + self.rounds = [ + Outround.objects.create( + num_teams=4, + type_of_round=Outround.VARSITY, + gov_team=teams[0], + opp_team=teams[3], + ), + Outround.objects.create( + num_teams=4, + type_of_round=Outround.VARSITY, + gov_team=teams[1], + opp_team=teams[2], + ), + ] + + self.bridge_judge = Judge.objects.create( + name="Bridge Judge", + rank=Decimal("4.00"), + ) + self.flex_judge = Judge.objects.create( + name="Flexible Judge", + rank=Decimal("3.00"), + ) + self.spare_judge_a = Judge.objects.create( + name="Spare Judge A", + rank=Decimal("2.00"), + ) + self.spare_judge_b = Judge.objects.create( + name="Spare Judge B", + rank=Decimal("1.00"), + ) + for judge in ( + self.bridge_judge, + self.flex_judge, + self.spare_judge_a, + self.spare_judge_b, + ): + CheckIn.objects.create(judge=judge, round_number=0) + + TabSettings.set("var_panel_size", 2) + TabSettings.set("outround_judge_priority", 0) + + def test_assign_outround_judges_avoids_dead_end_chair_assignments(self): + JudgeJudgeScratch.objects.create( + judge_one=self.bridge_judge, + judge_two=self.spare_judge_a, + ) + JudgeJudgeScratch.objects.create( + judge_one=self.bridge_judge, + judge_two=self.spare_judge_b, + ) + + assign_judges.add_outround_judges(round_specs=[(Outround.VARSITY, 4)]) + + rounds = list( + Outround.objects.filter( + type_of_round=Outround.VARSITY, + num_teams=4, + ) + ) + self.assertEqual(len(rounds), 2) + + for round_obj in rounds: + self.assertEqual(round_obj.judges.count(), 2) + + bridge_round = next( + round_obj + for round_obj in rounds + if round_obj.judges.filter(id=self.bridge_judge.id).exists() + ) + self.assertTrue(bridge_round.judges.filter(id=self.flex_judge.id).exists()) diff --git a/mittab/libs/tests/tab_logic/test_pairing.py b/mittab/libs/tests/tab_logic/test_pairing.py index ef0208009..3c506b549 100644 --- a/mittab/libs/tests/tab_logic/test_pairing.py +++ b/mittab/libs/tests/tab_logic/test_pairing.py @@ -1,3 +1,5 @@ +# pylint: disable=protected-access +from decimal import Decimal from django.db import transaction from django.test import TestCase import pytest @@ -5,14 +7,18 @@ from mittab.apps.tab.models import ( CheckIn, Judge, + JudgeJudgeScratch, Room, RoomCheckIn, Round, School, + Scratch, TabSettings, Team, + TeamTeamScratch, ) from mittab.libs import assign_judges, assign_rooms +from mittab.libs.errors import NotEnoughTeamsError from mittab.libs import tab_logic from mittab.libs.cacheing import cache_logic from mittab.libs.tests.helpers import generate_results @@ -162,3 +168,325 @@ def test_repair_recovers_after_data_mutations(self): self.re_pair_latest_round() self.assertEqual(self.pairings_for(second_round), baseline_pairings) + + +@pytest.mark.django_db +class TestScratchHonoring(TestCase): + """Test that scratches are properly honored during pairing""" + + fixtures = ["testing_db"] + pytestmark = pytest.mark.django_db + + def setUp(self): + super().setUp() + TabSettings.set("cur_round", 1) + self.generate_checkins(2) + + def generate_checkins(self, round_number): + CheckIn.objects.all().delete() + RoomCheckIn.objects.all().delete() + + judges = list(Judge.objects.all()) + rooms = list(Room.objects.all()) + checkins = [ + CheckIn(judge=j, round_number=rnd) + for rnd in range(0, round_number + 1) + for j in judges + ] + room_checkins = [ + RoomCheckIn(room=r, round_number=rnd) + for rnd in range(0, round_number + 1) + for r in rooms + ] + CheckIn.objects.bulk_create(checkins) + RoomCheckIn.objects.bulk_create(room_checkins) + + def test_team_team_scratch_prevents_pairing(self): + """Test that team-team scratches prevent those teams from being paired""" + # Get two teams + teams = list(Team.objects.all()[:2]) + team_one, team_two = teams[0], teams[1] + + # Create a team-team scratch + TeamTeamScratch.objects.create(team_one=team_one, team_two=team_two) + + # Pair the round + cache_logic.clear_cache() + tab_logic.pair_round() + + # Check that these two teams were not paired together + rounds_with_both = Round.objects.filter( + round_number=1 + ).filter( + gov_team__in=[team_one, team_two], + opp_team__in=[team_one, team_two] + ) + + self.assertEqual( + rounds_with_both.count(), + 0, + "Teams with a team-team scratch should not be paired together" + ) + + def test_judge_team_scratch_prevents_assignment(self): + """Test that judge-team scratches prevent judge assignment to team""" + # Pair the round first + cache_logic.clear_cache() + tab_logic.pair_round() + + # Get a round and create a scratch between judge and one team + round_obj = Round.objects.filter(round_number=1).first() + judge = Judge.objects.filter(checkin__round_number=1).first() + team = round_obj.gov_team + + Scratch.objects.create( + judge=judge, team=team, scratch_type=Scratch.TEAM_SCRATCH + ) + + # Clear existing judge assignments + round_obj.judges.clear() + round_obj.chair = None + round_obj.save() + + # Assign judges + assign_judges.add_judges() + + # Check that the judge was not assigned to this round + round_obj.refresh_from_db() + assigned_judges = list(round_obj.judges.all()) + if round_obj.chair: + assigned_judges.append(round_obj.chair) + + self.assertNotIn( + judge, + assigned_judges, + "Judge with scratch should not be assigned to team's round" + ) + + def test_judge_judge_scratch_prevents_panel_assignment(self): + """Test that judge-judge scratches prevent judges from being on same panel""" + # Get two judges + judges = list(Judge.objects.filter(checkin__round_number=1)[:2]) + if len(judges) < 2: + self.skipTest("Not enough judges for this test") + + judge_one, judge_two = judges[0], judges[1] + + # Create a judge-judge scratch + JudgeJudgeScratch.objects.create( + judge_one=judge_one, judge_two=judge_two + ) + + # Pair the round + cache_logic.clear_cache() + tab_logic.pair_round() + + # Assign judges + assign_judges.add_judges() + + # Check that these judges are not on the same panel + for round_obj in Round.objects.filter(round_number=1): + panel_judges = list(round_obj.judges.all()) + if round_obj.chair: + panel_judges.append(round_obj.chair) + + judge_ids = [j.id for j in panel_judges] + + # Both judges should not be in the same panel + has_both = judge_one.id in judge_ids and judge_two.id in judge_ids + self.assertFalse( + has_both, + f"Judges with scratch should not be on same panel. " + f"Panel: {judge_ids}, Scratched: {judge_one.id}, {judge_two.id}" + ) + + def test_multiple_scratches_honored(self): + """Test that multiple types of scratches are all honored""" + # Create various scratches + teams = list(Team.objects.all()[:4]) + judges = list(Judge.objects.filter(checkin__round_number=1)[:3]) + + # Team-team scratch + if len(teams) >= 2: + TeamTeamScratch.objects.create( + team_one=teams[0], team_two=teams[1] + ) + + # Judge-judge scratch + if len(judges) >= 2: + JudgeJudgeScratch.objects.create( + judge_one=judges[0], judge_two=judges[1] + ) + + # Pair and assign + cache_logic.clear_cache() + tab_logic.pair_round() + assign_judges.add_judges() + + # Verify team-team scratch + if len(teams) >= 2: + rounds_with_both_teams = Round.objects.filter( + round_number=1, + gov_team__in=[teams[0], teams[1]], + opp_team__in=[teams[0], teams[1]] + ) + self.assertEqual(rounds_with_both_teams.count(), 0) + + # Verify judge-judge scratch + if len(judges) >= 2: + for round_obj in Round.objects.filter(round_number=1): + panel_judges = list(round_obj.judges.all()) + if round_obj.chair: + panel_judges.append(round_obj.chair) + judge_ids = [j.id for j in panel_judges] + has_both = judges[0].id in judge_ids and judges[1].id in judge_ids + self.assertFalse(has_both) + + def test_impossible_team_team_scratches_raise_clean_error(self): + """Test impossible scratch constraints fail explicitly""" + teams = list(Team.objects.all()[:4]) + focal_team = teams[0] + + for blocked_team in teams[1:]: + TeamTeamScratch.objects.create( + team_one=focal_team, + team_two=blocked_team, + ) + + with self.assertRaisesRegex( + NotEnoughTeamsError, + "Could not satisfy all scratch constraints while pairing teams.", + ): + tab_logic.perfect_pairing(teams) + + def test_pullup_search_tries_alternate_team_when_default_pullup_is_scratched(self): + TabSettings.set("cur_round", 3) + school = School.objects.create(name="Pullup Scratch School") + teams = [ + Team.objects.create( + name=f"Pullup Scratch Team {idx}", + school=school, + seed=Team.UNSEEDED, + tiebreaker=idx, + ) + for idx in range(1, 7) + ] + bottom_team = teams[0] + middle_teams = teams[1:5] + top_team = teams[5] + + TeamTeamScratch.objects.create( + team_one=top_team, + team_two=middle_teams[-1], + ) + + pairings, pullups, bye_team = tab_logic._pair_brackets_with_pullup_search( + brackets=[ + [bottom_team], + list(middle_teams), + [top_team], + ], + current_round=3, + all_checked_in_teams=teams, + middle_of_bracket=[], + historical_pullups=[], + ) + + paired_team_ids = { + frozenset((team_one.id, team_two.id)) + for team_one, team_two in pairings + } + self.assertIn( + frozenset((top_team.id, middle_teams[-2].id)), + paired_team_ids, + ) + self.assertNotIn(middle_teams[-1], pullups) + self.assertIn(middle_teams[-2], pullups) + self.assertIsNone(bye_team) + + +@pytest.mark.django_db +class TestJudgeRoomQualityAlignment(TestCase): + pytestmark = pytest.mark.django_db + + def setUp(self): + super().setUp() + TabSettings.set("cur_round", 2) + TabSettings.set("pair_wings", 0) + TabSettings.set("enable_room_seeding", 1) + + school = School.objects.create(name="Judge Room School") + teams = [ + Team.objects.create( + name=f"Quality Team {seed}", + school=school, + seed=seed, + ) + for seed in ( + Team.FULL_SEED, + Team.HALF_SEED, + Team.FREE_SEED, + Team.UNSEEDED, + ) + ] + + self.top_round = Round.objects.create( + round_number=1, + gov_team=teams[0], + opp_team=teams[1], + ) + self.bottom_round = Round.objects.create( + round_number=1, + gov_team=teams[2], + opp_team=teams[3], + ) + + self.top_judge = Judge.objects.create( + name="Top Judge", + rank=Decimal("5.00"), + ) + self.bottom_judge = Judge.objects.create( + name="Bottom Judge", + rank=Decimal("1.00"), + ) + CheckIn.objects.create(judge=self.top_judge, round_number=1) + CheckIn.objects.create(judge=self.bottom_judge, round_number=1) + + self.top_room = Room.objects.create( + name="Top Room", + rank=Decimal("5.00"), + ) + self.bottom_room = Room.objects.create( + name="Bottom Room", + rank=Decimal("1.00"), + ) + RoomCheckIn.objects.create(room=self.top_room, round_number=1) + RoomCheckIn.objects.create(room=self.bottom_room, round_number=1) + + def test_best_round_gets_best_judge_and_room(self): + assign_judges.add_judges() + assign_rooms.add_rooms() + + self.top_round.refresh_from_db() + self.bottom_round.refresh_from_db() + + self.assertEqual( + self.top_round.chair, + self.top_judge, + "The strongest judge should chair the strongest round", + ) + self.assertEqual( + self.top_round.room, + self.top_room, + "The strongest round should receive the strongest room", + ) + self.assertEqual( + self.bottom_round.chair, + self.bottom_judge, + "The weaker judge should be assigned to the weaker round", + ) + self.assertEqual( + self.bottom_round.room, + self.bottom_room, + "The weaker round should receive the weaker room", + ) diff --git a/mittab/libs/tests/test_backup_handlers.py b/mittab/libs/tests/test_backup_handlers.py new file mode 100644 index 000000000..35476f52b --- /dev/null +++ b/mittab/libs/tests/test_backup_handlers.py @@ -0,0 +1,10 @@ +# pylint: disable=protected-access +from mittab.libs.backup.handlers import DB_NAME, MysqlDumpRestorer + + +def test_dump_cmd_excludes_all_scratch_tables_when_requested(): + cmd = MysqlDumpRestorer()._dump_cmd(include_scratches=False) + + assert f"--ignore-table={DB_NAME}.tab_scratch" in cmd + assert f"--ignore-table={DB_NAME}.tab_judgejudgescratch" in cmd + assert f"--ignore-table={DB_NAME}.tab_teamteamscratch" in cmd diff --git a/mittab/libs/tests/test_case.py b/mittab/libs/tests/test_case.py index 9e9d7d1e1..830ca27e3 100644 --- a/mittab/libs/tests/test_case.py +++ b/mittab/libs/tests/test_case.py @@ -39,7 +39,7 @@ def setUp(self): options=chrome_options) else: self.browser = Browser("firefox", - headless=False, + headless=True, wait_time=30) self.browser.driver.set_page_load_timeout(240) TabSettings.set("cur_round", 1) diff --git a/mittab/libs/tests/views/test_post_operations.py b/mittab/libs/tests/views/test_post_operations.py index 70ccb587c..f214582d1 100644 --- a/mittab/libs/tests/views/test_post_operations.py +++ b/mittab/libs/tests/views/test_post_operations.py @@ -43,7 +43,12 @@ def test_create_operations(self): {"name": "Test Team", "school": school.id, "debaters": [d.id for d in debaters], "seed": 5}), ("add_scratch", reverse("add_scratch"), - {"judge": judge.id, "team": team.id, "scratch_type": 0}), + { + "form_type": "judge_team", + "judge_team_0-judge": judge.id, + "judge_team_0-team": team.id, + "judge_team_0-scratch_type": 0, + }), ("toggle_pairing_released", reverse("toggle_pairing_released"), {}), ("enter_room_duplicate", reverse("enter_room"), diff --git a/mittab/libs/tests/views/test_scratch_views.py b/mittab/libs/tests/views/test_scratch_views.py new file mode 100644 index 000000000..44b0d2084 --- /dev/null +++ b/mittab/libs/tests/views/test_scratch_views.py @@ -0,0 +1,379 @@ +import copy + +import pytest +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import TestCase, Client, override_settings +from django.urls import reverse + +from mittab.apps.tab.models import ( + Judge, + School, + Team, + Scratch, + JudgeJudgeScratch, + TeamTeamScratch, +) + +TEST_WEBPACK_LOADER = copy.deepcopy(settings.WEBPACK_LOADER) +TEST_WEBPACK_LOADER["DEFAULT"]["LOADER_CLASS"] = ( + "webpack_loader.loaders.FakeWebpackLoader" +) + + +@pytest.mark.django_db(transaction=True) +@override_settings(WEBPACK_LOADER=TEST_WEBPACK_LOADER) +class TestScratchViews(TestCase): + """Test the scratch views functionality""" + + fixtures = ["testing_db"] + + def setUp(self): + super().setUp() + self.client = Client() + self.user = get_user_model().objects.create_superuser( + username="testuser", + password="testpass123", + email="test@test.com", + ) + self.client.login(username="testuser", password="testpass123") + + def test_add_scratch_view_loads(self): + """Test that the add scratch view loads successfully""" + response = self.client.get(reverse("add_scratch")) + self.assertEqual(response.status_code, 200) + self.assertIn("forms_by_type", response.context) + self.assertIn("judge_team", response.context["forms_by_type"]) + self.assertIn("judge_judge", response.context["forms_by_type"]) + self.assertIn("team_team", response.context["forms_by_type"]) + + def test_add_judge_team_scratch(self): + """Test adding a judge-team scratch""" + judge = Judge.objects.first() + team = Team.objects.first() + + response = self.client.post( + reverse("add_scratch"), + { + "form_type": "judge_team", + "judge_team_0-judge": judge.id, + "judge_team_0-team": team.id, + "judge_team_0-scratch_type": Scratch.TEAM_SCRATCH, + }, + ) + + # Should redirect on success + self.assertEqual(response.status_code, 302) + + # Verify scratch was created + self.assertTrue( + Scratch.objects.filter(judge=judge, team=team).exists() + ) + + def test_add_scratch_view_honors_requested_count(self): + """Test the add scratch view builds the requested number of forms""" + response = self.client.get( + reverse("add_scratch"), + {"team_id": Team.objects.first().id, "count": 2}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["forms_by_type"]["judge_team"]), 2) + + def test_add_multiple_judge_team_scratches(self): + """Test adding multiple judge-team scratches in one submission""" + judges = list(Judge.objects.all()[:2]) + teams = list(Team.objects.all()[:2]) + + response = self.client.post( + reverse("add_scratch"), + { + "form_type": "judge_team", + "count": 2, + "judge_team_0-judge": judges[0].id, + "judge_team_0-team": teams[0].id, + "judge_team_0-scratch_type": Scratch.TEAM_SCRATCH, + "judge_team_1-judge": judges[1].id, + "judge_team_1-team": teams[1].id, + "judge_team_1-scratch_type": Scratch.TAB_SCRATCH, + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertTrue(Scratch.objects.filter(judge=judges[0], team=teams[0]).exists()) + self.assertTrue( + Scratch.objects.filter( + judge=judges[1], + team=teams[1], + scratch_type=Scratch.TAB_SCRATCH, + ).exists() + ) + + def test_add_judge_judge_scratch(self): + """Test adding a judge-judge scratch""" + judges = list(Judge.objects.all()[:2]) + judge_one, judge_two = judges[0], judges[1] + + response = self.client.post( + reverse("add_scratch"), + { + "form_type": "judge_judge", + "judge_judge_0-judge_one": judge_one.id, + "judge_judge_0-judge_two": judge_two.id, + }, + ) + + # Should redirect on success + self.assertEqual(response.status_code, 302) + + # Verify scratch was created + self.assertTrue( + JudgeJudgeScratch.objects.filter( + judge_one=min(judge_one, judge_two, key=lambda j: j.id), + judge_two=max(judge_one, judge_two, key=lambda j: j.id), + ).exists() + ) + + def test_add_team_team_scratch(self): + """Test adding a team-team scratch""" + teams = list(Team.objects.all()[:2]) + team_one, team_two = teams[0], teams[1] + + response = self.client.post( + reverse("add_scratch"), + { + "form_type": "team_team", + "team_team_0-team_one": team_one.id, + "team_team_0-team_two": team_two.id, + }, + ) + + # Should redirect on success + self.assertEqual(response.status_code, 302) + + # Verify scratch was created + self.assertTrue( + TeamTeamScratch.objects.filter( + team_one=min(team_one, team_two, key=lambda t: t.id), + team_two=max(team_one, team_two, key=lambda t: t.id), + ).exists() + ) + + def test_view_scratches(self): + """Test viewing all scratches""" + # Create some scratches + judge = Judge.objects.first() + team = Team.objects.first() + Scratch.objects.create( + judge=judge, team=team, scratch_type=Scratch.TEAM_SCRATCH + ) + + response = self.client.get(reverse("view_scratches")) + self.assertEqual(response.status_code, 200) + self.assertIn("item_list", response.context) + + def test_view_scratches_for_judge(self): + """Test viewing scratches for a specific judge""" + judge = Judge.objects.first() + team = Team.objects.first() + Scratch.objects.create( + judge=judge, team=team, scratch_type=Scratch.TEAM_SCRATCH + ) + + response = self.client.get(reverse("view_scratches_judge", args=[judge.id])) + self.assertEqual(response.status_code, 200) + + def test_view_scratches_for_team(self): + """Test viewing scratches for a specific team""" + judge = Judge.objects.first() + team = Team.objects.first() + Scratch.objects.create( + judge=judge, team=team, scratch_type=Scratch.TEAM_SCRATCH + ) + + response = self.client.get(reverse("view_scratches_team", args=[team.id])) + self.assertEqual(response.status_code, 200) + + def test_view_scratches_for_team_saves_edits(self): + """Test editing an existing scratch from the object page""" + school = School.objects.create(name="Scratch View School") + team = Team.objects.create( + name="Scratch View Team", + school=school, + seed=Team.UNSEEDED, + ) + judges = [ + Judge.objects.create(name="Scratch View Judge 1", rank=1), + Judge.objects.create(name="Scratch View Judge 2", rank=2), + ] + scratch = Scratch.objects.create( + judge=judges[0], team=team, scratch_type=Scratch.TEAM_SCRATCH + ) + + response = self.client.post( + reverse("view_scratches_team", args=[team.id]), + { + "1-judge": judges[1].id, + "1-team": team.id, + "1-scratch_type": Scratch.TAB_SCRATCH, + }, + ) + + self.assertEqual(response.status_code, 302) + scratch.refresh_from_db() + self.assertEqual(scratch.judge_id, judges[1].id) + self.assertEqual(scratch.scratch_type, Scratch.TAB_SCRATCH) + + def test_scratch_detail_judge_team(self): + """Test viewing a single judge-team scratch detail""" + judge = Judge.objects.first() + team = Team.objects.first() + scratch = Scratch.objects.create( + judge=judge, team=team, scratch_type=Scratch.TAB_SCRATCH + ) + + response = self.client.get( + reverse("scratch_detail", args=["judge-team", scratch.id]) + ) + self.assertEqual(response.status_code, 200) + + def test_scratch_detail_judge_judge(self): + """Test viewing a single judge-judge scratch detail""" + judges = list(Judge.objects.all()[:2]) + scratch = JudgeJudgeScratch.objects.create( + judge_one=judges[0], judge_two=judges[1] + ) + + response = self.client.get( + reverse("scratch_detail", args=["judge-judge", scratch.id]) + ) + self.assertEqual(response.status_code, 200) + + def test_scratch_detail_team_team(self): + """Test viewing a single team-team scratch detail""" + teams = list(Team.objects.all()[:2]) + scratch = TeamTeamScratch.objects.create( + team_one=teams[0], team_two=teams[1] + ) + + response = self.client.get( + reverse("scratch_detail", args=["team-team", scratch.id]) + ) + self.assertEqual(response.status_code, 200) + + def test_scratch_detail_saves_edits(self): + """Test editing a scratch from the detail page""" + judge = Judge.objects.first() + team = Team.objects.first() + scratch = Scratch.objects.create( + judge=judge, team=team, scratch_type=Scratch.TEAM_SCRATCH + ) + + response = self.client.post( + reverse("scratch_detail", args=["judge-team", scratch.id]), + { + "judge": judge.id, + "team": team.id, + "scratch_type": Scratch.TAB_SCRATCH, + }, + ) + + self.assertEqual(response.status_code, 302) + scratch.refresh_from_db() + self.assertEqual(scratch.scratch_type, Scratch.TAB_SCRATCH) + + def test_scratch_delete_judge_team(self): + """Test deleting a judge-team scratch""" + judge = Judge.objects.first() + team = Team.objects.first() + scratch = Scratch.objects.create( + judge=judge, team=team, scratch_type=Scratch.TEAM_SCRATCH + ) + + response = self.client.get( + reverse("scratch_delete", args=["judge-team", scratch.id]) + ) + + # Should redirect on success + self.assertEqual(response.status_code, 302) + + # Verify scratch was deleted + self.assertFalse(Scratch.objects.filter(id=scratch.id).exists()) + + def test_scratch_delete_judge_judge(self): + """Test deleting a judge-judge scratch""" + judges = list(Judge.objects.all()[:2]) + scratch = JudgeJudgeScratch.objects.create( + judge_one=judges[0], judge_two=judges[1] + ) + + response = self.client.get( + reverse("scratch_delete", args=["judge-judge", scratch.id]) + ) + + # Should redirect on success + self.assertEqual(response.status_code, 302) + + # Verify scratch was deleted + self.assertFalse( + JudgeJudgeScratch.objects.filter(id=scratch.id).exists() + ) + + def test_scratch_delete_team_team(self): + """Test deleting a team-team scratch""" + teams = list(Team.objects.all()[:2]) + scratch = TeamTeamScratch.objects.create( + team_one=teams[0], team_two=teams[1] + ) + + response = self.client.get( + reverse("scratch_delete", args=["team-team", scratch.id]) + ) + + # Should redirect on success + self.assertEqual(response.status_code, 302) + + # Verify scratch was deleted + self.assertFalse( + TeamTeamScratch.objects.filter(id=scratch.id).exists() + ) + + def test_duplicate_scratch_error(self): + """Test that adding a duplicate scratch shows an error""" + judge = Judge.objects.first() + team = Team.objects.first() + + # Create first scratch + Scratch.objects.create( + judge=judge, team=team, scratch_type=Scratch.TEAM_SCRATCH + ) + + # Try to create duplicate + response = self.client.post( + reverse("add_scratch"), + { + "form_type": "judge_team", + "judge_team_0-judge": judge.id, + "judge_team_0-team": team.id, + "judge_team_0-scratch_type": Scratch.TEAM_SCRATCH, + }, + ) + + # Should not redirect (stays on page with error) + self.assertEqual(response.status_code, 200) + # Should still only have one scratch + self.assertEqual( + Scratch.objects.filter(judge=judge, team=team).count(), 1 + ) + + def test_team_detail_uses_current_scratch_link(self): + """Test the team detail page links to the current scratch route""" + team = Team.objects.first() + + response = self.client.get(reverse("view_team", args=[team.id])) + + self.assertEqual(response.status_code, 200) + self.assertContains( + response, + reverse("view_scratches_team", args=[team.id]), + ) diff --git a/mittab/libs/tests/views/test_tab_views.py b/mittab/libs/tests/views/test_tab_views.py index 0db71ecfe..532193321 100644 --- a/mittab/libs/tests/views/test_tab_views.py +++ b/mittab/libs/tests/views/test_tab_views.py @@ -68,7 +68,7 @@ def test_render(self): (reverse("view_judges"), judge.name), (reverse("view_judge", args=[judge.pk]), judge.name), (reverse("enter_judge"), "Create Judge"), - (reverse("view_scratches", args=[judge.pk]), "Scratches"), + (reverse("view_scratches_judge", args=[judge.pk]), "Scratches"), (reverse("view_schools"), school.name), (reverse("view_school", args=[school.pk]), school.name), (reverse("enter_school"), "Create School"), diff --git a/mittab/libs/tests/web/test_setting_up_a_tournament.py b/mittab/libs/tests/web/test_setting_up_a_tournament.py index f18995edd..8cd9bc61a 100644 --- a/mittab/libs/tests/web/test_setting_up_a_tournament.py +++ b/mittab/libs/tests/web/test_setting_up_a_tournament.py @@ -19,19 +19,34 @@ def test_tournament(self): self._add_judges() self._add_debaters() self._add_teams() - self._go_home() - - self.browser.find_by_xpath("//a[contains(text(), 'Team 0')]").first.click() - self.browser.find_by_xpath("//*[text()='Scratches for Team 0']").first.click() - - self.browser.find_by_xpath("//*[text()='Add Scratch']").last.click() - self.browser.find_by_xpath("//*[text()='Team 0']").first.click() - self.browser.find_by_xpath("//*[text()='Judge 2']").first.click() - self.browser.find_by_xpath("//*[text()='Tab Scratch']").first.click() - self.browser.find_by_xpath("//*[@value='Submit']").first.click() - - msg = "Scratches created successfully" - assert self._wait_for_text(msg) + self._add_scratches() + + def _add_scratches(self): + self._visit("/enter_scratch/") + + scratches = [ + ("judge_team", {"judge": "3", "team": "1", "scratch_type": "1"}), + ("judge_judge", {"judge_one": "1", "judge_two": "2"}), + ("team_team", {"team_one": "2", "team_two": "3"}), + ] + + for i, (tab, fields) in enumerate(scratches): + if i > 0: + self.browser.find_by_id(f"{tab}-tab").first.click() + self._wait() + self._wait() # Extra wait for tab transition + + for field_name, value in fields.items(): + self.browser.select(f"{tab}_0-{field_name}", value) + + self._wait() + # Find Submit button within the active tab pane + submit_btn = self.browser.find_by_xpath( + f"//*[@id='{tab}']//input[@value='Submit']" + ).first + submit_btn.click() + assert self._wait_for_text("Scratches created successfully") + self._visit("/enter_scratch/") def _add_teams(self): for i in range(4): @@ -133,7 +148,9 @@ def _add_entity(self, entity_name, custom_form_logic=None, **data): the custom_form_logic parameter to fill in any additional fields """ self._go_home() - self.browser.find_by_xpath(f"//*[@id='{entity_name.lower()}-list-btn-add']").first.click() + self.browser.find_by_xpath( + f"//*[@id='{entity_name.lower()}-list-btn-add']" + ).first.click() if custom_form_logic: custom_form_logic() @@ -144,10 +161,12 @@ def _add_entity(self, entity_name, custom_form_logic=None, **data): assert self._wait_for_text(msg) self._go_home() - self.browser.find_by_xpath(f"//a[contains(text(), '{data['name']}')]").first.click() + self.browser.find_by_xpath( + f"//a[contains(text(), '{data['name']}')]" + ).first.click() - for key in data: - assert self._wait_for_text(str(data[key])) + for _key, value in data.items(): + assert self._wait_for_text(str(value)) def _submit_form(self, **data): """ diff --git a/mittab/templates/common/_form.html b/mittab/templates/common/_form.html index fb2de5f8f..e2ec6b9a9 100644 --- a/mittab/templates/common/_form.html +++ b/mittab/templates/common/_form.html @@ -16,5 +16,9 @@ {% csrf_token %} {% buttons %} + {% if delete_link %} + Delete + {% endif %} {% endbuttons %} diff --git a/mittab/templates/scratches/add_scratches.html b/mittab/templates/scratches/add_scratches.html new file mode 100644 index 000000000..3c496a8af --- /dev/null +++ b/mittab/templates/scratches/add_scratches.html @@ -0,0 +1,59 @@ +{% extends "base/__normal.html" %} +{% load bootstrap4 %} + +{% block title %}Adding Scratches{% endblock %} +{% block banner %}Adding Scratches{% endblock %} + +{% block content %} +