Coverage for dao/match_dao.py: 20.36%

497 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-15 17:36 +0000

1""" 

2Match data access layer for MissingTable. 

3 

4Provides data access objects for matches, teams, leagues, divisions, seasons, 

5and related soccer/futbol data using Supabase. 

6""" 

7 

8import os 

9from datetime import UTC 

10 

11import httpx 

12import structlog 

13from dotenv import load_dotenv 

14from postgrest.exceptions import APIError 

15 

16from dao.base_dao import BaseDAO, clear_cache, dao_cache, invalidates_cache 

17from dao.exceptions import DuplicateRecordError 

18from dao.standings import ( 

19 calculate_standings_with_extras, 

20 filter_by_match_type, 

21 filter_completed_matches, 

22 filter_same_division_matches, 

23) 

24from supabase import create_client 

25 

26logger = structlog.get_logger() 

27 

28# Cache patterns for invalidation 

29MATCHES_CACHE_PATTERN = "mt:dao:matches:*" 

30PLAYOFF_CACHE_PATTERN = "mt:dao:playoffs:*" 

31TOURNAMENTS_CACHE_PATTERN = "mt:dao:tournaments:*" 

32 

33 

34# Load environment variables with environment-specific support 

35def load_environment(): 

36 """Load environment variables based on APP_ENV or default to local.""" 

37 # First load base .env file 

38 load_dotenv() 

39 

40 # Determine which environment to use 

41 app_env = os.getenv("APP_ENV", "local") # Default to local 

42 

43 # Load environment-specific file 

44 env_file = f".env.{app_env}" 

45 if os.path.exists(env_file): 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true

46 load_dotenv(env_file, override=False) 

47 else: 

48 # Fallback to .env.local for backwards compatibility 

49 if os.path.exists(".env.local"): 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true

50 load_dotenv(".env.local", override=False) 

51 

52 

53class SupabaseConnection: 

54 """Manage the connection to Supabase with SSL workaround.""" 

55 

56 def __init__(self): 

57 """Initialize Supabase client with custom SSL configuration.""" 

58 load_environment() 

59 self.url = os.getenv("SUPABASE_URL") 

60 # Backend should always use SERVICE_KEY for administrative operations 

61 service_key = os.getenv("SUPABASE_SERVICE_KEY") 

62 anon_key = os.getenv("SUPABASE_ANON_KEY") 

63 self.key = service_key or anon_key 

64 

65 if not self.url or not self.key: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true

66 raise ValueError("SUPABASE_URL and SUPABASE_ANON_KEY (or SUPABASE_SERVICE_KEY) must be set in .env file") 

67 

68 # Debug output - check what keys are actually set 

69 key_type = "SERVICE_KEY" if service_key and self.key == service_key else "ANON_KEY" 

70 logger.info(f"DEBUG SupabaseConnection: key_type={key_type}, service_key_present={bool(service_key)}") 

71 

72 try: 

73 # Try with custom httpx client 

74 transport = httpx.HTTPTransport(retries=3) 

75 timeout = httpx.Timeout(30.0, connect=10.0) 

76 

77 # Create custom client with extended timeout and retries 

78 http_client = httpx.Client(transport=transport, timeout=timeout, follow_redirects=True) 

79 

80 # Create Supabase client with custom HTTP client 

81 self.client = create_client(self.url, self.key, options={"httpx_client": http_client}) 

82 logger.debug("Connection to Supabase established") 

83 

84 except Exception: 

85 # Fallback to standard client 

86 self.client = create_client(self.url, self.key) 

87 logger.debug("Connection to Supabase established (fallback client)") 

88 

89 def get_client(self): 

90 """Get the Supabase client instance.""" 

91 return self.client 

92 

93 

94class MatchDAO(BaseDAO): 

95 """Data Access Object for match and league data using normalized schema.""" 

96 

97 # === Core Match Methods === 

98 

99 def get_match_by_external_id(self, external_match_id: str) -> dict | None: 

100 """Get a match by its external match_id (from match-scraper). 

101 

102 This is used for deduplication - checking if a match from match-scraper 

103 already exists in the database. 

104 

105 Returns: 

106 Match dict with flattened structure, or None if not found 

107 """ 

108 try: 

109 response = ( 

110 self.client.table("matches") 

111 .select(""" 

112 *, 

113 home_team:teams!matches_home_team_id_fkey(id, name), 

114 away_team:teams!matches_away_team_id_fkey(id, name), 

115 season:seasons(id, name), 

116 age_group:age_groups(id, name), 

117 match_type:match_types(id, name), 

118 division:divisions(id, name) 

119 """) 

120 .eq("match_id", external_match_id) 

121 .limit(1) 

122 .execute() 

123 ) 

124 

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

126 match = response.data[0] 

127 # Flatten to match format from get_match_by_id 

128 flat_match = { 

129 "id": match["id"], 

130 "match_date": match["match_date"], 

131 "scheduled_kickoff": match.get("scheduled_kickoff"), 

132 "home_team_id": match["home_team_id"], 

133 "away_team_id": match["away_team_id"], 

134 "home_team_name": match["home_team"]["name"] if match.get("home_team") else "Unknown", 

135 "away_team_name": match["away_team"]["name"] if match.get("away_team") else "Unknown", 

136 "home_score": match["home_score"], 

137 "away_score": match["away_score"], 

138 "season_id": match["season_id"], 

139 "season_name": match["season"]["name"] if match.get("season") else "Unknown", 

140 "age_group_id": match["age_group_id"], 

141 "age_group_name": match["age_group"]["name"] if match.get("age_group") else "Unknown", 

142 "match_type_id": match["match_type_id"], 

143 "match_type_name": match["match_type"]["name"] if match.get("match_type") else "Unknown", 

144 "division_id": match.get("division_id"), 

145 "division_name": match["division"]["name"] if match.get("division") else "Unknown", 

146 "match_status": match.get("match_status"), 

147 "created_by": match.get("created_by"), 

148 "updated_by": match.get("updated_by"), 

149 "source": match.get("source", "manual"), 

150 "match_id": match.get("match_id"), 

151 "created_at": match["created_at"], 

152 "updated_at": match["updated_at"], 

153 } 

154 return flat_match 

155 return None 

156 

157 except Exception: 

158 logger.exception("Error getting match by external ID", external_match_id=external_match_id) 

159 return None 

160 

161 def get_match_by_teams_and_date( 

162 self, home_team_id: int, away_team_id: int, match_date: str, age_group_id: int | None = None 

163 ) -> dict | None: 

164 """Get a match by home/away teams, date, and optionally age group. 

165 

166 This is used as a fallback for deduplication when match_id is not populated 

167 (e.g., manually-entered matches without external match IDs). 

168 

169 Args: 

170 home_team_id: Database ID of home team 

171 away_team_id: Database ID of away team 

172 match_date: ISO format date string (YYYY-MM-DD) 

173 age_group_id: Optional database ID of age group (recommended to avoid false matches) 

174 

175 Returns: 

176 Match dict with flattened structure, or None if not found 

177 """ 

178 try: 

179 query = ( 

180 self.client.table("matches") 

181 .select(""" 

182 *, 

183 home_team:teams!matches_home_team_id_fkey(id, name), 

184 away_team:teams!matches_away_team_id_fkey(id, name), 

185 season:seasons(id, name), 

186 age_group:age_groups(id, name), 

187 match_type:match_types(id, name), 

188 division:divisions(id, name) 

189 """) 

190 .eq("home_team_id", home_team_id) 

191 .eq("away_team_id", away_team_id) 

192 .eq("match_date", match_date) 

193 ) 

194 

195 # Add age_group filter if provided (prevents matching different age groups) 

196 if age_group_id is not None: 

