Coverage for dao/player_dao.py: 21.09%

215 statements  

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

1""" 

2Player Data Access Object. 

3 

4Handles all database operations related to players/user profiles including: 

5- User profile CRUD operations 

6- Team-player associations 

7- Player team history 

8- Admin player management 

9""" 

10 

11import structlog 

12 

13from dao.base_dao import BaseDAO, dao_cache, invalidates_cache 

14 

15logger = structlog.get_logger() 

16 

17# Cache patterns for invalidation 

18PLAYERS_CACHE_PATTERN = "mt:dao:players:*" 

19 

20 

21class PlayerDAO(BaseDAO): 

22 """Data access object for player/user profile operations.""" 

23 

24 # === User Profile Query Methods === 

25 

26 @dao_cache("players:profile:{user_id}") 

27 def get_user_profile_with_relationships(self, user_id: str) -> dict | None: 

28 """ 

29 Get user profile with team relationship. 

30 

31 Args: 

32 user_id: User ID to fetch profile for 

33 

34 Returns: 

35 User profile dict with team data, or None if not found 

36 """ 

37 try: 

38 response = ( 

39 self.client.table("user_profiles") 

40 .select(""" 

41 *, 

42 team:teams(id, name, city, club:clubs(id, name, primary_color, secondary_color, logo_url)), 

43 club:clubs(id, name, primary_color, secondary_color, logo_url) 

44 """) 

45 .eq("id", user_id) 

46 .execute() 

47 ) 

48 

49 if response.data and len(response.data) > 0: 

50 profile = response.data[0] 

51 if len(response.data) > 1: 

52 logger.warning(f"Multiple profiles found for user {user_id}, using first one") 

53 return profile 

54 return None 

55 except Exception as e: 

56 logger.error(f"Error fetching user profile: {e}") 

57 return None 

58 

59 def get_user_profile_by_email(self, email: str, exclude_user_id: str | None = None) -> dict | None: 

60 """ 

61 Get user profile by email, optionally excluding a specific user ID. 

62 

63 Useful for checking if an email is already in use by another user. 

64 No caching - used for auth/validation. 

65 

66 Args: 

67 email: Email address to search for 

68 exclude_user_id: Optional user ID to exclude from search 

69 

70 Returns: 

71 User profile dict if found, None otherwise 

72 """ 

73 try: 

74 query = self.client.table("user_profiles").select("id").eq("email", email) 

75 if exclude_user_id: 

76 query = query.neq("id", exclude_user_id) 

77 response = query.execute() 

78 if response.data and len(response.data) > 0: 

79 return response.data[0] 

80 return None 

81 except Exception as e: 

82 logger.error(f"Error checking user profile by email: {e}") 

83 return None 

84 

85 def get_user_profile_by_username(self, username: str, exclude_user_id: str | None = None) -> dict | None: 

86 """ 

87 Get user profile by username, optionally excluding a specific user ID. 

88 

89 Useful for checking if a username is already taken. 

90 No caching - used for auth/validation. 

91 

92 Args: 

93 username: Username to search for (will be lowercased) 

94 exclude_user_id: Optional user ID to exclude from search 

95 

96 Returns: 

97 User profile dict if found, None otherwise 

98 """ 

99 try: 

100 query = self.client.table("user_profiles").select("id").eq("username", username.lower()) 

101 if exclude_user_id: 

102 query = query.neq("id", exclude_user_id) 

103 response = query.execute() 

104 if response.data and len(response.data) > 0: 

105 return response.data[0] 

106 return None 

107 except Exception as e: 

108 logger.error(f"Error checking user profile by username: {e}") 

109 return None 

110 

111 def get_user_for_password_reset(self, identifier: str) -> dict | None: 

