Skip to content

Commit f2d5fe8

Browse files
authored
Add black-rod bundle export (#507)
* Add black-rod bundle export
1 parent f206087 commit f2d5fe8

6 files changed

Lines changed: 241 additions & 1 deletion

File tree

docs/conf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,6 @@
177177
myst_enable_extensions = [
178178
"colon_fence",
179179
]
180+
181+
# Ignore bad random CI warning
182+
linkcheck_ignore = [r"https://www\.wikiwand\.com/en/articles/Blossom_algorithm.*"]
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import json
2+
from datetime import datetime, timezone
3+
4+
from mittab.apps.tab.models import Debater, Judge, Outround, Round, School, Team
5+
6+
7+
class BlackRodBundleExporter:
8+
SCHEMA_VERSION = 1
9+
SOURCE = "mit_tab_black_rod_bundle"
10+
11+
def __init__(self, tournament_name):
12+
self.tournament_name = tournament_name
13+
self._school_by_debater_id = None
14+
15+
def export_tournament(self):
16+
payload = {
17+
"schema_version": self.SCHEMA_VERSION,
18+
"source": self.SOURCE,
19+
"exported_at": datetime.now(timezone.utc).isoformat(),
20+
"tournament_name": self.tournament_name,
21+
"schools": self._build_schools(),
22+
"debaters": self._build_debaters(),
23+
"rounds": self._build_rounds(),
24+
}
25+
return json.dumps(payload, indent=2, sort_keys=True)
26+
27+
def _build_schools(self):
28+
return [
29+
{
30+
"id": school.id,
31+
"apda_id": self._normalize_apda_id(school.apda_id),
32+
"name": school.name,
33+
}
34+
for school in School.objects.order_by("id")
35+
]
36+
37+
def _build_debaters(self):
38+
school_by_debater_id = self._build_school_by_debater_id()
39+
return [
40+
{
41+
"id": debater.id,
42+
"apda_id": self._normalize_apda_id(debater.apda_id),
43+
"name": debater.name,
44+
"novice_status": self._division_for_debater(debater),
45+
"school_id": school_by_debater_id.get(debater.id),
46+
}
47+
for debater in Debater.objects.order_by("id")
48+
]
49+
50+
def _build_rounds(self):
51+
rounds = []
52+
rounds.extend(self._build_prelim_rounds())
53+
rounds.extend(self._build_outrounds())
54+
return sorted(
55+
rounds,
56+
key=lambda round_row: (
57+
0 if round_row["stage"] == "prelim" else 1,
58+
int(round_row["round_number"]),
59+
str(round_row["import_key"]),
60+
),
61+
)
62+
63+
def _build_prelim_rounds(self):
64+
qs = (
65+
Round.objects.order_by("round_number", "id")
66+
.select_related("gov_team", "opp_team", "chair")
67+
.prefetch_related("judges")
68+
)
69+
return [
70+
{
71+
"import_key": f"prelim:{round_obj.id}",
72+
"round_number": round_obj.round_number,
73+
"label": f"Round {round_obj.round_number}",
74+
"stage": "prelim",
75+
"division": None,
76+
"elim_size": None,
77+
"victor": round_obj.victor,
78+
"gov": self._serialize_team(round_obj.gov_team),
79+
"opp": self._serialize_team(round_obj.opp_team),
80+
"judges": self._serialize_judges(round_obj.chair, round_obj.judges.all()),
81+
}
82+
for round_obj in qs
83+
]
84+
85+
def _build_outrounds(self):
86+
qs = (
87+
Outround.objects.order_by("num_teams", "type_of_round", "id")
88+
.select_related("gov_team", "opp_team", "chair")
89+
.prefetch_related("judges")
90+
)
91+
return [
92+
{
93+
"import_key": (
94+
f"outround:{self._division_for_outround(outround)}:"
95+
f"{outround.num_teams}:{outround.id}"
96+
),
97+
"round_number": outround.num_teams,
98+
"label": self._label_for_outround(outround),
99+
"stage": "outround",
100+
"division": self._division_for_outround(outround),
101+
"elim_size": outround.num_teams,
102+
"victor": outround.victor,
103+
"gov": self._serialize_team(outround.gov_team),
104+
"opp": self._serialize_team(outround.opp_team),
105+
"judges": self._serialize_judges(outround.chair, outround.judges.all()),
106+
}
107+
for outround in qs
108+
]
109+
110+
def _serialize_team(self, team):
111+
debaters = list(team.debaters.order_by("id"))
112+
return {
113+
"debater_ids": [debater.id for debater in debaters],
114+
"source_names": [debater.name for debater in debaters],
115+
}
116+
117+
@staticmethod
118+
def _serialize_judges(chair, judges):
119+
judge_rows = []
120+
seen_ids = set()
121+
122+
if chair is not None:
123+
judge_rows.append({"original_name": chair.name, "is_chair": True})
124+
seen_ids.add(chair.id)
125+
126+
for judge in sorted(judges, key=lambda value: value.id):
127+
if judge.id in seen_ids:
128+
continue
129+
judge_rows.append({"original_name": judge.name, "is_chair": False})
130+
seen_ids.add(judge.id)
131+
132+
return judge_rows
133+
134+
def _build_school_by_debater_id(self):
135+
if self._school_by_debater_id is not None:
136+
return self._school_by_debater_id
137+
138+
school_by_debater_id = {}
139+
teams = Team.objects.select_related("school", "hybrid_school").prefetch_related("debaters")
140+
for team in teams.order_by("id"):
141+
debaters = list(team.debaters.order_by("id"))
142+
if not debaters:
143+
continue
144+
145+
if team.hybrid_school_id and len(debaters) >= 2:
146+
school_by_debater_id.setdefault(debaters[0].id, team.school_id)
147+
school_by_debater_id.setdefault(debaters[1].id, team.hybrid_school_id)
148+
for debater in debaters[2:]:
149+
school_by_debater_id.setdefault(debater.id, team.school_id)
150+
continue
151+
152+
for debater in debaters:
153+
school_by_debater_id.setdefault(debater.id, team.school_id)
154+
155+
self._school_by_debater_id = school_by_debater_id
156+
return self._school_by_debater_id
157+
158+
@staticmethod
159+
def _normalize_apda_id(value):
160+
if value in (None, "", -1):
161+
return None
162+
return int(value)
163+
164+
@staticmethod
165+
def _division_for_debater(debater):
166+
return "novice" if debater.novice_status == Debater.NOVICE else "varsity"
167+
168+
@staticmethod
169+
def _division_for_outround(outround):
170+
return "novice" if outround.type_of_round == Outround.NOVICE else "varsity"
171+
172+
def _label_for_outround(self, outround):
173+
division = "Novice" if outround.type_of_round == Outround.NOVICE else "Varsity"
174+
return f"{division} {self._elim_label(outround.num_teams)}"
175+
176+
@staticmethod
177+
def _elim_label(num_teams):
178+
labels = {
179+
2: "Final",
180+
4: "Semifinal",
181+
8: "Quarterfinal",
182+
16: "Octofinal",
183+
32: "Double-Octofinal",
184+
64: "Triple-Octofinal",
185+
}
186+
return labels.get(num_teams, f"Elim of {num_teams}")

mittab/apps/tab/views/views.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import yaml
1212

1313
from mittab.apps.tab.archive import ArchiveExporter
14+
from mittab.apps.tab.black_rod_bundle import BlackRodBundleExporter
1415
from mittab.apps.tab.views.debater_views import get_speaker_rankings
1516
from mittab.apps.tab.forms import (
1617
MiniRankingGroupForm,
@@ -550,6 +551,19 @@ def generate_archive(request):
550551
return response
551552

552553

554+
@permission_required("tab.tab_settings.can_change", login_url="/403/")
555+
def generate_black_rod_bundle(request):
556+
tournament_name = request.META["SERVER_NAME"].split(".")[0]
557+
filename = tournament_name + "-black-rod-bundle.json"
558+
559+
bundle = BlackRodBundleExporter(tournament_name).export_tournament()
560+
561+
response = HttpResponse(bundle, content_type="application/json; charset=utf-8")
562+
response["Content-Length"] = len(bundle.encode("utf-8"))
563+
response["Content-Disposition"] = f"attachment; filename={filename}"
564+
return response
565+
566+
553567
@permission_required("tab.tab_settings.can_change", login_url="/403")
554568
def simulate_round(request):
555569
enviornment = os.environ.get("MITTAB_ENV")

mittab/libs/tests/views/test_export_views.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
13
import pytest
24
from django.test import TestCase, Client
35
from django.urls import reverse
@@ -46,6 +48,7 @@ def test_render(self):
4648
csv_exports = [
4749
reverse("export_pairings_csv"),
4850
reverse("export_outround_pairings_csv", args=[0]),
51+
reverse("download_black_rod_bundle"),
4952
]
5053

5154
tab_card_exports = [
@@ -64,6 +67,33 @@ def test_render(self):
6467
self.assertEqual(response.status_code, 200,
6568
f"Failed to render tab card {url}, got status {response.status_code}")
6669

70+
def test_black_rod_bundle_export_payload(self):
71+
response = self.client.get(reverse("download_black_rod_bundle"))
72+
73+
self.assertEqual(response.status_code, 200)
74+
self.assertEqual(response["Content-Type"], "application/json; charset=utf-8")
75+
76+
payload = json.loads(response.content)
77+
self.assertEqual(payload["schema_version"], 1)
78+
self.assertEqual(payload["source"], "mit_tab_black_rod_bundle")
79+
self.assertIn("schools", payload)
80+
self.assertIn("debaters", payload)
81+
self.assertIn("rounds", payload)
82+
83+
round_row = payload["rounds"][0]
84+
self.assertIn("import_key", round_row)
85+
self.assertIn("judges", round_row)
86+
self.assertIn("gov", round_row)
87+
self.assertIn("opp", round_row)
88+
self.assertNotIn("team_name", round_row)
89+
self.assertNotIn("name", round_row["gov"])
90+
self.assertNotIn("name", round_row["opp"])
91+
92+
outround_rows = [row for row in payload["rounds"] if row["stage"] == "outround"]
93+
self.assertTrue(outround_rows)
94+
self.assertIn(outround_rows[0]["division"], {"varsity", "novice"})
95+
self.assertIsInstance(outround_rows[0]["elim_size"], int)
96+
6797
def test_n_plus_one(self):
6898
export_views = [
6999
("all_tab_cards",),

mittab/templates/base/_navigation.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
{% url "rank_teams_ajax" as rank_teams %}
1919
{% url "all_tab_cards" as tab_cards %}
2020
{% url "download_archive" as download_archive %}
21+
{% url "download_black_rod_bundle" as download_black_rod_bundle %}
2122
{% url "round_stats" as round_stats %}
2223
{% url "forum_post" as forum_post %}
2324

@@ -91,14 +92,15 @@
9192
</li>
9293

9394
<li class="nav-item dropdown">
94-
<a data-toggle="dropdown" class="nav-link dropdown-toggle {% active request tab_cards %}{% active request backup %}{% active request view_backups %}{%active request download_archive %}">
95+
<a data-toggle="dropdown" class="nav-link dropdown-toggle {% active request tab_cards %}{% active request backup %}{% active request view_backups %}{%active request download_archive %}{% active request download_black_rod_bundle %}">
9596
Backups
9697
</a>
9798

9899
<div class="dropdown-menu">
99100
<a class="dropdown-item {%active request view_backups %}" href="{{ view_backups|with_return_to }}">View Backups</a>
100101
<a class="dropdown-item {%active request tab_cards %}" href="{{ tab_cards|with_return_to }}">View Tab Cards</a>
101102
<a class="dropdown-item {%active request download_archive %}" href="{{ download_archive|with_return_to }}" title="DebateXML is an archive format to preserve tournaments and perform analyses.">Create DebateXML</a>
103+
<a class="dropdown-item {% active request download_black_rod_bundle %}" href="{{ download_black_rod_bundle|with_return_to }}" title="Export a tournament bundle for Black Rod ingest.">Export Black-Rod Bundle</a>
102104
</div>
103105
</li>
104106

mittab/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,11 @@
321321

322322
# Tournament Archive
323323
path("archive/download/", views.generate_archive, name="download_archive"),
324+
path(
325+
"archive/black_rod_bundle/",
326+
views.generate_black_rod_bundle,
327+
name="download_black_rod_bundle",
328+
),
324329

325330
# Standings API
326331
path("forum_post", views.forum_post, name="forum_post"),

0 commit comments

Comments
 (0)