197 query = query.eq("age_group_id", age_group_id) 

198 

199 response = query.limit(1).execute() 

200 

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

202 match = response.data[0] 

203 # Flatten to match format from get_match_by_id 

204 flat_match = { 

205 "id": match["id"], 

206 "match_date": match["match_date"], 

207 "scheduled_kickoff": match.get("scheduled_kickoff"), 

208 "home_team_id": match["home_team_id"], 

209 "away_team_id": match["away_team_id"], 

210 "home_team_name": match["home_team"]["name"] if match.get("home_team") else "Unknown", 

211 "away_team_name": match["away_team"]["name"] if match.get("away_team") else "Unknown", 

212 "home_score": match["home_score"], 

213 "away_score": match["away_score"], 

214 "season_id": match["season_id"], 

215 "season_name": match["season"]["name"] if match.get("season") else "Unknown", 

216 "age_group_id": match["age_group_id"], 

217 "age_group_name": match["age_group"]["name"] if match.get("age_group") else "Unknown", 

218 "match_type_id": match["match_type_id"], 

219 "match_type_name": match["match_type"]["name"] if match.get("match_type") else "Unknown", 

220 "division_id": match.get("division_id"), 

221 "division_name": match["division"]["name"] if match.get("division") else "Unknown", 

222 "match_status": match.get("match_status"), 

223 "created_by": match.get("created_by"), 

224 "updated_by": match.get("updated_by"), 

225 "source": match.get("source", "manual"), 

226 "match_id": match.get("match_id"), 

227 "created_at": match["created_at"], 

228 "updated_at": match["updated_at"], 

229 } 

230 return flat_match 

231 return None 

232 

233 except Exception: 

234 logger.exception("Error getting match by teams and date") 

235 return None 

236 

237 @invalidates_cache(MATCHES_CACHE_PATTERN) 

238 def update_match_external_id(self, match_id: int, external_match_id: str) -> bool: 

239 """Update only the external match_id field on an existing match. 

240 

241 This is used when a manually-entered match is matched with a scraped match, 

242 allowing future scrapes to use the external_match_id for deduplication. 

243 

244 Args: 

245 match_id: Database ID of the match to update 

246 external_match_id: External match ID from match-scraper (e.g., "98966") 

247 

248 Returns: 

249 True if update successful, False otherwise 

250 """ 

251 try: 

252 response = ( 

253 self.client.table("matches") 

254 .update( 

255 { 

256 "match_id": external_match_id, 

257 "source": "match-scraper", # Update source to indicate scraper now manages this 

258 } 

259 ) 

260 .eq("id", match_id) 

261 .execute() 

262 ) 

263 

264 if response.data: 

265 logger.info( 

266 "Updated match with external match_id", 

267 match_id=match_id, 

268 external_match_id=external_match_id, 

269 ) 

270 return True 

271 return False 

272 

273 except Exception: 

274 logger.exception("Error updating match external_id") 

275 return False 

276 

277 @invalidates_cache(MATCHES_CACHE_PATTERN) 

278 def create_match( 

279 self, 

280 home_team_id: int, 

281 away_team_id: int, 

282 match_date: str, 

283 season_id: int, 

284 home_score: int | None = None, 

285 away_score: int | None = None, 

286 match_status: str = "scheduled", 

287 source: str = "manual", 

288 match_id: str | None = None, 

289 age_group_id: int = 1, 

290 division_id: int | None = None, 

291 scheduled_kickoff: str | None = None, 

292 ) -> int | None: 

293 """Create a new match with pre-resolved IDs. 

294 

295 Callers are responsible for resolving names to IDs before calling this 

296 method. The Celery task and API layer both do this already. 

297 

298 Args: 

299 home_team_id: Database ID of home team 

300 away_team_id: Database ID of away team 

301 match_date: ISO format date string 

302 season_id: Database ID of season 

303 home_score: Home team score (optional) 

304 away_score: Away team score (optional) 

305 match_status: Match status (scheduled, live, completed, etc.) 

306 source: Data source (default: "manual", use "match-scraper" for external) 

307 match_id: External match ID for deduplication (optional) 

308 age_group_id: Database ID of age group (default: 1) 

309 division_id: Database ID of division (optional) 

310 

311 Returns: 

312 Created match ID, or None on failure 

313 """ 

314 try: 

315 # Validate division for match-scraper sourced matches 

316 if source == "match-scraper" and division_id is None: 

317 logger.error("Division is required for match-scraper sourced matches but was not provided") 

318 raise ValueError("Division is required for match-scraper sourced matches") 

319 

320 match_type_id = 1 # Default League 

321 

322 data = { 

323 "match_date": match_date, 

324 "home_team_id": home_team_id, 

325 "away_team_id": away_team_id, 

326 "home_score": home_score, 

327 "away_score": away_score, 

328 "season_id": season_id, 

329 "age_group_id": age_group_id, 

330 "match_type_id": match_type_id, 

331 "division_id": division_id, 

332 "match_status": match_status, 

333 "source": source, 

334 } 

335 

336 if match_id: 

337 data["match_id"] = match_id 

338 if scheduled_kickoff: 

339 data["scheduled_kickoff"] = scheduled_kickoff 

340 

341 response = self.client.table("matches").insert(data).execute() 

342 

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

344 return response.data[0]["id"] 

345 return None 

346 

347 except Exception: 

348 logger.exception("Error creating match") 

349 return None 

350 

351 # === Match Methods === 

352 

353 def get_all_matches( 

354 self, 

355 season_id: int | None = None, 

356 age_group_id: int | None = None, 

357 division_id: int | None = None, 

358 team_id: int | None = None, 

359 match_type: str | None = None, 

360 start_date: str | None = None, 

361 end_date: str | None = None, 

362 ) -> list[dict]: 

363 """Get all matches with optional filters. 

364 

365 Args: 

366 season_id: Filter by season ID 

367 age_group_id: Filter by age group ID 

368 division_id: Filter by division ID 

369 team_id: Filter by team ID (home or away) 

370 match_type: Filter by match type name 

371 start_date: Filter by start date (YYYY-MM-DD) 

372 end_date: Filter by end date (YYYY-MM-DD) 

373 """ 

374 try: 

375 query = self.client.table("matches").select(""" 

376 *, 

377 home_team:teams!matches_home_team_id_fkey(id, name, club:clubs(id, name, logo_url)), 

378 away_team:teams!matches_away_team_id_fkey(id, name, club:clubs(id, name, logo_url)), 

379 season:seasons(id, name), 

380 age_group:age_groups(id, name), 

381 match_type:match_types(id, name), 

382 division:divisions(id, name, league_id, leagues!divisions_league_id_fkey(id, name)) 

383 """) 

384 

385 # Apply filters 

386 if season_id: 

387 query = query.eq("season_id", season_id) 

388 if age_group_id: 

389 query = query.eq("age_group_id", age_group_id) 

390 if division_id: 

391 query = query.eq("division_id", division_id) 

392 

393 # Date range filters 

394 if start_date: 

395 query = query.gte("match_date", start_date) 

396 if end_date: 

397 query = query.lte("match_date", end_date) 

398 

399 # For team_id, we need to match either home_team_id OR away_team_id 

400 if team_id: 

401 query = query.or_(f"home_team_id.eq.{team_id},away_team_id.eq.{team_id}") 

402 

403 response = query.order("match_date", desc=True).execute() 

404 

405 # Filter by match_type name if specified (post-query filtering) 

406 filtered_matches = response.data 

407 if match_type: 

408 filtered_matches = [ 

409 match for match in response.data if match.get("match_type", {}).get("name") == match_type 

410 ] 

411 

412 # Flatten the response for easier use 

413 matches = [] 

414 for match in filtered_matches: 

