fix: polylan sync

This commit is contained in:
2026-09-16 18:42:37 +02:00
parent 9ec9a54649
commit 4392122cfc
6 changed files with 107 additions and 25 deletions
@@ -155,7 +155,7 @@ AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL", "https://polylan.ch")
# Base URL of this site, used to build submission links pushed back to the # Base URL of this site, used to build submission links pushed back to the
# legacy AAndT match messages (see submissions.services.aandt_sync) # legacy AAndT match messages (see submissions.services.aandt_sync)
SUBMITTER_BASE_URL = os.environ.get("SUBMITTER_BASE_URL", "http://127.0.0.1:7777") SUBMITTER_BASE_URL = os.environ.get("SUBMITTER_BASE_URL", "http://localhost:7777")
# File Upload Settings # File Upload Settings
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
@@ -31,7 +31,7 @@ class Match:
rounds_count (int | Unset): rounds_count (int | Unset):
use_points (bool | Unset): use_points (bool | Unset):
points_are_positions (bool | Unset): points_are_positions (bool | Unset):
multiround_modes (MatchMultiroundModes | Unset): multiround_modes (MatchMultiroundModes | None | Unset):
multiround_modes_display (None | str | Unset): multiround_modes_display (None | str | Unset):
creation_date (str | Unset): Example: 2026-08-01 10:00:00+00:00. creation_date (str | Unset): Example: 2026-08-01 10:00:00+00:00.
playing_date (str | Unset): playing_date (str | Unset):
@@ -49,7 +49,7 @@ class Match:
rounds_count: int | Unset = UNSET rounds_count: int | Unset = UNSET
use_points: bool | Unset = UNSET use_points: bool | Unset = UNSET
points_are_positions: bool | Unset = UNSET points_are_positions: bool | Unset = UNSET
multiround_modes: MatchMultiroundModes | Unset = UNSET multiround_modes: MatchMultiroundModes | None | Unset = UNSET
multiround_modes_display: None | str | Unset = UNSET multiround_modes_display: None | str | Unset = UNSET
creation_date: str | Unset = UNSET creation_date: str | Unset = UNSET
playing_date: str | Unset = UNSET playing_date: str | Unset = UNSET
@@ -78,9 +78,13 @@ class Match:
points_are_positions = self.points_are_positions points_are_positions = self.points_are_positions
multiround_modes: str | Unset = UNSET multiround_modes: None | str | Unset
if not isinstance(self.multiround_modes, Unset): if isinstance(self.multiround_modes, Unset):
multiround_modes = UNSET
elif isinstance(self.multiround_modes, MatchMultiroundModes):
multiround_modes = self.multiround_modes.value multiround_modes = self.multiround_modes.value
else:
multiround_modes = self.multiround_modes
multiround_modes_display: None | str | Unset multiround_modes_display: None | str | Unset
if isinstance(self.multiround_modes_display, Unset): if isinstance(self.multiround_modes_display, Unset):
@@ -172,12 +176,24 @@ class Match:
points_are_positions = d.pop("points_are_positions", UNSET) points_are_positions = d.pop("points_are_positions", UNSET)
_multiround_modes = d.pop("multiround_modes", UNSET) def _parse_multiround_modes(
multiround_modes: MatchMultiroundModes | Unset data: object,
if isinstance(_multiround_modes, Unset): ) -> MatchMultiroundModes | None | Unset:
multiround_modes = UNSET if data is None:
else: return data
multiround_modes = MatchMultiroundModes(_multiround_modes) if isinstance(data, Unset):
return data
try:
if not isinstance(data, str):
raise TypeError()
multiround_modes_type_1 = MatchMultiroundModes(data)
return multiround_modes_type_1
except (TypeError, ValueError, AttributeError, KeyError):
pass
return cast(MatchMultiroundModes | None | Unset, data)
multiround_modes = _parse_multiround_modes(d.pop("multiround_modes", UNSET))
def _parse_multiround_modes_display(data: object) -> None | str | Unset: def _parse_multiround_modes_display(data: object) -> None | str | Unset:
if data is None: if data is None:
@@ -125,6 +125,10 @@ components:
items: items:
$ref: "#/components/schemas/MatchRoundResult" $ref: "#/components/schemas/MatchRoundResult"
MatchMultiroundModes:
type: string
enum: [PA, PS, AP]
Match: Match:
type: object type: object
properties: properties:
@@ -146,9 +150,9 @@ components:
points_are_positions: points_are_positions:
type: boolean type: boolean
multiround_modes: multiround_modes:
type: string allOf:
- $ref: "#/components/schemas/MatchMultiroundModes"
nullable: true nullable: true
enum: [PA, PS, AP]
multiround_modes_display: multiround_modes_display:
type: string type: string
nullable: true nullable: true
@@ -14,11 +14,13 @@ from games.models import EventGame
from ..models import Scope from ..models import Scope
from .aandt_client import AuthenticatedClient from .aandt_client import AuthenticatedClient
from .aandt_client.api.default import ( from .aandt_client.api.default import (
aandt_api_infos,
aandt_api_match_messages_set, aandt_api_match_messages_set,
aandt_api_matches, aandt_api_matches,
aandt_api_members, aandt_api_members,
) )
from .aandt_client.models import ( from .aandt_client.models import (
AandtApiInfosResponse200,
AandtApiMatchesResponse200, AandtApiMatchesResponse200,
AandtApiMatchMessagesSetBody, AandtApiMatchMessagesSetBody,
AandtApiMembersResponse200, AandtApiMembersResponse200,
@@ -33,6 +35,11 @@ from .aandt_client.types import Unset
# uvx openapi-python-client generate --path aandt_openapi.yaml --meta none \ # uvx openapi-python-client generate --path aandt_openapi.yaml --meta none \
# --output-path aandt_client --overwrite # --output-path aandt_client --overwrite
# AAndTInfosStatus value for "matches in progress" (see the / endpoint). Compared
# as a plain string rather than the generated enum, whose member names
# (VALUE_0, VALUE_1, ...) aren't stable across regenerations.
AANDT_STATUS_IN_PROGRESS = "7inprog"
class ScopeSyncError(Exception): class ScopeSyncError(Exception):
"""Raised when an EventGame cannot be synced against the AAndT API.""" """Raised when an EventGame cannot be synced against the AAndT API."""
@@ -43,6 +50,7 @@ class ScopeSyncResult:
created: int = 0 created: int = 0
updated: int = 0 updated: int = 0
warnings: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list)
status: str | None = None
_T = TypeVar("_T") _T = TypeVar("_T")
@@ -109,11 +117,16 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
submission page's URL pushed back as the match's player-facing message, so submission page's URL pushed back as the match's player-facing message, so
participants browsing the legacy site get a direct link here. participants browsing the legacy site get a direct link here.
Scopes whose match is no longer returned by the API (deleted, or from a
previous AAndT run reusing the same token) are deactivated.
Args: Args:
event_game: The EventGame to sync; must have `apibot_token` set. event_game: The EventGame to sync; must have `apibot_token` set.
Returns: Returns:
A summary of created/updated scopes and any unresolved participants. A summary of created/updated scopes and any unresolved participants,
plus the AAndT's current status (see `AANDT_STATUS_IN_PROGRESS`), so
callers can decide whether to keep polling.
Raises: Raises:
ScopeSyncError: When the EventGame has no token, or the API call fails. ScopeSyncError: When the EventGame has no token, or the API call fails.
@@ -124,6 +137,7 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
client = _client(event_game) client = _client(event_game)
try: try:
infos_response = aandt_api_infos.sync_detailed(client=client)
members_response = aandt_api_members.sync_detailed(client=client) members_response = aandt_api_members.sync_detailed(client=client)
matches_response = aandt_api_matches.sync_detailed(client=client) matches_response = aandt_api_matches.sync_detailed(client=client)
except (httpx.HTTPError, ValueError) as exc: except (httpx.HTTPError, ValueError) as exc:
@@ -134,32 +148,52 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
# resync this copy and regenerate. # resync this copy and regenerate.
raise ScopeSyncError(f"Failed to reach AAndT API: {exc}") from exc raise ScopeSyncError(f"Failed to reach AAndT API: {exc}") from exc
infos_parsed = infos_response.parsed
members_parsed = members_response.parsed members_parsed = members_response.parsed
matches_parsed = matches_response.parsed matches_parsed = matches_response.parsed
if not isinstance(members_parsed, AandtApiMembersResponse200) or not isinstance( if (
matches_parsed, AandtApiMatchesResponse200 not isinstance(infos_parsed, AandtApiInfosResponse200)
or not isinstance(members_parsed, AandtApiMembersResponse200)
or not isinstance(matches_parsed, AandtApiMatchesResponse200)
): ):
raise ScopeSyncError( raise ScopeSyncError(
"AAndT API returned " "AAndT API returned "
f"{members_response.status_code.value}/{matches_response.status_code.value}" f"{infos_response.status_code.value}/{members_response.status_code.value}"
f"/{matches_response.status_code.value}"
) )
aandt_infos = _unwrap(infos_parsed.aandt)
status = _unwrap(aandt_infos.status) if aandt_infos is not None else None
members = _unwrap(members_parsed.members, []) members = _unwrap(members_parsed.members, [])
matches = _unwrap(matches_parsed.matches, []) matches = _unwrap(matches_parsed.matches, [])
userids_by_member_id = _userids_by_member_id(members) userids_by_member_id = _userids_by_member_id(members)
result = ScopeSyncResult() result = ScopeSyncResult(status=status.value if status is not None else None)
now = timezone.now() now = timezone.now()
synced_match_ids: set[int] = set()
for match in matches: for match in matches:
created, warnings = _sync_match(event_game, client, match, userids_by_member_id, now) match_id = _unwrap(match.id)
created, warnings = _sync_match(
event_game, client, match, userids_by_member_id, now
)
if created is None: if created is None:
continue continue
synced_match_ids.add(match_id)
result.created += created result.created += created
result.updated += not created result.updated += not created
result.warnings.extend(warnings) result.warnings.extend(warnings)
(
Scope.objects.filter(
event_game=event_game, is_active=True, external_id__isnull=False
)
.exclude(external_id__in=synced_match_ids)
.update(is_active=False)
)
return result return result
@@ -210,11 +244,13 @@ def _sync_match(
return created, warnings return created, warnings
def _push_submission_link(client: AuthenticatedClient, match_id: int, scope: Scope) -> list[str]: def _push_submission_link(
client: AuthenticatedClient, match_id: int, scope: Scope
) -> list[str]:
body = AandtApiMatchMessagesSetBody( body = AandtApiMatchMessagesSetBody(
players=( players=(
"<h3>Submit your result</h3>" "<h2>Submit your result</h2>"
f'<a href="{_submission_url(scope)}" target="_blank" rel="noopener">' f'<a href="{_submission_url(scope)}" target="_blank" rel="noopener" class="btn btn-info">'
"Submit your proof here</a>" "Submit your proof here</a>"
), ),
) )
+23 -2
View File
@@ -1,11 +1,20 @@
from datetime import timedelta
from django.tasks import task from django.tasks import task
from django.utils import timezone
from games.models import EventGame from games.models import EventGame
from submissions.models import ImageCrop, SubmissionFile, OCRStatus from submissions.models import ImageCrop, SubmissionFile, OCRStatus
from submissions.services.aandt_sync import sync_event_game_scopes from submissions.services.aandt_sync import (
AANDT_STATUS_IN_PROGRESS,
sync_event_game_scopes,
)
from PIL import Image from PIL import Image
import pytesseract import pytesseract
# How often to repoll an EventGame's AAndT while it's still "in progress".
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool: def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
if obj.ocr_status != OCRStatus.TO_PROCESS: if obj.ocr_status != OCRStatus.TO_PROCESS:
@@ -86,9 +95,21 @@ def sync_all_event_games_task():
@task @task
def sync_event_game_scopes_task(event_game_id: int): def sync_event_game_scopes_task(event_game_id: int):
"""Sync an EventGame's Scopes, repolling every minute while matches are in progress.
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.
"""
event_game = EventGame.objects.filter(id=event_game_id).first() event_game = EventGame.objects.filter(id=event_game_id).first()
if not event_game: if not event_game:
return return
sync_event_game_scopes(event_game) result = sync_event_game_scopes(event_game)
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)
+6 -1
View File
@@ -14,12 +14,17 @@ def submission_home(request: HttpRequest, event_path: str, game_path: str):
game = get_object_or_404(Game, path=game_path) game = get_object_or_404(Game, path=game_path)
scopes = ( scopes = (
Scope.objects.filter(event_game__event=event, event_game__game=game) Scope.objects.filter(
event_game__event=event,
event_game__game=game,
is_active=True,
)
if request.user.is_superuser if request.user.is_superuser
else Scope.objects.filter( else Scope.objects.filter(
event_game__event=event, event_game__event=event,
event_game__game=game, event_game__game=game,
assigned_users=request.user, assigned_users=request.user,
is_active=True,
) )
) )