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_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): """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) _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. Args: event_game: The EventGame to sync; must have `apibot_token` set. Returns: A summary of created/updated scopes and any unresolved participants. 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: 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: 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 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=( "