415 flat_match = { 

416 "id": match["id"], 

417 "match_date": match["match_date"], 

418 "scheduled_kickoff": match.get("scheduled_kickoff"), 

419 "home_team_id": match["home_team_id"], 

420 "away_team_id": match["away_team_id"], 

421 "home_team_name": match["home_team"]["name"] if match.get("home_team") else "Unknown", 

422 "away_team_name": match["away_team"]["name"] if match.get("away_team") else "Unknown", 

423 "home_team_club": match["home_team"].get("club") if match.get("home_team") else None, 

424 "away_team_club": match["away_team"].get("club") if match.get("away_team") else None, 

425 "home_score": match["home_score"], 

426 "away_score": match["away_score"], 

427 "season_id": match["season_id"], 

428 "season_name": match["season"]["name"] if match.get("season") else "Unknown", 

429 "age_group_id": match["age_group_id"], 

430 "age_group_name": match["age_group"]["name"] if match.get("age_group") else "Unknown", 

431 "match_type_id": match["match_type_id"], 

432 "match_type_name": match["match_type"]["name"] if match.get("match_type") else "Unknown", 

433 "division_id": match.get("division_id"), 

434 "division_name": match["division"]["name"] if match.get("division") else "Unknown", 

435 "league_id": match["division"]["league_id"] if match.get("division") else None, 

436 "league_name": match["division"]["leagues"]["name"] 

437 if match.get("division") and match["division"].get("leagues") 

438 else "Unknown", 

439 "division": match.get("division"), # Include full division object with leagues 

440 "match_status": match.get("match_status"), 

441 "created_by": match.get("created_by"), 

442 "updated_by": match.get("updated_by"), 

443 "source": match.get("source", "manual"), 

444 "match_id": match.get("match_id"), # External match identifier 

445 "created_at": match["created_at"], 

446 "updated_at": match["updated_at"], 

447 } 

448 matches.append(flat_match) 

449 

450 return matches 

451 

452 except Exception: 

453 logger.exception("Error querying matches") 

454 return [] 

455 

456 def get_match_summary( 

457 self, 

458 season_name: str, 

459 score_from: str | None = None, 

460 score_to: str | None = None, 

461 ) -> list[dict]: 

462 """Get match summary statistics grouped by age group, league, and division. 

463 

464 Used by the match-scraper-agent to understand what MT already has 

465 and make smart decisions about what to scrape. 

466 

467 Args: 

468 season_name: Season name, e.g. '2025-2026'. 

469 score_from: If set, only count needs_score for matches >= this date. 

470 score_to: If set, only count needs_score for matches <= this date. 

471 """ 

472 from collections import defaultdict 

473 from datetime import date, timedelta 

474 

475 today = date.today().isoformat() 

476 kickoff_horizon = (date.today() + timedelta(days=14)).isoformat() 

477 

478 # Look up season by name 

479 season_resp = self.client.table("seasons").select("id").eq("name", season_name).limit(1).execute() 

480 if not season_resp.data: 

481 return [] 

482 season_id = season_resp.data[0]["id"] 

483 

484 # Fetch all non-cancelled matches for this season with joins. 

485 # Supabase defaults to 1000 rows — paginate to get the full season. 

486 _page_size = 1000 

487 _offset = 0 

488 all_matches: list[dict] = [] 

489 while True: 

490 response = ( 

491 self.client.table("matches") 

492 .select(""" 

493 match_date, match_status, home_score, away_score, scheduled_kickoff, 

494 age_group:age_groups(name), 

495 division:divisions(name, league_id, leagues:leagues!divisions_league_id_fkey(name)) 

496 """) 

497 .eq("season_id", season_id) 

498 .neq("match_status", "cancelled") 

499 .range(_offset, _offset + _page_size - 1) 

500 .execute() 

501 ) 

502 all_matches.extend(response.data) 

503 if len(response.data) < _page_size: 

504 break 

505 _offset += _page_size 

506 

507 # Group by (age_group, league, division) 

508 groups = defaultdict(list) 

509 for m in all_matches: 

510 ag = m["age_group"]["name"] if m.get("age_group") else "Unknown" 

511 div_name = m["division"]["name"] if m.get("division") else "Unknown" 

512 league_name = ( 

513 m["division"]["leagues"]["name"] if m.get("division") and m["division"].get("leagues") else "Unknown" 

514 ) 

515 groups[(ag, league_name, div_name)].append(m) 

516 

517 # Compute summary per group 

518 summaries = [] 

519 for (ag, league, div), matches in sorted(groups.items()): 

520 by_status = defaultdict(int) 

521 needs_score = 0 

522 needs_kickoff = 0 

523 dates = [] 

524 last_played = None 

525 

526 for m in matches: 

527 status = m.get("match_status", "scheduled") 

528 by_status[status] += 1 

529 md = m["match_date"] 

530 dates.append(md) 

531 

532 if md < today and status in ("scheduled", "tbd") and m.get("home_score") is None: 

533 in_window = (not score_from or md >= score_from) and (not score_to or md <= score_to) 

534 if in_window: 534 ↛ 537line 534 didn't jump to line 537 because the condition on line 534 was always true

535 needs_score += 1 

536 

537 if status in ("scheduled", "tbd") and today <= md <= kickoff_horizon and not m.get("scheduled_kickoff"): 537 ↛ 538line 537 didn't jump to line 538 because the condition on line 537 was never true

538 needs_kickoff += 1 

539 

540 if status in ("completed", "forfeit") and (last_played is None or md > last_played): 

541 last_played = md 

542 

543 summaries.append( 

544 { 

545 "age_group": ag, 

546 "league": league, 

547 "division": div, 

548 "total": len(matches), 

549 "by_status": dict(by_status), 

550 "needs_score": needs_score, 

551 "needs_kickoff": needs_kickoff, 

552 "date_range": { 

553 "earliest": min(dates) if dates else None, 

554 "latest": max(dates) if dates else None, 

555 }, 

556 "last_played_date": last_played, 

557 } 

558 ) 

559 

560 return summaries 

561 

562 def get_matches_by_team( 

563 self, team_id: int, season_id: int | None = None, age_group_id: int | None = None 

564 ) -> list[dict]: 

565 """Get all matches for a specific team.""" 

566 try: 

567 query = ( 

568 self.client.table("matches") 

569 .select(""" 

570 *, 

571 home_team:teams!matches_home_team_id_fkey(id, name, club:clubs(id, name, logo_url)), 

572 away_team:teams!matches_away_team_id_fkey(id, name, club:clubs(id, name, logo_url)), 

573 season:seasons(id, name), 

574 age_group:age_groups(id, name), 

575 match_type:match_types(id, name), 

576 division:divisions(id, name) 

577 """) 

578 .or_(f"home_team_id.eq.{team_id},away_team_id.eq.{team_id}") 

579 ) 

580 

581 if season_id: 

582 query = query.eq("season_id", season_id) 

583 

584 if age_group_id: 

585 query = query.eq("age_group_id", age_group_id) 

586 

587 response = query.order("match_date", desc=True).execute() 

588 

589 # Flatten response (same as get_all_matches) 

590 matches = [] 

591 for match in response.data: 