112 """ 

113 Look up a user by username or email for the password reset flow. 

114 

115 If ``@`` appears in the identifier it is treated as an email address; 

116 otherwise it is treated as a username (lowercased). 

117 

118 Selects only ``id``, ``username``, and ``email`` — no caching because 

119 this is used in an auth-sensitive path. 

120 

121 Args: 

122 identifier: Username or email address supplied by the user 

123 

124 Returns: 

125 Dict with ``id``, ``username``, ``email`` keys, or None if not found 

126 """ 

127 try: 

128 if "@" in identifier: 

129 query = self.client.table("user_profiles").select("id, username, email").eq("email", identifier) 

130 else: 

131 query = ( 

132 self.client.table("user_profiles") 

133 .select("id, username, email") 

134 .eq("username", identifier.lower()) 

135 ) 

136 response = query.execute() 

137 if response.data and len(response.data) > 0: 

138 return response.data[0] 

139 return None 

140 except Exception as e: 

141 logger.error(f"Error looking up user for password reset: {e}") 

142 return None 

143 

144 @dao_cache("players:all") 

145 def get_all_user_profiles(self) -> list[dict]: 

146 """ 

147 Get all user profiles with team relationships. 

148 

149 Returns: 

150 List of user profile dicts 

151 """ 

152 try: 

153 response = ( 

154 self.client.table("user_profiles") 

155 .select(""" 

156 *, 

157 team:teams(id, name, city) 

158 """) 

159 .order("created_at", desc=True) 

160 .execute() 

161 ) 

162 return response.data or [] 

163 except Exception as e: 

164 logger.error(f"Error fetching all user profiles: {e}") 

165 return [] 

166 

167 # === User Profile CRUD Methods === 

168 

169 @invalidates_cache(PLAYERS_CACHE_PATTERN) 

170 def create_or_update_user_profile(self, profile_data: dict) -> dict | None: 

171 """ 

172 Create or update a user profile. 

173 

174 Args: 

175 profile_data: Dictionary containing user profile data 

176 

177 Returns: 

178 Created/updated profile dict, or None on error 

179 """ 

180 try: 

181 response = self.client.table("user_profiles").upsert(profile_data).execute() 

182 if response.data and len(response.data) > 0: 

183 return response.data[0] 

184 return None 

185 except Exception as e: 

186 logger.error(f"Error creating/updating user profile: {e}") 

187 return None 

188 

189 @invalidates_cache(PLAYERS_CACHE_PATTERN) 

190 def update_user_profile(self, user_id: str, update_data: dict) -> dict | None: 

191 """ 

192 Update user profile fields. 

193 

194 Args: 

195 user_id: User ID to update 

196 update_data: Dictionary of fields to update 

197 

198 Returns: 

199 Updated profile dict, or None on error 

200 """ 

201 try: 

202 response = self.client.table("user_profiles").update(update_data).eq("id", user_id).execute() 

203 if response.data and len(response.data) > 0: 

204 return response.data[0] 

205 return None 

206 except Exception as e: 

207 logger.error(f"Error updating user profile: {e}") 

208 return None 

209 

210 # === Team Players Methods === 

211 

212 @dao_cache("players:by_team:{team_id}") 

213 def get_team_players(self, team_id: int) -> list[dict]: 

214 """ 

215 Get all players currently on a team for the team roster page. 

216 

217 Uses player_team_history to support multi-team players. 

218 

219 Returns player profiles with fields needed for player cards: 

220 - id, display_name, player_number, positions 

221 - photo_1_url, photo_2_url, photo_3_url, profile_photo_slot 

222 - overlay_style, primary_color, text_color, accent_color 

223 - instagram_handle, snapchat_handle, tiktok_handle 

224 

225 Args: 

226 team_id: The team ID to get players for 

227 

228 Returns: 

229 List of player profile dicts 

230 """ 

231 try: 

232 # Query player_team_history for current team members 

