feat(submissions): sync scopes admin from token
This commit is contained in:
@@ -5,10 +5,17 @@ from ninja import Router, File, Form
|
||||
from ninja.files import UploadedFile
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from games.models import Game
|
||||
from games.models import EventGame, Game
|
||||
from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope
|
||||
from .schemas import SubmissionOut, ImageCropIn, ImageCropOut, ScopeOut
|
||||
from .tasks import process_submission_file, process_crop
|
||||
from .schemas import (
|
||||
SubmissionOut,
|
||||
ImageCropIn,
|
||||
ImageCropOut,
|
||||
ScopeOut,
|
||||
SyncScopesIn,
|
||||
SyncScopesOut,
|
||||
)
|
||||
from .tasks import process_submission_file, process_crop, schedule_scope_sync
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -66,6 +73,41 @@ def list_available_scopes(request: HttpRequest, event_path: str, game_path: str)
|
||||
return list(scopes.order_by("name"))
|
||||
|
||||
|
||||
@router.post(
|
||||
f"{PREFIX}/{{game_path}}/sync-scopes",
|
||||
response={200: SyncScopesOut, 400: dict, 403: dict},
|
||||
)
|
||||
def sync_scopes(
|
||||
request: HttpRequest, event_path: str, game_path: str, payload: SyncScopesIn
|
||||
):
|
||||
"""Start the AAndT scope sync loop for a (non custom path) game, superusers
|
||||
only. Stores `apibot_token` first when given; without one and none stored
|
||||
yet, answers 400 with `token_required` so the frontend asks for it."""
|
||||
if not request.user.is_superuser:
|
||||
return 403, {"detail": "Superuser required"}
|
||||
|
||||
event_game = get_object_or_404(
|
||||
EventGame,
|
||||
event=request.event,
|
||||
game__path=game_path,
|
||||
game__custom_path=False,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
token = (payload.apibot_token or "").strip()
|
||||
if token:
|
||||
event_game.apibot_token = token
|
||||
event_game.save(update_fields=["apibot_token"])
|
||||
|
||||
if not event_game.apibot_token:
|
||||
return 400, {
|
||||
"detail": "No AAndT apibot token configured",
|
||||
"token_required": True,
|
||||
}
|
||||
|
||||
return {"started": schedule_scope_sync(event_game.id)}
|
||||
|
||||
|
||||
@router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict})
|
||||
def create_submission(
|
||||
request: HttpRequest,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from games.models import EventGame
|
||||
from submissions.tasks import sync_all_event_games_task, sync_event_game_scopes_task
|
||||
from submissions.tasks import schedule_scope_sync, sync_all_event_games_task
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -25,10 +25,16 @@ class Command(BaseCommand):
|
||||
if not EventGame.objects.filter(id=event_game_id).exists():
|
||||
raise CommandError(f"No EventGame with id={event_game_id}")
|
||||
|
||||
sync_event_game_scopes_task.enqueue(event_game_id)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Queued sync for EventGame {event_game_id}")
|
||||
)
|
||||
if schedule_scope_sync(event_game_id):
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Started sync for EventGame {event_game_id}")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Sync already running for EventGame {event_game_id}"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
|
||||
@@ -10,6 +10,15 @@ class ScopeOut(Schema):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SyncScopesIn(Schema):
|
||||
apibot_token: str | None = None
|
||||
|
||||
|
||||
class SyncScopesOut(Schema):
|
||||
# False when a sync loop was already running for this game
|
||||
started: bool
|
||||
|
||||
|
||||
class ImageCropIn(Schema):
|
||||
x1: int
|
||||
y1: int
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -1 +1,129 @@
|
||||
# Create your tests here.
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
|
||||
from accounts.models import CustomUser
|
||||
from events.models import Event
|
||||
from games.models import EventGame, Game
|
||||
|
||||
from . import tasks
|
||||
from .services.aandt_sync import ScopeSyncError, ScopeSyncResult
|
||||
|
||||
|
||||
class SyncScopesApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.event = Event.objects.create(name="Event", path="event")
|
||||
self.game = Game.objects.create(name="Game", path="game")
|
||||
self.event_game = EventGame.objects.create(event=self.event, game=self.game)
|
||||
self.url = (
|
||||
f"/api/events/{self.event.path}/submissions/{self.game.path}/sync-scopes"
|
||||
)
|
||||
self.superuser = CustomUser.objects.create_user(
|
||||
username="admin", password="x", is_staff=True, is_superuser=True
|
||||
)
|
||||
|
||||
patcher = mock.patch("submissions.api.schedule_scope_sync", return_value=True)
|
||||
self.schedule = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def _post(self, body=None):
|
||||
return self.client.post(self.url, body or {}, content_type="application/json")
|
||||
|
||||
def test_staff_is_forbidden(self):
|
||||
staff = CustomUser.objects.create_user(
|
||||
username="staff", password="x", is_staff=True
|
||||
)
|
||||
self.client.force_login(staff)
|
||||
|
||||
self.assertEqual(self._post().status_code, 403)
|
||||
self.schedule.assert_not_called()
|
||||
|
||||
def test_asks_for_token_when_missing(self):
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
response = self._post()
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertTrue(response.json()["token_required"])
|
||||
self.schedule.assert_not_called()
|
||||
|
||||
def test_stores_token_and_starts_sync(self):
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
response = self._post({"apibot_token": " abc "})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"started": True})
|
||||
self.event_game.refresh_from_db()
|
||||
self.assertEqual(self.event_game.apibot_token, "abc")
|
||||
self.schedule.assert_called_once_with(self.event_game.id)
|
||||
|
||||
def test_custom_path_game_is_404(self):
|
||||
self.game.custom_path = True
|
||||
self.game.save()
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
self.assertEqual(self._post({"apibot_token": "abc"}).status_code, 404)
|
||||
|
||||
|
||||
class SyncEventGameScopesTaskTests(TestCase):
|
||||
def setUp(self):
|
||||
event = Event.objects.create(name="Event", path="event")
|
||||
game = Game.objects.create(name="Game", path="game")
|
||||
self.event_game = EventGame.objects.create(
|
||||
event=event, game=game, apibot_token="abc"
|
||||
)
|
||||
self.guard_key = tasks._scope_sync_guard_key(self.event_game.id)
|
||||
self.loop_id = "loop"
|
||||
cache.set(self.guard_key, self.loop_id)
|
||||
self.addCleanup(cache.delete, self.guard_key)
|
||||
|
||||
def _run(self, status=None, exc=None):
|
||||
run = tasks.sync_event_game_scopes_task.func
|
||||
with (
|
||||
mock.patch.object(
|
||||
tasks,
|
||||
"sync_event_game_scopes",
|
||||
side_effect=exc,
|
||||
return_value=ScopeSyncResult(status=status),
|
||||
) as sync,
|
||||
mock.patch.object(tasks, "sync_event_game_scopes_task") as task,
|
||||
mock.patch.object(tasks, "logger"),
|
||||
):
|
||||
run(self.event_game.id, loop_id=self.loop_id)
|
||||
return sync, task
|
||||
|
||||
def test_schedule_starts_only_one_loop(self):
|
||||
cache.delete(self.guard_key)
|
||||
|
||||
with mock.patch.object(tasks, "sync_event_game_scopes_task") as task:
|
||||
self.assertTrue(tasks.schedule_scope_sync(self.event_game.id))
|
||||
self.assertFalse(tasks.schedule_scope_sync(self.event_game.id))
|
||||
|
||||
task.enqueue.assert_called_once()
|
||||
|
||||
def test_reschedules_before_and_during_aandt(self):
|
||||
for status in ["3insc", "7inprog"]:
|
||||
with self.subTest(status=status):
|
||||
_, task = self._run(status=status)
|
||||
task.using.assert_called_once()
|
||||
|
||||
def test_reschedules_after_api_failure(self):
|
||||
_, task = self._run(exc=ScopeSyncError("down"))
|
||||
|
||||
task.using.assert_called_once()
|
||||
|
||||
def test_stops_once_aandt_is_done(self):
|
||||
_, task = self._run(status="9done")
|
||||
|
||||
task.using.assert_not_called()
|
||||
self.assertIsNone(cache.get(self.guard_key))
|
||||
|
||||
def test_stale_loop_stops(self):
|
||||
cache.set(self.guard_key, "another-loop")
|
||||
|
||||
sync, task = self._run(status="7inprog")
|
||||
|
||||
sync.assert_not_called()
|
||||
task.using.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user