592 flat_match = { 

593 "id": match["id"], 

594 "match_date": match["match_date"], 

595 "scheduled_kickoff": match.get("scheduled_kickoff"), 

596 "home_team_id": match["home_team_id"], 

597 "away_team_id": match["away_team_id"], 

598 "home_team_name": match["home_team"]["name"] if match.get("home_team") else "Unknown", 

599 "away_team_name": match["away_team"]["name"] if match.get("away_team") else "Unknown", 

600 "home_team_club": match["home_team"].get("club") if match.get("home_team") else None, 

601 "away_team_club": match["away_team"].get("club") if match.get("away_team") else None, 

602 "home_score": match["home_score"], 

603 "away_score": match["away_score"], 

604 "season_id": match["season_id"], 

605 "season_name": match["season"]["name"] if match.get("season") else "Unknown", 

606 "age_group_id": match["age_group_id"], 

607 "age_group_name": match["age_group"]["name"] if match.get("age_group") else "Unknown", 

608 "match_type_id": match["match_type_id"], 

609 "match_type_name": match["match_type"]["name"] if match.get("match_type") else "Unknown", 

610 "division_id": match.get("division_id"), 

611 "division_name": match["division"]["name"] if match.get("division") else "Unknown", 

612 "division": match.get("division"), # Include full division object with leagues 

613 "match_status": match.get("match_status"), 

614 "created_by": match.get("created_by"), 

615 "updated_by": match.get("updated_by"), 

616 "source": match.get("source", "manual"), 

617 "match_id": match.get("match_id"), # External match identifier 

618 "created_at": match.get("created_at"), 

619 "updated_at": match.get("updated_at"), 

620 } 

621 matches.append(flat_match) 

622 

623 return matches 

624 

625 except Exception: 

626 logger.exception("Error querying matches by team") 

627 return [] 

628 

629 def get_match_preview( 

630 self, 

631 home_team_id: int, 

632 away_team_id: int, 

633 season_id: int | None = None, 

634 age_group_id: int | None = None, 

635 recent_count: int = 5, 

636 ) -> dict: 

637 """Get match preview data: recent form, common opponents, and head-to-head history. 

638 

639 Args: 

640 home_team_id: ID of the home team in the upcoming match 

641 away_team_id: ID of the away team in the upcoming match 

642 season_id: Season for recent form and common opponents (None = all seasons) 

643 age_group_id: Optional filter to restrict to a specific age group 

644 recent_count: How many recent matches to return per team 

645 

646 Returns: 

647 Dict with home_team_recent, away_team_recent, common_opponents, head_to_head 

648 """ 

649 select_str = """ 

650 *, 

651 home_team:teams!matches_home_team_id_fkey(id, name), 

652 away_team:teams!matches_away_team_id_fkey(id, name), 

653 season:seasons(id, name), 

654 age_group:age_groups(id, name), 

655 match_type:match_types(id, name), 

656 division:divisions(id, name) 

657 """ 

658 

659 def flatten(match: dict) -> dict: 

660 return { 

661 "id": match["id"], 

662 "match_date": match["match_date"], 

663 "scheduled_kickoff": match.get("scheduled_kickoff"), 

664 "home_team_id": match["home_team_id"], 

665 "away_team_id": match["away_team_id"], 

666 "home_team_name": match["home_team"]["name"] if match.get("home_team") else "Unknown", 

667 "away_team_name": match["away_team"]["name"] if match.get("away_team") else "Unknown", 

668 "home_score": match["home_score"], 

669 "away_score": match["away_score"], 

670 "season_id": match["season_id"], 

671 "season_name": match["season"]["name"] if match.get("season") else "Unknown", 

672 "age_group_id": match["age_group_id"], 

673 "age_group_name": match["age_group"]["name"] if match.get("age_group") else "Unknown", 

674 "match_type_id": match["match_type_id"], 

675 "match_type_name": match["match_type"]["name"] if match.get("match_type") else "Unknown", 

676 "division_id": match.get("division_id"), 

677 "division_name": match["division"]["name"] if match.get("division") else "Unknown", 

678 "match_status": match.get("match_status"), 

679 "source": match.get("source", "manual"), 

680 "match_id": match.get("match_id"), 

681 "created_at": match.get("created_at"), 

682 "updated_at": match.get("updated_at"), 

683 } 

684 

685 def build_base_query(team_id: int): 

686 q = ( 

687 self.client.table("matches") 

688 .select(select_str) 

689 .or_(f"home_team_id.eq.{team_id},away_team_id.eq.{team_id}") 

690 .in_("match_status", ["completed", "forfeit"]) 

691 ) 

692 if season_id: 

693 q = q.eq("season_id", season_id) 

694 if age_group_id: 

695 q = q.eq("age_group_id", age_group_id) 

696 return q 

697 

698 try: 

699 # --- Recent form for each team --- 

700 home_resp = build_base_query(home_team_id).order("match_date", desc=True).limit(recent_count).execute() 

701 away_resp = build_base_query(away_team_id).order("match_date", desc=True).limit(recent_count).execute() 

702 home_recent = [flatten(m) for m in (home_resp.data or [])] 

703 away_recent = [flatten(m) for m in (away_resp.data or [])] 

704 

705 # --- Common opponents (season-scoped) --- 

706 # Fetch all season matches for both teams to find shared opponents 

707 home_all_resp = build_base_query(home_team_id).order("match_date", desc=True).execute() 

708 away_all_resp = build_base_query(away_team_id).order("match_date", desc=True).execute() 

709 home_all = [flatten(m) for m in (home_all_resp.data or [])] 

710 away_all = [flatten(m) for m in (away_all_resp.data or [])] 

711 

712 def extract_opponents(matches: list[dict], team_id: int) -> dict[int, str]: 

713 """Return {opponent_id: opponent_name} excluding the two preview teams.""" 

714 opps: dict[int, str] = {} 

715 for m in matches: 

716 if m["home_team_id"] == team_id: 

717 opp_id, opp_name = m["away_team_id"], m["away_team_name"] 

718 else: 

719 opp_id, opp_name = m["home_team_id"], m["home_team_name"] 

720 # Exclude head-to-head matches between the two preview teams 

721 if opp_id not in (home_team_id, away_team_id): 

722 opps[opp_id] = opp_name 

723 return opps 

724 

725 home_opps = extract_opponents(home_all, home_team_id) 

726 away_opps = extract_opponents(away_all, away_team_id) 

727 common_ids = sorted(set(home_opps) & set(away_opps), key=lambda i: home_opps[i]) 

728 

729 common_opponents = [] 

730 for opp_id in common_ids: 

731 home_vs = [m for m in home_all if opp_id in (m["home_team_id"], m["away_team_id"])] 

732 away_vs = [m for m in away_all if opp_id in (m["home_team_id"], m["away_team_id"])] 

733 common_opponents.append( 

734 { 

735 "opponent_id": opp_id, 

736 "opponent_name": home_opps[opp_id], 

737 "home_team_matches": home_vs, 

738 "away_team_matches": away_vs, 

739 } 

740 ) 

741 

742 # --- Head-to-head history (all seasons, all age groups) --- 

743 # Intentionally NOT filtered by season or age group — teams age up 

744 # each year so cross-age-group history is meaningful and expected. 

745 h2h_query = ( 

746 self.client.table("matches") 

747 .select(select_str) 

748 .or_(f"home_team_id.eq.{home_team_id},away_team_id.eq.{home_team_id}") 

749 .in_("match_status", ["completed", "forfeit"]) 

750 ) 

751 h2h_resp = h2h_query.order("match_date", desc=True).execute() 

752 head_to_head = [ 

753 flatten(m) 

754 for m in (h2h_resp.data or []) 

755 if away_team_id in (m["home_team_id"], m["away_team_id"]) 

756 ] 

757 

758 return { 

759 "home_team_id": home_team_id, 

760 "away_team_id": away_team_id, 

761 "home_team_recent": home_recent, 

762 "away_team_recent": away_recent, 

763 "common_opponents": common_opponents, 

764 "head_to_head": head_to_head, 

765 } 

766 

767 except Exception: 

768 logger.exception("Error building match preview") 

769 return { 

770 "home_team_id": home_team_id, 

771 "away_team_id": away_team_id, 

772 "home_team_recent": [], 

773 "away_team_recent": [], 

774 "common_opponents": [], 

775 "head_to_head": [], 

776 } 

