Files

303 lines
10 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 ..signals import broadcast_scope_updates
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.
Users of scopes that opened/closed or got their players changed are
notified over the submissions websocket (see `broadcast_scope_updates`).
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()
changes: dict[Scope, set[int]] = {}
for match in matches:
match_id = _unwrap(match.id)
created, warnings, change = _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)
if change is not None:
changes[change[0]] = change[1]
gone = list(
Scope.objects.filter(
event_game=event_game, is_active=True, external_id__isnull=False
)
.exclude(external_id__in=synced_match_ids)
.prefetch_related("assigned_users")
)
Scope.objects.filter(id__in=[scope.id for scope in gone]).update(is_active=False)
for scope in gone:
scope.is_active = False
changes[scope] = {user.id for user in scope.assigned_users.all()}
broadcast_scope_updates(changes)
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], tuple[Scope, set[int]] | None]:
"""Upsert the Scope of one match.
Returns:
Whether the Scope was created (None when the match has no id), the
warnings, and when it opened/closed or its players changed, the Scope
with every user id that was or now is assigned to it.
"""
match_id = _unwrap(match.id)
if match_id is None:
return None, [], 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))
previous = Scope.objects.filter(event_game=event_game, external_id=match_id).first()
was_active = previous.is_active if previous else False
previous_user_ids = (
set(previous.assigned_users.values_list("id", flat=True)) if previous else set()
)
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))
user_ids = {user.id for user in users}
change = None
if scope.is_active != was_active or user_ids != previous_user_ids:
change = (scope, user_ids | previous_user_ids)
return created, warnings, change
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 []