233 response = ( 

234 self.client.table("player_team_history") 

235 .select(""" 

236 player_id, 

237 jersey_number, 

238 positions, 

239 user_profiles!player_team_history_player_id_fkey( 

240 id, 

241 display_name, 

242 player_number, 

243 positions, 

244 photo_1_url, 

245 photo_2_url, 

246 photo_3_url, 

247 profile_photo_slot, 

248 overlay_style, 

249 primary_color, 

250 text_color, 

251 accent_color, 

252 instagram_handle, 

253 snapchat_handle, 

254 tiktok_handle 

255 ) 

256 """) 

257 .eq("team_id", team_id) 

258 .eq("is_current", True) 

259 .execute() 

260 ) 

261 

262 # Flatten the results - extract user_profiles and merge with history data 

263 players = [] 

264 for entry in response.data or []: 

265 profile = entry.get("user_profiles") 

266 if profile: 

267 # Use jersey_number from history if available, fallback to profile 

268 player = {**profile} 

269 if entry.get("jersey_number") is not None: 

270 player["player_number"] = entry["jersey_number"] 

271 # Use positions from history if available, fallback to profile 

272 if entry.get("positions"): 

273 player["positions"] = entry["positions"] 

274 players.append(player) 

275 

276 # Sort by player_number 

277 players.sort(key=lambda p: p.get("player_number") or 999) 

278 return players 

279 except Exception as e: 

280 logger.error(f"Error fetching team players for team {team_id}: {e}") 

281 return [] 

282 

283 # === Player Team History Methods === 

284 

285 @dao_cache("players:history:{player_id}") 

286 def get_player_team_history(self, player_id: str) -> list[dict]: 

287 """ 

288 Get complete team history for a player across all seasons. 

289 

290 Returns history entries ordered by season (most recent first), 

291 with full team, season, age_group, league, and division details. 

292 

293 Args: 

294 player_id: User ID of the player 

295 

296 Returns: 

297 List of history entry dicts with related data 

298 """ 

299 try: 

300 response = ( 

301 self.client.table("player_team_history") 

302 .select(""" 

303 *, 

304 team:teams(id, name, city, 

305 club:clubs(id, name, primary_color, secondary_color) 

306 ), 

307 season:seasons(id, name, start_date, end_date), 

308 age_group:age_groups(id, name), 

309 league:leagues(id, name), 

310 division:divisions(id, name) 

311 """) 

312 .eq("player_id", player_id) 

313 .order("season_id", desc=True) 

314 .execute() 

315 ) 

316 return response.data or [] 

317 except Exception as e: 

318 logger.error(f"Error fetching player team history: {e}") 

319 return [] 

320 

321 def get_current_player_team_assignment(self, player_id: str) -> dict | None: 

322 """ 

323 Get the current team assignment for a player (is_current=true). 

324 

325 No caching - this is used for real-time checks. 

326 

327 Args: 

328 player_id: User ID of the player 

329 

330 Returns: 

331 Current history entry dict with related data, or None if not found 

332 """ 

333 try: 

334 response = ( 

335 self.client.table("player_team_history") 

336 .select(""" 

337 *, 

338 team:teams(id, name, city, 

339 club:clubs(id, name, primary_color, secondary_color) 

340 ), 

341 season:seasons(id, name, start_date, end_date), 

342 age_group:age_groups(id, name), 

343 league:leagues(id, name), 

344 division:divisions(id, name) 

345 """) 

346 .eq("player_id", player_id) 

347 .eq("is_current", True) 

348 .limit(1) 

349 .execute() 

350 ) 

351 

352 if response.data and len(response.data) > 0: 

353 return response.data[0] 

354 return None 

355 except Exception as e: 

356 logger.error(f"Error fetching current player team assignment: {e}") 

357 return None 

358 

359 def get_all_current_player_teams(self, player_id: str) -> list[dict]: 