777 

778 @invalidates_cache(MATCHES_CACHE_PATTERN) 

779 def add_match( 

780 self, 

781 home_team_id: int, 

782 away_team_id: int, 

783 match_date: str, 

784 home_score: int, 

785 away_score: int, 

786 season_id: int, 

787 age_group_id: int, 

788 match_type_id: int, 

789 division_id: int | None = None, 

790 status: str | None = "scheduled", 

791 created_by: str | None = None, 

792 source: str = "manual", 

793 external_match_id: str | None = None, 

794 scheduled_kickoff: str | None = None, 

795 ) -> bool: 

796 """Add a new match with audit trail and optional external match_id.""" 

797 try: 

798 data = { 

799 "match_date": match_date, 

800 "home_team_id": home_team_id, 

801 "away_team_id": away_team_id, 

802 "home_score": home_score, 

803 "away_score": away_score, 

804 "season_id": season_id, 

805 "age_group_id": age_group_id, 

806 "match_type_id": match_type_id, 

807 "source": source, 

808 } 

809 

810 # Add optional fields 

811 if division_id: 

812 data["division_id"] = division_id 

813 if status: 

814 data["match_status"] = status # Map status to match_status column 

815 if created_by: 

816 data["created_by"] = created_by 

817 if external_match_id: 

818 data["match_id"] = external_match_id 

819 if scheduled_kickoff: 

820 data["scheduled_kickoff"] = scheduled_kickoff 

821 

822 response = self.client.table("matches").insert(data).execute() 

823 

824 return bool(response.data) 

825 

826 except APIError as e: 

827 error_dict = e.args[0] if e.args else {} 

828 if error_dict.get("code") == "23505": 

829 # Duplicate key violation 

830 logger.warning( 

831 "Duplicate match detected", 

832 home_team_id=home_team_id, 

833 away_team_id=away_team_id, 

834 match_date=match_date, 

835 details=error_dict.get("details"), 

836 ) 

837 raise DuplicateRecordError( 

838 message="A match with these teams on this date already exists", 

839 details=error_dict.get("details"), 

840 ) from e 

841 logger.exception("Error adding match") 

842 return False 

843 except Exception: 

844 logger.exception("Error adding match") 

845 return False 

846 

847 @invalidates_cache(MATCHES_CACHE_PATTERN) 

848 def add_match_with_external_id( 

849 self, 

850 home_team_id: int, 

851 away_team_id: int, 

852 match_date: str, 

853 home_score: int, 

854 away_score: int, 

855 season_id: int, 

856 age_group_id: int, 

857 match_type_id: int, 

858 external_match_id: str, 

859 division_id: int | None = None, 

860 created_by: str | None = None, 

861 source: str = "match-scraper", 

862 ) -> bool: 

863 """Add a new match with external match_id and audit trail. 

864 

865 This is a convenience wrapper around add_match() for backwards compatibility. 

866 Consider using add_match() directly with external_match_id parameter. 

867 """ 

868 return self.add_match( 

869 home_team_id=home_team_id, 

870 away_team_id=away_team_id, 

871 match_date=match_date, 

872 home_score=home_score, 

873 away_score=away_score, 

874 season_id=season_id, 

875 age_group_id=age_group_id, 

876 match_type_id=match_type_id, 

877 division_id=division_id, 

878 created_by=created_by, 

879 source=source, 

880 external_match_id=external_match_id, 

881 ) 

882 

883 @invalidates_cache(MATCHES_CACHE_PATTERN, PLAYOFF_CACHE_PATTERN, TOURNAMENTS_CACHE_PATTERN) 

884 def update_match( 

885 self, 

886 match_id: int, 

887 home_team_id: int, 

888 away_team_id: int, 

889 match_date: str, 

890 home_score: int, 

891 away_score: int, 

892 season_id: int, 

893 age_group_id: int, 

894 match_type_id: int, 

895 division_id: int | None = None, 

896 status: str | None = None, 

897 updated_by: str | None = None, 

898 external_match_id: str | None = None, 

899 scheduled_kickoff: str | None = None, 

900 ) -> dict | None: 

901 """Update an existing match with audit trail and optional external match_id. 

902 

903 Returns the updated match data to avoid read-after-write consistency issues. 

904 """ 

905 try: 

906 data = { 

907 "match_date": match_date, 

908 "home_team_id": home_team_id, 

909 "away_team_id": away_team_id, 

910 "home_score": home_score, 

911 "away_score": away_score, 

912 "season_id": season_id, 

913 "age_group_id": age_group_id, 

914 "match_type_id": match_type_id, 

915 "division_id": division_id, 

916 } 

917 

918 # Add optional fields 

919 if status: 

920 data["match_status"] = status 

921 if updated_by: 

922 data["updated_by"] = updated_by 

923 if external_match_id is not None: # Allow explicit None to clear match_id 

924 data["match_id"] = external_match_id 

925 if scheduled_kickoff is not None: # Allow explicit None to clear scheduled_kickoff 

926 data["scheduled_kickoff"] = scheduled_kickoff 

927 

928 # Execute update 

929 response = self.client.table("matches").update(data).eq("id", match_id).execute() 

930 

931 # Check if update actually affected any rows 

932 if not response.data or len(response.data) == 0: 

933 logger.warning("Update match failed - no rows affected", match_id=match_id) 

934 # Return None to signal failure 

935 return None 

936 

937 # Clear cache BEFORE re-fetch to avoid returning stale cached data. 

938 # The @invalidates_cache decorator clears AFTER the function returns, 

939 # but get_match_by_id uses @dao_cache and would hit stale cache. 

940 clear_cache(MATCHES_CACHE_PATTERN) 

941 clear_cache(PLAYOFF_CACHE_PATTERN) 

942 clear_cache(TOURNAMENTS_CACHE_PATTERN) 

943 

944 # Get the updated match to return with full relations 

945 return self.get_match_by_id(match_id) 

946 

947 except Exception: 

948 logger.exception("Error updating match") 

949 return None 

950 

951 @dao_cache("matches:by_id:{match_id}") 

952 def get_match_by_id(self, match_id: int) -> dict | None: 

953 """Get a single match by ID with all related data.""" 

954 try: 

955 response = ( 

956 self.client.table("matches") 

957 .select(""" 

958 *, 

959 home_team:teams!matches_home_team_id_fkey( 

960 id, name, 

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

962 division:divisions(id, leagues(sport_type)) 

963 ), 

964 away_team:teams!matches_away_team_id_fkey( 

965 id, name, 

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

967 ), 

968 season:seasons(id, name), 

969 age_group:age_groups(id, name), 

970 match_type:match_types(id, name), 

971 division:divisions(id, name, leagues(id, name, sport_type)) 

972 """) 

973 .eq("id", match_id) 

974 .execute() 

975 ) 

976 

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

978 match = response.data[0] 

979 # Extract sport_type from division -> leagues (match division) 

980 division = match.get("division") or {} 

981 league = division.get("leagues") or {} 

982 sport_type = league.get("sport_type") 

983 

984 # Fallback: get sport_type from home team's division -> league 

985 if not sport_type: 

986 home_team = match.get("home_team") or {} 

987 team_div = home_team.get("division") or {} 

988 team_league = team_div.get("leagues") or {} 

989 sport_type = team_league.get("sport_type", "soccer") 

990 

991 # Flatten the response to match the format from get_all_matches 

