Coverage for api_client/client.py: 20.11%
606 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 13:38 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-15 13:38 +0000
1"""
2Type-safe API client for Missing Table backend.
4Provides a comprehensive client with:
5- Full type safety via Pydantic models
6- Automatic authentication handling
7- Retry logic with exponential backoff
8- Request/response logging
9- Error handling
10"""
12import logging
13from typing import Any, TypeVar
14from urllib.parse import urljoin
16import httpx
17from pydantic import BaseModel
19from .exceptions import (
20 APIError,
21 AuthenticationError,
22 AuthorizationError,
23 NotFoundError,
24 RateLimitError,
25 ServerError,
26 ValidationError,
27)
28from .models import (
29 AdminPlayerTeamAssignment,
30 AdminPlayerUpdate,
31 AdvanceWinnerRequest,
32 AgeGroupCreate,
33 AgeGroupUpdate,
34 BulkRenumberRequest,
35 BulkRosterCreate,
36 ChannelAccessRequestCreate,
37 ChannelAccessStatusUpdate,
38 DivisionCreate,
39 DivisionUpdate,
40 EnhancedGame,
41 GamePatch,
42 GenerateBracketRequest,
43 GoalEvent,
44 InviteRequestCreate,
45 InviteRequestStatusUpdate,
46 JerseyNumberUpdate,
47 LineupSave,
48 LiveMatchClock,
49 MatchSubmissionData,
50 MessageEvent,
51 PlayerCustomization,
52 PlayerHistoryCreate,
53 PlayerHistoryUpdate,
54 RefreshTokenRequest,
55 RoleUpdate,
56 RosterPlayerCreate,
57 RosterPlayerUpdate,
58 SeasonCreate,
59 SeasonUpdate,
60 Team,
61 UserProfileUpdate,
62)
64T = TypeVar("T", bound=BaseModel)
66logger = logging.getLogger(__name__)
69class MissingTableClient:
70 """Type-safe API client for Missing Table backend."""
72 def __init__(
73 self,
74 base_url: str = "http://localhost:8000",
75 access_token: str | None = None,
76 timeout: float = 30.0,
77 max_retries: int = 3,
78 ):
79 """
80 Initialize the API client.
82 Args:
83 base_url: Base URL of the API
84 access_token: JWT access token for authentication
85 timeout: Request timeout in seconds
86 max_retries: Maximum number of retry attempts
87 """
88 self.base_url = base_url.rstrip("/")
89 self._access_token = access_token
90 self._refresh_token: str | None = None
91 self.timeout = timeout
92 self.max_retries = max_retries
94 self._client = httpx.Client(
95 timeout=timeout,
96 follow_redirects=True,
97 )
99 def __enter__(self) -> "MissingTableClient":
100 """Context manager entry."""
101 return self
103 def __exit__(self, *args: Any) -> None:
104 """Context manager exit."""
105 self.close()
107 def close(self) -> None:
108 """Close the HTTP client."""
109 self._client.close()
111 def _get_headers(self) -> dict[str, str]:
112 """Get request headers with authentication."""
113 headers = {"Content-Type": "application/json"}
114 if self._access_token:
115 headers["Authorization"] = f"Bearer {self._access_token}"
116 return headers
118 def _handle_response_error(self, response: httpx.Response) -> None:
119 """Handle HTTP error responses."""
120 try:
121 error_data = response.json()
122 except Exception:
123 error_data = {"detail": response.text}
125 error_message = error_data.get("detail", f"HTTP {response.status_code}")
127 if response.status_code == 401:
128 raise AuthenticationError(error_message, response.status_code, error_data)
129 elif response.status_code == 403:
130 raise AuthorizationError(error_message, response.status_code, error_data)
131 elif response.status_code == 404:
132 raise NotFoundError(error_message, response.status_code, error_data)
133 elif response.status_code == 422:
134 raise ValidationError(error_message, response.status_code, error_data)
135 elif response.status_code == 429:
136 raise RateLimitError(error_message, response.status_code, error_data)
137 elif response.status_code >= 500:
138 raise ServerError(error_message, response.status_code, error_data)
139 else:
140 raise APIError(error_message, response.status_code, error_data)
142 def _request(
143 self,
144 method: str,
145 path: str,
146 json_data: dict[str, Any] | None = None,
147 params: dict[str, Any] | None = None,
148 ) -> httpx.Response:
149 """Make an HTTP request with error handling."""
150 url = urljoin(self.base_url, path)
151 headers = self._get_headers()
153 logger.debug(f"{method} {url}")
154 if json_data:
155 logger.debug(f"Request body: {json_data}")
157 response = self._client.request(
158 method=method,
159 url=url,
160 headers=headers,
161 json=json_data,
162 params=params,
163 )
165 if not response.is_success:
166 self._handle_response_error(response)
168 return response
170 def _request_multipart(
171 self,
172 method: str,
173 path: str,
174 files: dict[str, Any] | None = None,
175 data: dict[str, Any] | None = None,
176 params: dict[str, Any] | None = None,
177 ) -> httpx.Response:
178 """Make a multipart/form-data request (for file uploads)."""
179 url = urljoin(self.base_url, path)
180 # Omit Content-Type header; httpx sets multipart boundary automatically
181 headers = {}
182 if self._access_token:
183 headers["Authorization"] = f"Bearer {self._access_token}"
185 logger.debug(f"{method} {url} (multipart)")
187 response = self._client.request(
188 method=method,
189 url=url,
190 headers=headers,
191 files=files,
192 data=data,
193 params=params,
194 )
196 if not response.is_success:
197 self._handle_response_error(response)
199 return response
201 # Authentication endpoints
203 def login(self, username: str, password: str) -> dict[str, Any]:
204 """
205 Login with username and password.
207 Args:
208 username: Username (primary identifier)
209 password: User password
211 Returns:
212 Dict with access_token, refresh_token, and user info
213 """
214 response = self._request(
215 "POST",
216 "/api/auth/login",
217 json_data={"username": username, "password": password},
218 )
219 data = response.json()
221 # Handle nested session structure from API
222 if "session" in data:
223 session = data["session"]
224 self._access_token = session.get("access_token")
225 self._refresh_token = session.get("refresh_token")
226 # Return flattened structure for backward compatibility
227 return {
228 "access_token": self._access_token,
229 "refresh_token": self._refresh_token,
230 "token_type": session.get("token_type", "bearer"),
231 "user": data.get("user"),
232 "message": data.get("message"),
233 }
234 else:
235 # Fallback for flat structure
236 self._access_token = data.get("access_token")
237 self._refresh_token = data.get("refresh_token")
238 return data
240 def signup(
241 self,
242 username: str,
243 password: str,
244 display_name: str | None = None,
245 email: str | None = None,
246 invite_code: str | None = None,
247 ) -> dict[str, Any]:
248 """
249 Sign up a new user with username authentication.
251 Args:
252 username: Username (required, primary identifier)
253 password: User password
254 display_name: Optional display name
255 email: Optional email for notifications
256 invite_code: Optional invite code
258 Returns:
259 Dict with user info (may include access_token depending on API configuration)
260 """
261 payload = {"username": username, "password": password}
262 if display_name:
263 payload["display_name"] = display_name
264 if email:
265 payload["email"] = email
266 if invite_code:
267 payload["invite_code"] = invite_code
269 response = self._request("POST", "/api/auth/signup", json_data=payload)
270 data = response.json()
272 # Handle nested session structure if tokens are returned
273 if "session" in data:
274 session = data["session"]
275 self._access_token = session.get("access_token")
276 self._refresh_token = session.get("refresh_token")
278 return data
280 def logout(self) -> dict[str, Any]:
281 """Logout the current user."""
282 response = self._request("POST", "/api/auth/logout")
283 self._access_token = None
284 self._refresh_token = None
285 return response.json()
287 def refresh_access_token(self) -> dict[str, Any]:
288 """Refresh the access token using refresh token."""
289 if not self._refresh_token:
290 raise AuthenticationError("No refresh token available")
292 model = RefreshTokenRequest(refresh_token=self._refresh_token)
293 response = self._request("POST", "/api/auth/refresh", json_data=model.model_dump())
294 data = response.json()
296 # Handle nested session structure
297 if "session" in data:
298 session = data["session"]
299 self._access_token = session.get("access_token")
300 self._refresh_token = session.get("refresh_token")
301 # Return flattened structure for backward compatibility
302 return {
303 "access_token": self._access_token,
304 "refresh_token": self._refresh_token,
305 "token_type": session.get("token_type", "bearer"),
306 }
307 else:
308 # Fallback for flat structure
309 self._access_token = data.get("access_token")
310 return data
312 def get_profile(self) -> dict[str, Any]:
313 """Get current user's profile."""
314 response = self._request("GET", "/api/auth/profile")
315 return response.json()
317 def update_profile(self, display_name: str | None = None, team_id: int | None = None) -> dict[str, Any]:
318 """Update current user's profile."""
319 payload = {}
320 if display_name is not None:
321 payload["display_name"] = display_name
322 if team_id is not None:
323 payload["team_id"] = team_id
325 response = self._request("PUT", "/api/auth/profile", json_data=payload)
326 return response.json()
328 # Teams endpoints
330 def get_teams(
331 self,
332 game_type_id: int | None = None,
333 age_group_id: int | None = None,
334 ) -> list[dict[str, Any]]:
335 """Get all teams with optional filters."""
336 params = {}
337 if game_type_id is not None:
338 params["game_type_id"] = game_type_id
339 if age_group_id is not None:
340 params["age_group_id"] = age_group_id
342 response = self._request("GET", "/api/teams", params=params)
343 return response.json()
345 def get_team(self, team_id: int) -> dict[str, Any]:
346 """Get a specific team by ID."""
347 response = self._request("GET", f"/api/teams/{team_id}")
348 return response.json()
350 def create_team(self, team: Team) -> dict[str, Any]:
351 """Create a new team."""
352 response = self._request("POST", "/api/teams", json_data=team.model_dump(exclude_none=True))
353 return response.json()
355 def update_team(self, team_id: int, team: Team) -> dict[str, Any]:
356 """Update a team."""
357 response = self._request("PUT", f"/api/teams/{team_id}", json_data=team.model_dump(exclude_none=True))
358 return response.json()
360 def delete_team(self, team_id: int) -> dict[str, Any]:
361 """Delete a team."""
362 response = self._request("DELETE", f"/api/teams/{team_id}")
363 return response.json()
365 # Games endpoints
367 def get_games(
368 self,
369 season_id: int | None = None,
370 age_group_id: int | None = None,
371 game_type_id: int | None = None,
372 match_type_id: int | None = None,
373 team_id: int | None = None,
374 limit: int | None = None,
375 upcoming: bool | None = None,
376 start_date: str | None = None,
377 end_date: str | None = None,
378 ) -> list[dict[str, Any]]:
379 """Get matches (games) with optional filters."""
380 params = {}
381 if season_id is not None:
382 params["season_id"] = season_id
383 if age_group_id is not None:
384 params["age_group_id"] = age_group_id
385 # Support both game_type_id (legacy) and match_type_id (current)
386 match_type = match_type_id if match_type_id is not None else game_type_id
387 if match_type is not None:
388 params["match_type_id"] = match_type
389 if team_id is not None:
390 params["team_id"] = team_id
391 if limit is not None:
392 params["limit"] = limit
393 if upcoming is not None:
394 params["upcoming"] = upcoming
395 if start_date is not None:
396 params["start_date"] = start_date
397 if end_date is not None:
398 params["end_date"] = end_date
400 response = self._request("GET", "/api/matches", params=params)
401 return response.json()
403 def get_game(self, game_id: int) -> dict[str, Any]:
404 """Get a specific match (game) by ID."""
405 response = self._request("GET", f"/api/matches/{game_id}")
406 return response.json()
408 def get_games_by_team(
409 self, team_id: int, season_id: int | None = None, age_group_id: int | None = None
410 ) -> list[dict[str, Any]]:
411 """Get all matches (games) for a specific team."""
412 params = {}
413 if season_id:
414 params["season_id"] = season_id
415 if age_group_id:
416 params["age_group_id"] = age_group_id
417 response = self._request("GET", f"/api/matches/team/{team_id}", params=params)
418 return response.json()
420 def create_game(self, game: EnhancedGame) -> dict[str, Any]:
421 """Create a new match (game)."""
422 # Convert game_type_id to match_type_id for API compatibility
423 game_data = game.model_dump(exclude_none=True)
424 if "game_type_id" in game_data:
425 game_data["match_type_id"] = game_data.pop("game_type_id")
426 response = self._request("POST", "/api/matches", json_data=game_data)
427 return response.json()
429 def update_game(self, game_id: int, game: EnhancedGame) -> dict[str, Any]:
430 """Update a match (game) - full update."""
431 # Convert game_type_id to match_type_id for API compatibility
432 game_data = game.model_dump(exclude_none=True)
433 if "game_type_id" in game_data:
434 game_data["match_type_id"] = game_data.pop("game_type_id")
435 response = self._request("PUT", f"/api/matches/{game_id}", json_data=game_data)
436 return response.json()
438 def patch_game(self, game_id: int, game_patch: GamePatch) -> dict[str, Any]:
439 """Partially update a match (game)."""
440 # Convert game_type_id to match_type_id for API compatibility
441 patch_data = game_patch.model_dump(exclude_none=True)
442 if "game_type_id" in patch_data:
443 patch_data["match_type_id"] = patch_data.pop("game_type_id")
444 response = self._request("PATCH", f"/api/matches/{game_id}", json_data=patch_data)
445 return response.json()
447 def delete_game(self, game_id: int) -> dict[str, Any]:
448 """Delete a match (game)."""
449 response = self._request("DELETE", f"/api/matches/{game_id}")
450 return response.json()
452 # Reference data endpoints
454 def get_age_groups(self) -> list[dict[str, Any]]:
455 """Get all age groups."""
456 response = self._request("GET", "/api/age-groups")
457 return response.json()
459 def create_age_group(self, age_group: AgeGroupCreate) -> dict[str, Any]:
460 """Create a new age group (admin only)."""
461 response = self._request("POST", "/api/age-groups", json_data=age_group.model_dump())
462 return response.json()
464 def update_age_group(self, age_group_id: int, age_group: AgeGroupUpdate) -> dict[str, Any]:
465 """Update an age group (admin only)."""
466 response = self._request("PUT", f"/api/age-groups/{age_group_id}", json_data=age_group.model_dump())
467 return response.json()
469 def delete_age_group(self, age_group_id: int) -> dict[str, Any]:
470 """Delete an age group (admin only)."""
471 response = self._request("DELETE", f"/api/age-groups/{age_group_id}")
472 return response.json()
474 def get_seasons(self) -> list[dict[str, Any]]:
475 """Get all seasons."""
476 response = self._request("GET", "/api/seasons")
477 return response.json()
479 def get_current_season(self) -> dict[str, Any]:
480 """Get the current season."""
481 response = self._request("GET", "/api/current-season")
482 return response.json()
484 def get_active_seasons(self) -> list[dict[str, Any]]:
485 """Get all active seasons."""
486 response = self._request("GET", "/api/active-seasons")
487 return response.json()
489 def create_season(self, season: SeasonCreate) -> dict[str, Any]:
490 """Create a new season (admin only)."""
491 response = self._request("POST", "/api/seasons", json_data=season.model_dump())
492 return response.json()
494 def update_season(self, season_id: int, season: SeasonUpdate) -> dict[str, Any]:
495 """Update a season (admin only)."""
496 response = self._request("PUT", f"/api/seasons/{season_id}", json_data=season.model_dump())
497 return response.json()
499 def delete_season(self, season_id: int) -> dict[str, Any]:
500 """Delete a season (admin only)."""
501 response = self._request("DELETE", f"/api/seasons/{season_id}")
502 return response.json()
504 def get_divisions(self) -> list[dict[str, Any]]:
505 """Get all divisions."""
506 response = self._request("GET", "/api/divisions")
507 return response.json()
509 def create_division(self, division: DivisionCreate) -> dict[str, Any]:
510 """Create a new division (admin only)."""
511 response = self._request("POST", "/api/divisions", json_data=division.model_dump(exclude_none=True))
512 return response.json()
514 def update_division(self, division_id: int, division: DivisionUpdate) -> dict[str, Any]:
515 """Update a division (admin only)."""
516 response = self._request(
517 "PUT", f"/api/divisions/{division_id}", json_data=division.model_dump(exclude_none=True)
518 )
519 return response.json()
521 def delete_division(self, division_id: int) -> dict[str, Any]:
522 """Delete a division (admin only)."""
523 response = self._request("DELETE", f"/api/divisions/{division_id}")
524 return response.json()
526 def get_game_types(self) -> list[dict[str, Any]]:
527 """Get all match types (game types)."""
528 response = self._request("GET", "/api/match-types")
529 return response.json()
531 def get_positions(self) -> list[dict[str, Any]]:
532 """Get all player positions."""
533 response = self._request("GET", "/api/positions")
534 return response.json()
536 # League table endpoints
538 def get_table(
539 self,
540 season_id: int | None = None,
541 age_group_id: int | None = None,
542 game_type_id: int | None = None,
543 division_id: int | None = None,
544 ) -> list[dict[str, Any]]:
545 """Get league standings table."""
546 params = {}
547 if season_id is not None:
548 params["season_id"] = season_id
549 if age_group_id is not None:
550 params["age_group_id"] = age_group_id
551 if game_type_id is not None:
552 params["game_type_id"] = game_type_id
553 if division_id is not None:
554 params["division_id"] = division_id
556 response = self._request("GET", "/api/table", params=params)
557 return response.json()
559 # Admin endpoints
561 def get_users(self) -> list[dict[str, Any]]:
562 """Get all users (admin only)."""
563 response = self._request("GET", "/api/auth/users")
564 return response.json()
566 def update_user_role(self, role_update: RoleUpdate) -> dict[str, Any]:
567 """Update a user's role (admin only)."""
568 response = self._request("PUT", "/api/auth/users/role", json_data=role_update.model_dump(exclude_none=True))
569 return response.json()
571 def delete_user(self, user_id: str) -> dict[str, Any]:
572 """Delete a user by ID (admin only)."""
573 response = self._request("DELETE", f"/api/auth/users/{user_id}")
574 return response.json()
576 # Utility endpoints
578 def health_check(self) -> dict[str, Any]:
579 """Check API health."""
580 response = self._request("GET", "/health")
581 return response.json()
583 def full_health_check(self) -> dict[str, Any]:
584 """Comprehensive health check."""
585 response = self._request("GET", "/health/full")
586 return response.json()
588 def get_csrf_token(self) -> str:
589 """Get CSRF token."""
590 response = self._request("GET", "/api/csrf-token")
591 return response.json()["csrf_token"]
593 # Club endpoints
595 def get_clubs(self, include_teams: bool = False) -> list[dict[str, Any]]:
596 """Get all clubs."""
597 params = {"include_teams": include_teams}
598 response = self._request("GET", "/api/clubs", params=params)
599 return response.json()
601 def get_club(self, club_id: int) -> dict[str, Any]:
602 """Get a specific club by ID."""
603 response = self._request("GET", f"/api/clubs/{club_id}")
604 return response.json()
606 def get_club_teams(self, club_id: int) -> list[dict[str, Any]]:
607 """Get all teams for a club."""
608 response = self._request("GET", f"/api/clubs/{club_id}/teams")
609 return response.json()
611 def create_club(self, name: str, city: str | None = None, description: str | None = None) -> dict[str, Any]:
612 """Create a new club (admin only)."""
613 payload = {"name": name}
614 if city:
615 payload["city"] = city
616 if description:
617 payload["description"] = description
618 response = self._request("POST", "/api/clubs", json_data=payload)
619 return response.json()
621 def update_club(
622 self,
623 club_id: int,
624 name: str | None = None,
625 city: str | None = None,
626 description: str | None = None,
627 ) -> dict[str, Any]:
628 """Update a club (admin only)."""
629 payload = {}
630 if name:
631 payload["name"] = name
632 if city:
633 payload["city"] = city
634 if description:
635 payload["description"] = description
636 response = self._request("PUT", f"/api/clubs/{club_id}", json_data=payload)
637 return response.json()
639 def delete_club(self, club_id: int) -> dict[str, Any]:
640 """Delete a club (admin only)."""
641 response = self._request("DELETE", f"/api/clubs/{club_id}")
642 return response.json()
644 # Invite endpoints
646 def validate_invite(self, invite_code: str) -> dict[str, Any]:
647 """Validate an invite code (public endpoint)."""
648 response = self._request("GET", f"/api/invites/validate/{invite_code}")
649 return response.json()
651 def get_my_invites(self, status: str | None = None) -> list[dict[str, Any]]:
652 """Get invites created by current user."""
653 params = {}
654 if status:
655 params["status"] = status
656 response = self._request("GET", "/api/invites/my-invites", params=params)
657 return response.json()
659 def cancel_invite(self, invite_id: str) -> dict[str, Any]:
660 """Cancel a pending invite."""
661 response = self._request("DELETE", f"/api/invites/{invite_id}")
662 return response.json()
664 # Admin invite creation endpoints
666 def create_club_manager_invite(self, club_id: int, email: str | None = None) -> dict[str, Any]:
667 """Create a club manager invite (admin only)."""
668 payload = {"club_id": club_id}
669 if email:
670 payload["email"] = email
671 response = self._request("POST", "/api/invites/admin/club-manager", json_data=payload)
672 return response.json()
674 def create_team_manager_invite(self, team_id: int, age_group_id: int, email: str | None = None) -> dict[str, Any]:
675 """Create a team manager invite (admin only)."""
676 payload = {
677 "invite_type": "team_manager",
678 "team_id": team_id,
679 "age_group_id": age_group_id,
680 }
681 if email:
682 payload["email"] = email
683 response = self._request("POST", "/api/invites/admin/team-manager", json_data=payload)
684 return response.json()
686 def create_team_player_invite_admin(
687 self, team_id: int, age_group_id: int, email: str | None = None
688 ) -> dict[str, Any]:
689 """Create a team player invite (admin only)."""
690 payload = {
691 "invite_type": "team_player",
692 "team_id": team_id,
693 "age_group_id": age_group_id,
694 }
695 if email:
696 payload["email"] = email
697 response = self._request("POST", "/api/invites/admin/team-player", json_data=payload)
698 return response.json()
700 def create_team_fan_invite_admin(self, team_id: int, age_group_id: int, email: str | None = None) -> dict[str, Any]:
701 """Create a team fan invite (admin only)."""
702 payload = {
703 "invite_type": "team_fan",
704 "team_id": team_id,
705 "age_group_id": age_group_id,
706 }
707 if email:
708 payload["email"] = email
709 response = self._request("POST", "/api/invites/admin/team-fan", json_data=payload)
710 return response.json()
712 def create_club_fan_invite_admin(self, club_id: int, email: str | None = None) -> dict[str, Any]:
713 """Create a club fan invite (admin only)."""
714 payload = {"club_id": club_id}
715 if email:
716 payload["email"] = email
717 response = self._request("POST", "/api/invites/admin/club-fan", json_data=payload)
718 return response.json()
720 # Club manager invite creation endpoints
722 def create_club_fan_invite(self, club_id: int, email: str | None = None) -> dict[str, Any]:
723 """Create a club fan invite (club manager or admin)."""
724 payload = {"club_id": club_id}
725 if email:
726 payload["email"] = email
727 response = self._request("POST", "/api/invites/club-manager/club-fan", json_data=payload)
728 return response.json()
730 # Team manager invite creation endpoints
732 def create_team_player_invite(
733 self, team_id: int, age_group_id: int, email: str | None = None, player_id: int | None = None
734 ) -> dict[str, Any]:
735 """Create a team player invite (team manager or admin)."""
736 payload: dict[str, Any] = {
737 "invite_type": "team_player",
738 "team_id": team_id,
739 "age_group_id": age_group_id,
740 }
741 if email:
742 payload["email"] = email
743 if player_id is not None:
744 payload["player_id"] = player_id
745 response = self._request("POST", "/api/invites/team-manager/team-player", json_data=payload)
746 return response.json()
748 def create_team_fan_invite(self, team_id: int, age_group_id: int, email: str | None = None) -> dict[str, Any]:
749 """Create a team fan invite (team manager or admin)."""
750 payload = {
751 "invite_type": "team_fan",
752 "team_id": team_id,
753 "age_group_id": age_group_id,
754 }
755 if email:
756 payload["email"] = email
757 response = self._request("POST", "/api/invites/team-manager/team-fan", json_data=payload)
758 return response.json()
760 def get_team_manager_assignments(self) -> list[dict[str, Any]]:
761 """Get team assignments for current team manager."""
762 response = self._request("GET", "/api/invites/team-manager/assignments")
763 return response.json()
765 # League endpoints
767 def get_leagues(self) -> list[dict[str, Any]]:
768 """Get all leagues."""
769 response = self._request("GET", "/api/leagues")
770 return response.json()
772 def get_league(self, league_id: int) -> dict[str, Any]:
773 """Get a specific league by ID."""
774 response = self._request("GET", f"/api/leagues/{league_id}")
775 return response.json()
777 def create_league(self, name: str, description: str | None = None, is_active: bool = True) -> dict[str, Any]:
778 """Create a new league (admin only)."""
779 payload = {"name": name, "is_active": is_active}
780 if description:
781 payload["description"] = description
782 response = self._request("POST", "/api/leagues", json_data=payload)
783 return response.json()
785 def update_league(
786 self,
787 league_id: int,
788 name: str | None = None,
789 description: str | None = None,
790 is_active: bool | None = None,
791 ) -> dict[str, Any]:
792 """Update a league (admin only)."""
793 payload = {}
794 if name is not None:
795 payload["name"] = name
796 if description is not None:
797 payload["description"] = description
798 if is_active is not None:
799 payload["is_active"] = is_active
800 response = self._request("PUT", f"/api/leagues/{league_id}", json_data=payload)
801 return response.json()
803 def delete_league(self, league_id: int) -> dict[str, Any]:
804 """Delete a league (admin only)."""
805 response = self._request("DELETE", f"/api/leagues/{league_id}")
806 return response.json()
808 # Auth extras
810 def get_me(self) -> dict[str, Any]:
811 """Get current authenticated user info."""
812 response = self._request("GET", "/api/auth/me")
813 return response.json()
815 def check_username_available(self, username: str) -> dict[str, Any]:
816 """Check if a username is available."""
817 response = self._request("GET", f"/api/auth/username-available/{username}")
818 return response.json()
820 # Profile photos
822 def upload_profile_photo(self, slot: str, file_path: str) -> dict[str, Any]:
823 """Upload a profile photo to a slot (multipart)."""
824 with open(file_path, "rb") as f:
825 response = self._request_multipart(
826 "POST",
827 f"/api/auth/profile/photo/{slot}",
828 files={"file": (file_path.split("/")[-1], f)},
829 )
830 return response.json()
832 def delete_profile_photo(self, slot: str) -> dict[str, Any]:
833 """Delete a profile photo from a slot."""
834 response = self._request("DELETE", f"/api/auth/profile/photo/{slot}")
835 return response.json()
837 def set_profile_photo_slot(self, slot: str) -> dict[str, Any]:
838 """Set the active profile photo slot."""
839 response = self._request("PUT", "/api/auth/profile/photo/profile-slot", json_data={"slot": slot})
840 return response.json()
842 # Profile customization
844 def update_player_customization(self, customization: PlayerCustomization) -> dict[str, Any]:
845 """Update player customization settings."""
846 response = self._request(
847 "PUT", "/api/auth/profile/customization", json_data=customization.model_dump(exclude_none=True)
848 )
849 return response.json()
851 # Player history
853 def get_player_history(self) -> list[dict[str, Any]]:
854 """Get current user's player history."""
855 response = self._request("GET", "/api/auth/profile/history")
856 return response.json()
858 def get_current_team_assignment(self) -> dict[str, Any]:
859 """Get current team assignment for the authenticated player."""
860 response = self._request("GET", "/api/auth/profile/history/current")
861 return response.json()
863 def get_all_current_teams(self) -> list[dict[str, Any]]:
864 """Get all current team assignments for the authenticated player."""
865 response = self._request("GET", "/api/auth/profile/teams/current")
866 return response.json()
868 def create_player_history(self, history: PlayerHistoryCreate) -> dict[str, Any]:
869 """Create a player history entry."""
870 response = self._request(
871 "POST", "/api/auth/profile/history", json_data=history.model_dump(exclude_none=True)
872 )
873 return response.json()
875 def update_player_history(self, history_id: int, history: PlayerHistoryUpdate) -> dict[str, Any]:
876 """Update a player history entry."""
877 response = self._request(
878 "PUT", f"/api/auth/profile/history/{history_id}", json_data=history.model_dump(exclude_none=True)
879 )
880 return response.json()
882 def delete_player_history(self, history_id: int) -> dict[str, Any]:
883 """Delete a player history entry."""
884 response = self._request("DELETE", f"/api/auth/profile/history/{history_id}")
885 return response.json()
887 # Admin user profile
889 def admin_update_user_profile(self, profile: UserProfileUpdate) -> dict[str, Any]:
890 """Update a user profile (admin only)."""
891 response = self._request("PUT", "/api/auth/users/profile", json_data=profile.model_dump(exclude_none=True))
892 return response.json()
894 # Admin player management
896 def get_admin_players(self, **params: Any) -> list[dict[str, Any]]:
897 """Get players list (admin only)."""
898 response = self._request("GET", "/api/admin/players", params=params or None)
899 return response.json()
901 def update_admin_player(self, player_id: int, update: AdminPlayerUpdate) -> dict[str, Any]:
902 """Update a player (admin only)."""
903 response = self._request(
904 "PUT", f"/api/admin/players/{player_id}", json_data=update.model_dump(exclude_none=True)
905 )
906 return response.json()
908 def add_admin_player_team(self, player_id: int, assignment: AdminPlayerTeamAssignment) -> dict[str, Any]:
909 """Assign a player to a team (admin only)."""
910 response = self._request(
911 "POST", f"/api/admin/players/{player_id}/teams", json_data=assignment.model_dump(exclude_none=True)
912 )
913 return response.json()
915 def end_admin_player_team(self, history_id: int) -> dict[str, Any]:
916 """End a player's team assignment (admin only)."""
917 response = self._request("PUT", f"/api/admin/players/teams/{history_id}/end")
918 return response.json()
920 # Team roster
922 def get_team_roster(self, team_id: int, season_id: int | None = None) -> dict[str, Any]:
923 """Get team roster for a season."""
924 params = {}
925 if season_id is not None:
926 params["season_id"] = season_id
927 response = self._request("GET", f"/api/teams/{team_id}/roster", params=params or None)
928 return response.json()
930 def create_roster_entry(self, team_id: int, entry: RosterPlayerCreate) -> dict[str, Any]:
931 """Create a roster entry for a team."""
932 response = self._request(
933 "POST", f"/api/teams/{team_id}/roster", json_data=entry.model_dump(exclude_none=True)
934 )
935 return response.json()
937 def bulk_create_roster(self, team_id: int, bulk: BulkRosterCreate) -> dict[str, Any]:
938 """Bulk create roster entries for a team."""
939 response = self._request(
940 "POST", f"/api/teams/{team_id}/roster/bulk", json_data=bulk.model_dump(exclude_none=True)
941 )
942 return response.json()
944 def update_roster_entry(self, team_id: int, player_id: int, update: RosterPlayerUpdate) -> dict[str, Any]:
945 """Update a roster entry."""
946 response = self._request(
947 "PUT", f"/api/teams/{team_id}/roster/{player_id}", json_data=update.model_dump(exclude_none=True)
948 )
949 return response.json()
951 def update_jersey_number(self, team_id: int, player_id: int, update: JerseyNumberUpdate) -> dict[str, Any]:
952 """Update a player's jersey number."""
953 response = self._request(
954 "PUT", f"/api/teams/{team_id}/roster/{player_id}/number", json_data=update.model_dump(exclude_none=True)
955 )
956 return response.json()
958 def bulk_renumber_roster(self, team_id: int, renumber: BulkRenumberRequest) -> dict[str, Any]:
959 """Bulk renumber roster entries."""
960 response = self._request(
961 "POST", f"/api/teams/{team_id}/roster/renumber", json_data=renumber.model_dump(exclude_none=True)
962 )
963 return response.json()
965 def delete_roster_entry(self, team_id: int, player_id: int) -> dict[str, Any]:
966 """Delete a roster entry."""
967 response = self._request("DELETE", f"/api/teams/{team_id}/roster/{player_id}")
968 return response.json()
970 # Team players & stats
972 def get_team_players(self, team_id: int) -> list[dict[str, Any]]:
973 """Get all players for a team."""
974 response = self._request("GET", f"/api/teams/{team_id}/players")
975 return response.json()
977 def get_team_stats(self, team_id: int, season_id: int | None = None) -> dict[str, Any]:
978 """Get team stats."""
979 params = {}
980 if season_id is not None:
981 params["season_id"] = season_id
982 response = self._request("GET", f"/api/teams/{team_id}/stats", params=params or None)
983 return response.json()
985 # Team match types
987 def add_team_match_type(self, team_id: int, match_type_id: int, age_group_id: int) -> dict[str, Any]:
988 """Add a match type to a team."""
989 response = self._request(
990 "POST",
991 f"/api/teams/{team_id}/match-types",
992 json_data={"match_type_id": match_type_id, "age_group_id": age_group_id},
993 )
994 return response.json()
996 def delete_team_match_type(self, team_id: int, match_type_id: int, age_group_id: int) -> dict[str, Any]:
997 """Remove a match type from a team."""
998 response = self._request("DELETE", f"/api/teams/{team_id}/match-types/{match_type_id}/{age_group_id}")
999 return response.json()
1001 # Match live
1003 def get_live_matches(self) -> list[dict[str, Any]]:
1004 """Get all currently live matches."""
1005 response = self._request("GET", "/api/matches/live")
1006 return response.json()
1008 def get_live_match_state(self, match_id: int) -> dict[str, Any]:
1009 """Get live state for a match."""
1010 response = self._request("GET", f"/api/matches/{match_id}/live")
1011 return response.json()
1013 def update_match_clock(self, match_id: int, clock: LiveMatchClock) -> dict[str, Any]:
1014 """Update the match clock."""
1015 response = self._request(
1016 "POST", f"/api/matches/{match_id}/live/clock", json_data=clock.model_dump(exclude_none=True)
1017 )
1018 return response.json()
1020 def post_goal(self, match_id: int, goal: GoalEvent) -> dict[str, Any]:
1021 """Record a goal event."""
1022 response = self._request(
1023 "POST", f"/api/matches/{match_id}/live/goal", json_data=goal.model_dump(exclude_none=True)
1024 )
1025 return response.json()
1027 def post_message(self, match_id: int, message: MessageEvent) -> dict[str, Any]:
1028 """Post a match message event."""
1029 response = self._request(
1030 "POST", f"/api/matches/{match_id}/live/message", json_data=message.model_dump(exclude_none=True)
1031 )
1032 return response.json()
1034 def delete_match_event(self, match_id: int, event_id: int) -> dict[str, Any]:
1035 """Delete a match event."""
1036 response = self._request("DELETE", f"/api/matches/{match_id}/live/events/{event_id}")
1037 return response.json()
1039 def get_match_events(self, match_id: int) -> list[dict[str, Any]]:
1040 """Get all events for a match."""
1041 response = self._request("GET", f"/api/matches/{match_id}/live/events")
1042 return response.json()
1044 # Match lineup
1046 def get_lineup(self, match_id: int, team_id: int) -> dict[str, Any]:
1047 """Get lineup for a team in a match."""
1048 response = self._request("GET", f"/api/matches/{match_id}/lineup/{team_id}")
1049 return response.json()
1051 def save_lineup(self, match_id: int, team_id: int, lineup: LineupSave) -> dict[str, Any]:
1052 """Save lineup for a team in a match."""
1053 response = self._request(
1054 "PUT", f"/api/matches/{match_id}/lineup/{team_id}", json_data=lineup.model_dump(exclude_none=True)
1055 )
1056 return response.json()
1058 # Match operations
1060 def submit_match_async(self, submission: MatchSubmissionData) -> dict[str, Any]:
1061 """Submit a match asynchronously via message queue."""
1062 response = self._request(
1063 "POST", "/api/matches/submit", json_data=submission.model_dump(exclude_none=True)
1064 )
1065 return response.json()
1067 def get_task_status(self, task_id: str) -> dict[str, Any]:
1068 """Get the status of an async task."""
1069 response = self._request("GET", f"/api/matches/task/{task_id}")
1070 return response.json()
1072 # Team mappings
1074 def create_team_mapping(self, team_id: int, age_group_id: int, division_id: int) -> dict[str, Any]:
1075 """Create a team mapping."""
1076 response = self._request(
1077 "POST",
1078 "/api/team-mappings",
1079 json_data={"team_id": team_id, "age_group_id": age_group_id, "division_id": division_id},
1080 )
1081 return response.json()
1083 def delete_team_mapping(self, team_id: int, age_group_id: int, division_id: int) -> dict[str, Any]:
1084 """Delete a team mapping."""
1085 response = self._request("DELETE", f"/api/team-mappings/{team_id}/{age_group_id}/{division_id}")
1086 return response.json()
1088 # Club logo
1090 def upload_club_logo(self, club_id: int, file_path: str) -> dict[str, Any]:
1091 """Upload a club logo (multipart)."""
1092 with open(file_path, "rb") as f:
1093 response = self._request_multipart(
1094 "POST",
1095 f"/api/clubs/{club_id}/logo",
1096 files={"file": (file_path.split("/")[-1], f)},
1097 )
1098 return response.json()
1100 # Player stats
1102 def get_my_player_stats(self, season_id: int | None = None) -> dict[str, Any]:
1103 """Get current user's player stats."""
1104 params = {}
1105 if season_id is not None:
1106 params["season_id"] = season_id
1107 response = self._request("GET", "/api/me/player-stats", params=params or None)
1108 return response.json()
1110 def get_player_profile(self, user_id: str) -> dict[str, Any]:
1111 """Get a player's public profile."""
1112 response = self._request("GET", f"/api/players/{user_id}/profile")
1113 return response.json()
1115 def get_roster_player_stats(self, player_id: int, season_id: int | None = None) -> dict[str, Any]:
1116 """Get stats for a roster player."""
1117 params = {}
1118 if season_id is not None:
1119 params["season_id"] = season_id
1120 response = self._request("GET", f"/api/roster/{player_id}/stats", params=params or None)
1121 return response.json()
1123 # Leaderboards
1125 def get_goals_leaderboard(self, **params: Any) -> list[dict[str, Any]]:
1126 """Get the goals leaderboard."""
1127 response = self._request("GET", "/api/leaderboards/goals", params=params or None)
1128 return response.json()
1130 # Invite requests
1132 def create_invite_request(self, request: InviteRequestCreate) -> dict[str, Any]:
1133 """Submit a public invite request."""
1134 response = self._request("POST", "/api/invite-requests", json_data=request.model_dump(exclude_none=True))
1135 return response.json()
1137 def list_invite_requests(
1138 self, status: str | None = None, limit: int | None = None, offset: int | None = None
1139 ) -> list[dict[str, Any]]:
1140 """List invite requests (admin only)."""
1141 params: dict[str, Any] = {}
1142 if status is not None:
1143 params["status"] = status
1144 if limit is not None:
1145 params["limit"] = limit
1146 if offset is not None:
1147 params["offset"] = offset
1148 response = self._request("GET", "/api/invite-requests", params=params or None)
1149 return response.json()
1151 def get_invite_request_stats(self) -> dict[str, Any]:
1152 """Get invite request statistics (admin only)."""
1153 response = self._request("GET", "/api/invite-requests/stats")
1154 return response.json()
1156 def get_invite_request(self, request_id: str) -> dict[str, Any]:
1157 """Get a specific invite request (admin only)."""
1158 response = self._request("GET", f"/api/invite-requests/{request_id}")
1159 return response.json()
1161 def update_invite_request_status(
1162 self, request_id: str, update: InviteRequestStatusUpdate
1163 ) -> dict[str, Any]:
1164 """Update invite request status (admin only)."""
1165 response = self._request(
1166 "PUT", f"/api/invite-requests/{request_id}/status", json_data=update.model_dump(exclude_none=True)
1167 )
1168 return response.json()
1170 def delete_invite_request(self, request_id: str) -> dict[str, Any]:
1171 """Delete an invite request (admin only)."""
1172 response = self._request("DELETE", f"/api/invite-requests/{request_id}")
1173 return response.json()
1175 # Channel access requests
1177 def get_my_channel_request(self) -> dict[str, Any]:
1178 """Get the current user's channel access request."""
1179 response = self._request("GET", "/api/channel-requests/me")
1180 return response.json()
1182 def create_channel_request(self, request: ChannelAccessRequestCreate) -> dict[str, Any]:
1183 """Submit or update a channel access request (logged-in users)."""
1184 response = self._request("POST", "/api/channel-requests", json_data=request.model_dump(exclude_none=True))
1185 return response.json()
1187 def list_channel_requests(
1188 self,
1189 status: str | None = None,
1190 platform: str | None = None,
1191 team_id: int | None = None,
1192 limit: int | None = None,
1193 offset: int | None = None,
1194 ) -> list[dict[str, Any]]:
1195 """List channel access requests (admin/club_manager only)."""
1196 params: dict[str, Any] = {}
1197 if status:
1198 params["status"] = status
1199 if platform:
1200 params["platform"] = platform
1201 if team_id:
1202 params["team_id"] = team_id
1203 if limit is not None:
1204 params["limit"] = limit
1205 if offset is not None:
1206 params["offset"] = offset
1207 response = self._request("GET", "/api/channel-requests", params=params)
1208 return response.json()
1210 def get_channel_request_stats(self) -> dict[str, Any]:
1211 """Get channel access request statistics (admin/club_manager only)."""
1212 response = self._request("GET", "/api/channel-requests/stats")
1213 return response.json()
1215 def get_channel_request(self, request_id: str) -> dict[str, Any]:
1216 """Get a specific channel access request (admin/club_manager only)."""
1217 response = self._request("GET", f"/api/channel-requests/{request_id}")
1218 return response.json()
1220 def update_channel_request_status(
1221 self, request_id: str, update: ChannelAccessStatusUpdate
1222 ) -> dict[str, Any]:
1223 """Update per-platform status on a channel access request (admin/club_manager only)."""
1224 response = self._request(
1225 "PUT",
1226 f"/api/channel-requests/{request_id}/status",
1227 json_data=update.model_dump(exclude_none=True),
1228 )
1229 return response.json()
1231 def delete_channel_request(self, request_id: str) -> dict[str, Any]:
1232 """Delete a channel access request (admin only)."""
1233 response = self._request("DELETE", f"/api/channel-requests/{request_id}")
1234 return response.json()
1236 # Playoffs
1238 def get_playoff_bracket(
1239 self, league_id: int, season_id: int, age_group_id: int
1240 ) -> dict[str, Any]:
1241 """Get playoff bracket for a league/season/age group."""
1242 params = {"league_id": league_id, "season_id": season_id, "age_group_id": age_group_id}
1243 response = self._request("GET", "/api/playoffs/bracket", params=params)
1244 return response.json()
1246 def advance_playoff_winner(self, request: AdvanceWinnerRequest) -> dict[str, Any]:
1247 """Advance the winner of a completed bracket slot (team manager or admin)."""
1248 response = self._request("POST", "/api/playoffs/advance", json_data=request.model_dump())
1249 return response.json()
1251 def generate_playoff_bracket(self, request: GenerateBracketRequest) -> dict[str, Any]:
1252 """Generate playoff brackets from current standings (admin only)."""
1253 response = self._request("POST", "/api/admin/playoffs/generate", json_data=request.model_dump())
1254 return response.json()
1256 def advance_playoff_winner_admin(self, request: AdvanceWinnerRequest) -> dict[str, Any]:
1257 """Advance the winner of a completed bracket slot (admin only)."""
1258 response = self._request("POST", "/api/admin/playoffs/advance", json_data=request.model_dump())
1259 return response.json()
1261 def delete_playoff_bracket(
1262 self, league_id: int, season_id: int, age_group_id: int
1263 ) -> dict[str, Any]:
1264 """Delete an entire playoff bracket and its matches (admin only)."""
1265 params = {"league_id": league_id, "season_id": season_id, "age_group_id": age_group_id}
1266 response = self._request("DELETE", "/api/admin/playoffs/bracket", params=params)
1267 return response.json()
1269 # Cache management
1271 def get_cache_stats(self) -> dict[str, Any]:
1272 """Get cache statistics and keys grouped by type (admin only)."""
1273 response = self._request("GET", "/api/admin/cache")
1274 return response.json()
1276 def clear_all_cache(self) -> dict[str, Any]:
1277 """Clear all DAO cache entries (admin only)."""
1278 response = self._request("DELETE", "/api/admin/cache")
1279 return response.json()
1281 def clear_cache_by_type(self, cache_type: str) -> dict[str, Any]:
1282 """Clear cache entries for a specific type (admin only)."""
1283 response = self._request("DELETE", f"/api/admin/cache/{cache_type}")
1284 return response.json()
1286 # Version
1288 def get_version(self) -> dict[str, Any]:
1289 """Get API version information."""
1290 response = self._request("GET", "/api/version")
1291 return response.json()