360 """ 

361 Get ALL current team assignments for a player (is_current=true). 

362 

363 This supports players being on multiple teams simultaneously 

364 (e.g., for futsal/soccer leagues). 

365 No caching - this is used for real-time checks. 

366 

367 Args: 

368 player_id: User ID of the player 

369 

370 Returns: 

371 List of current history entries with related team and club data 

372 """ 

373 try: 

374 response = ( 

375 self.client.table("player_team_history") 

376 .select(""" 

377 *, 

378 team:teams(id, name, city, 

379 club:clubs(id, name, logo_url, primary_color, secondary_color), 

380 league:leagues(id, name), 

381 division:divisions(id, name) 

382 ), 

383 season:seasons(id, name, start_date, end_date), 

384 age_group:age_groups(id, name) 

385 """) 

386 .eq("player_id", player_id) 

387 .eq("is_current", True) 

388 .execute() 

389 ) 

390 return response.data or [] 

391 except Exception as e: 

392 logger.error(f"Error fetching all current player teams: {e}") 

393 return [] 

394 

395 @invalidates_cache(PLAYERS_CACHE_PATTERN) 

396 def create_player_history_entry( 

397 self, 

398 player_id: str, 

399 team_id: int, 

400 season_id: int, 

401 jersey_number: int | None = None, 

402 positions: list[str] | None = None, 

403 notes: str | None = None, 

404 is_current: bool = False, 

405 ) -> dict | None: 

406 """ 

407 Create or update a player team history entry. 

408 

409 Uses UPSERT to handle the unique constraint on (player_id, team_id, season_id). 

410 If an entry already exists for the same player/team/season, it updates that 

411 entry instead of failing. This supports: 

412 - Re-adding a player to a team they were previously on 

413 - Updating is_current flag on existing entries 

414 - Players on multiple teams simultaneously (futsal use case) 

415 

416 Args: 

417 player_id: User ID of the player 

418 team_id: Team ID 

419 season_id: Season ID 

420 jersey_number: Optional jersey number for that season 

421 positions: Optional list of positions played 

422 notes: Optional notes about the assignment 

423 is_current: Whether this is the current assignment 

424 

425 Returns: 

426 Created/updated history entry dict, or None on error 

427 """ 

428 try: 

429 # Get team details for league_id, division_id 

430 team_response = ( 

431 self.client.table("teams").select("league_id, division_id").eq("id", team_id).execute() 

432 ) 

433 

434 team_data = team_response.data[0] if team_response.data else {} 

435 

436 # Get age_group_id from team_mappings (teams.age_group_id is deprecated/never populated) 

437 mapping_response = ( 

438 self.client.table("team_mappings").select("age_group_id").eq("team_id", team_id).limit(1).execute() 

439 ) 

440 age_group_id = mapping_response.data[0]["age_group_id"] if mapping_response.data else None 

441 

442 upsert_data = { 

443 "player_id": player_id, 

444 "team_id": team_id, 

445 "season_id": season_id, 

446 "age_group_id": age_group_id, 

447 "league_id": team_data.get("league_id"), 

448 "division_id": team_data.get("division_id"), 

449 "jersey_number": jersey_number, 

450 "positions": positions, 

451 "is_current": is_current, 

452 "notes": notes, 

453 } 

454 

455 # Use upsert to handle existing entries - if (player_id, team_id, season_id) 

456 # already exists, update it instead of failing 

457 response = ( 

458 self.client.table("player_team_history") 

459 .upsert(upsert_data, on_conflict="player_id,team_id,season_id") 

460 .execute() 

461 ) 

462 if response.data and len(response.data) > 0: 

463 return self.get_player_history_entry_by_id(response.data[0]["id"]) 

464 return None 

465 except Exception as e: 

466 logger.error(f"Error creating/updating player history entry: {e}") 

467 return None 

468 

469 def get_player_history_entry_by_id(self, history_id: int) -> dict | None: 