992 flat_match = { 

993 "id": match["id"], 

994 "match_date": match["match_date"], 

995 "scheduled_kickoff": match.get("scheduled_kickoff"), 

996 "home_team_id": match["home_team_id"], 

997 "away_team_id": match["away_team_id"], 

998 "home_team_name": match["home_team"]["name"] if match.get("home_team") else "Unknown", 

999 "away_team_name": match["away_team"]["name"] if match.get("away_team") else "Unknown", 

1000 "home_team_club": match["home_team"].get("club") if match.get("home_team") else None, 

1001 "away_team_club": match["away_team"].get("club") if match.get("away_team") else None, 

1002 "home_score": match["home_score"], 

1003 "away_score": match["away_score"], 

1004 "season_id": match["season_id"], 

1005 "season_name": match["season"]["name"] if match.get("season") else "Unknown", 

1006 "age_group_id": match["age_group_id"], 

1007 "age_group_name": match["age_group"]["name"] if match.get("age_group") else "Unknown", 

1008 "match_type_id": match["match_type_id"], 

1009 "match_type_name": match["match_type"]["name"] if match.get("match_type") else "Unknown", 

1010 "division_id": match.get("division_id"), 

1011 "division_name": match["division"]["name"] if match.get("division") else "Unknown", 

1012 "division": match.get("division"), # Include full division object with leagues 

1013 "sport_type": sport_type, 

1014 "match_status": match.get("match_status"), 

1015 "created_by": match.get("created_by"), 

1016 "updated_by": match.get("updated_by"), 

1017 "source": match.get("source", "manual"), 

1018 "match_id": match.get("match_id"), # External match identifier 

1019 "created_at": match["created_at"], 

1020 "updated_at": match["updated_at"], 

1021 # Live match clock fields 

1022 "kickoff_time": match.get("kickoff_time"), 

1023 "halftime_start": match.get("halftime_start"), 

1024 "second_half_start": match.get("second_half_start"), 

1025 "match_end_time": match.get("match_end_time"), 

1026 "half_duration": match.get("half_duration", 45), 

1027 } 

1028 return flat_match 

1029 else: 

1030 return None 

1031 

1032 except Exception: 

1033 logger.exception("Error retrieving match by ID") 

1034 return None 

1035 

1036 @invalidates_cache(MATCHES_CACHE_PATTERN, TOURNAMENTS_CACHE_PATTERN) 

1037 def delete_match(self, match_id: int) -> bool: 

1038 """Delete a match.""" 

1039 try: 

1040 self.client.table("matches").delete().eq("id", match_id).execute() 

1041 

1042 return True # Supabase delete returns empty data even on success 

1043 

1044 except Exception: 

1045 logger.exception("Error deleting match") 

1046 return False 

1047 

1048 @dao_cache("matches:table:{season_id}:{age_group_id}:{division_id}:{match_type}") 

1049 def get_league_table( 

1050 self, 

1051 season_id: int | None = None, 

1052 age_group_id: int | None = None, 

1053 division_id: int | None = None, 

1054 match_type: str = "League", 

1055 ) -> list[dict]: 

1056 """ 

1057 Generate league table with optional filters. 

1058 

1059 This method fetches matches from the database and delegates 

1060 the standings calculation to pure functions in dao/standings.py. 

1061 

1062 Args: 

1063 season_id: Filter by season 

1064 age_group_id: Filter by age group 

1065 division_id: Filter by division 

1066 match_type: Filter by match type name (default: "League") 

1067 

1068 Returns: 

1069 List of team standings sorted by points, goal difference, goals scored 

1070 """ 

1071 try: 

1072 logger.info( 

1073 "generating league table from database", 

1074 season_id=season_id, 

1075 age_group_id=age_group_id, 

1076 division_id=division_id, 

1077 match_type=match_type, 

1078 ) 

1079 # Fetch matches from database 

1080 matches = self._fetch_matches_for_standings(season_id, age_group_id, division_id) 

1081 

1082 # Apply filters using pure functions 

1083 matches = filter_by_match_type(matches, match_type) 

1084 if division_id: 

1085 matches = filter_same_division_matches(matches, division_id) 

1086 matches = filter_completed_matches(matches) 

1087 

1088 # Calculate standings using pure function (with form + movement) 

1089 return calculate_standings_with_extras(matches) 

1090 

1091 except Exception: 

1092 logger.exception("Error generating league table") 

1093 return [] 

1094 

1095 def _fetch_matches_for_standings( 

1096 self, 

1097 season_id: int | None = None, 

1098 age_group_id: int | None = None, 

1099 division_id: int | None = None, 

1100 ) -> list[dict]: 

1101 """ 

1102 Fetch matches from database for standings calculation. 

1103 

1104 This is a thin data access method that only handles the query. 

1105 All business logic is in pure functions. 

1106 

1107 Args: 

1108 season_id: Filter by season 

1109 age_group_id: Filter by age group 

1110 division_id: Filter by division 

1111 

1112 Returns: 

1113 List of match dictionaries from database 

1114 """ 

1115 logger.info( 

1116 "fetching matches for standings calculation from database", 

1117 season_id=season_id, 

1118 age_group_id=age_group_id, 

1119 division_id=division_id, 

1120 ) 

1121 query = self.client.table("matches").select(""" 

1122 *, 

1123 home_team:teams!matches_home_team_id_fkey(id, name, division_id, club:clubs(id, name, logo_url)), 

1124 away_team:teams!matches_away_team_id_fkey(id, name, division_id, club:clubs(id, name, logo_url)), 

1125 match_type:match_types(id, name) 

1126 """) 

1127 

1128 # Apply database-level filters 

1129 if season_id: 

1130 query = query.eq("season_id", season_id) 

1131 if age_group_id: 

1132 query = query.eq("age_group_id", age_group_id) 

1133 if division_id: 

1134 query = query.eq("division_id", division_id) 

1135 

1136 response = query.execute() 

1137 return response.data 

1138 

1139 # === Live Match Methods === 

1140 

1141 def get_live_matches(self) -> list[dict]: 

1142 """Get all matches with status 'live'. 

1143 

1144 Returns minimal data for the LIVE tab polling. 

1145 """ 

1146 try: 

1147 response = ( 

1148 self.client.table("matches") 

1149 .select(""" 

1150 id, 

1151 match_status, 

1152 match_date, 

1153 home_score, 

1154 away_score, 

1155 kickoff_time, 

1156 home_team:teams!matches_home_team_id_fkey(id, name), 

1157 away_team:teams!matches_away_team_id_fkey(id, name) 

1158 """) 

1159 .eq("match_status", "live") 

1160 .execute() 

1161 ) 

1162 

1163 # Flatten the response 

1164 result = [] 

1165 for match in response.data or []: 

1166 result.append( 

1167 { 

1168 "match_id": match["id"], 

1169 "match_status": match["match_status"], 

1170 "match_date": match["match_date"], 

1171 "home_score": match["home_score"], 

1172 "away_score": match["away_score"], 

1173 "kickoff_time": match.get("kickoff_time"), 

1174 "home_team_name": match["home_team"]["name"] if match.get("home_team") else "Unknown", 

1175 "away_team_name": match["away_team"]["name"] if match.get("away_team") else "Unknown", 

1176 } 

1177 ) 

1178 return result 

1179 

1180 except Exception: 

1181 logger.exception("Error getting live matches") 

1182 return [] 

1183 

1184 def get_live_match_state(self, match_id: int) -> dict | None: 

1185 """Get full live match state including clock timestamps. 

1186 

1187 Returns match data with clock fields for the live match view. 

1188 """ 

1189 try: 

1190 response = ( 

1191 self.client.table("matches") 

1192 .select(""" 

1193 *, 

1194 home_team:teams!matches_home_team_id_fkey( 

1195 id, name, 

1196 club:clubs(id, logo_url), 

1197 division:divisions(id, leagues(sport_type)) 

1198 ), 

1199 away_team:teams!matches_away_team_id_fkey(id, name, club:clubs(id, logo_url)), 

1200 age_group:age_groups(id, name), 

1201 match_type:match_types(id, name), 

1202 division:divisions(id, name, leagues(id, name, sport_type)) 

1203 """) 

1204 .eq("id", match_id) 

1205 .single() 

1206 .execute() 

1207 ) 

