feat(submissions): sync scopes admin from token

This commit is contained in:
2026-10-16 01:34:56 +02:00
parent 8daa91d6c5
commit de5ee8ae7a
13 changed files with 480 additions and 40 deletions
+87 -16
View File
@@ -1,19 +1,61 @@
import logging
from datetime import timedelta
from uuid import uuid4
from django.core.cache import cache
from django.tasks import task
from django.utils import timezone
from games.models import EventGame
from submissions.models import ImageCrop, SubmissionFile, OCRStatus
from submissions.services.aandt_sync import (
AANDT_STATUS_IN_PROGRESS,
ScopeSyncError,
sync_event_game_scopes,
)
from PIL import Image
import pytesseract
# How often to repoll an EventGame's AAndT while it's still "in progress".
logger = logging.getLogger(__name__)
# How often to repoll an EventGame's AAndT while it's "in progress", and while
# it isn't yet (registrations, preparation, ...) or the API call failed.
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
SCOPE_SYNC_IDLE_INTERVAL = timedelta(minutes=1)
# AAndT statuses after which there's nothing left to sync, the loop stops.
AANDT_FINISHED_STATUSES = {"9done", "11reviewed"}
def _scope_sync_guard_key(event_game_id: int) -> str:
return f"submissions:scope_sync:{event_game_id}"
def _scope_sync_guard_timeout() -> int:
return int(SCOPE_SYNC_IDLE_INTERVAL.total_seconds() * 3)
def schedule_scope_sync(event_game_id: int, /) -> bool:
"""Start the sync loop of an EventGame, unless one is already running.
Safe to call any number of times (admin button spam, multiple workers...).
The loop holds a per-EventGame guard with its id (same idea as
games.tasks.schedule_steam_refresh), so if the guard expires while an old
loop is just late, the old one stops at its next run."""
loop_id = uuid4().hex
if not cache.add(
_scope_sync_guard_key(event_game_id),
loop_id,
timeout=_scope_sync_guard_timeout(),
):
return False
sync_event_game_scopes_task.enqueue(event_game_id, loop_id=loop_id)
return True
def stop_scope_sync(event_game_id: int, /):
"""Release the guard, the running loop (if any) stops at its next run."""
cache.delete(_scope_sync_guard_key(event_game_id))
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
@@ -90,26 +132,55 @@ def sync_all_event_games_task():
)
for event_game in event_games:
sync_event_game_scopes_task.enqueue(event_game.id)
schedule_scope_sync(event_game.id)
@task
def sync_event_game_scopes_task(event_game_id: int):
"""Sync an EventGame's Scopes, repolling every minute while matches are in progress.
def sync_event_game_scopes_task(event_game_id: int, loop_id: str | None = None):
"""Sync an EventGame's Scopes, then re-enqueue itself until the AAndT is done.
Each run re-checks the AAndT's status (from the `/` endpoint). While it's
still "in progress" (`7inprog`), the task re-enqueues itself with a delay
instead of relying on an external scheduler; once the status moves on, it
simply doesn't reschedule and the loop stops.
Started once (see schedule_scope_sync), it keeps going on its own without
an external scheduler: every SCOPE_SYNC_REPOLL_INTERVAL while the AAndT is
"in progress" (`7inprog`), every SCOPE_SYNC_IDLE_INTERVAL before that or
when the API call failed. Stops once the AAndT is done/reviewed, when the
EventGame (or its token) is gone, or when another loop took the guard.
Without loop_id it's a one-off run that leaves the loop alone.
"""
event_game = EventGame.objects.filter(id=event_game_id).first()
if not event_game:
guard_key = _scope_sync_guard_key(event_game_id)
if loop_id is not None and cache.get(guard_key) != loop_id:
# Another loop owns the guard (or it expired), stop this one
return
result = sync_event_game_scopes(event_game)
event_game = EventGame.objects.filter(id=event_game_id).first()
if result.status == AANDT_STATUS_IN_PROGRESS:
sync_event_game_scopes_task.using(
run_after=timezone.now() + SCOPE_SYNC_REPOLL_INTERVAL
).enqueue(event_game_id)
if not event_game or not event_game.apibot_token:
if loop_id is not None:
cache.delete(guard_key)
return
status = None
try:
status = sync_event_game_scopes(event_game).status
except ScopeSyncError:
if loop_id is None:
raise
# Probably transient (AAndT site down, ...), retry later
logger.exception("Scope sync failed for EventGame %s", event_game_id)
if loop_id is None:
return
if status in AANDT_FINISHED_STATUSES:
cache.delete(guard_key)
return
interval = (
SCOPE_SYNC_REPOLL_INTERVAL
if status == AANDT_STATUS_IN_PROGRESS
else SCOPE_SYNC_IDLE_INTERVAL
)
cache.set(guard_key, loop_id, timeout=_scope_sync_guard_timeout())
sync_event_game_scopes_task.using(run_after=timezone.now() + interval).enqueue(
event_game_id, loop_id=loop_id
)