From b7dda3eb1c26e3a06eb5f5ee4e04cdbe0b75dab0 Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 22:59:46 -0800 Subject: [PATCH 01/11] Install requirements for prereqs --- backend/environment.yml | 1 + environment-dev.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/backend/environment.yml b/backend/environment.yml index 5ce2e493b..0a08901d9 100644 --- a/backend/environment.yml +++ b/backend/environment.yml @@ -28,3 +28,4 @@ dependencies: - django_rest_passwordreset==1.3.0 - djangorestframework-simplejwt==5.2.2 - google-cloud-scheduler==2.7.3 + - requests==2.28.1 diff --git a/environment-dev.yml b/environment-dev.yml index c14ccb9f2..8e167f11e 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -39,3 +39,5 @@ dependencies: - django_rest_passwordreset==1.3.0 - djangorestframework-simplejwt==5.2.2 - google-cloud-scheduler==2.7.3 + - requests==2.28.1 + - types-requests==2.28.11.7 From ef0f7222c5fa7f583bde2d577f5d42743569d4cf Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:01:16 -0800 Subject: [PATCH 02/11] Create Challonge API module-looking thing --- backend/siarnaq/api/episodes/challonge.py | 25 +++++++++++++++++++++++ backend/siarnaq/settings.py | 6 ++++++ 2 files changed, 31 insertions(+) create mode 100644 backend/siarnaq/api/episodes/challonge.py diff --git a/backend/siarnaq/api/episodes/challonge.py b/backend/siarnaq/api/episodes/challonge.py new file mode 100644 index 000000000..5c5526174 --- /dev/null +++ b/backend/siarnaq/api/episodes/challonge.py @@ -0,0 +1,25 @@ +# An API-esque module for our usage. +# Commands here are not very generic (like a good API), +# and are instead tailored to Battlecode's specific usage, +# to improve dev efficiency + +import requests +from django.conf import settings + +_headers = { + "Accept": "application/json", + "Authorization-Type": "v1", + "Authorization": settings.CHALLONGE_API_KEY, + "Content-Type": "application/vnd.api+json", + # requests' default user agent causes Challonge's API to crash. + "User-Agent": "", +} + +AUTH_TYPE = "v1" +URL_BASE = "https://api.challonge.com/v2/" + + +def set_api_key(api_key): + """Set the challonge.com api credentials to use.""" + _headers["Authorization"] = api_key + diff --git a/backend/siarnaq/settings.py b/backend/siarnaq/settings.py index 2591e5336..babc35335 100644 --- a/backend/siarnaq/settings.py +++ b/backend/siarnaq/settings.py @@ -290,6 +290,7 @@ class Local(Base): "MAILJET_API_KEY": "", "MAILJET_SECRET_KEY": "", } + CHALLONGE_API_KEY = "" @property def DATABASES(self): @@ -379,6 +380,10 @@ def pre_setup(cls): "MAILJET_API_KEY": secrets["mailjet-api-key"], "MAILJET_SECRET_KEY": secrets["mailjet-api-secret"], } + # Right now this is Nathan's personal key. + # This should become a dedicated alternate account for us + # Track in #549 + cls.CHALLONGE_API_KEY = secrets["challonge-api-key"] cls.DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", @@ -469,6 +474,7 @@ def pre_setup(cls): "MAILJET_API_KEY": secrets["mailjet-api-key"], "MAILJET_SECRET_KEY": secrets["mailjet-api-secret"], } + cls.CHALLONGE_API_KEY = secrets["challonge-api-key"] structlog.configure( From 4fbeaabe63054507efe3426f537b89c6c5e18370 Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:04:37 -0800 Subject: [PATCH 03/11] Model changes; associated admin tweaks and migrs --- backend/siarnaq/api/compete/admin.py | 21 +++++++-- ...llonge_id_matchparticipant_challonge_id.py | 23 ++++++++++ backend/siarnaq/api/compete/models.py | 17 +++++++- backend/siarnaq/api/episodes/admin.py | 12 +++--- ...e_tournament_challonge_private_and_more.py | 43 +++++++++++++++++++ backend/siarnaq/api/episodes/models.py | 30 +++++++++---- 6 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 backend/siarnaq/api/compete/migrations/0004_match_challonge_id_matchparticipant_challonge_id.py create mode 100644 backend/siarnaq/api/episodes/migrations/0003_remove_tournament_challonge_private_and_more.py diff --git a/backend/siarnaq/api/compete/admin.py b/backend/siarnaq/api/compete/admin.py index 2196bc756..5fbcf2c1c 100644 --- a/backend/siarnaq/api/compete/admin.py +++ b/backend/siarnaq/api/compete/admin.py @@ -81,11 +81,11 @@ def has_delete_permission(self, request, obj=None): class MatchParticipantInline(admin.TabularInline): model = MatchParticipant extra = 0 - fields = ("team", "submission", "player_index", "score", "rating") + fields = ("team", "submission", "player_index", "score", "rating", "challonge_id") max_num = 2 ordering = ("player_index",) raw_id_fields = ("team", "submission") - readonly_fields = ("rating",) + readonly_fields = ("rating", "challonge_id") def get_queryset(self, request): return ( @@ -102,7 +102,6 @@ class MatchAdmin(admin.ModelAdmin): { "fields": ( "episode", - "tournament_round", "replay", "alternate_order", "is_ranked", @@ -116,6 +115,12 @@ class MatchAdmin(admin.ModelAdmin): "fields": ("status", "created", "num_failures", "logs"), }, ), + ( + "Tournament metadata", + { + "fields": ("tournament_round", "challonge_id"), + }, + ), ) inlines = [MatchParticipantInline] list_display = ( @@ -128,7 +133,15 @@ class MatchAdmin(admin.ModelAdmin): list_filter = ("episode", "status") ordering = ("-pk",) raw_id_fields = ("tournament_round",) - readonly_fields = ("replay", "status", "created", "num_failures", "logs") + readonly_fields = ( + "replay", + "status", + "created", + "num_failures", + "logs", + "tournament_round", + "challonge_id", + ) def formfield_for_manytomany(self, db_field, request, **kwargs): pk = request.resolver_match.kwargs.get("object_id", None) diff --git a/backend/siarnaq/api/compete/migrations/0004_match_challonge_id_matchparticipant_challonge_id.py b/backend/siarnaq/api/compete/migrations/0004_match_challonge_id_matchparticipant_challonge_id.py new file mode 100644 index 000000000..8656a281c --- /dev/null +++ b/backend/siarnaq/api/compete/migrations/0004_match_challonge_id_matchparticipant_challonge_id.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.2 on 2023-01-17 06:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("compete", "0003_initial"), + ] + + operations = [ + migrations.AddField( + model_name="match", + name="challonge_id", + field=models.IntegerField(blank=True, null=True, unique=True), + ), + migrations.AddField( + model_name="matchparticipant", + name="challonge_id", + field=models.CharField(blank=True, max_length=64, null=True), + ), + ] diff --git a/backend/siarnaq/api/compete/models.py b/backend/siarnaq/api/compete/models.py index d6f59af6a..a829b087b 100644 --- a/backend/siarnaq/api/compete/models.py +++ b/backend/siarnaq/api/compete/models.py @@ -205,7 +205,7 @@ class Match(SaturnInvocation): """The maps to be played in this match.""" alternate_order = models.BooleanField() - """Whether players should alternate orderGbetween successive games of this match.""" + """Whether players should alternate order between successive games of this match.""" is_ranked = models.BooleanField() """Whether this match counts for ranked ratings.""" @@ -213,6 +213,14 @@ class Match(SaturnInvocation): replay = models.UUIDField(default=uuid.uuid4) """The replay file of this match.""" + # NOTE: I'm not sure if this field _has_ to be unique. + # Feel free to relax it later. + # (Not enforcing it now, and then enforcing it later + # when a duplicate may have snuck in, would be hard.) + challonge_id = models.IntegerField(blank=True, null=True, unique=True) + """If this match is referenced in a private Challonge bracket, + Challonge's internal ID of the match in the bracket.""" + objects = MatchQuerySet.as_manager() def __str__(self): @@ -344,6 +352,13 @@ class MatchParticipant(models.Model): ) """The team's previous participation, or null if there is none.""" + challonge_id = models.CharField(null=True, blank=True, max_length=64) + """ + If the associated match is in Challonge, + Challonge's internal ID of this participant. + (This saves many API calls.) + """ + objects = MatchParticipantManager() def __str__(self): diff --git a/backend/siarnaq/api/episodes/admin.py b/backend/siarnaq/api/episodes/admin.py index 1086879c1..ca6c8daa3 100644 --- a/backend/siarnaq/api/episodes/admin.py +++ b/backend/siarnaq/api/episodes/admin.py @@ -134,7 +134,10 @@ class TournamentAdmin(admin.ModelAdmin): ( "Challonge configuration", { - "fields": ("challonge_private", "challonge_public", "in_progress"), + "fields": ( + "challonge_id_private", + "challonge_id_public", + ), }, ), ) @@ -146,12 +149,10 @@ class TournamentAdmin(admin.ModelAdmin): "episode", "submission_freeze", "is_public", - "in_progress", ) list_filter = ("episode",) list_select_related = ("episode",) ordering = ("-episode__game_release", "-submission_freeze") - readonly_fields = ("in_progress",) search_fields = ("name_short", "name_long") search_help_text = "Search for a full or abbreviated name." @@ -182,13 +183,14 @@ class TournamentRoundAdmin(admin.ModelAdmin): "challonge_id", "release_status", "maps", + "in_progress", ) inlines = [MatchInline] - list_display = ("name", "tournament", "release_status") + list_display = ("name", "tournament", "release_status", "in_progress") list_filter = ("tournament", "release_status") list_select_related = ("tournament",) ordering = ("-tournament__submission_freeze", "challonge_id") - readonly_fields = ("challonge_id",) + readonly_fields = ("challonge_id", "in_progress") def formfield_for_manytomany(self, db_field, request, **kwargs): pk = request.resolver_match.kwargs.get("object_id", None) diff --git a/backend/siarnaq/api/episodes/migrations/0003_remove_tournament_challonge_private_and_more.py b/backend/siarnaq/api/episodes/migrations/0003_remove_tournament_challonge_private_and_more.py new file mode 100644 index 000000000..fdfd5b550 --- /dev/null +++ b/backend/siarnaq/api/episodes/migrations/0003_remove_tournament_challonge_private_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.1.2 on 2023-01-17 06:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "episodes", + "0002_rename_release_version_episode_release_version_public_and_more", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="tournament", + name="challonge_private", + ), + migrations.RemoveField( + model_name="tournament", + name="challonge_public", + ), + migrations.RemoveField( + model_name="tournament", + name="in_progress", + ), + migrations.AddField( + model_name="tournament", + name="challonge_id_private", + field=models.SlugField(blank=True, null=True), + ), + migrations.AddField( + model_name="tournament", + name="challonge_id_public", + field=models.SlugField(blank=True, null=True), + ), + migrations.AddField( + model_name="tournamentround", + name="in_progress", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index c09c95278..984d94bdc 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -1,10 +1,15 @@ +import random + import structlog from django.apps import apps -from django.db import models +from django.db import models, transaction from django.utils import timezone from sortedm2m.fields import SortedManyToManyField +from siarnaq.api.compete.models import Match, MatchParticipant +from siarnaq.api.episodes import challonge from siarnaq.api.episodes.managers import EpisodeQuerySet, TournamentQuerySet +from siarnaq.api.teams.models import Team logger = structlog.get_logger(__name__) @@ -256,14 +261,11 @@ class Tournament(models.Model): time. """ - in_progress = models.BooleanField(default=False) - """Whether the tournament is currently being run on the Saturn compute cluster.""" - - challonge_private = models.URLField(null=True, blank=True) - """A private Challonge bracket showing matches in progress as they are run.""" + challonge_id_private = models.SlugField(null=True, blank=True) + """The Challonge ID of the associated private bracket.""" - challonge_public = models.URLField(null=True, blank=True) - """A public Challonge bracket showing match results as they are released.""" + challonge_id_public = models.SlugField(null=True, blank=True) + """The Challonge ID of the associated private bracket.""" objects = TournamentQuerySet.as_manager() @@ -314,6 +316,15 @@ class TournamentRound(models.Model): ) """The tournament to which this round belongs.""" + # NOTE: this is not really an "ID" in the unique sense. + # Instead it is more like an index. + # (It takes on values of ints close to 0, + # and two rounds from the same Challonge bracket + # can have the same value here of course.) + # You could rename this field, but that's a + # very widespread code change and migration, + # with low probability of success and + # high impact of failure. challonge_id = models.SmallIntegerField(null=True, blank=True) """The ID of this round as referenced by Challonge.""" @@ -328,6 +339,9 @@ class TournamentRound(models.Model): ) """THe degree to which matches in this round are released.""" + in_progress = models.BooleanField(default=False) + """Whether the round is currently being run on the Saturn compute cluster.""" + class Meta: constraints = [ models.UniqueConstraint( From 4564f3ca79500e161e342768bd21533aa335e6a1 Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:05:30 -0800 Subject: [PATCH 04/11] Impl computing participants --- backend/siarnaq/api/episodes/models.py | 15 ++++++++++++++- backend/siarnaq/api/teams/managers.py | 5 ++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index 984d94bdc..a3cf17cc5 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -272,7 +272,20 @@ class Tournament(models.Model): def __str__(self): return self.name_short - def seed_by_scrimmage(self): + def get_potential_participants(self): + """Returns the list of participants that would be entered in this tournament, + if it were to start right now.""" + # NOTE: this hasn't really been tested well. + # Test all parts of eligibility filtering + # (includes, excludes, resume) + # Test also that special teams (eg devs) don't enter + # Track in #549 + return ( + Team.objects.with_active_submission() + .filter_eligible(self) + .all() + .order_by("-profile__rating__value") + ) """ Seed the tournament with eligible teamsn in order of decreasing rating, and populate the Challonge brackets. diff --git a/backend/siarnaq/api/teams/managers.py b/backend/siarnaq/api/teams/managers.py index 18b163da8..e268c5416 100644 --- a/backend/siarnaq/api/teams/managers.py +++ b/backend/siarnaq/api/teams/managers.py @@ -104,7 +104,10 @@ def visible(self): return self.filter(status__in=[TeamStatus.REGULAR, TeamStatus.STAFF]) def filter_eligible(self, tournament): - """Filter for teams that are eligible for a tournament.""" + """ + Filter for teams that are eligible for a tournament. + NOTE: Does not filter for having an active submission or not. + """ from siarnaq.api.teams.models import TeamStatus teams = self.annotate( From 56b2f3ed106fd0241df9e07e661fe66640285f6e Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:12:21 -0800 Subject: [PATCH 05/11] Impl creating tour in challonge --- backend/siarnaq/api/episodes/admin.py | 13 ++++++- backend/siarnaq/api/episodes/challonge.py | 24 +++++++++++++ backend/siarnaq/api/episodes/models.py | 44 +++++++++++++++++++++-- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/backend/siarnaq/api/episodes/admin.py b/backend/siarnaq/api/episodes/admin.py index ca6c8daa3..f0ab7e097 100644 --- a/backend/siarnaq/api/episodes/admin.py +++ b/backend/siarnaq/api/episodes/admin.py @@ -1,4 +1,5 @@ -from django.contrib import admin +import structlog +from django.contrib import admin, messages from siarnaq.api.compete.models import Match from siarnaq.api.episodes.models import ( @@ -9,6 +10,8 @@ TournamentRound, ) +logger = structlog.get_logger(__name__) + class MapInline(admin.TabularInline): model = Map @@ -108,8 +111,16 @@ def get_queryset(self, request): return super().get_queryset(request).prefetch_related("maps") +@admin.action(description="Initialize a tournament") +def initialize(modeladmin, request, queryset): + logger.info("initialize", message=f"Initializing tournaments in {queryset}") + for tournament in queryset: + tournament.initialize() + + @admin.register(Tournament) class TournamentAdmin(admin.ModelAdmin): + actions = [initialize] fieldsets = ( ( "General", diff --git a/backend/siarnaq/api/episodes/challonge.py b/backend/siarnaq/api/episodes/challonge.py index 5c5526174..21c6ec08a 100644 --- a/backend/siarnaq/api/episodes/challonge.py +++ b/backend/siarnaq/api/episodes/challonge.py @@ -23,3 +23,27 @@ def set_api_key(api_key): """Set the challonge.com api credentials to use.""" _headers["Authorization"] = api_key + +def create_tournament( + tournament_url, tournament_name, is_private=True, is_single_elim=True +): + tournament_type = "single elimination" if is_single_elim else "double elimination" + + url = f"{URL_BASE}tournaments.json" + + payload = { + "data": { + "type": "tournaments", + "attributes": { + "name": tournament_name, + "tournament_type": tournament_type, + "private": is_private, + "url": tournament_url, + }, + } + } + + r = requests.post(url, headers=_headers, json=payload) + r.raise_for_status() + + diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index a3cf17cc5..80cfd466d 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -286,12 +286,50 @@ def get_potential_participants(self): .all() .order_by("-profile__rating__value") ) + + def initialize(self): """ - Seed the tournament with eligible teamsn in order of decreasing rating, and - populate the Challonge brackets. + Seed the tournament with eligible teams in order of decreasing rating, + populate the Challonge brackets, and create TournamentRounds. """ - raise NotImplementedError + tournament_name_public = self.name_long + tournament_name_private = tournament_name_public + " (private)" + + # For security by obfuscation, + # and to allow easy regeneration of bracket + # In #549, use letters (even capitals!) + # and use more characters (altho staying under the 32-char lim). + key = random.randint(1000, 9999) + # Challonge does not allow hyphens in its IDs + # so substitute them just in case + tournament_id_public = f"{self.name_short}_{key}".replace("-", "_") + tournament_id_private = f"{tournament_id_public}_private" + + # NOTE: We don't support double elim yet. + # Tracked in #548. (Also make sure to actually read the "style" field) + is_single_elim = True + participants = self.get_potential_participants() + # Parse into a format Challonge enjoys + # 1-idx seed + # Store team id in misc, for convenience (re-looking up is annoying) + # Store tournament submission in misc, for consistency and convenience + # Note that tournament submission should + # never change during a tournament anyways + # due to submission freeze. Bad things might happen if it does tho + participants = [ + {"name": p.name, "seed": idx + 1, "misc": f"{p.id},{p.active_submission}"} + for (idx, p) in enumerate(participants) + ] + + # First bracket made should be private, + # to hide results and enable fixing accidents + # In #549 it would be nice to have the function + # take in the actual TournamentStyle value, + # and do some true/false check there + challonge.create_tournament( + tournament_id_private, tournament_name_private, True, is_single_elim + ) def start_progress(self): """Start or resume the tournament.""" raise NotImplementedError From 31df1d20bc485eaae3d66290f6dca8e63dcd7bcd Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:13:12 -0800 Subject: [PATCH 06/11] Impl adding participants in challonge --- backend/siarnaq/api/episodes/challonge.py | 20 ++++++++++++++++++++ backend/siarnaq/api/episodes/models.py | 1 + 2 files changed, 21 insertions(+) diff --git a/backend/siarnaq/api/episodes/challonge.py b/backend/siarnaq/api/episodes/challonge.py index 21c6ec08a..0ffb25739 100644 --- a/backend/siarnaq/api/episodes/challonge.py +++ b/backend/siarnaq/api/episodes/challonge.py @@ -47,3 +47,23 @@ def create_tournament( r.raise_for_status() +def bulk_add_participants(tournament_url, participants): + """ + Adds participants in bulk. + Expects `participants` to be formatted in the format Challonge expects. + Note especially that seeds must be 1-indexed. + """ + url = f"{URL_BASE}tournaments/{tournament_url}/participants/bulk_add.json" + + payload = { + "data": { + "type": "Participant", + "attributes": { + "participants": participants, + }, + } + } + + r = requests.post(url, headers=_headers, json=payload) + r.raise_for_status() + diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index 80cfd466d..bde2ce9e9 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -330,6 +330,7 @@ def initialize(self): challonge.create_tournament( tournament_id_private, tournament_name_private, True, is_single_elim ) + challonge.bulk_add_participants(tournament_id_private, participants) def start_progress(self): """Start or resume the tournament.""" raise NotImplementedError From 670214ee3dbeedb21580d51824e6f9d5c655d354 Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:13:42 -0800 Subject: [PATCH 07/11] Impl marking tour as in progress --- backend/siarnaq/api/episodes/challonge.py | 9 +++++++++ backend/siarnaq/api/episodes/models.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/backend/siarnaq/api/episodes/challonge.py b/backend/siarnaq/api/episodes/challonge.py index 0ffb25739..155cd8261 100644 --- a/backend/siarnaq/api/episodes/challonge.py +++ b/backend/siarnaq/api/episodes/challonge.py @@ -67,3 +67,12 @@ def bulk_add_participants(tournament_url, participants): r = requests.post(url, headers=_headers, json=payload) r.raise_for_status() + +def start_tournament(tournament_url): + url = f"{URL_BASE}tournaments/{tournament_url}/change_state.json" + + payload = {"data": {"type": "TournamentState", "attributes": {"state": "start"}}} + + r = requests.put(url, headers=_headers, json=payload) + r.raise_for_status() + diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index bde2ce9e9..8dfc997be 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -331,6 +331,8 @@ def initialize(self): tournament_id_private, tournament_name_private, True, is_single_elim ) challonge.bulk_add_participants(tournament_id_private, participants) + challonge.start_tournament(tournament_id_private) + def start_progress(self): """Start or resume the tournament.""" raise NotImplementedError From f157ebd984a8fb888707fdeb933544bf53a1d1b5 Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:15:39 -0800 Subject: [PATCH 08/11] Impl creating TournamentRound objects --- backend/siarnaq/api/episodes/challonge.py | 8 ++++++ backend/siarnaq/api/episodes/models.py | 31 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/backend/siarnaq/api/episodes/challonge.py b/backend/siarnaq/api/episodes/challonge.py index 155cd8261..be95efc3e 100644 --- a/backend/siarnaq/api/episodes/challonge.py +++ b/backend/siarnaq/api/episodes/challonge.py @@ -76,3 +76,11 @@ def start_tournament(tournament_url): r = requests.put(url, headers=_headers, json=payload) r.raise_for_status() + +def get_tournament(tournament_url): + url = f"{URL_BASE}tournaments/{tournament_url}.json" + + r = requests.get(url, headers=_headers) + r.raise_for_status() + return r.json() + diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index 8dfc997be..9745b42af 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -333,6 +333,37 @@ def initialize(self): challonge.bulk_add_participants(tournament_id_private, participants) challonge.start_tournament(tournament_id_private) + tournament = challonge.get_tournament(tournament_id_private) + # Derive round IDs + # Takes some wrangling with API response format + # We should move this block later + # (to keep all code that directly hits challonge + # in its own module) Track in #549 + rounds = set() + for item in tournament["included"]: + # Cleaner w match-case block + # Track in #549 + if item["type"] == "match": + round_idx = item["attributes"]["round"] + rounds.add(round_idx) + + # NOTE: rounds' order and indexes get weird in double elim. + # Tracked in #548 + round_objects = [ + TournamentRound( + tournament=self, + challonge_id=round_idx, + name=f"{tournament_name_private} Round {round_idx}", + ) + for round_idx in rounds + ] + TournamentRound.objects.bulk_create(round_objects) + + self.challonge_id_private = tournament_id_private + self.challonge_id_public = tournament_id_public + # Optimize this save w the `update_fields` kwarg + # Tracked in #549 + self.save() def start_progress(self): """Start or resume the tournament.""" raise NotImplementedError From 37e73fe4d78910def698b02303002e704b8b7fd6 Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:16:23 -0800 Subject: [PATCH 09/11] Impl enqueueing matches of a round --- backend/siarnaq/api/episodes/admin.py | 11 +++ backend/siarnaq/api/episodes/models.py | 121 +++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/backend/siarnaq/api/episodes/admin.py b/backend/siarnaq/api/episodes/admin.py index f0ab7e097..ffe0c7d33 100644 --- a/backend/siarnaq/api/episodes/admin.py +++ b/backend/siarnaq/api/episodes/admin.py @@ -186,8 +186,19 @@ def has_delete_permission(self, request, obj): return False +@admin.action(description="Create and enqueue matches of a tournament round") +def enqueue(modeladmin, request, queryset): + logger.info("enqueue", message=f"Enqueueing tournament rounds {queryset}") + for round in queryset: + try: + round.enqueue() + except RuntimeError as e: + messages.error(request, str(e)) + + @admin.register(TournamentRound) class TournamentRoundAdmin(admin.ModelAdmin): + actions = [enqueue] fields = ( "name", "tournament", diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index 9745b42af..573d35363 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -437,3 +437,124 @@ class Meta: def __str__(self): return f"{self.tournament} ({self.name})" + + def enqueue(self): + """Creates and enqueues all matches for this round. + Fails if this round is already in progress.""" + + if self.in_progress: + raise RuntimeError("The round's matches are already running in Saturn.") + + num_maps = len(self.maps.all()) + # Sure, matches with even number of maps won't run. + # But might as well fail fast. + if num_maps % 2 == 0: + raise RuntimeError("The round does not have an odd number of maps.") + + tournament = challonge.get_tournament(self.tournament.challonge_id_private) + # Derive matches of this round + # NOTE this probably makes more sense (efficiency and consistency) + # as a dict. Track in #549 + matches = [] + # Takes some wrangling with API response format + # We should move this block later + # (to keep all code that directly hits challonge + # in its own module) Track in #549 + for item in tournament["included"]: + # Much cleaner w match-case and multiple keys. + # Track in #549 + if item["type"] == "match": + round_idx = item["attributes"]["round"] + if round_idx == self.challonge_id: + # Only enqueue the round if all matches are "open". + # NOTE: it would be good to have a "force re-enqueue round", + # which re-enqueues matches even if matches or round + # already in progress. + # This would change the following check -- + # matches could be open _or done_. + # !!! This is also _really hard_ right now + # cuz it involves match deletion which is really hard. + # Track in #549 + if item["attributes"]["state"] != "open": + # For later, have this raise a more specific exception. + # Then have the caller handle this return + # and translate it into an HTTP response. + raise RuntimeError( + "The bracket service's round does not only\ + have matches that are ready." + ) + matches.append(item) + + # Map participant "objects" with IDs for easy lookup + participants = dict() + for item in tournament["included"]: + # Cleaner with match-case, + # and would also allow for just one iteration over tournament, not 2. + # Track in #549 + if item["type"] == "participant": + id = item["id"] + participants[id] = item + + match_objects = [] + maps_for_match_objects = [] + match_participant_objects = [] + + for m in matches: + match_object = Match( + episode=self.tournament.episode, + tournament_round=self, + alternate_order=True, + is_ranked=False, + challonge_id=m["id"], + ) + match_objects.append(match_object) + + # NOTE the following code is ridiculously inherent to challonge model. + # Should probably get participants in away that's cleaner + # tracked in #549 + # NOTE could prob wrap this in a for loop for partipant 1 and 2 + # tracked in #549 + p1_id = m["relationships"]["player1"]["data"]["id"] + p1_misc_key = participants[p1_id]["attributes"]["misc"] + team_id_1, submission_id_1 = (int(_) for _ in p1_misc_key.split(",")) + match_participant_1_object = MatchParticipant( + team_id=team_id_1, + submission_id=submission_id_1, + match=match_object, + # Note that player_index is 0-indexed. + # This may be tricky if you optimize code in #549. + player_index=0, + challonge_id=p1_id, + ) + match_participant_objects.append(match_participant_1_object) + + p2_id = m["relationships"]["player2"]["data"]["id"] + p2_misc_key = participants[p2_id]["attributes"]["misc"] + team_id_2, submission_id_2 = (int(_) for _ in p2_misc_key.split(",")) + match_participant_2_object = MatchParticipant( + team_id=team_id_2, + submission_id=submission_id_2, + match=match_object, + # Note that player_index is 0-indexed. + # This may be tricky if you optimize code in #549. + player_index=1, + challonge_id=p2_id, + ) + match_participant_objects.append(match_participant_2_object) + + with transaction.atomic(): + matches = Match.objects.bulk_create(match_objects) + # Can only create these objects after matches are saved, + # because beforehand, matches will not have a pk. + maps_for_match_objects = [ + Match.maps.through(match_id=match.pk, map_id=map.pk) + for match in matches + for map in self.maps.all() + ] + Match.maps.through.objects.bulk_create(maps_for_match_objects) + MatchParticipant.objects.bulk_create(match_participant_objects) + + Match.objects.filter(pk__in=[match.pk for match in matches]).enqueue() + + self.in_progress = True + self.save() From 149475637467f58e18d89a685e398ea373f9326e Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:16:56 -0800 Subject: [PATCH 10/11] Impl match reporting for tour matches to bracket --- backend/siarnaq/api/episodes/challonge.py | 15 +++++++ backend/siarnaq/api/episodes/models.py | 49 +++++++++++++++++++++++ backend/siarnaq/api/episodes/signals.py | 29 ++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/backend/siarnaq/api/episodes/challonge.py b/backend/siarnaq/api/episodes/challonge.py index be95efc3e..33e651826 100644 --- a/backend/siarnaq/api/episodes/challonge.py +++ b/backend/siarnaq/api/episodes/challonge.py @@ -84,3 +84,18 @@ def get_tournament(tournament_url): r.raise_for_status() return r.json() + +def update_match(tournament_url, match_id, match): + url = f"{URL_BASE}tournaments/{tournament_url}/matches/{match_id}.json" + + payload = { + "data": { + "type": "Match", + "attributes": { + "match": match, + }, + } + } + + r = requests.put(url, headers=_headers, json=payload) + r.raise_for_status() diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index 573d35363..0cd1eca58 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -364,6 +364,55 @@ def initialize(self): # Optimize this save w the `update_fields` kwarg # Tracked in #549 self.save() + + def report_for_tournament(self, match: Match): + """ + If a match is associated with a tournament bracket, + update that tournament bracket. + """ + # NOTE this data format is ultra-specific to Challonge + # Make more general and modular, tracked in #549 + scores_of_participants = dict() + + for p in match.participants.all(): + scores_of_participants[p.challonge_id] = {"score": p.score} + + # Challonge needs to explicitly be told who advances. + # There's probably a better way to derive this... + # NOTE this part should definitely go into challonge.py + # tracked in #549 + high_score = -1 + # Better to use + # `key, val in dict.items() ` + # track in #549 + for p in scores_of_participants: + score = scores_of_participants[p]["score"] + if score >= high_score: + high_score = score + + for p in scores_of_participants: + scores_of_participants[p]["advancing"] = ( + True if scores_of_participants[p]["score"] == high_score else False + ) + + # Refold back into a data format Challonge likes + scores_for_challonge = [ + { + "participant_id": p, + "score_set": str(scores_of_participants[p]["score"]), + "advancing": scores_of_participants[p]["advancing"], + } + for p in scores_of_participants + ] + + challonge.update_match( + self.challonge_id_private, match.challonge_id, scores_for_challonge + ) + + # Consider dropping these stubs, cuz they're bloat. + # We're not confident whether or not the stubs might actually be used, + # and someone can always remake them. + # track in #549. def start_progress(self): """Start or resume the tournament.""" raise NotImplementedError diff --git a/backend/siarnaq/api/episodes/signals.py b/backend/siarnaq/api/episodes/signals.py index 492b375e6..b7bee615a 100644 --- a/backend/siarnaq/api/episodes/signals.py +++ b/backend/siarnaq/api/episodes/signals.py @@ -7,6 +7,7 @@ from django.dispatch import receiver from django.urls import reverse +from siarnaq.api.compete.models import Match, SaturnStatus from siarnaq.api.episodes.models import Episode logger = structlog.get_logger(__name__) @@ -82,3 +83,31 @@ def update_autoscrim_schedule(instance, update_fields, **kwargs): else: log.info("autoscrim_modify", message="Updating autoscrim schedule.") client.update_job(request=dict(job=job)) + + +@receiver(pre_save, sender=Match) +def report_for_tournament(instance, **kwargs): + """ + If a match is associated with a tournament bracket, + update that tournament bracket. + """ + if instance.status == SaturnStatus.COMPLETED and instance.challonge_id is not None: + # NOTE: the following is a _draft_ code block that ensures that + # matches aren't unnecessarily reported due to Saturn health-checks. + # I'm not actually sure if it's useful but it might be + # so keeping around to not reinvent the wheel. + # If this check is useful, then implement this and test fully. + # Debate this in #549. + + # # Check that the match has gone from not completed to completed. + # # This protects against reporting to the bracket service _twice_ + # if instance.id: # (to check if the instance has already been saved before) + # original_status = Match.objects.get(pk=instance.pk).status + # if original_status != SaturnStatus.COMPLETED: + # # Do the thing here: + # pass + + # NOTE: not sure where the code that derives the match's tournament + # should live. Question of abstraction? + # Open to suggestions, track in #549 + instance.tournament_round.tournament.report_for_tournament(instance) From f2bea77db8cfd7894f8b41284af9c3b7fb33cfd2 Mon Sep 17 00:00:00 2001 From: Nathan Kim Date: Mon, 16 Jan 2023 23:42:02 -0800 Subject: [PATCH 11/11] Remove obsolete field from serializer --- backend/siarnaq/api/episodes/serializers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/siarnaq/api/episodes/serializers.py b/backend/siarnaq/api/episodes/serializers.py index 02153401b..1dfd2b755 100644 --- a/backend/siarnaq/api/episodes/serializers.py +++ b/backend/siarnaq/api/episodes/serializers.py @@ -72,7 +72,6 @@ class Meta: "is_public", "submission_freeze", "submission_unfreeze", - "challonge_public", "is_eligible", ]