From 1ba787b9f191f6c56956709b3602ef7ebeef467d Mon Sep 17 00:00:00 2001 From: JoeyRubas Date: Fri, 14 Nov 2025 19:10:01 -0500 Subject: [PATCH 1/2] status --- assets/js/batchCheckin.js | 38 +++++++++++-- ...dding-Teams,-Judges,-Rooms-and-Debaters.md | 9 ++++ .../tab/migrations/0033_auto_20251114_2230.py | 26 +++++++++ mittab/apps/tab/models.py | 24 +++++++++ mittab/apps/tab/templatetags/tags.py | 21 ++++++++ mittab/apps/tab/views/public_views.py | 13 +++-- mittab/apps/tab/views/views.py | 53 +++++++++++++++---- mittab/libs/data_import/import_judges.py | 53 +++++++++++++++++-- .../tests/data_import/test_import_judges.py | 39 ++++++++++++-- mittab/libs/tests/views/test_public_views.py | 32 ++++++++++- .../batch_check_in/_checkin_pane.html | 22 ++++++-- mittab/templates/batch_check_in/check_in.html | 6 +++ mittab/templates/public/judges.html | 2 +- 13 files changed, 305 insertions(+), 33 deletions(-) create mode 100644 mittab/apps/tab/migrations/0033_auto_20251114_2230.py diff --git a/assets/js/batchCheckin.js b/assets/js/batchCheckin.js index d468a0ebb..0fb1856c2 100644 --- a/assets/js/batchCheckin.js +++ b/assets/js/batchCheckin.js @@ -18,6 +18,30 @@ const updateCheckinCounts = (entityType, roundDeltas) => { }); }; +const updateToggleLabel = ($cb, checked) => { + const prefix = $cb.data("labelPrefix") || "Checked"; + const labelOn = $cb.data("labelOn") || "In"; + const labelOff = $cb.data("labelOff") || "Out"; + const status = checked ? labelOn : labelOff; + $cb.next("label").text(`${prefix} ${status}`); +}; + +const updateJudgeHighlights = (ids, rounds, expected) => { + ids.forEach((id) => { + rounds.forEach((round) => { + const selector = `#judge td.judge-checkin-cell[data-judge-id='${id}'][data-round='${round}']`; + const $cell = $(selector); + if (!$cell.length) return; + $cell.toggleClass("checkin-cell-expected table-success", expected); + if (expected) { + $cell.attr("title", "Expected this round"); + } else { + $cell.removeAttr("title"); + } + }); + }); +}; + const submitCheckIn = (checkboxes, checked) => { const $boxes = $(checkboxes); if (!$boxes.length) return; @@ -45,12 +69,16 @@ const submitCheckIn = (checkboxes, checked) => { action: checked ? "check_in" : "check_out", entity_ids: ids, rounds: [...new Set(rounds)].filter((r) => r != null), - }) + }) .done(() => { - const status = checked ? "In" : "Out"; - $boxes.each((_, cb) => - $(cb).prop("checked", checked).next("label").text(`Checked ${status}`), - ); + $boxes.each((_, cb) => { + const $cb = $(cb); + $cb.prop("checked", checked); + updateToggleLabel($cb, checked); + }); + if (entityType === "judge_expected") { + updateJudgeHighlights(ids, [...new Set(rounds)], checked); + } if (Object.keys(roundDeltas).length) { updateCheckinCounts(entityType, roundDeltas); } diff --git a/docs/Adding-Teams,-Judges,-Rooms-and-Debaters.md b/docs/Adding-Teams,-Judges,-Rooms-and-Debaters.md index 46ec30655..054e7ad4f 100644 --- a/docs/Adding-Teams,-Judges,-Rooms-and-Debaters.md +++ b/docs/Adding-Teams,-Judges,-Rooms-and-Debaters.md @@ -25,6 +25,15 @@ file You can use [these templates](https://drive.google.com/drive/folders/1ElIk0bM9uMpuewmOxb2e3-cWiLhCYv_5?usp=sharing) for the teams, judges, and rooms files +### Judge spreadsheet columns + +- Column A: Judge Name +- Column B: Judge Rank +- Columns C onward: Affiliated schools (one per column). Blank cells are ignored, so you can leave extra cells empty without creating phantom schools. +- The final *N* columns (where *N* is the value of the **Total Rounds** setting) represent the rounds you expect the judge to attend (`R1` through `RN`). Use `Y`, `Yes`, `True`, `1`, `X`, or `In` to mark a judge as expected for that round; leave the cell empty (or anything else) if they are not expected. + +When the spreadsheet is imported, the expected-round columns populate the “Expected Judges” tab in batch check-in and the public judge list. + **NOTE:** The files _must_ be `.xlsx` files. Not `.xls`, `.csv`, or anything similar diff --git a/mittab/apps/tab/migrations/0033_auto_20251114_2230.py b/mittab/apps/tab/migrations/0033_auto_20251114_2230.py new file mode 100644 index 000000000..4d2273f15 --- /dev/null +++ b/mittab/apps/tab/migrations/0033_auto_20251114_2230.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.25 on 2025-11-14 22:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tab', '0032_manualjudgeassignment'), + ] + + operations = [ + migrations.CreateModel( + name='JudgeExpectedCheckIn', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('round_number', models.IntegerField()), + ('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expected_checkins', to='tab.judge')), + ], + ), + migrations.AddConstraint( + model_name='judgeexpectedcheckin', + constraint=models.UniqueConstraint(fields=('judge', 'round_number'), name='unique_judge_expectation_per_round'), + ), + ] diff --git a/mittab/apps/tab/models.py b/mittab/apps/tab/models.py index c6684956b..fbdc4eb25 100644 --- a/mittab/apps/tab/models.py +++ b/mittab/apps/tab/models.py @@ -362,6 +362,10 @@ def is_checked_in_for_round(self, round_number): return any(checkin.round_number == round_number for checkin in self.checkin_set.all()) + def is_expected_for_round(self, round_number): + return any(expectation.round_number == round_number + for expectation in self.expected_checkins.all()) + def __str__(self): return self.name @@ -647,6 +651,26 @@ class Meta: ] +class JudgeExpectedCheckIn(models.Model): + judge = models.ForeignKey( + Judge, + on_delete=models.CASCADE, + related_name="expected_checkins", + ) + round_number = models.IntegerField() + + def __str__(self): + return f"Judge {self.judge} is expected for round {self.round_number}" + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["judge", "round_number"], + name="unique_judge_expectation_per_round", + ) + ] + + class RoomCheckIn(models.Model): room = models.ForeignKey(Room, on_delete=models.CASCADE) round_number = models.IntegerField() diff --git a/mittab/apps/tab/templatetags/tags.py b/mittab/apps/tab/templatetags/tags.py index ee97bc3d7..f72bbd9e1 100644 --- a/mittab/apps/tab/templatetags/tags.py +++ b/mittab/apps/tab/templatetags/tags.py @@ -44,6 +44,27 @@ def is_checked_in(judge, round_value): return judge.is_checked_in_for_round(round_value) +@register.filter("is_expected") +def is_expected(judge, round_value): + return judge.is_expected_for_round(round_value) + + +@register.filter +def lookup(sequence, index): + if sequence is None: + return None + if isinstance(sequence, str): + return None + try: + index = int(index) + except (TypeError, ValueError): + return None + try: + return sequence[index] + except (IndexError, TypeError, KeyError): + return None + + @register.simple_tag(takes_context=True) def judge_team_count(context, judge, pairing): judge_rejudge_counts = context.get("judge_rejudge_counts", {}) diff --git a/mittab/apps/tab/views/public_views.py b/mittab/apps/tab/views/public_views.py index a6189e087..772423e1b 100644 --- a/mittab/apps/tab/views/public_views.py +++ b/mittab/apps/tab/views/public_views.py @@ -115,10 +115,15 @@ def public_view_judges(request): rounds = [num for num in range(1, num_rounds + 1)] return render( - request, "public/judges.html", { - "judges": Judge.objects.order_by("name").prefetch_related("schools", "checkin_set").all(), - "rounds": rounds - }) + request, + "public/judges.html", + { + "judges": Judge.objects.order_by("name") + .prefetch_related("schools", "checkin_set", "expected_checkins") + .all(), + "rounds": rounds, + }, + ) @cache_public_view(timeout=60) diff --git a/mittab/apps/tab/views/views.py b/mittab/apps/tab/views/views.py index fbfe6834c..4c3ad6adf 100644 --- a/mittab/apps/tab/views/views.py +++ b/mittab/apps/tab/views/views.py @@ -323,6 +323,8 @@ def bulk_check_in(request): if entity_type == "judge": checkInObj, id_field = CheckIn, "judge_id" + elif entity_type == "judge_expected": + checkInObj, id_field = JudgeExpectedCheckIn, "judge_id" else: checkInObj, id_field = RoomCheckIn, "room_id" @@ -576,22 +578,48 @@ def batch_checkin(request): all_round_numbers = [0] + round_numbers team_data = [ - {"entity": t, "school": t.school, "debaters": t.debaters_display, - "checked_in": t.checked_in} + { + "entity": t, + "school": t.school, + "debaters": t.debaters_display, + "checked_in": t.checked_in, + "expected_checkins": [], + } for t in Team.objects.prefetch_related("school", "debaters").all() ] - judge_data = [ - {"entity": j, "schools": j.schools.all(), - "checkins": [rn in {c.round_number for c in j.checkin_set.all()} - for rn in all_round_numbers]} - for j in Judge.objects.prefetch_related("checkin_set", "schools") - ] + judges = Judge.objects.prefetch_related("checkin_set", "expected_checkins", "schools") + judge_data = [] + expected_judge_data = [] + + for j in judges: + checkin_rounds = {c.round_number for c in j.checkin_set.all()} + expected_rounds = {c.round_number for c in j.expected_checkins.all()} + checkins = [rn in checkin_rounds for rn in all_round_numbers] + expected_flags = [rn in expected_rounds for rn in all_round_numbers] + + judge_row = { + "entity": j, + "schools": j.schools.all(), + "checkins": checkins, + "expected_checkins": expected_flags, + } + judge_data.append(judge_row) + + judge_expected_row = { + "entity": j, + "schools": j.schools.all(), + "checkins": expected_flags, + "expected_checkins": expected_flags, + } + expected_judge_data.append(judge_expected_row) room_data = [ {"entity": r, "checkins": [rn in {c.round_number for c in r.roomcheckin_set.all()} - for rn in all_round_numbers]} + for rn in all_round_numbers], + "expected_checkins": [], + } for r in Room.objects.prefetch_related("roomcheckin_set") ] @@ -604,6 +632,7 @@ def column_counts(rows): return counts judge_counts = column_counts(judge_data) + expected_judge_counts = column_counts(expected_judge_data) room_counts = column_counts(room_data) def round_count_data(counts): @@ -614,12 +643,14 @@ def round_count_data(counts): } for idx in range(len(round_numbers))] judge_round_counts = round_count_data(judge_counts) + expected_judge_round_counts = round_count_data(expected_judge_counts) room_round_counts = round_count_data(room_counts) return render(request, "batch_check_in/check_in.html", { "team_data": team_data, "team_headers": ["School", "Team", "Debater Names"], "judge_data": judge_data, + "judge_expectation_data": expected_judge_data, "judge_headers": ["School", "Judge"], "room_data": room_data, "room_headers": ["Room"], @@ -629,8 +660,12 @@ def round_count_data(counts): "judge_total_count": len(judge_data), "room_total_count": len(room_data), "judge_round_counts": judge_round_counts, + "judge_expectation_round_counts": expected_judge_round_counts, "room_round_counts": room_round_counts, "judge_outround_count": judge_counts[0] if judge_counts else 0, + "judge_expectation_outround_count": ( + expected_judge_counts[0] if expected_judge_counts else 0 + ), "room_outround_count": room_counts[0] if room_counts else 0, }) diff --git a/mittab/libs/data_import/import_judges.py b/mittab/libs/data_import/import_judges.py index f9af02f3d..1d5a222b0 100644 --- a/mittab/libs/data_import/import_judges.py +++ b/mittab/libs/data_import/import_judges.py @@ -1,4 +1,4 @@ -from mittab.apps.tab.models import School +from mittab.apps.tab.models import School, JudgeExpectedCheckIn, TabSettings from mittab.apps.tab.forms import JudgeForm from mittab.libs.data_import import Workbook, WorkbookImporter, InvalidWorkbookException @@ -12,6 +12,45 @@ def import_judges(file_to_import): class JudgeImporter(WorkbookImporter): + truthy_expectation_values = { + "1", + "true", + "yes", + "y", + "expected", + "in", + "x", + "check", + "checked", + } + + def _split_row(self, row): + num_rounds = TabSettings.get("tot_rounds", 5) + if num_rounds and len(row) >= 2 + num_rounds: + expectation_cells = row[-num_rounds:] + school_cells = row[2:-num_rounds] + else: + expectation_cells = [] + school_cells = row[2:] + return school_cells, expectation_cells + + def _expected_rounds(self, expectation_cells): + expected_rounds = [] + for index, cell in enumerate(expectation_cells, start=1): + normalized = str(cell or "").strip().lower() + if normalized and normalized in self.truthy_expectation_values: + expected_rounds.append(index) + return expected_rounds + + def _apply_expected_rounds(self, judge, expected_rounds): + for round_number in expected_rounds: + self.create( + JudgeExpectedCheckIn( + judge=judge, + round_number=round_number, + ) + ) + def import_row(self, row, row_number): judge_name = row[0] judge_rank = row[1] @@ -21,8 +60,13 @@ def import_row(self, row, row_number): except ValueError: self.error("Judge rank is not a number", row) + school_cells, expectation_cells = self._split_row(row) + schools = [] - for school_name in row[2:]: + for school_name in school_cells: + school_name = (school_name or "").strip() + if not school_name: + continue school_query = School.objects.filter(name__iexact=school_name) if school_query.exists(): school = school_query.first() @@ -37,7 +81,10 @@ def import_row(self, row, row_number): data = {"name": judge_name, "rank": judge_rank, "schools": schools} form = JudgeForm(data=data) if form.is_valid(): - self.create(form) + judge = self.create(form) + expected_rounds = self._expected_rounds(expectation_cells) + if expected_rounds: + self._apply_expected_rounds(judge, expected_rounds) else: for _field, error_msgs in form.errors.items(): for error_msg in error_msgs: diff --git a/mittab/libs/tests/data_import/test_import_judges.py b/mittab/libs/tests/data_import/test_import_judges.py index 8b60aa222..9ce4ee440 100644 --- a/mittab/libs/tests/data_import/test_import_judges.py +++ b/mittab/libs/tests/data_import/test_import_judges.py @@ -1,7 +1,7 @@ from django.test import TestCase import pytest -from mittab.apps.tab.models import School, Judge +from mittab.apps.tab.models import School, Judge, JudgeExpectedCheckIn, TabSettings from mittab.libs.tests.data_import import MockWorkbook from mittab.libs.data_import.import_judges import JudgeImporter @@ -15,9 +15,11 @@ def test_valid_judges(self): assert Judge.objects.count() == 0 assert School.objects.count() == 0 - data = [["Judge 1", "9.5", "Harvard"], - ["Judge 2", "10.5555", "Yale", "Harvard", "Northeastern"], - ["Judge 3", "20"]] + TabSettings.set("tot_rounds", 2) + + data = [["Judge 1", "9.5", "Harvard", "yes", ""], + ["Judge 2", "10.5555", "Yale", "Harvard", "Northeastern", "1", "0"], + ["Judge 3", "20", "", "", ""]] importer = JudgeImporter(MockWorkbook(data)) errors = importer.import_data() @@ -41,6 +43,10 @@ def test_valid_judges(self): assert float(judge_3.rank) == 20.0 assert judge_3.name == "Judge 3" assert not judge_3.schools.all() + assert JudgeExpectedCheckIn.objects.filter(judge=judge_1, + round_number=1).exists() + assert not JudgeExpectedCheckIn.objects.filter(judge=judge_1, + round_number=2).exists() def test_rollback_from_duplicate(self): assert Judge.objects.count() == 0 @@ -90,3 +96,28 @@ def test_schools_not_rolledback_if_existed_before(self): assert len(errors) == 1 assert errors[0] == "Row 1: Judge 1 - Ensure that there are" \ " no more than 4 digits in total." + + def test_expected_rounds_imported(self): + TabSettings.set("tot_rounds", 3) + + data = [["Judge 4", "9.5", "Harvard", "Y", "", "1"]] + importer = JudgeImporter(MockWorkbook(data)) + errors = importer.import_data() + + assert not errors + judge = Judge.objects.get(name="Judge 4") + expected_rounds = sorted( + JudgeExpectedCheckIn.objects.filter(judge=judge).values_list( + "round_number", flat=True) + ) + assert expected_rounds == [1, 3] + + def test_blank_school_cells_ignored(self): + data = [["Judge 5", "9.5", "", " ", ""]] + importer = JudgeImporter(MockWorkbook(data)) + errors = importer.import_data() + + assert not errors + judge = Judge.objects.get(name="Judge 5") + assert judge.schools.count() == 0 + assert School.objects.count() == 0 diff --git a/mittab/libs/tests/views/test_public_views.py b/mittab/libs/tests/views/test_public_views.py index 493944b08..16d9f163e 100644 --- a/mittab/libs/tests/views/test_public_views.py +++ b/mittab/libs/tests/views/test_public_views.py @@ -4,8 +4,16 @@ from django.urls import reverse from nplusone.core import profiler -from mittab.apps.tab.models import (Room, TabSettings, Team, - Round, Outround) +from mittab.apps.tab.models import ( + Room, + TabSettings, + Team, + Round, + Outround, + Judge, + School, + JudgeExpectedCheckIn, +) from mittab.apps.tab.public_rankings import PublicRankingMode from mittab.libs.cacheing import cache_logic @@ -179,6 +187,26 @@ def test_permissions(self): f"hidden when {setting_name}={denied_value}") + def test_public_judges_use_expectations(self): + client = Client() + school = School.objects.first() + judge = Judge.objects.create(name="Expectation Judge", rank=2.0) + judge.schools.add(school) + + JudgeExpectedCheckIn.objects.create(judge=judge, round_number=1) + + response = client.get(reverse("public_judges")) + self.assertEqual(response.status_code, 200) + + content = response.content.decode() + row_start = content.find(judge.name) + self.assertNotEqual(row_start, -1, "Judge row not rendered") + row_end = content.find("", row_start) + self.assertNotEqual(row_end, -1, "Judge row not properly closed") + row_html = content[row_start:row_end] + self.assertIn("✔", row_html, + "Expected attendance indicator not shown for judge") + def test_public_ballot_modes(self): client = Client() diff --git a/mittab/templates/batch_check_in/_checkin_pane.html b/mittab/templates/batch_check_in/_checkin_pane.html index d5ddd10fc..493c8dda1 100644 --- a/mittab/templates/batch_check_in/_checkin_pane.html +++ b/mittab/templates/batch_check_in/_checkin_pane.html @@ -2,6 +2,7 @@ {% csrf_token %} +{% with label_prefix=action_label|default:"Checked" label_on=action_on_label|default:"In" label_off=action_off_label|default:"Out" team_column_header=team_column_label|default:"Checked In?" %}
@@ -68,7 +69,7 @@ {% else %} + {% endwith %} {% endfor %} {% else %} @@ -161,3 +172,4 @@
- Checked In? + {{ team_column_header }} {{row.debaters}} - {% elif entity_type == 'judge' %} + {% elif entity_type == 'judge' or entity_type == 'judge_expected' %}
{% for school in row.schools %} {{ school.name }}{% if not forloop.last %}, {% endif %} @@ -130,28 +131,38 @@ {% if entity_type != 'team' %} {% for is_checked_in in row.checkins %} - + {% with expected_active=row.expected_checkins|lookup:forloop.counter0 %} +
+{% endwith %} diff --git a/mittab/templates/batch_check_in/check_in.html b/mittab/templates/batch_check_in/check_in.html index 4e9a87c31..bdae281ea 100644 --- a/mittab/templates/batch_check_in/check_in.html +++ b/mittab/templates/batch_check_in/check_in.html @@ -22,6 +22,9 @@ +