Coverage for api_client/client.py: 20.11%

606 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-13 14:11 +0000

1""" 

2Type-safe API client for Missing Table backend. 

3 

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""" 

11 

12import logging 

13from typing import Any, TypeVar 

14from urllib.parse import urljoin 

15 

16import httpx 

17from pydantic import BaseModel 

18 

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) 

63 

64T = TypeVar("T", bound=BaseModel) 

65 

66logger = logging.getLogger(__name__) 

67 

68 

69class MissingTableClient: 

70 """Type-safe API client for Missing Table backend.""" 

71 

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. 

81 

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 

93 

94 self._client = httpx.Client( 

95 timeout=timeout, 

96 follow_redirects=True, 

97 ) 

98 

99 def __enter__(self) -> "MissingTableClient": 

100 """Context manager entry.""" 

101 return self 

102 

103 def __exit__(self, *args: Any) -> None: 

104 """Context manager exit.""" 

105 self.close() 

106 

107 def close(self) -> None: 

108 """Close the HTTP client.""" 

109 self._client.close() 

110 

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 

117 

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} 

124 

125 error_message = error_data.get("detail", f"HTTP {response.status_code}") 

126 

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) 

141 

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() 

152 

153 logger.debug(f"{method} {url}") 

154 if json_data: 

155 logger.debug(f"Request body: {json_data}") 

156 

157 response = self._client.request( 

158 method=method, 

159 url=url, 

160 headers=headers, 

161 json=json_data, 

162 params=params, 

163 ) 

164 

165 if not response.is_success: 

166 self._handle_response_error(response) 

167 

168 return response 

169 

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}" 

184 

185 logger.debug(f"{method} {url} (multipart)") 

186 

187 response = self._client.request( 

188 method=method, 

189 url=url, 

190 headers=headers, 

191 files=files, 

192 data=data, 

193 params=params, 

194 ) 

195 

196 if not response.is_success: 

197 self._handle_response_error(response) 

198 

199 return response 

200 

201 # Authentication endpoints 

202 

203 def login(self, username: str, password: str) -> dict[str, Any]: 

204 """ 

205 Login with username and password. 

206 

207 Args: 

208 username: Username (primary identifier) 

209 password: User password 

210 

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() 

220 

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 

239 

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. 

250 

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 

257 

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 

268 

269 response = self._request("POST", "/api/auth/signup", json_data=payload) 

270 data = response.json() 

271 

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") 

277 

278 return data 

279 

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() 

286 

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") 

291 

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() 

295 

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 

311 

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() 

316 

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 

324 

325 response = self._request("PUT", "/api/auth/profile", json_data=payload) 

326 return response.json() 

327 

328 # Teams endpoints 

329 

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 

341 

342 response = self._request("GET", "/api/teams", params=params) 

343 return response.json() 

344 

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() 

349 

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() 

354 

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() 

359 

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() 

364 

365 # Games endpoints 

366 

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 

399 

400 response = self._request("GET", "/api/matches", params=params) 

401 return response.json() 

402 

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() 

407 

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() 

419 

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() 

428 

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() 

437 

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() 

446 

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() 

451 

452 # Reference data endpoints 

453 

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() 

458 

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() 

463 

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() 

468 

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() 

473 

474 def get_seasons(self) -> list[dict[str, Any]]: 

475 """Get all seasons.""" 

476 response = self._request("GET", "/api/seasons") 

477 return response.json() 

478 

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() 

483 

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() 

488 

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() 

493 

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() 

498 

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() 

503 

504 def get_divisions(self) -> list[dict[str, Any]]: 

505 """Get all divisions.""" 

506 response = self._request("GET", "/api/divisions") 

507 return response.json() 

508 

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() 

513 

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() 

520 

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() 

525 

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() 

530 

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() 

535 

536 # League table endpoints 

537 

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 

555 

556 response = self._request("GET", "/api/table", params=params) 

557 return response.json() 

558 

559 # Admin endpoints 

560 

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() 

565 

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() 

570 

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() 

575 

576 # Utility endpoints 

577 

578 def health_check(self) -> dict[str, Any]: 

579 """Check API health.""" 

580 response = self._request("GET", "/health") 

581 return response.json() 

582 

583 def full_health_check(self) -> dict[str, Any]: 

584 """Comprehensive health check.""" 

585 response = self._request("GET", "/health/full") 

586 return response.json() 

587 

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"] 

592 

593 # Club endpoints 

594 

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() 

600 

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() 

605 

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() 

610 

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() 

620 

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() 

638 

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() 

643 

644 # Invite endpoints 

645 

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() 

650 

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() 

658 

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() 

663 

664 # Admin invite creation endpoints 

665 

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() 

673 

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() 

685 

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() 

699 

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() 

711 

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() 

719 

720 # Club manager invite creation endpoints 

721 

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() 

729 

730 # Team manager invite creation endpoints 

731 

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() 

747 

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() 

759 

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() 

764 

765 # League endpoints 

766 

767 def get_leagues(self) -> list[dict[str, Any]]: 

768 """Get all leagues.""" 

769 response = self._request("GET", "/api/leagues") 

770 return response.json() 

771 

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() 

776 

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() 

784 

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() 

802 

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() 

807 

808 # Auth extras 

809 

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() 

814 

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() 

819 

820 # Profile photos 

821 

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() 

831 

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() 

836 

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() 

841 

842 # Profile customization 

843 

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() 

850 

851 # Player history 

852 

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() 

857 

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() 

862 

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() 

867 

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() 

874 

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() 

881 

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() 

886 

887 # Admin user profile 

888 

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() 

893 

894 # Admin player management 

895 

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() 

900 

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() 

907 

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() 

914 

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() 

919 

920 # Team roster 

921 

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() 

929 

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() 

936 

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() 

943 

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() 

950 

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() 

957 

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() 

964 

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() 

969 

970 # Team players & stats 

971 

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() 

976 

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() 

984 

985 # Team match types 

986 

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() 

995 

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() 

1000 

1001 # Match live 

1002 

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() 

1007 

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() 

1012 

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() 

1019 

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() 

1026 

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() 

1033 

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() 

1038 

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() 

1043 

1044 # Match lineup 

1045 

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() 

1050 

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() 

1057 

1058 # Match operations 

1059 

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() 

1066 

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() 

1071 

1072 # Team mappings 

1073 

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() 

1082 

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() 

1087 

1088 # Club logo 

1089 

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() 

1099 

1100 # Player stats 

1101 

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() 

1109 

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() 

1114 

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() 

1122 

1123 # Leaderboards 

1124 

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() 

1129 

1130 # Invite requests 

1131 

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() 

1136 

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() 

1150 

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() 

1155 

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() 

1160 

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() 

1169 

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() 

1174 

1175 # Channel access requests 

1176 

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() 

1181 

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() 

1186 

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() 

1209 

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() 

1214 

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() 

1219 

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() 

1230 

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() 

1235 

1236 # Playoffs 

1237 

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() 

1245 

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() 

1250 

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() 

1255 

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() 

1260 

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() 

1268 

1269 # Cache management 

1270 

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() 

1275 

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() 

1280 

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() 

1285 

1286 # Version 

1287 

1288 def get_version(self) -> dict[str, Any]: 

1289 """Get API version information.""" 

1290 response = self._request("GET", "/api/version") 

1291 return response.json()