feat(submissions): scoped submissions per user/matchs, sync from polylan
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import requests
|
||||
|
||||
|
||||
class AAndTAPIError(Exception):
|
||||
"""Raised when the legacy PolyLAN AAndT API is unreachable or errors out."""
|
||||
|
||||
|
||||
class AAndTClient:
|
||||
"""Thin wrapper around the legacy PolyLAN site's per-AAndT JWT-bearer API."""
|
||||
|
||||
def __init__(self, base_url: str, token: str, /, *, timeout: int = 15) -> None:
|
||||
self.timeout = timeout
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
def get_members(self) -> list[dict]:
|
||||
return self._get("members")["members"]
|
||||
|
||||
def get_matches(self) -> list[dict]:
|
||||
return self._get("matches")["matches"]
|
||||
|
||||
def set_match_message(
|
||||
self,
|
||||
match_id: int,
|
||||
/,
|
||||
*,
|
||||
players: str | None = None,
|
||||
admin: str | None = None,
|
||||
msg_all: str | None = None,
|
||||
) -> None:
|
||||
body = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"players": players,
|
||||
"admin": admin,
|
||||
"all": msg_all,
|
||||
}.items()
|
||||
if value is not None
|
||||
}
|
||||
self._post(f"match/{match_id}/messages", body)
|
||||
|
||||
def _get(self, path: str) -> dict:
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/Plamingo/aandt/api/aandt/{path}", timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise AAndTAPIError(f"Failed to reach AAndT API ({path}): {exc}") from exc
|
||||
|
||||
return response.json()
|
||||
|
||||
def _post(self, path: str, data: dict) -> dict:
|
||||
try:
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/Plamingo/aandt/api/aandt/{path}",
|
||||
json=data,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise AAndTAPIError(f"Failed to reach AAndT API ({path}): {exc}") from exc
|
||||
|
||||
return response.json()
|
||||
@@ -0,0 +1,131 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
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 AAndTAPIError, AAndTClient
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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 _userid_by_members(members: list[dict]) -> dict[int, set[int]]:
|
||||
userids_by_members: 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
|
||||
|
||||
return userids_by_members
|
||||
|
||||
|
||||
@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 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.
|
||||
|
||||
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 = AAndTClient(settings.AANDT_API_BASE_URL, event_game.apibot_token)
|
||||
|
||||
try:
|
||||
userids_by_members = _userid_by_members(client.get_members())
|
||||
matches = client.get_matches()
|
||||
except AAndTAPIError as exc:
|
||||
raise ScopeSyncError(str(exc)) from exc
|
||||
|
||||
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)
|
||||
|
||||
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})"
|
||||
)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user