refactor: add polylan-aandt generated client

This commit is contained in:
2026-09-16 15:36:51 +02:00
parent fabf06e6d4
commit 9ec9a54649
73 changed files with 7000 additions and 125 deletions
@@ -1,5 +1,8 @@
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
@@ -9,7 +12,26 @@ from accounts.models import CustomUser
from games.models import EventGame
from ..models import Scope
from .aandt_client import AAndTAPIError, AAndTClient
from .aandt_client import AuthenticatedClient
from .aandt_client.api.default import (
aandt_api_match_messages_set,
aandt_api_matches,
aandt_api_members,
)
from .aandt_client.models import (
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
class ScopeSyncError(Exception):
@@ -23,6 +45,21 @@ class ScopeSyncResult:
warnings: list[str] = field(default_factory=list)
_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",
@@ -35,17 +72,31 @@ def _submission_url(scope: Scope, /) -> str:
return f"{settings.SUBMITTER_BASE_URL.rstrip('/')}{path}"
def _userid_by_members(members: list[dict]) -> dict[int, set[int]]:
userids_by_members: dict[int, set[int]] = {}
def _userids_by_member_id(members: list[Member]) -> dict[int, set[int]]:
userids_by_member_id: dict[int, set[int]] = {}
for member in members:
if "team" in member:
userids = {u["id"] for u in member["team"]["members"]}
else:
userids = {member["user"]["id"]}
userids_by_members[member["id"]] = userids
member_id = _unwrap(member.id)
if member_id is None:
continue
return userids_by_members
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
@@ -54,9 +105,9 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
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 username. 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.
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.
Args:
event_game: The EventGame to sync; must have `apibot_token` set.
@@ -70,62 +121,115 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
if not event_game.apibot_token:
raise ScopeSyncError(f"{event_game} has no AAndT token configured")
client = AAndTClient(settings.AANDT_API_BASE_URL, event_game.apibot_token)
client = _client(event_game)
try:
userids_by_members = _userid_by_members(client.get_members())
matches = client.get_matches()
except AAndTAPIError as exc:
raise ScopeSyncError(str(exc)) from exc
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
members_parsed = members_response.parsed
matches_parsed = matches_response.parsed
if not isinstance(members_parsed, AandtApiMembersResponse200) or not isinstance(
matches_parsed, AandtApiMatchesResponse200
):
raise ScopeSyncError(
"AAndT API returned "
f"{members_response.status_code.value}/{matches_response.status_code.value}"
)
members = _unwrap(members_parsed.members, [])
matches = _unwrap(matches_parsed.matches, [])
userids_by_member_id = _userids_by_member_id(members)
result = ScopeSyncResult()
now = timezone.now()
for match in matches:
userids: set[int] = set()
for member_id in match.get("players", {}):
userids.update(userids_by_members.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": match.get("title") or f"Match {match['id']}",
"is_active": match.get("status") == "playing",
"synced_at": now,
},
)
scope.assigned_users.set(users)
created, warnings = _sync_match(event_game, client, match, userids_by_member_id, now)
if created is None:
continue
result.created += created
result.updated += not created
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:
result.warnings.append(
f"Match {match['id']}: no local account for {', '.join(sorted(map(str, missing)))}"
)
if scope.is_active:
try:
client.set_match_message(
match["id"],
players=(
"<h3>Submit your result</h3>"
f'<a href="{_submission_url(scope)}" target="_blank" rel="noopener">'
"Submit your proof here</a>"
),
)
except AAndTAPIError as exc:
result.warnings.append(
f"Match {match['id']}: failed to push submission link ({exc})"
)
result.warnings.extend(warnings)
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=(
"<h3>Submit your result</h3>"
f'<a href="{_submission_url(scope)}" target="_blank" rel="noopener">'
"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 []