272 lines
8.6 KiB
Python
272 lines
8.6 KiB
Python
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import Any, TypeVar
|
|
|
|
import httpx
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from accounts.models import CustomUser
|
|
from games.models import EventGame
|
|
|
|
from ..models import Scope
|
|
from .aandt_client import AuthenticatedClient
|
|
from .aandt_client.api.default import (
|
|
aandt_api_infos,
|
|
aandt_api_match_messages_set,
|
|
aandt_api_matches,
|
|
aandt_api_members,
|
|
)
|
|
from .aandt_client.models import (
|
|
AandtApiInfosResponse200,
|
|
AandtApiMatchesResponse200,
|
|
AandtApiMatchMessagesSetBody,
|
|
AandtApiMembersResponse200,
|
|
Match,
|
|
Member,
|
|
MatchStatus,
|
|
)
|
|
from .aandt_client.types import Unset
|
|
|
|
# Generated from ../aandt_openapi.yaml (a hand-maintained snapshot of the legacy
|
|
# PolyLAN site's AAndT bot API). Regenerate after the spec changes with:
|
|
# uvx openapi-python-client generate --path aandt_openapi.yaml --meta none \
|
|
# --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):
|
|
"""Raised when an EventGame cannot be synced against the AAndT API."""
|
|
|
|
|
|
@dataclass
|
|
class ScopeSyncResult:
|
|
created: int = 0
|
|
updated: int = 0
|
|
warnings: list[str] = field(default_factory=list)
|
|
status: str | None = None
|
|
|
|
|
|
_T = TypeVar("_T")
|
|
|
|
|
|
def _unwrap(value: "_T | Unset", default: Any = None) -> "_T | Any":
|
|
return default if isinstance(value, Unset) else value
|
|
|
|
|
|
def _client(event_game: EventGame, /) -> AuthenticatedClient:
|
|
base_url = settings.AANDT_API_BASE_URL.rstrip("/")
|
|
return AuthenticatedClient(
|
|
base_url=f"{base_url}/Plamingo/aandt/api/aandt",
|
|
token=event_game.apibot_token,
|
|
)
|
|
|
|
|
|
def _submission_url(scope: Scope, /) -> str:
|
|
path = reverse(
|
|
"submissions.view",
|
|
kwargs={
|
|
"event_path": scope.event_game.event.path,
|
|
"game_path": scope.event_game.game.path,
|
|
"scope_id": scope.id,
|
|
},
|
|
)
|
|
return f"{settings.SUBMITTER_BASE_URL.rstrip('/')}{path}"
|
|
|
|
|
|
def _userids_by_member_id(members: list[Member]) -> dict[int, set[int]]:
|
|
userids_by_member_id: dict[int, set[int]] = {}
|
|
|
|
for member in members:
|
|
member_id = _unwrap(member.id)
|
|
if member_id is None:
|
|
continue
|
|
|
|
userids_by_member_id[int(member_id)] = _userids_for_member(member)
|
|
|
|
return userids_by_member_id
|
|
|
|
|
|
def _userids_for_member(member: Member) -> set[int]:
|
|
team = _unwrap(member.team)
|
|
if team is not None:
|
|
return {
|
|
int(u.id) for u in _unwrap(team.members, []) if not isinstance(u.id, Unset)
|
|
}
|
|
|
|
user = _unwrap(member.user)
|
|
if user is not None and not isinstance(user.id, Unset):
|
|
return {int(user.id)}
|
|
|
|
return set()
|
|
|
|
|
|
@transaction.atomic
|
|
def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
|
|
"""Fetch matches and members from the legacy AAndT API and upsert local Scopes.
|
|
|
|
Each remote match becomes one Scope (matched on `external_id`), with
|
|
`assigned_users` set to every local account tied to either side of the
|
|
match, resolved by legacy user id (`cas_user_id`). Active scopes get their
|
|
submission page's URL pushed back as the match's player-facing message, so
|
|
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:
|
|
event_game: The EventGame to sync; must have `apibot_token` set.
|
|
|
|
Returns:
|
|
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:
|
|
ScopeSyncError: When the EventGame has no token, or the API call fails.
|
|
"""
|
|
if not event_game.apibot_token:
|
|
raise ScopeSyncError(f"{event_game} has no AAndT token configured")
|
|
|
|
client = _client(event_game)
|
|
|
|
try:
|
|
infos_response = aandt_api_infos.sync_detailed(client=client)
|
|
members_response = aandt_api_members.sync_detailed(client=client)
|
|
matches_response = aandt_api_matches.sync_detailed(client=client)
|
|
except (httpx.HTTPError, ValueError) as exc:
|
|
# ValueError: the spec's 401 response is mistakenly typed `text/json` with an
|
|
# object schema, so the generated client tries `dict(response.text)` on an
|
|
# actual 401 and blows up instead of returning a parsed error. Fix upstream in
|
|
# aandt_openapi.yaml (Unauthorized response) -> `application/json`, then
|
|
# resync this copy and regenerate.
|
|
raise ScopeSyncError(f"Failed to reach AAndT API: {exc}") from exc
|
|
|
|
infos_parsed = infos_response.parsed
|
|
members_parsed = members_response.parsed
|
|
matches_parsed = matches_response.parsed
|
|
|
|
if (
|
|
not isinstance(infos_parsed, AandtApiInfosResponse200)
|
|
or not isinstance(members_parsed, AandtApiMembersResponse200)
|
|
or not isinstance(matches_parsed, AandtApiMatchesResponse200)
|
|
):
|
|
raise ScopeSyncError(
|
|
"AAndT API returned "
|
|
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, [])
|
|
matches = _unwrap(matches_parsed.matches, [])
|
|
|
|
userids_by_member_id = _userids_by_member_id(members)
|
|
result = ScopeSyncResult(status=status.value if status is not None else None)
|
|
now = timezone.now()
|
|
synced_match_ids: set[int] = set()
|
|
|
|
for match in matches:
|
|
match_id = _unwrap(match.id)
|
|
created, warnings = _sync_match(
|
|
event_game, client, match, userids_by_member_id, now
|
|
)
|
|
if created is None:
|
|
continue
|
|
synced_match_ids.add(match_id)
|
|
result.created += created
|
|
result.updated += not created
|
|
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
|
|
|
|
|
|
def _sync_match(
|
|
event_game: EventGame,
|
|
client: AuthenticatedClient,
|
|
match: Match,
|
|
userids_by_member_id: dict[int, set[int]],
|
|
now: datetime,
|
|
) -> tuple[bool | None, list[str]]:
|
|
match_id = _unwrap(match.id)
|
|
if match_id is None:
|
|
return None, []
|
|
|
|
warnings: list[str] = []
|
|
|
|
userids: set[int] = set()
|
|
players = _unwrap(match.players)
|
|
if players is not None:
|
|
for member_id in players.additional_properties:
|
|
userids.update(userids_by_member_id.get(int(member_id), set()))
|
|
|
|
users = list(CustomUser.objects.filter(cas_user_id__in=userids))
|
|
|
|
scope, created = Scope.objects.update_or_create(
|
|
event_game=event_game,
|
|
external_id=match_id,
|
|
defaults={
|
|
"name": _unwrap(match.title) or f"Match {match_id}",
|
|
"is_active": _unwrap(match.status) == MatchStatus.PLAYING,
|
|
"synced_at": now,
|
|
},
|
|
)
|
|
scope.assigned_users.set(users)
|
|
|
|
matched_ids = {
|
|
int(u.cas_user_id) for u in users if u.cas_user_id and u.cas_user_id.isdigit()
|
|
}
|
|
missing = userids - matched_ids
|
|
if missing:
|
|
warnings.append(
|
|
f"Match {match_id}: no local account for {', '.join(sorted(map(str, missing)))}"
|
|
)
|
|
|
|
if scope.is_active:
|
|
warnings.extend(_push_submission_link(client, match_id, scope))
|
|
|
|
return created, warnings
|
|
|
|
|
|
def _push_submission_link(
|
|
client: AuthenticatedClient, match_id: int, scope: Scope
|
|
) -> list[str]:
|
|
body = AandtApiMatchMessagesSetBody(
|
|
players=(
|
|
"<h2>Submit your result</h2>"
|
|
f'<a href="{_submission_url(scope)}" target="_blank" rel="noopener" class="btn btn-info">'
|
|
"Submit your proof here</a>"
|
|
),
|
|
)
|
|
|
|
try:
|
|
response = aandt_api_match_messages_set.sync_detailed(
|
|
pk=match_id, client=client, body=body
|
|
)
|
|
except (httpx.HTTPError, ValueError) as exc:
|
|
return [f"Match {match_id}: failed to push submission link ({exc})"]
|
|
|
|
if response.status_code != 200:
|
|
return [
|
|
f"Match {match_id}: failed to push submission link "
|
|
f"(HTTP {response.status_code.value})"
|
|
]
|
|
|
|
return []
|