1208 

1209 if not response.data: 

1210 return None 

1211 

1212 match = response.data 

1213 # Extract club logo URLs from nested team -> club relationships 

1214 home_team = match.get("home_team") or {} 

1215 away_team = match.get("away_team") or {} 

1216 home_club = home_team.get("club") or {} 

1217 away_club = away_team.get("club") or {} 

1218 

1219 # Extract sport_type from division -> leagues 

1220 division = match.get("division") or {} 

1221 league = division.get("leagues") or {} 

1222 sport_type = league.get("sport_type") 

1223 

1224 # Fallback: get sport_type from home team's division -> league 

1225 if not sport_type: 

1226 team_div = home_team.get("division") or {} 

1227 team_league = team_div.get("leagues") or {} 

1228 sport_type = team_league.get("sport_type", "soccer") 

1229 

1230 return { 

1231 "match_id": match["id"], 

1232 "match_status": match.get("match_status"), 

1233 "match_date": match["match_date"], 

1234 "scheduled_kickoff": match.get("scheduled_kickoff"), 

1235 "home_score": match["home_score"], 

1236 "away_score": match["away_score"], 

1237 "kickoff_time": match.get("kickoff_time"), 

1238 "halftime_start": match.get("halftime_start"), 

1239 "second_half_start": match.get("second_half_start"), 

1240 "match_end_time": match.get("match_end_time"), 

1241 "half_duration": match.get("half_duration", 45), 

1242 "season_id": match.get("season_id"), 

1243 "home_team_id": match["home_team_id"], 

1244 "home_team_name": home_team.get("name", "Unknown"), 

1245 "home_team_logo": home_club.get("logo_url"), 

1246 "away_team_id": match["away_team_id"], 

1247 "away_team_name": away_team.get("name", "Unknown"), 

1248 "away_team_logo": away_club.get("logo_url"), 

1249 "age_group_name": match["age_group"]["name"] if match.get("age_group") else None, 

1250 "match_type_name": match["match_type"]["name"] if match.get("match_type") else None, 

1251 "division_name": match["division"]["name"] if match.get("division") else None, 

1252 "sport_type": sport_type, 

1253 } 

1254 

1255 except Exception: 

1256 logger.exception("Error getting live match state", match_id=match_id) 

1257 return None 

1258 

1259 @invalidates_cache(MATCHES_CACHE_PATTERN, TOURNAMENTS_CACHE_PATTERN) 

1260 def update_match_clock( 

1261 self, 

1262 match_id: int, 

1263 action: str, 

1264 updated_by: str | None = None, 

1265 half_duration: int | None = None, 

1266 ) -> dict | None: 

1267 """Update match clock based on action. 

1268 

1269 Args: 

1270 match_id: The match to update 

1271 action: Clock action - 'start_first_half', 'start_halftime', 

1272 'start_second_half', 'end_match' 

1273 updated_by: UUID of user performing the action 

1274 half_duration: Duration of each half in minutes (only for start_first_half) 

1275 

1276 Returns: 

1277 Updated match state or None on error 

1278 """ 

1279 from datetime import datetime 

1280 

1281 try: 

1282 now = datetime.now(UTC).isoformat() 

1283 data: dict = {"updated_by": updated_by} if updated_by else {} 

1284 

1285 if action == "start_first_half": 

1286 # Start the match - set kickoff time and status to live 

1287 data["kickoff_time"] = now 

1288 data["match_status"] = "live" 

1289 # Set half duration if provided (default is 45) 

1290 if half_duration: 

1291 data["half_duration"] = half_duration 

1292 elif action == "start_halftime": 

1293 # Mark halftime started 

1294 data["halftime_start"] = now 

1295 elif action == "start_second_half": 

1296 # Start second half 

1297 data["second_half_start"] = now 

1298 elif action == "end_match": 

1299 # End the match 

1300 data["match_end_time"] = now 

1301 data["match_status"] = "completed" 

1302 else: 

1303 logger.warning("Invalid clock action", action=action) 

1304 return None 

1305 

1306 response = self.client.table("matches").update(data).eq("id", match_id).execute() 

1307 

1308 if not response.data: 

1309 logger.warning("Clock update failed - no rows affected", match_id=match_id) 

1310 return None 

1311 

1312 logger.info( 

1313 "match_clock_updated", 

1314 match_id=match_id, 

1315 action=action, 

1316 ) 

1317 

1318 # Return updated state 

1319 return self.get_live_match_state(match_id) 

1320 

1321 except Exception: 

1322 logger.exception("Error updating match clock", match_id=match_id, action=action) 

1323 return None 

1324 

1325 @invalidates_cache(MATCHES_CACHE_PATTERN, TOURNAMENTS_CACHE_PATTERN) 

1326 def update_match_score( 

1327 self, 

1328 match_id: int, 

1329 home_score: int, 

1330 away_score: int, 

1331 updated_by: str | None = None, 

1332 ) -> dict | None: 

1333 """Update match score (used when posting a goal). 

1334 

1335 Args: 

1336 match_id: The match to update 

1337 home_score: New home team score 

1338 away_score: New away team score 

1339 updated_by: UUID of user performing the action 

1340 

1341 Returns: 

1342 Updated match state or None on error 

1343 """ 

1344 try: 

1345 data = { 

1346 "home_score": home_score, 

1347 "away_score": away_score, 

1348 } 

1349 if updated_by: 

1350 data["updated_by"] = updated_by 

1351 

1352 response = self.client.table("matches").update(data).eq("id", match_id).execute() 

1353 

1354 if not response.data: 

1355 logger.warning("Score update failed - no rows affected", match_id=match_id) 

1356 return None 

1357 

1358 logger.info( 

1359 "match_score_updated", 

1360 match_id=match_id, 

1361 home_score=home_score, 

1362 away_score=away_score, 

1363 ) 

1364 

1365 return self.get_live_match_state(match_id) 

1366 

1367 except Exception: 

1368 logger.exception("Error updating match score", match_id=match_id) 

1369 return None 

1370 

1371 def get_agent_matches( 

1372 self, 

1373 team: str, 

1374 age_group: str, 

1375 league: str, 

1376 division: str, 

1377 season: str, 

1378 start_date: str | None = None, 

1379 end_date: str | None = None, 

1380 ) -> list[dict]: 

1381 """Get individual match records for the audit agent's comparison step. 

1382 

1383 Returns matches involving the specified team (home or away) in the 

1384 given age-group/league/division/season. Used by GET /api/agent/matches. 

1385 

1386 Args: 

1387 team: MT canonical team name, e.g. "IFA". 

1388 age_group: e.g. "U14". 

1389 league: e.g. "Homegrown". 

1390 division: e.g. "Northeast". 

1391 season: e.g. "2025-2026". 

1392 start_date: If set, only return matches on or after this date (YYYY-MM-DD). 

1393 end_date: If set, only return matches on or before this date (YYYY-MM-DD). 

1394 

1395 Returns: 

1396 List of match dicts with fields the audit comparator expects. 

1397 """ 

1398 # Resolve IDs for the reference dimensions 

1399 season_resp = self.client.table("seasons").select("id").eq("name", season).limit(1).execute() 

1400 if not season_resp.data: 

1401 logger.warning("get_agent_matches.season_not_found", season=season) 

1402 return [] 

1403 season_id = season_resp.data[0]["id"] 

1404 

