diff --git a/mittab/apps/tab/forms.py b/mittab/apps/tab/forms.py index 06d54d38a..9edab79ba 100644 --- a/mittab/apps/tab/forms.py +++ b/mittab/apps/tab/forms.py @@ -720,6 +720,13 @@ def save(self, _commit=True): breaking_team.save() return round_obj +class ExportFormatForm(forms.Form): + EXPORT_CHOICES = [ + ("csv", "CSV"), + ("xml", "XML"), + ("json", "JSON"), + ] + format = forms.ChoiceField(choices=EXPORT_CHOICES, label="Export Format") class RoomTagForm(forms.ModelForm): teams = forms.ModelMultipleChoiceField( @@ -763,6 +770,7 @@ def __init__(self, *args, **kwargs): self.fields.pop("judges") self.fields.pop("rooms") + class BackupForm(forms.Form): backup_name = forms.CharField( max_length=255, diff --git a/mittab/apps/tab/models.py b/mittab/apps/tab/models.py index da71674f7..ed36a7e09 100644 --- a/mittab/apps/tab/models.py +++ b/mittab/apps/tab/models.py @@ -264,6 +264,12 @@ def save(self, super(Team, self).save(force_insert, force_update, using, update_fields) + def get_or_create_team_code(self): + if not self.team_code: + self.set_unique_team_code() + self.save() + return self.team_code + @property def display_backend(self): use_team_codes_backend = TabSettings.get("team_codes_backend", 0) diff --git a/mittab/apps/tab/views/team_views.py b/mittab/apps/tab/views/team_views.py index 2981f329f..dbbf4ef52 100644 --- a/mittab/apps/tab/views/team_views.py +++ b/mittab/apps/tab/views/team_views.py @@ -3,14 +3,14 @@ from django.shortcuts import render from mittab.apps.tab.forms import TeamForm, TeamEntryForm, ScratchForm +from mittab.libs.data_export.tab_card import get_tab_card_data from mittab.libs.cacheing import cache_logic from mittab.libs.errors import * from mittab.apps.tab.helpers import redirect_and_flash_error, \ redirect_and_flash_success from mittab.apps.tab.models import * from mittab.libs import tab_logic -from mittab.libs.tab_logic import TabFlags, tot_speaks_deb, \ - tot_ranks_deb, tot_speaks, tot_ranks +from mittab.libs.tab_logic import TabFlags from mittab.libs.tab_logic import rankings @@ -235,141 +235,7 @@ def pretty_tab_card(request, team_id): def tab_card(request, team_id): - try: - team_id = int(team_id) - except ValueError: - return redirect_and_flash_error(request, "Invalid team id") - team = Team.with_preloaded_relations_for_tab_card().get(pk=team_id) - - rounds = ([r for r in team.gov_team.all()] + - [r for r in team.opp_team.all()]) - rounds.sort(key=lambda x: x.round_number) - - debaters = [d for d in team.debaters.all()] - iron_man = False - - if len(debaters) == 1: - iron_man = True - - deb1 = debaters[0] - deb2 = debaters[0] if iron_man else debaters[1] - - round_stats = [] - num_rounds = TabSettings.objects.get(key="tot_rounds").value - cur_round = TabSettings.objects.get(key="cur_round").value - blank = " " - - for i in range(num_rounds): - round_stats.append([blank] * 7) - - round_stats_by_round_and_debater_id = dict() - - for debater in team.debaters.all(): - for stat in debater.roundstats_set.all(): - round_number = stat.round.round_number - debater_id = stat.debater.id - - if round_number not in round_stats_by_round_and_debater_id: - round_stats_by_round_and_debater_id[round_number] = dict() - - if debater_id not in round_stats_by_round_and_debater_id[round_number]: - round_stats_by_round_and_debater_id[round_number][debater_id] = [] - - round_stats_by_round_and_debater_id[round_number][debater_id].append(stat) - - for round_obj in rounds: - round_number = round_obj.round_number - dstat1 = [] - dstat2 = [] - if round_number in round_stats_by_round_and_debater_id: - dstat1 = round_stats_by_round_and_debater_id[round_number].get(deb1.id, []) - dstat2 = round_stats_by_round_and_debater_id[round_number].get(deb2.id, []) - - blank_rs = RoundStats(debater=deb1, round=round_obj, speaks=0, ranks=0) - - while len(dstat1) + len(dstat2) < 2: - # Something is wrong with our data, but we don't want to crash - dstat1.append(blank_rs) - - if not dstat2 and not dstat1: - break - if not dstat2: - dstat1, dstat2 = dstat1[0], dstat1[1] - elif not dstat1: - dstat1, dstat2 = dstat2[0], dstat2[1] - else: - dstat1, dstat2 = dstat1[0], dstat2[0] - - index = round_obj.round_number - 1 - - round_stats[index][3] = " - ".join( - [j.name for j in round_obj.judges.all()]) - round_stats[index][4] = (float(dstat1.speaks), float(dstat1.ranks)) - round_stats[index][5] = (float(dstat2.speaks), float(dstat2.ranks)) - round_stats[index][6] = (float(dstat1.speaks + dstat2.speaks), - float(dstat1.ranks + dstat2.ranks)) - - if round_obj.gov_team == team: - round_stats[index][2] = round_obj.opp_team - round_stats[index][0] = "G" - if round_obj.victor == 1: - round_stats[index][1] = "W" - elif round_obj.victor == 2: - round_stats[index][1] = "L" - elif round_obj.victor == 3: - round_stats[index][1] = "WF" - elif round_obj.victor == 4: - round_stats[index][1] = "LF" - elif round_obj.victor == 5: - round_stats[index][1] = "AD" - elif round_obj.victor == 6: - round_stats[index][1] = "AW" - elif round_obj.opp_team == team: - round_stats[index][2] = round_obj.gov_team - round_stats[index][0] = "O" - if round_obj.victor == 1: - round_stats[index][1] = "L" - elif round_obj.victor == 2: - round_stats[index][1] = "W" - elif round_obj.victor == 3: - round_stats[index][1] = "LF" - elif round_obj.victor == 4: - round_stats[index][1] = "WF" - elif round_obj.victor == 5: - round_stats[index][1] = "AD" - elif round_obj.victor == 6: - round_stats[index][1] = "AW" - - for i in range(cur_round - 1): - if round_stats[i][6] == blank: - round_stats[i][6] = (0, 0) - for i in range(1, cur_round - 1): - round_stats[i][6] = (round_stats[i][6][0] + round_stats[i - 1][6][0], - round_stats[i][6][1] + round_stats[i - 1][6][1]) - # Error out if we don't have a bye - try: - bye_round = Bye.objects.get(bye_team=team).round_number - except Exception: - bye_round = None - - # Duplicates Debater 1 for display if Ironman team - return render( - request, "tab/tab_card.html", { - "team_name": team.display_backend, - "team_school": team.school, - "debater_1": deb1.name, - "debater_1_status": Debater.NOVICE_CHOICES[deb1.novice_status][1], - "debater_2": deb2.name, - "debater_2_status": Debater.NOVICE_CHOICES[deb2.novice_status][1], - "round_stats": round_stats, - "d1st": tot_speaks_deb(deb1), - "d1rt": tot_ranks_deb(deb1), - "d2st": tot_speaks_deb(deb2), - "d2rt": tot_ranks_deb(deb2), - "ts": tot_speaks(team), - "tr": tot_ranks(team), - "bye_round": bye_round - }) + return render(request, "tab/tab_card.html", get_tab_card_data(request, team_id)) def rank_teams_ajax(request): @@ -389,4 +255,3 @@ def rank_teams(request): "novice": nov_teams, "title": "Team Rankings" }) - diff --git a/mittab/apps/tab/views/views.py b/mittab/apps/tab/views/views.py index fa3b3b906..a15da5388 100644 --- a/mittab/apps/tab/views/views.py +++ b/mittab/apps/tab/views/views.py @@ -1,17 +1,33 @@ import os +import csv +import json +import io from django.db import IntegrityError from django.contrib.auth.decorators import permission_required from django.contrib.auth import logout from django.conf import settings -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest from django.shortcuts import render, redirect, reverse from django.core.management import call_command import yaml -from mittab.apps.tab.archive import ArchiveExporter +from mittab.libs.data_export.tab_card import ( + JSONDecimalEncoder, + csv_tab_cards, + get_all_json_data, +) +from mittab.libs.data_export.xml_archive import ArchiveExporter from mittab.apps.tab.views.debater_views import get_speaker_rankings -from mittab.apps.tab.forms import MiniRoomTagForm, RoomTagForm, SchoolForm, RoomForm, \ - UploadDataForm, ScratchForm, SettingsForm +from mittab.apps.tab.forms import ( + ExportFormatForm, + MiniRoomTagForm, + RoomTagForm, + SchoolForm, + RoomForm, + UploadDataForm, + ScratchForm, + SettingsForm, +) from mittab.apps.tab.helpers import redirect_and_flash_error, \ redirect_and_flash_success from mittab.apps.tab.models import * @@ -24,6 +40,7 @@ from mittab.libs.data_import import import_judges, import_rooms, import_teams, \ import_scratches from mittab.libs.tab_logic.rankings import get_team_rankings +from mittab.libs.data_export.s3_connector import schedule_results_export def index(request): @@ -491,6 +508,81 @@ def upload_data(request): }) +def _get_tournament_name(request): + return request.META["SERVER_NAME"].split(".")[0] + + +def _dispatch_tournament_export(request, fmt): + tournament_name = _get_tournament_name(request) + if fmt == "json": + return tab_cards_json(request, tournament_name) + if fmt == "csv": + return tab_cards_csv(request, tournament_name) + if fmt == "xml": + return xml_archive(request, tournament_name) + return HttpResponseBadRequest("Invalid format.") + + +@permission_required("tab.tab_settings.can_change", login_url="/403/") +def export_tournament(request, format=None): + if format is not None: + return _dispatch_tournament_export(request, format.lower()) + + if request.method == "POST": + form = ExportFormatForm(request.POST) + if form.is_valid(): + fmt = form.cleaned_data["format"] + return _dispatch_tournament_export(request, fmt) + else: + form = ExportFormatForm(initial={"format": "csv"}) + + return render( + request, + "common/data_entry.html", + { + "form": form, + "title": "Export Tournament", + "custom_submit": "Export" + }) + + +@permission_required("tab.tab_settings.can_change", login_url="/403/") +def tab_cards_json(request, tournament_name): + json_data = json.dumps( + {"tab_cards": get_all_json_data()}, + indent=4, + cls=JSONDecimalEncoder, + ) + response = HttpResponse(json_data, content_type="application/json") + response["Content-Disposition"] = f"attachment; filename={tournament_name}.json" + return response + + +@permission_required("tab.tab_settings.can_change", login_url="/403/") +def tab_cards_csv(request, tournament_name): + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = f"attachment; filename={tournament_name}.csv" + buffer = io.StringIO(newline="") + writer = csv.writer(buffer) + csv_tab_cards(writer) + response.write(buffer.getvalue()) + return response + + +def _xml_archive_response(tournament_name): + filename = f"{tournament_name}.xml" + xml = ArchiveExporter(tournament_name).export_tournament() + response = HttpResponse(xml, content_type="text/xml; charset=utf-8") + response["Content-Length"] = len(xml) + response["Content-Disposition"] = f"attachment; filename={filename}" + return response + + +@permission_required("tab.tab_settings.can_change", login_url="/403/") +def xml_archive(request, tournament_name): + return _xml_archive_response(tournament_name) + + def force_cache_refresh(request): key = request.GET.get("key", "") @@ -505,15 +597,8 @@ def force_cache_refresh(request): @permission_required("tab.tab_settings.can_change", login_url="/403/") def generate_archive(request): - tournament_name = request.META["SERVER_NAME"].split(".")[0] - filename = tournament_name + ".xml" - - xml = ArchiveExporter(tournament_name).export_tournament() - - response = HttpResponse(xml, content_type="text/xml; charset=utf-8") - response["Content-Length"] = len(xml) - response["Content-Disposition"] = f"attachment; filename={filename}" - return response + tournament_name = _get_tournament_name(request) + return _xml_archive_response(tournament_name) @permission_required("tab.tab_settings.can_change", login_url="/403") @@ -610,11 +695,12 @@ def publish_results(request, new_setting): # Convert URL parameter: 0 = unpublish, 1 = publish new_setting = bool(new_setting) current_setting = TabSettings.get("results_published", False) + should_export = new_setting if new_setting != current_setting: TabSettings.set("results_published", new_setting) status = "published" if new_setting else "unpublished" - return redirect_and_flash_success( + response = redirect_and_flash_success( request, f"Results successfully {status}. Results are now " f"{'visible' if new_setting else 'hidden'}.", @@ -622,11 +708,15 @@ def publish_results(request, new_setting): ) else: status = "published" if current_setting else "unpublished" - return redirect_and_flash_success( + response = redirect_and_flash_success( request, f"Results are already {status}.", path="/", ) + if should_export: + tournament_name = _get_tournament_name(request) + schedule_results_export(tournament_name) + return response def forum_post(request): diff --git a/mittab/libs/backup/storage.py b/mittab/libs/backup/storage.py index 6625488ab..3763ac05d 100644 --- a/mittab/libs/backup/storage.py +++ b/mittab/libs/backup/storage.py @@ -12,26 +12,31 @@ SUFFIX = ".dump.sql" -def with_backup_dir(func): - def wrapper(*args, **kwargs): - if not os.path.exists(BACKUP_PREFIX): - os.makedirs(BACKUP_PREFIX) - return func(*args, **kwargs) - return wrapper +class LocalFilesystem: + def __init__(self, prefix=BACKUP_PREFIX, suffix=SUFFIX): + self.prefix = prefix + self.suffix = suffix + def _ensure_dir(self): + if not os.path.exists(self.prefix): + os.makedirs(self.prefix) -class LocalFilesystem: - @with_backup_dir def keys(self): - return [name[:-len(SUFFIX)] for name in os.listdir(BACKUP_PREFIX)] + self._ensure_dir() + return [ + name[:-len(self.suffix)] + for name in os.listdir(self.prefix) + if name.endswith(self.suffix) + ] - @with_backup_dir def __setitem__(self, key, content): - dst_filename = os.path.join(BACKUP_PREFIX, key + SUFFIX) + self._ensure_dir() + dst_filename = self._get_backup_filename(key) with open(dst_filename, "wb+") as destination: destination.write(content) def __getitem__(self, key): + self._ensure_dir() with open(self._get_backup_filename(key), "rb") as f: return f.read() @@ -39,18 +44,21 @@ def __contains__(self, key): return os.path.exists(self._get_backup_filename(key)) def _get_backup_filename(self, key): - if len(key) < len(SUFFIX) or not key.endswith(SUFFIX): - key += SUFFIX - return os.path.join(BACKUP_PREFIX, key) + if len(key) < len(self.suffix) or not key.endswith(self.suffix): + key += self.suffix + return os.path.join(self.prefix, key) class ObjectStorage: - def __init__(self): + def __init__(self, prefix=BACKUP_PREFIX, suffix=SUFFIX): if not BUCKET_NAME: raise ValueError("Need bucket name for S3 storage") if not BACKUP_PREFIX: raise ValueError("Need backup path for S3 storage") + self.prefix = prefix + self.suffix = suffix + if S3_ENDPOINT is None: self.s3_client = boto3.client("s3") else: @@ -59,9 +67,9 @@ def __init__(self): def keys(self): paginator = self.s3_client.get_paginator("list_objects_v2") to_return = [] - for page in paginator.paginate(Bucket=BUCKET_NAME, Prefix=BACKUP_PREFIX): + for page in paginator.paginate(Bucket=BUCKET_NAME, Prefix=self.prefix): keys = map( - lambda obj: obj["Key"][(len(BACKUP_PREFIX) + 1):-len(SUFFIX)], + lambda obj: obj["Key"][(len(self.prefix) + 1):-len(self.suffix)], page.get("Contents", [])) to_return += list(keys) return to_return @@ -95,4 +103,4 @@ def __getitem__(self, key): return f.read() def _object_path(self, key): - return f"{BACKUP_PREFIX}/{key}{SUFFIX}" + return f"{self.prefix}/{key}{self.suffix}" diff --git a/mittab/libs/data_export/s3_connector.py b/mittab/libs/data_export/s3_connector.py new file mode 100644 index 000000000..78151927d --- /dev/null +++ b/mittab/libs/data_export/s3_connector.py @@ -0,0 +1,62 @@ +import json +import logging +import os +import threading +from datetime import datetime + +from django.conf import settings + +from mittab.libs.data_export.tab_card import ( + JSONDecimalEncoder, + get_all_json_data, +) +from mittab.libs.backup.storage import LocalFilesystem, ObjectStorage + +LOG = logging.getLogger(__name__) + +RESULTS_FILENAME = "published_results" +RESULTS_SUFFIX = ".json" + +if settings.BACKUPS["use_s3"]: + RESULTS_PREFIX = f"{settings.BACKUPS['prefix']}_results" + RESULTS_STORAGE = ObjectStorage( + prefix=RESULTS_PREFIX, + suffix=RESULTS_SUFFIX, + ) +else: + RESULTS_PREFIX = os.path.join(settings.BACKUPS["prefix"], "results") + RESULTS_STORAGE = LocalFilesystem( + prefix=RESULTS_PREFIX, + suffix=RESULTS_SUFFIX, + ) + + +def _generate_payload(tournament_name): + export_ts = datetime.utcnow().isoformat() + "Z" + return json.dumps( + { + "tournament": tournament_name, + "exported_at": export_ts, + "tab_cards": get_all_json_data(), + }, + indent=2, + cls=JSONDecimalEncoder, + ).encode("utf-8") + + +def export_results_now(tournament_name): + try: + RESULTS_STORAGE[RESULTS_FILENAME] = _generate_payload( + tournament_name + ) + except Exception: # pragma: no cover + LOG.exception("Failed to export published results") + + +def schedule_results_export(tournament_name): + worker = threading.Thread( + target=export_results_now, + args=(tournament_name,), + daemon=True, + ) + worker.start() diff --git a/mittab/libs/data_export/tab_card.py b/mittab/libs/data_export/tab_card.py new file mode 100644 index 000000000..1f0fec8ee --- /dev/null +++ b/mittab/libs/data_export/tab_card.py @@ -0,0 +1,507 @@ +from decimal import Decimal +import json +from django.db.models import Prefetch + +from mittab.apps.tab.helpers import redirect_and_flash_error +from mittab.apps.tab.models import Bye, RoundStats, Team, Round, TabSettings, Debater +from mittab.libs.tab_logic.stats import ( + tot_ranks, + tot_ranks_deb, + tot_speaks, + tot_speaks_deb, +) + +GOV = "G" +OPP = "O" + +def get_team_rounds(team): + rounds = list(team.gov_team.all()) + list(team.opp_team.all()) + rounds.sort(key=lambda round_obj: (round_obj.round_number, round_obj.pk)) + return rounds + +def round_stats_lookup(round_obj): + lookup = getattr(round_obj, "round_stats_lookup_cache", None) + if lookup is None: + stats = getattr(round_obj, "round_stats_cache", None) + if stats is None: + cache = getattr(round_obj, "_prefetched_objects_cache", {}) + stats = cache.get("roundstats_set") + if stats is None: + stats = list(round_obj.roundstats_set.all()) + setattr(round_obj, "round_stats_cache", stats) + lookup = {} + for stat in stats: + lookup.setdefault(stat.debater_id, []).append(stat) + setattr(round_obj, "round_stats_lookup_cache", lookup) + return lookup + + +def safe_name(obj): + return getattr(obj, "name", None) if obj else None + + +def safe_school_name(team): + school = getattr(team, "school", None) + return safe_name(school) + + +def get_debater_status(debater): + if not debater: + return None + try: + return Debater.NOVICE_CHOICES[debater.novice_status][1] + except (AttributeError, IndexError, TypeError): + return None + + +def get_debater_status_short(debater): + status = get_debater_status(debater) + return status[0] if status else "" + + +def get_victor_label(victor_code, side): + side = 0 if side == GOV else 1 + victor_map = { + 0 : ("", ""), + 1: ("W", "L"), + 2: ("L", "W"), + 3: ("WF", "LF"), + 4: ("LF", "WF"), + 5: ("AD", "AD"), + 6: ("AW", "AW"), + } + return victor_map[victor_code][side] + + +def get_dstats(round_obj, deb1, deb2, iron_man): + if deb1 is None: + return None, None + dstat_lookup = round_stats_lookup(round_obj) + dstat1 = list(dstat_lookup.get(deb1.pk, [])) + if iron_man or deb2 is None: + dstat2 = [] + else: + dstat2 = list(dstat_lookup.get(deb2.pk, [])) + blank_rs = RoundStats(debater=deb1, round=round_obj, speaks=0, ranks=0) + while len(dstat1) + len(dstat2) < 2: + # Something is wrong with our data, but we don't want to crash + dstat1.append(blank_rs) + if not dstat2 and not dstat1: + return None, None + if not dstat2: + dstat1, dstat2 = dstat1[0], dstat1[1] + elif not dstat1: + dstat1, dstat2 = dstat2[0], dstat2[1] + else: + dstat1, dstat2 = dstat1[0], dstat2[0] + return dstat1, dstat2 + + +def json_get_round(round_obj, team, deb1, deb2, bye_lookup=None): + chair = round_obj.chair + chair_name = safe_name(chair) + judges = list(round_obj.judges.all()) + wings = [ + safe_name(judge) + for judge in judges + if judge + and (not chair or judge.pk != chair.pk) + and safe_name(judge) + ] + json_round = { + "round_number": round_obj.round_number, + "round_id": round_obj.pk, + "side": GOV if round_obj.gov_team == team else OPP, + "result": get_victor_label( + round_obj.victor, GOV if round_obj.gov_team == team else OPP + ), + "chair": chair_name, + "wings": wings, + } + + opponent = round_obj.opp_team if round_obj.gov_team == team else round_obj.gov_team + if opponent: + opponent_debaters = list(opponent.debaters.all()) + json_round["opponent"] = { + "name": opponent.get_or_create_team_code(), + "school": safe_school_name(opponent), + "debater1": safe_name(opponent_debaters[0]) if opponent_debaters else None, + "debater2": ( + safe_name(opponent_debaters[1]) + if len(opponent_debaters) > 1 + else None + ), + } + + stats_lookup = round_stats_lookup(round_obj) + if deb1: + json_round["debater1"] = [ + (stat.speaks, stat.ranks) + for stat in stats_lookup.get(deb1.pk, []) + ] + else: + json_round["debater1"] = [] + + if deb2: + json_round["debater2"] = [ + (stat.speaks, stat.ranks) + for stat in stats_lookup.get(deb2.pk, []) + ] + else: + json_round["debater2"] = [] + + if bye_lookup is not None: + json_round["bye_round"] = bye_lookup.get(team.pk) + else: + json_round["bye_round"] = ( + Bye.objects.filter(bye_team=team) + .values_list("round_number", flat=True) + .first() + ) + + return json_round + + +class JSONDecimalEncoder(json.JSONEncoder): + def default(self, o): # pylint: disable=E0202 + if isinstance(o, Decimal): + return float(o) + return super().default(o) + + +def get_all_json_data(): + all_tab_cards_data = {} + bye_lookup = {} + for bye_team_id, round_number in Bye.objects.values_list( + "bye_team_id", "round_number" + ): + bye_lookup.setdefault(bye_team_id, round_number) + + round_queryset = ( + Round.objects.select_related( + "gov_team__school", + "opp_team__school", + "chair", + ) + .prefetch_related( + "judges", + "gov_team__debaters", + "opp_team__debaters", + Prefetch("roundstats_set", queryset=RoundStats.objects.select_related("round")), + ) + .order_by("round_number", "pk") + ) + + debater_queryset = Debater.objects.prefetch_related( + Prefetch( + "roundstats_set", + queryset=RoundStats.objects.select_related("round"), + ), + "team_set", + "team_set__no_shows", + ) + + teams = ( + Team.objects.select_related("school") + .prefetch_related( + Prefetch("debaters", queryset=debater_queryset), + Prefetch("gov_team", queryset=round_queryset), + Prefetch("opp_team", queryset=round_queryset), + "no_shows", + "byes", + ) + .order_by("pk") + ) + + for team in teams: + tab_card_data = { + "team_name": team.get_or_create_team_code(), + "team_school": safe_school_name(team), + } + + debaters = list(team.debaters.all()) + deb1 = debaters[0] if debaters else None + tab_card_data["debater_1"] = safe_name(deb1) + tab_card_data["debater_1_status"] = get_debater_status(deb1) + + if len(debaters) > 1: + deb2 = debaters[1] + else: + deb2 = None + tab_card_data["debater_2"] = safe_name(deb2) + tab_card_data["debater_2_status"] = get_debater_status(deb2) + + rounds = get_team_rounds(team) + + round_data = [] + for round_obj in rounds: + if round_obj.victor != 0: # Don't include rounds without a result + round_data.append( + json_get_round(round_obj, team, deb1, deb2, bye_lookup=bye_lookup) + ) + tab_card_data["rounds"] = round_data + + all_tab_cards_data[team.get_or_create_team_code()] = tab_card_data + return all_tab_cards_data + + +def get_tab_card_data(request, team_id): + try: + team_id = int(team_id) + except ValueError: + return redirect_and_flash_error(request, "Invalid team id") + + # gov_team -> rounds where team is gov, opp_team -> rounds where team is opp + team = Team.objects.prefetch_related("gov_team", "opp_team", "debaters").get( + pk=team_id + ) + rounds = list(Round.objects.filter(gov_team=team)) + list( + Round.objects.filter(opp_team=team) + ) + rounds.sort(key=lambda x: x.round_number) + debaters = list(team.debaters.all()) + iron_man = len(debaters) == 1 + deb1 = debaters[0] if debaters else None + if iron_man: + deb2 = debaters[0] + elif len(debaters) > 1: + deb2 = debaters[1] + else: + deb2 = None + + num_rounds = TabSettings.get("tot_rounds", 0) or 0 + cur_round = TabSettings.get("cur_round", 1) or 1 + blank = " " + round_stats = [[blank] * 7 for _ in range(num_rounds)] + speaks_rolling = 0 + ranks_rolling = 0 + for round_obj in rounds: + if round_obj.victor != 0: # Don't include rounds without a result + dstat1, dstat2 = get_dstats(round_obj, deb1, deb2, iron_man) + if not dstat1: + break + side = GOV if round_obj.gov_team == team else OPP + opponent = ( + round_obj.opp_team if round_obj.gov_team == team else round_obj.gov_team + ) + opponent_name = ( + opponent.display_backend if opponent else "BYE" + ) + judge_names = " - ".join( + filter(None, (safe_name(j) for j in round_obj.judges.all())) + ) + if dstat1: + deb1_stats = (float(dstat1.speaks), float(dstat1.ranks)) + else: + deb1_stats = blank + if dstat2: + deb2_stats = (float(dstat2.speaks), float(dstat2.ranks)) + else: + deb2_stats = blank + if dstat1 and dstat2: + speaks_rolling += float(dstat1.speaks + dstat2.speaks) + ranks_rolling += float(dstat1.ranks + dstat2.ranks) + totals = (float(speaks_rolling), float(ranks_rolling)) + else: + totals = blank + index = round_obj.round_number - 1 + if 0 <= index < len(round_stats): + round_stats[index] = [ + side, + get_victor_label(round_obj.victor, side), + opponent_name, + judge_names, + deb1_stats, + deb2_stats, + totals, + ] + + max_index = min(cur_round - 1, len(round_stats)) + for i in range(max_index): + # Don't fill in totals for incomplete rounds + if round_stats[i][6] == blank and len(round_stats)>=i+2 and blank not in round_stats[i + 1][:5]: + round_stats[i][6] = (0, 0) if i == 0 else round_stats[i - 1][6] + + bye_round = ( + Bye.objects.filter(bye_team=team) + .values_list("round_number", flat=True) + .first() + ) + + return { + "team_school": team.school, + "debater_1": safe_name(deb1), + "debater_1_status": get_debater_status(deb1), + "debater_2": safe_name(deb2), + "debater_2_status": get_debater_status(deb2), + "round_stats": round_stats, + "d1st": tot_speaks_deb(deb1) if deb1 else 0, + "d1rt": tot_ranks_deb(deb1) if deb1 else 0, + "d2st": tot_speaks_deb(deb2) if deb2 else 0, + "d2rt": tot_ranks_deb(deb2) if deb2 else 0, + "ts": tot_speaks(team), + "tr": tot_ranks(team), + "bye_round": bye_round, + } + + +def csv_tab_cards(writer): + # Write the CSV header row + header = [ + "Team Name", + "School", + "Round id", + "Round", + "Gov/Opp", + "Win/Loss", + "Opponent", + "Chair", + "Wing(s)", + "Debater 1", + "N/V", + "Speaks", + "Ranks", + "Debater 2", + "N/V", + "Speaks", + "Ranks", + "Total Speaks", + "Total Ranks", + ] + writer.writerow(header) + + round_queryset = ( + Round.objects.select_related( + "gov_team__school", + "opp_team__school", + "chair", + ) + .prefetch_related( + "judges", + "gov_team__debaters", + "opp_team__debaters", + Prefetch("roundstats_set", queryset=RoundStats.objects.select_related("round")), + ) + .order_by("round_number", "pk") + ) + + debater_queryset = Debater.objects.prefetch_related( + Prefetch( + "roundstats_set", + queryset=RoundStats.objects.select_related("round"), + ), + "team_set", + "team_set__no_shows", + ) + + teams = ( + Team.objects.select_related("school") + .prefetch_related( + Prefetch("debaters", queryset=debater_queryset), + Prefetch("gov_team", queryset=round_queryset), + Prefetch("opp_team", queryset=round_queryset), + "no_shows", + "byes", + ) + .order_by("pk") + ) + total_rounds = TabSettings.get("tot_rounds", 0) or 0 + + for team in teams: + team_code = team.get_or_create_team_code() + team_school_name = safe_school_name(team) or "" + debaters = list(team.debaters.all()) + deb1 = debaters[0] if debaters else None + deb1_status = get_debater_status_short(deb1) + iron_man = len(debaters) < 2 + if iron_man: + deb2 = deb1 + deb2_status = deb1_status + else: + deb2 = debaters[1] if len(debaters) > 1 else None + deb2_status = get_debater_status_short(deb2) + + rounds = get_team_rounds(team) + round_data = [tuple() for _ in range(total_rounds)] + + for round_obj in rounds: + side = GOV if round_obj.gov_team == team else OPP + result = get_victor_label(round_obj.victor, side) + opponent = ( + round_obj.opp_team if round_obj.gov_team == team else round_obj.gov_team + ) + opponent_name = opponent.get_or_create_team_code() if opponent else "BYE" + chair_name = safe_name(round_obj.chair) or "" + judges = list(round_obj.judges.all()) + wings = " - ".join( + filter( + None, + ( + safe_name(judge) + for judge in judges + if not round_obj.chair_id or judge.pk != round_obj.chair_id + ), + ) + ) + + dstat1, dstat2 = get_dstats( + round_obj, deb1, deb2, len(debaters) == 1) + index = round_obj.round_number - 1 + if 0 <= index < len(round_data): + round_data[index] = [ + round_obj.pk, + round_obj.round_number, + side, + result, + opponent_name, + chair_name, + wings, + safe_name(deb1) or "", + deb1_status, + float(dstat1.speaks) if dstat1 else 0, + float(dstat1.ranks) if dstat1 else 0, + safe_name(deb2) or "", + deb2_status, + float(dstat2.speaks) if dstat2 else 0, + float(dstat2.ranks) if dstat2 else 0, + ] + + # Write round data for this team + for round_stat in round_data: + if not round_stat: + writer.writerow([team_code, team_school_name]) + continue + writer.writerow( + [ + team_code, + team_school_name, + *round_stat, + # Round, Gov/Opp, Win/Loss, Opponent, Judges, Debater 1, N/V, + # Debater 1 S/R, Debater 2, N/V, Debater 2 S/R, Total + ] + ) + + # Write the total stats for this team + writer.writerow( + [ + "", + team_code, + team_school_name, + "Total", + "", + "", + "", + "", + "", + safe_name(deb1) or "", + deb1_status, + tot_speaks_deb(deb1) if deb1 else 0, + tot_ranks_deb(deb1) if deb1 else 0, + safe_name(deb2) or "", + deb2_status, + tot_speaks_deb(deb2) if deb2 else 0, + tot_ranks_deb(deb2) if deb2 else 0, + tot_speaks(team), + tot_ranks(team), + ] + ) diff --git a/mittab/apps/tab/archive.py b/mittab/libs/data_export/xml_archive.py similarity index 100% rename from mittab/apps/tab/archive.py rename to mittab/libs/data_export/xml_archive.py diff --git a/mittab/libs/tests/views/test_exports.py b/mittab/libs/tests/views/test_exports.py new file mode 100644 index 000000000..8fef042f2 --- /dev/null +++ b/mittab/libs/tests/views/test_exports.py @@ -0,0 +1,178 @@ +import csv +import io +import json +import os +import tempfile +from unittest import mock + +import pytest +from django.contrib.messages.storage.fallback import FallbackStorage +from django.test import TestCase, RequestFactory + +from mittab.apps.tab.models import Team, Round, RoundStats, TabSettings +from mittab.libs.data_export.tab_card import ( + get_all_json_data, + csv_tab_cards, + get_tab_card_data, +) +from mittab.libs.data_export.xml_archive import ArchiveExporter +from mittab.libs.backup.storage import LocalFilesystem +from mittab.libs.data_export.s3_connector import ( + export_results_now, + RESULTS_FILENAME, + RESULTS_SUFFIX, +) +from mittab.apps.tab.views.views import publish_results + + +@pytest.mark.django_db(transaction=True) +class TestExports(TestCase): + fixtures = ["testing_finished_db"] + + def setUp(self): + super().setUp() + self.factory = RequestFactory() + TabSettings.set("cur_round", 2) + TabSettings.set("tot_rounds", 5) + self.permissive_user = type( + "PermissiveUser", + (), + { + "is_authenticated": True, + "is_active": True, + "has_perm": lambda self, perm: True, + }, + )() + + def test_exports_with_standard_data(self): + team = Team.with_preloaded_relations_for_tab_card().first() + fallback_team = Team.objects.first() + team = team or fallback_team + self.assertIsNotNone(team, "Expected at least one team in fixtures") + + export_data = get_all_json_data() + self.assertTrue(export_data, "JSON export should not be empty") + team_code = team.get_or_create_team_code() + if team_code not in export_data: + team_code = next(iter(export_data.keys())) + team = Team.objects.filter(team_code=team_code).first() or team + self.assertIn( + team_code, + export_data, + "JSON export should contain the selected team", + ) + + csv_buffer = io.StringIO() + csv_writer = csv.writer(csv_buffer) + csv_tab_cards(csv_writer) + self.assertIn( + team.get_or_create_team_code(), + csv_buffer.getvalue(), + "CSV export should reference the selected team", + ) + + xml_bytes = ArchiveExporter("test-tournament").export_tournament() + self.assertTrue(xml_bytes.startswith(b" View Backups View Tab Cards + Export Tournament Create DebateXML diff --git a/mittab/templates/common/_form.html b/mittab/templates/common/_form.html index 1d3e91d4f..dd58cd617 100644 --- a/mittab/templates/common/_form.html +++ b/mittab/templates/common/_form.html @@ -13,6 +13,10 @@ {% csrf_token %} {% buttons %} - + {% if custom_submit %} + + {% else %} + + {% endif %} {% endbuttons %} diff --git a/mittab/urls.py b/mittab/urls.py index f0eec1c7e..71367761b 100644 --- a/mittab/urls.py +++ b/mittab/urls.py @@ -88,6 +88,9 @@ re_path(r"^team/card/(\d+)/pretty/$", team_views.pretty_tab_card, name="pretty_tab_card"), + path("export_tournament/", views.export_tournament, name="export_tournament"), + re_path(r"^archive/download/(?Pjson|csv|xml)/$", + views.export_tournament, name="export_tournament_format"), path("team/ranking/", team_views.rank_teams_ajax, name="rank_teams_ajax"), path("team/rank/", team_views.rank_teams, name="rank_teams"),