refactor: add polylan-aandt generated client
This commit is contained in:
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user