fix: polylan sync
This commit is contained in:
@@ -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>"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user