470 """ 

471 Get a single player history entry by ID with related data. 

472 

473 Args: 

474 history_id: History entry ID 

475 

476 Returns: 

477 History entry dict with related data, or None if not found 

478 """ 

479 try: 

480 response = ( 

481 self.client.table("player_team_history") 

482 .select(""" 

483 *, 

484 team:teams(id, name, city, 

485 club:clubs(id, name, primary_color, secondary_color) 

486 ), 

487 season:seasons(id, name, start_date, end_date), 

488 age_group:age_groups(id, name), 

489 league:leagues(id, name), 

490 division:divisions(id, name) 

491 """) 

492 .eq("id", history_id) 

493 .execute() 

494 ) 

495 

496 if response.data and len(response.data) > 0: 

497 return response.data[0] 

498 return None 

499 except Exception as e: 

500 logger.error(f"Error fetching player history entry: {e}") 

501 return None 

502 

503 @invalidates_cache(PLAYERS_CACHE_PATTERN) 

504 def update_player_history_entry( 

505 self, 

506 history_id: int, 

507 jersey_number: int | None = None, 

508 positions: list[str] | None = None, 

509 notes: str | None = None, 

510 is_current: bool | None = None, 

511 ) -> dict | None: 

512 """ 

513 Update a player team history entry. 

514 

515 Args: 

516 history_id: History entry ID to update 

517 jersey_number: Optional new jersey number 

518 positions: Optional new positions list 

519 notes: Optional new notes 

520 is_current: Optional new is_current flag 

521 

522 Returns: 

523 Updated history entry dict, or None on error 

524 """ 

525 try: 

526 update_data = {"updated_at": "now()"} 

527 

528 if jersey_number is not None: 

529 update_data["jersey_number"] = jersey_number 

530 if positions is not None: 

531 update_data["positions"] = positions 

532 if notes is not None: 

533 update_data["notes"] = notes 

534 if is_current is not None: 

535 update_data["is_current"] = is_current 

536 # Note: We allow multiple current teams (for futsal/multi-league players) 

537 # So we don't automatically unset is_current on other entries 

538 

539 response = self.client.table("player_team_history").update(update_data).eq("id", history_id).execute() 

540 

541 if response.data and len(response.data) > 0: 

542 return self.get_player_history_entry_by_id(history_id) 

543 return None 

544 except Exception as e: 

545 logger.error(f"Error updating player history entry: {e}") 

546 return None 

547 

548 @invalidates_cache(PLAYERS_CACHE_PATTERN) 

549 def delete_player_history_entry(self, history_id: int) -> bool: 

550 """ 

551 Delete a player team history entry. 

552 

553 Args: 

554 history_id: History entry ID to delete 

555 

556 Returns: 

557 True if deleted successfully, False otherwise 

558 """ 

559 try: 

560 self.client.table("player_team_history").delete().eq("id", history_id).execute() 

561 return True 

562 except Exception as e: 

563 logger.error(f"Error deleting player history entry: {e}") 

564 return False 

565 

566 # === Admin Player Management Methods === 

567 

568 def get_all_players_admin( 

569 self, 

570 search: str | None = None, 

571 club_id: int | None = None, 

572 team_id: int | None = None, 

573 limit: int = 50, 

574 offset: int = 0, 

575 ) -> dict: 

576 """ 

577 Get all players with their current team assignments for admin management. 

578 No caching - admin queries should always be fresh. 

579 

580 Args: 

581 search: Optional text search on display_name or email 

582 club_id: Optional filter by club ID 

583 team_id: Optional filter by team ID 

584 limit: Max number of results (default 50) 

585 offset: Offset for pagination (default 0) 

586 

587 Returns: 

588 Dict with 'players' list and 'total' count 

589 """ 

590 try: 

591 # Build the base query for players (team-player role) 