1405 ag_resp = self.client.table("age_groups").select("id").eq("name", age_group).limit(1).execute() 

1406 if not ag_resp.data: 

1407 logger.warning("get_agent_matches.age_group_not_found", age_group=age_group) 

1408 return [] 

1409 age_group_id = ag_resp.data[0]["id"] 

1410 

1411 # Resolve division_id via league join 

1412 div_resp = ( 

1413 self.client.table("divisions") 

1414 .select("id, leagues!divisions_league_id_fkey(name)") 

1415 .eq("name", division) 

1416 .execute() 

1417 ) 

1418 division_id = None 

1419 for row in div_resp.data or []: 

1420 if row.get("leagues") and row["leagues"].get("name") == league: 

1421 division_id = row["id"] 

1422 break 

1423 if division_id is None: 

1424 logger.warning("get_agent_matches.division_not_found", division=division, league=league) 

1425 return [] 

1426 

1427 # Resolve team IDs matching the given name 

1428 team_resp = self.client.table("teams").select("id").eq("name", team).execute() 

1429 team_ids = [r["id"] for r in (team_resp.data or [])] 

1430 if not team_ids: 

1431 logger.warning("get_agent_matches.team_not_found", team=team) 

1432 return [] 

1433 

1434 # Build OR filter for home/away team membership 

1435 or_filter = ",".join( 

1436 [f"home_team_id.eq.{tid}" for tid in team_ids] + [f"away_team_id.eq.{tid}" for tid in team_ids] 

1437 ) 

1438 

1439 try: 

1440 query = ( 

1441 self.client.table("matches") 

1442 .select( 

1443 "match_id, match_date, scheduled_kickoff, home_score, away_score, match_status, " 

1444 "home_team:teams!matches_home_team_id_fkey(name), " 

1445 "away_team:teams!matches_away_team_id_fkey(name)" 

1446 ) 

1447 .eq("season_id", season_id) 

1448 .eq("age_group_id", age_group_id) 

1449 .eq("division_id", division_id) 

1450 .neq("match_status", "cancelled") 

1451 ) 

1452 if start_date: 

1453 query = query.gte("match_date", start_date) 

1454 if end_date: 

1455 query = query.lte("match_date", end_date) 

1456 response = query.or_(or_filter).order("match_date", desc=False).execute() 

1457 except Exception: 

1458 logger.exception("get_agent_matches.query_error", team=team) 

1459 return [] 

1460 

1461 results = [] 

1462 for m in response.data or []: 

1463 # Format match_time as "HH:MM" from scheduled_kickoff (UTC) 

1464 match_time = None 

1465 if m.get("scheduled_kickoff"): 

1466 try: 

1467 from datetime import datetime 

1468 

1469 kt = datetime.fromisoformat(m["scheduled_kickoff"].replace("Z", "+00:00")) 

1470 if kt.hour or kt.minute: 

1471 match_time = kt.strftime("%H:%M") 

1472 except (ValueError, AttributeError): 

1473 pass 

1474 

1475 results.append( 

1476 { 

1477 "external_match_id": m.get("match_id"), 

1478 "home_team": m["home_team"]["name"] if m.get("home_team") else None, 

1479 "away_team": m["away_team"]["name"] if m.get("away_team") else None, 

1480 "match_date": m["match_date"], 

1481 "match_time": match_time, 

1482 "home_score": m.get("home_score"), 

1483 "away_score": m.get("away_score"), 

1484 "match_status": m.get("match_status"), 

1485 "age_group": age_group, 

1486 "league": league, 

1487 "division": division, 

1488 "season": season, 

1489 } 

1490 ) 

1491 

1492 logger.info( 

1493 "get_agent_matches.done", 

1494 team=team, 

1495 age_group=age_group, 

1496 count=len(results), 

1497 ) 

1498 return results 

1499 

1500 def cancel_match( 

1501 self, 

1502 home_team: str, 

1503 away_team: str, 

1504 match_date: str, 

1505 age_group: str, 

1506 league: str, 

1507 division: str, 

1508 season: str, 

1509 ) -> bool: 

1510 """Mark a match as cancelled by natural key. 

1511 

1512 Only cancels matches with no score (home_score IS NULL) to avoid 

1513 accidentally cancelling completed matches. 

1514 

1515 Returns True if a match was found and cancelled, False if not found. 

1516 """ 

1517 # Resolve dimension IDs (same pattern as get_agent_matches) 

1518 season_resp = self.client.table("seasons").select("id").eq("name", season).limit(1).execute() 

1519 if not season_resp.data: 

1520 logger.warning("cancel_match.season_not_found", season=season) 

1521 return False 

1522 season_id = season_resp.data[0]["id"] 

1523 

1524 ag_resp = self.client.table("age_groups").select("id").eq("name", age_group).limit(1).execute() 

1525 if not ag_resp.data: 

1526 logger.warning("cancel_match.age_group_not_found", age_group=age_group) 

1527 return False 

1528 age_group_id = ag_resp.data[0]["id"] 

1529 

1530 div_resp = ( 

1531 self.client.table("divisions") 

1532 .select("id, leagues!divisions_league_id_fkey(name)") 

1533 .eq("name", division) 

1534 .execute() 

1535 ) 

1536 division_id = None 

1537 for row in div_resp.data or []: 

1538 if row.get("leagues") and row["leagues"].get("name") == league: 

1539 division_id = row["id"] 

1540 break 

1541 if division_id is None: 

1542 logger.warning("cancel_match.division_not_found", division=division, league=league) 

1543 return False 

1544 

1545 home_resp = self.client.table("teams").select("id").eq("name", home_team).execute() 

1546 home_ids = [r["id"] for r in (home_resp.data or [])] 

1547 away_resp = self.client.table("teams").select("id").eq("name", away_team).execute() 

1548 away_ids = [r["id"] for r in (away_resp.data or [])] 

1549 if not home_ids or not away_ids: 

1550 logger.warning("cancel_match.team_not_found", home_team=home_team, away_team=away_team) 

1551 return False 

1552 

1553 or_parts = [ 

1554 f"and(home_team_id.eq.{hid},away_team_id.eq.{aid})" 

1555 for hid in home_ids 

1556 for aid in away_ids 

1557 ] 

1558 resp = ( 

1559 self.client.table("matches") 

1560 .select("id, home_score") 

1561 .eq("season_id", season_id) 

1562 .eq("age_group_id", age_group_id) 

1563 .eq("division_id", division_id) 

1564 .eq("match_date", match_date) 

1565 .is_("home_score", "null") # safety: never cancel scored matches 

1566 .or_(",".join(or_parts)) 

1567 .limit(1) 

1568 .execute() 

1569 ) 

1570 

1571 if not resp.data: 

1572 logger.warning( 

1573 "cancel_match.not_found", 

1574 home_team=home_team, 

1575 away_team=away_team, 

1576 match_date=match_date, 

1577 ) 

1578 return False 

1579 

1580 internal_id = resp.data[0]["id"] 

1581 self.client.table("matches").update({"match_status": "cancelled"}).eq("id", internal_id).execute() 

1582 logger.info( 

1583 "cancel_match.done", 

1584 internal_id=internal_id, 

1585 home_team=home_team, 

1586 away_team=away_team, 

1587 match_date=match_date, 

1588 ) 

1589 return True 

1590 

1591 # Admin CRUD methods for reference data have been moved to: 

1592 # - SeasonDAO (seasons, age_groups) 

1593 # - LeagueDAO (leagues, divisions) 

1594 # - MatchTypeDAO (match_types) 

1595 # Team methods moved to TeamDAO - see dao/team_dao.py 

1596 # Club methods moved to ClubDAO - see dao/club_dao.py 

1597 # User profile and player methods moved to PlayerDAO - see dao/player_dao.py