592 query = ( 

593 self.client.table("user_profiles") 

594 .select( 

595 """ 

596 id, 

597 email, 

598 display_name, 

599 player_number, 

600 positions, 

601 photo_1_url, 

602 profile_photo_slot, 

603 team_id, 

604 created_at, 

605 team:teams(id, name, club_id) 

606 """, 

607 count="exact", 

608 ) 

609 .eq("role", "team-player") 

610 ) 

611 

612 # Apply text search filter 

613 if search: 

614 query = query.or_(f"display_name.ilike.%{search}%,email.ilike.%{search}%") 

615 

616 # Apply team filter 

617 if team_id: 

618 query = query.eq("team_id", team_id) 

619 # Apply club filter (via team) 

620 elif club_id: 

621 # Need to get team IDs for this club first 

622 teams_response = self.client.table("teams").select("id").eq("club_id", club_id).execute() 

623 if teams_response.data: 

624 team_ids = [t["id"] for t in teams_response.data] 

625 query = query.in_("team_id", team_ids) 

626 

627 # Apply pagination and ordering 

628 query = query.order("display_name").range(offset, offset + limit - 1) 

629 

630 response = query.execute() 

631 

632 players = response.data or [] 

633 total = response.count or 0 

634 

635 # Enrich with current team assignments from player_team_history 

636 for player in players: 

637 history_response = ( 

638 self.client.table("player_team_history") 

639 .select( 

640 """ 

641 id, 

642 team_id, 

643 season_id, 

644 jersey_number, 

645 is_current, 

646 created_at, 

647 team:teams(id, name, club:clubs(id, name)), 

648 season:seasons(id, name) 

649 """ 

650 ) 

651 .eq("player_id", player["id"]) 

652 .eq("is_current", True) 

653 .execute() 

654 ) 

655 player["current_teams"] = history_response.data or [] 

656 

657 return {"players": players, "total": total} 

658 

659 except Exception as e: 

660 logger.error(f"Error fetching players for admin: {e}") 

661 return {"players": [], "total": 0} 

662 

663 @invalidates_cache(PLAYERS_CACHE_PATTERN) 

664 def update_player_admin( 

665 self, 

666 player_id: str, 

667 display_name: str | None = None, 

668 player_number: int | None = None, 

669 positions: list[str] | None = None, 

670 ) -> dict | None: 

671 """ 

672 Update player profile info (admin/manager operation). 

673 

674 Args: 

675 player_id: User ID of the player 

676 display_name: Optional new display name 

677 player_number: Optional new jersey number 

678 positions: Optional new positions list 

679 

680 Returns: 

681 Updated player profile dict, or None on error 

682 """ 

683 try: 

684 update_data = {"updated_at": "now()"} 

685 

686 if display_name is not None: 

687 update_data["display_name"] = display_name 

688 if player_number is not None: 

689 update_data["player_number"] = player_number 

690 if positions is not None: 

691 update_data["positions"] = positions 

692 

693 response = self.client.table("user_profiles").update(update_data).eq("id", player_id).execute() 

694 

695 if response.data and len(response.data) > 0: 

696 return response.data[0] 

697 return None 

698 

699 except Exception as e: 

700 logger.error(f"Error updating player admin: {e}") 

701 return None 

702 

703 @invalidates_cache(PLAYERS_CACHE_PATTERN) 

704 def end_player_team_assignment(self, history_id: int) -> dict | None: 

705 """ 

706 End a player's team assignment by setting is_current=false. 

707 

708 Args: 

709 history_id: Player team history ID 

710 

711 Returns: 

712 Updated history entry dict, or None on error 

713 """ 

714 try: 

715 update_data = {"is_current": False, "updated_at": "now()"} 

716 

717 response = self.client.table("player_team_history").update(update_data).eq("id", history_id).execute() 

718 

719 if response.data and len(response.data) > 0: 

720 return self.get_player_history_entry_by_id(history_id) 

721 return None 

722 

723 except Exception as e: 

724 logger.error(f"Error ending player team assignment: {e}") 

725 return None