Coverage for manage_clubs.py: 0.00%

579 statements  

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

1#!/usr/bin/env python3 

2""" 

3Club and Team Management CLI Tool 

4 

5This tool manages clubs and teams based on clubs.json file. 

6Uses the backend API to create, update, and delete clubs and teams. 

7 

8Usage: 

9 python manage_clubs.py sync # Sync clubs.json to database 

10 python manage_clubs.py list # List all clubs and teams 

11 python manage_clubs.py delete-club <id> # Delete a club 

12 python manage_clubs.py delete-team <id> # Delete a team 

13 python manage_clubs.py logo-status # Show logo status for all clubs 

14 python manage_clubs.py upload-logos # Upload prepared logos to DB 

15""" 

16 

17import colorsys 

18import json 

19import os 

20from collections import Counter 

21from pathlib import Path 

22from typing import Any 

23 

24import requests 

25import typer 

26from dotenv import load_dotenv 

27from rich import box 

28from rich.console import Console 

29from rich.progress import Progress, SpinnerColumn, TextColumn 

30from rich.prompt import Confirm 

31from rich.table import Table 

32 

33from models.clubs import ClubData, TeamData, club_name_to_slug, load_clubs_from_json 

34 

35# Initialize Typer app and Rich console 

36app = typer.Typer(help="Club and Team Management CLI Tool") 

37console = Console() 

38 

39# API Configuration 

40API_URL = os.getenv("API_URL", "http://localhost:8000") 

41CLUBS_JSON_PATH = Path(__file__).parent.parent / "clubs.json" 

42LOGO_DIR = Path(__file__).parent.parent / "club-logos" 

43LOGO_READY_DIR = LOGO_DIR / "ready" 

44 

45 

46# ============================================================================ 

47# Authentication & API Helper Functions 

48# ============================================================================ 

49 

50 

51def get_auth_token() -> str: 

52 """Get authentication token for API requests.""" 

53 # For local development, use admin credentials 

54 env = os.getenv("APP_ENV", "local") 

55 

56 # Load environment file from backend directory 

57 backend_dir = Path(__file__).parent 

58 env_file = backend_dir / f".env.{env}" 

59 load_dotenv(env_file) 

60 

61 # Try to login as admin user 

62 username = "tom" 

63 password = os.getenv("TEST_USER_PASSWORD_TOM", "admin123") 

64 

65 response = requests.post( 

66 f"{API_URL}/api/auth/login", 

67 json={"username": username, "password": password}, 

68 headers={"Content-Type": "application/json"}, 

69 ) 

70 

71 if response.status_code == 200: 

72 data = response.json() 

73 return data["access_token"] 

74 else: 

75 console.print(f"[red]❌ Authentication failed: {response.text}[/red]") 

76 raise typer.Exit(code=1) 

77 

78 

79def api_request(method: str, endpoint: str, token: str, data: dict[str, Any] | None = None) -> requests.Response: 

80 """Make an authenticated API request.""" 

81 headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} 

82 

83 url = f"{API_URL}{endpoint}" 

84 

85 if method.upper() == "GET": 

86 response = requests.get(url, headers=headers) 

87 elif method.upper() == "POST": 

88 response = requests.post(url, headers=headers, json=data) 

89 elif method.upper() == "PUT": 

90 response = requests.put(url, headers=headers, json=data) 

91 elif method.upper() == "DELETE": 

92 response = requests.delete(url, headers=headers) 

93 else: 

94 raise ValueError(f"Unsupported HTTP method: {method}") 

95 

96 return response 

97 

98 

99# ============================================================================ 

100# Data Loading Functions 

101# ============================================================================ 

102 

103 

104def load_clubs_json() -> list[ClubData]: 

105 """Load and validate clubs data from clubs.json file.""" 

106 if not CLUBS_JSON_PATH.exists(): 

107 console.print(f"[red]❌ clubs.json not found at: {CLUBS_JSON_PATH}[/red]") 

108 raise typer.Exit(code=1) 

109 

110 with open(CLUBS_JSON_PATH) as f: 

111 clubs_raw = json.load(f) 

112 

113 try: 

114 clubs = load_clubs_from_json(clubs_raw) 

115 return clubs 

116 except Exception as e: 

117 console.print(f"[red]❌ Error parsing clubs.json: {e}[/red]") 

118 raise typer.Exit(code=1) from e 

119 

120 

121def get_league_id_by_name(token: str, league_name: str) -> int | None: 

122 """Get league ID by name.""" 

123 response = api_request("GET", "/api/leagues", token) 

124 

125 if response.status_code == 200: 

126 leagues = response.json() 

127 for league in leagues: 

128 if league["name"].lower() == league_name.lower(): 

129 return league["id"] 

130 

131 return None 

132 

133 

134def get_division_id_by_name_and_league(token: str, division_name: str, league_id: int) -> int | None: 

135 """Get division ID by name within a specific league. 

136 

137 Args: 

138 token: Authentication token 

139 division_name: Division name (e.g., "Northeast Division", "New England Conference") 

140 league_id: League ID to filter divisions 

141 

142 Returns: 

143 Division ID if found, None otherwise 

144 """ 

145 response = api_request("GET", f"/api/divisions?league_id={league_id}", token) 

146 

147 if response.status_code == 200: 

148 divisions = response.json() 

149 for division in divisions: 

150 if division["name"].lower() == division_name.lower(): 

151 return division["id"] 

152 

153 return None 

154 

155 

156def get_age_group_ids_by_names(token: str, age_group_names: list[str]) -> list[int]: 

157 """Get age group IDs by names. 

158 

159 Args: 

160 token: Authentication token 

161 age_group_names: List of age group names (e.g., ["U13", "U14", "U15"]) 

162 

163 Returns: 

164 List of age group IDs that were found (may be shorter than input list) 

165 """ 

166 response = api_request("GET", "/api/age-groups", token) 

167 

168 if response.status_code == 200: 

169 age_groups = response.json() 

170 # Create a case-insensitive mapping 

171 age_group_map = {ag["name"].lower(): ag["id"] for ag in age_groups} 

172 

173 # Look up each requested age group 

174 ids = [] 

175 for name in age_group_names: 

176 ag_id = age_group_map.get(name.lower()) 

177 if ag_id: 

178 ids.append(ag_id) 

179 else: 

180 console.print(f"[yellow]⚠️ Age group not found: {name}[/yellow]") 

181 

182 return ids 

183 

184 return [] 

185 

186 

187# ============================================================================ 

188# Club Management Functions 

189# ============================================================================ 

190 

191 

192def get_all_clubs(token: str) -> list[dict[str, Any]]: 

193 """Fetch all clubs from the API.""" 

194 # Note: include_teams may fail if teams.club_id column doesn't exist yet 

195 # Try without teams first as a fallback 

196 response = api_request("GET", "/api/clubs?include_teams=false", token) 

197 

198 if response.status_code == 200: 

199 clubs = response.json() 

200 # Handle both list and dict responses 

201 if isinstance(clubs, dict): 

202 # If API returns a dict, it might be wrapped or have errors 

203 if "data" in clubs: 

204 return clubs["data"] 

205 elif "clubs" in clubs: 

206 return clubs["clubs"] 

207 else: 

208 # Log the unexpected format for debugging 

209 console.print(f"[yellow]⚠️ Unexpected clubs response format: {type(clubs)}[/yellow]") 

210 return [] 

211 # Return clubs if it's a list, otherwise empty list 

212 if type(clubs).__name__ == "list": 

213 return clubs 

214 return [] 

215 else: 

216 console.print(f"[red]❌ Failed to fetch clubs (status {response.status_code}): {response.text}[/red]") 

217 return [] 

218 

219 

220def find_club_by_name(token: str, club_name: str) -> dict[str, Any] | None: 

221 """Find a club by name.""" 

222 clubs = get_all_clubs(token) 

223 for club in clubs: 

224 if club["name"].lower() == club_name.lower(): 

225 return club 

226 return None 

227 

228 

229def create_club(token: str, club: ClubData) -> dict[str, Any] | None: 

230 """Create a new club.""" 

231 payload = { 

232 "name": club.club_name, 

233 "city": club.location, 

234 "website": club.website, 

235 "description": f"Club based in {club.location}", 

236 "is_active": True, 

237 "logo_url": club.logo_url or None, 

238 "primary_color": club.primary_color or None, 

239 "secondary_color": club.secondary_color or None, 

240 "instagram": club.instagram or None, 

241 } 

242 

243 response = api_request("POST", "/api/clubs", token, data=payload) 

244 

245 if response.status_code == 200: 

246 return response.json() 

247 elif "already exists" in response.text.lower(): 

248 # Club already exists - this is OK for idempotent operation 

249 # Return a dummy club object so caller knows it exists 

250 return {"name": club.club_name, "exists": True} 

251 else: 

252 console.print(f"[red]❌ Failed to create club '{club.club_name}': {response.text}[/red]") 

253 return None 

254 

255 

256def update_club(token: str, club_id: int, club: ClubData) -> bool: 

257 """Update an existing club.""" 

258 payload = { 

259 "name": club.club_name, 

260 "city": club.location, 

261 "website": club.website, 

262 "description": f"Club based in {club.location}", 

263 "is_active": True, 

264 "logo_url": club.logo_url or None, 

265 "primary_color": club.primary_color or None, 

266 "secondary_color": club.secondary_color or None, 

267 "instagram": club.instagram or None, 

268 } 

269 

270 response = api_request("PUT", f"/api/clubs/{club_id}", token, data=payload) 

271 

272 if response.status_code == 200: 

273 return True 

274 else: 

275 console.print(f"[yellow]⚠️ Failed to update club (ID: {club_id}): {response.text}[/yellow]") 

276 return False 

277 

278 

279def upload_club_logo(token: str, club_id: int, logo_path: Path) -> bool: 

280 """Upload a local logo file for a club via the API. 

281 

282 The API endpoint generates _sm (64px) and _md (128px) size variants 

283 automatically from the uploaded base image. 

284 """ 

285 url = f"{API_URL}/api/clubs/{club_id}/logo" 

286 headers = {"Authorization": f"Bearer {token}"} 

287 with open(logo_path, "rb") as f: 

288 response = requests.post(url, headers=headers, files={"file": (logo_path.name, f, "image/png")}) 

289 if response.status_code == 200: 

290 return True 

291 else: 

292 console.print(f"[yellow] Failed to upload logo for club {club_id}: {response.text}[/yellow]") 

293 return False 

294 

295 

296def extract_brand_colors(logo_path: Path) -> tuple[str | None, str | None]: 

297 """Extract primary and secondary brand colors from a logo image. 

298 

299 Analyzes opaque, saturated pixels to find the dominant brand colors, 

300 filtering out transparent, near-white, near-black, and gray pixels. 

301 

302 Returns: 

303 (primary_hex, secondary_hex) tuple, e.g. ('#0060a0', '#c09040'). 

304 Returns (None, None) if no suitable colors are found. 

305 """ 

306 from PIL import Image 

307 

308 img = Image.open(logo_path).convert("RGBA") 

309 pixels = list(img.getdata()) 

310 

311 # Keep only opaque, saturated, mid-brightness pixels 

312 filtered: list[tuple[int, int, int]] = [] 

313 for r, g, b, a in pixels: 

314 if a < 128: 

315 continue 

316 brightness = (r + g + b) / 3 

317 if brightness < 30 or brightness > 225: 

318 continue 

319 _, s, _ = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255) 

320 if s < 0.15: 

321 continue 

322 # Quantize to reduce noise (round to nearest 16) 

323 qr, qg, qb = (r >> 4) << 4, (g >> 4) << 4, (b >> 4) << 4 

324 filtered.append((qr, qg, qb)) 

325 

326 if not filtered: 

327 return None, None 

328 

329 counts = Counter(filtered).most_common(20) 

330 

331 # Primary = most common saturated color 

332 primary = counts[0][0] 

333 ph, _, _ = colorsys.rgb_to_hsv(primary[0] / 255, primary[1] / 255, primary[2] / 255) 

334 

335 # Secondary = most common color with a different hue 

336 secondary = None 

337 for color, _ in counts[1:]: 

338 ch, _, _ = colorsys.rgb_to_hsv(color[0] / 255, color[1] / 255, color[2] / 255) 

339 hue_diff = min(abs(ch - ph), 1 - abs(ch - ph)) 

340 if hue_diff > 0.05: 

341 secondary = color 

342 break 

343 

344 def to_hex(c: tuple[int, int, int]) -> str: 

345 return f"#{c[0]:02x}{c[1]:02x}{c[2]:02x}" 

346 

347 return to_hex(primary), to_hex(secondary) if secondary else to_hex(primary) 

348 

349 

350# ============================================================================ 

351# Team Management Functions 

352# ============================================================================ 

353 

354 

355def get_all_teams(token: str) -> list[dict[str, Any]]: 

356 """Fetch all teams from the API.""" 

357 response = api_request("GET", "/api/teams?include_parent=true", token) 

358 

359 if response.status_code == 200: 

360 return response.json() 

361 else: 

362 console.print(f"[red]❌ Failed to fetch teams: {response.text}[/red]") 

363 return [] 

364 

365 

366def find_team_by_name_and_division( 

367 token: str, team_name: str, division_id: int, club_id: int | None = None 

368) -> dict[str, Any] | None: 

369 """Find a team by name and division ID. 

370 

371 Teams are uniquely identified by (name, division_id). 

372 Optionally filter by club_id for additional specificity. 

373 """ 

374 teams = get_all_teams(token) 

375 

376 for team in teams: 

377 # Match by team name and division_id 

378 if team["name"].lower() == team_name.lower() and team.get("division_id") == division_id: 

379 # If club_id specified, also match on that 

380 if club_id is not None: 

381 if team.get("club_id") == club_id: 

382 return team 

383 else: 

384 return team 

385 

386 return None 

387 

388 

389def create_team(token: str, team: TeamData, club_id: int, is_pro_academy: bool = False) -> dict[str, Any] | None: 

390 """Create a new team with league, division, and age groups. 

391 

392 Args: 

393 token: Authentication token 

394 team: TeamData model with all team information 

395 club_id: Parent club ID 

396 is_pro_academy: Whether this club is a Pro Academy (all teams inherit this) 

397 

398 Returns: 

399 Created team data or None on failure 

400 """ 

401 # Look up league ID 

402 league_id = get_league_id_by_name(token, team.league) 

403 if not league_id: 

404 console.print(f"[red]❌ League not found: {team.league}[/red]") 

405 return None 

406 

407 # Look up division ID (required by API) 

408 division_name = team.division_or_conference 

409 if not division_name: 

410 console.print( 

411 f"[yellow]⚠️ No division/conference specified for team: {team.team_name}. Skipping team creation.[/yellow]" 

412 ) 

413 return None 

414 

415 division_id = get_division_id_by_name_and_league(token, division_name, league_id) 

416 if not division_id: 

417 console.print(f"[red]❌ Division/conference not found: {division_name} (in league: {team.league})[/red]") 

418 return None 

419 

420 # Look up age group IDs (required by API - at least one) 

421 if not team.age_groups: 

422 console.print( 

423 f"[yellow]⚠️ No age groups specified for team: {team.team_name}. Skipping team creation.[/yellow]" 

424 ) 

425 return None 

426 

427 age_group_ids = get_age_group_ids_by_names(token, team.age_groups) 

428 if not age_group_ids: 

429 console.print( 

430 f"[yellow]⚠️ No valid age groups found for team: {team.team_name}. Skipping team creation.[/yellow]" 

431 ) 

432 return None 

433 

434 # Build payload for team creation 

435 payload = { 

436 "name": team.team_name, 

437 "city": "", # City will be inherited from club 

438 "age_group_ids": age_group_ids, 

439 "division_id": division_id, 

440 "club_id": club_id, # Always set parent club - every team belongs to a club 

441 "academy_team": is_pro_academy, # Inherited from club level - only true for Pro Academy clubs 

442 } 

443 

444 response = api_request("POST", "/api/teams", token, data=payload) 

445 

446 if response.status_code == 200: 

447 result = response.json() 

448 return result 

449 elif ( 

450 response.status_code == 409 or "already exists" in response.text.lower() or "duplicate" in response.text.lower() 

451 ): 

452 # Team already exists - this is OK for idempotent operation 

453 return {"name": team.team_name, "exists": True} 

454 else: 

455 # Only print error if it's not a simple "already exists" situation 

456 console.print(f"[red]❌ Failed to create team '{team.team_name}': {response.text}[/red]") 

457 return None 

458 

459 

460def update_team( 

461 token: str, 

462 team_id: int, 

463 team_name: str, 

464 league_name: str, 

465 club_id: int, 

466 is_pro_academy: bool = False, 

467) -> bool: 

468 """Update an existing team. 

469 

470 Updates club_id relationship and academy_team flag. 

471 The academy_team flag is updated to match the club's is_pro_academy value. 

472 """ 

473 payload = { 

474 "name": team_name, 

475 "city": "", # Required field 

476 "club_id": club_id, 

477 "academy_team": is_pro_academy, # Update to match club's is_pro_academy value 

478 } 

479 

480 response = api_request("PUT", f"/api/teams/{team_id}", token, data=payload) 

481 

482 if response.status_code == 200: 

483 return True 

484 else: 

485 console.print(f"[yellow]⚠️ Failed to update team (ID: {team_id}): {response.text}[/yellow]") 

486 return False 

487 

488 

489# ============================================================================ 

490# Sync Command - Main Logic 

491# ============================================================================ 

492 

493 

494@app.command() 

495def sync( 

496 dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done without making changes"), 

497): 

498 """ 

499 Sync clubs and teams from clubs.json to the database. 

500 

501 This command: 

502 1. Reads clubs.json 

503 2. Creates or updates clubs 

504 3. Creates or updates teams under each club 

505 4. Can be run repeatedly (idempotent) 

506 """ 

507 console.print("[bold cyan]🔄 Club & Team Sync Tool[/bold cyan]") 

508 console.print(f"📁 Reading: {CLUBS_JSON_PATH}") 

509 

510 if dry_run: 

511 console.print("[yellow]🏃 DRY RUN MODE - No changes will be made[/yellow]\n") 

512 

513 # Load clubs data 

514 clubs_data = load_clubs_json() 

515 console.print(f"✅ Loaded {len(clubs_data)} clubs from JSON\n") 

516 

517 # Authenticate 

518 with console.status("[bold yellow]🔐 Authenticating...", spinner="dots"): 

519 token = get_auth_token() 

520 console.print("✅ Authenticated as admin\n") 

521 

522 # Fetch all clubs, teams, leagues, divisions, and age groups ONCE (optimization to prevent N+1 API calls) 

523 with console.status("[bold yellow]📡 Fetching existing data from API...", spinner="dots"): 

524 all_clubs = get_all_clubs(token) 

525 all_teams = get_all_teams(token) 

526 

527 # Fetch leagues and create lookup dict 

528 leagues_response = api_request("GET", "/api/leagues", token) 

529 all_leagues = leagues_response.json() if leagues_response.status_code == 200 else [] 

530 league_lookup = {league["name"].lower(): league["id"] for league in all_leagues} 

531 

532 # Fetch all divisions (we'll filter by league as needed) 

533 divisions_response = api_request("GET", "/api/divisions", token) 

534 all_divisions = divisions_response.json() if divisions_response.status_code == 200 else [] 

535 

536 # Fetch age groups and create lookup dict 

537 age_groups_response = api_request("GET", "/api/age-groups", token) 

538 all_age_groups = age_groups_response.json() if age_groups_response.status_code == 200 else [] 

539 age_group_lookup = {ag["name"].lower(): ag["id"] for ag in all_age_groups} 

540 

541 console.print( 

542 f"✅ Found {len(all_clubs)} clubs, {len(all_teams)} teams, {len(all_leagues)} leagues, {len(all_divisions)} divisions\n" 

543 ) 

544 

545 # Statistics 

546 stats = { 

547 "clubs_created": 0, 

548 "clubs_updated": 0, 

549 "clubs_unchanged": 0, 

550 "logos_uploaded": 0, 

551 "teams_created": 0, 

552 "teams_updated": 0, 

553 "teams_unchanged": 0, 

554 "errors": 0, 

555 } 

556 

557 # Process each club 

558 with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console) as progress: 

559 task = progress.add_task("[cyan]Processing clubs...", total=len(clubs_data)) 

560 

561 for club in clubs_data: 

562 progress.update(task, description=f"[cyan]Processing {club.club_name}...") 

563 

564 # Check if club exists (search in-memory cache) 

565 existing_club = next((c for c in all_clubs if c["name"].lower() == club.club_name.lower()), None) 

566 

567 if existing_club: 

568 # Club exists - check if update needed 

569 needs_update = ( 

570 existing_club.get("city") != club.location 

571 or existing_club.get("website") != club.website 

572 or (club.logo_url and existing_club.get("logo_url") != club.logo_url) 

573 or (club.primary_color and existing_club.get("primary_color") != club.primary_color) 

574 or (club.secondary_color and existing_club.get("secondary_color") != club.secondary_color) 

575 or (club.instagram and existing_club.get("instagram") != club.instagram) 

576 ) 

577 

578 if needs_update and not dry_run: 

579 if update_club(token, existing_club["id"], club): 

580 console.print(f" [blue]🔄 Updated club: {club.club_name}[/blue]") 

581 stats["clubs_updated"] += 1 

582 else: 

583 stats["errors"] += 1 

584 elif needs_update and dry_run: 

585 console.print(f" [blue]🔄 Would update club: {club.club_name}[/blue]") 

586 stats["clubs_updated"] += 1 

587 else: 

588 console.print(f" [dim]✓ Club unchanged: {club.club_name}[/dim]") 

589 stats["clubs_unchanged"] += 1 

590 

591 club_id = existing_club["id"] 

592 else: 

593 # Create new club 

594 if not dry_run: 

595 new_club = create_club(token, club) 

596 if new_club: 

597 if new_club.get("exists"): 

598 # Club already exists (caught the duplicate error) 

599 console.print(f" [dim]✓ Club already exists: {club.club_name}[/dim]") 

600 stats["clubs_unchanged"] += 1 

601 # Find the club to get its ID for team processing (search in-memory cache) 

602 found_club = next( 

603 (c for c in all_clubs if c["name"].lower() == club.club_name.lower()), 

604 None, 

605 ) 

606 club_id = found_club["id"] if found_club else None 

607 else: 

608 console.print(f" [green]✨ Created club: {club.club_name}[/green]") 

609 stats["clubs_created"] += 1 

610 club_id = new_club.get("id") 

611 else: 

612 stats["errors"] += 1 

613 progress.update(task, advance=1) 

614 continue 

615 else: 

616 console.print(f" [green]✨ Would create club: {club.club_name}[/green]") 

617 stats["clubs_created"] += 1 

618 club_id = None # Can't process teams in dry run without club ID 

619 

620 # Upload local logo if available 

621 if club_id and not dry_run: 

622 slug = club_name_to_slug(club.club_name) 

623 logo_path = LOGO_READY_DIR / f"{slug}.png" 

624 if logo_path.exists(): 

625 if upload_club_logo(token, club_id, logo_path): 

626 console.print(f" [magenta]🖼️ Uploaded logo: {slug}.png[/magenta]") 

627 stats["logos_uploaded"] += 1 

628 

629 # Process teams for this club 

630 for team in club.teams: 

631 if dry_run and club_id is None: 

632 console.print(f" [dim]→ Would create team: {team.team_name} ({team.league})[/dim]") 

633 stats["teams_created"] += 1 

634 continue 

635 

636 # Look up division_id for this team (use cached data) 

637 league_id = league_lookup.get(team.league.lower()) 

638 if not league_id: 

639 console.print( 

640 f"[yellow]⚠️ League not found: {team.league}. Skipping team: {team.team_name}[/yellow]" 

641 ) 

642 stats["errors"] += 1 

643 continue 

644 

645 division_name = team.division_or_conference 

646 if not division_name: 

647 console.print(f"[yellow]⚠️ No division/conference for team: {team.team_name}. Skipping.[/yellow]") 

648 stats["errors"] += 1 

649 continue 

650 

651 # Find division in cached data 

652 division_id = None 

653 for div in all_divisions: 

654 if div["name"].lower() == division_name.lower() and div.get("league_id") == league_id: 

655 division_id = div["id"] 

656 break 

657 

658 if not division_id: 

659 console.print( 

660 f"[yellow]⚠️ Division '{division_name}' not found in league '{team.league}'. Skipping team: {team.team_name}[/yellow]" 

661 ) 

662 stats["errors"] += 1 

663 continue 

664 

665 # Check if team exists (search in-memory cache using division_id) 

666 existing_team = None 

667 for t in all_teams: 

668 if t["name"].lower() == team.team_name.lower() and t.get("division_id") == division_id: 

669 # If club_id specified, also match on that 

670 if club_id is not None: 

671 if t.get("club_id") == club_id: 

672 existing_team = t 

673 break 

674 else: 

675 existing_team = t 

676 break 

677 

678 if existing_team: 

679 # Team exists - check if update needed 

680 needs_update = ( 

681 existing_team.get("club_id") != club_id 

682 or existing_team.get("academy_team") != club.is_pro_academy 

683 ) 

684 

685 if needs_update and not dry_run: 

686 if update_team( 

687 token, 

688 existing_team["id"], 

689 team.team_name, 

690 team.league, 

691 club_id, 

692 club.is_pro_academy, 

693 ): 

694 console.print(f" [blue]🔄 Updated team: {team.team_name} ({team.league})[/blue]") 

695 stats["teams_updated"] += 1 

696 else: 

697 stats["errors"] += 1 

698 elif needs_update and dry_run: 

699 console.print(f" [blue]🔄 Would update team: {team.team_name} ({team.league})[/blue]") 

700 stats["teams_updated"] += 1 

701 else: 

702 console.print(f" [dim]✓ Team unchanged: {team.team_name} ({team.league})[/dim]") 

703 stats["teams_unchanged"] += 1 

704 else: 

705 # Create new team (use cached data to avoid N+1 API calls) 

706 if not dry_run: 

707 # Look up age group IDs from cached data 

708 if not team.age_groups: 

709 console.print( 

710 f"[yellow]⚠️ No age groups specified for team: {team.team_name}. Skipping team creation.[/yellow]" 

711 ) 

712 stats["errors"] += 1 

713 continue 

714 

715 age_group_ids = [] 

716 for ag_name in team.age_groups: 

717 ag_id = age_group_lookup.get(ag_name.lower()) 

718 if ag_id: 

719 age_group_ids.append(ag_id) 

720 else: 

721 console.print(f"[yellow]⚠️ Age group not found: {ag_name}[/yellow]") 

722 

723 if not age_group_ids: 

724 console.print( 

725 f"[yellow]⚠️ No valid age groups found for team: {team.team_name}. Skipping team creation.[/yellow]" 

726 ) 

727 stats["errors"] += 1 

728 continue 

729 

730 # Build payload and create team directly 

731 payload = { 

732 "name": team.team_name, 

733 "city": "", 

734 "age_group_ids": age_group_ids, 

735 "division_id": division_id, 

736 "club_id": club_id, 

737 "academy_team": club.is_pro_academy, 

738 } 

739 

740 response = api_request("POST", "/api/teams", token, data=payload) 

741 new_team = response.json() if response.status_code == 200 else None 

742 

743 if ( 

744 response.status_code == 200 

745 or response.status_code == 409 

746 or "already exists" in response.text.lower() 

747 ) and (response.status_code == 409 or "already exists" in response.text.lower()): 

748 new_team = {"exists": True} 

749 

750 if new_team: 

751 if new_team.get("exists"): 

752 # Team already exists (caught the duplicate error) 

753 # Need to update it to set club_id 

754 console.print(f" [dim]✓ Team already exists: {team.team_name} ({team.league})[/dim]") 

755 

756 # Find the existing team and update club_id if needed (search in-memory cache) 

757 existing = None 

758 for t in all_teams: 

759 if ( 

760 t["name"].lower() == team.team_name.lower() 

761 and t.get("division_id") == division_id 

762 ): 

763 existing = t 

764 break 

765 

766 if existing and existing.get("club_id") != club_id: 

767 if update_team( 

768 token, 

769 existing["id"], 

770 team.team_name, 

771 team.league, 

772 club_id, 

773 club.is_pro_academy, 

774 ): 

775 console.print(" [blue] └─ Updated parent club link[/blue]") 

776 stats["teams_updated"] += 1 

777 else: 

778 stats["errors"] += 1 

779 else: 

780 stats["teams_unchanged"] += 1 

781 else: 

782 # Team was newly created with club_id already set 

783 console.print(f" [green]✨ Created team: {team.team_name} ({team.league})[/green]") 

784 stats["teams_created"] += 1 

785 else: 

786 stats["errors"] += 1 

787 else: 

788 console.print(f" [green]✨ Would create team: {team.team_name} ({team.league})[/green]") 

789 stats["teams_created"] += 1 

790 

791 progress.update(task, advance=1) 

792 

793 # Print summary 

794 console.print("\n[bold green]" + "=" * 60 + "[/bold green]") 

795 console.print("[bold green]📊 Sync Summary[/bold green]") 

796 console.print("[bold green]" + "=" * 60 + "[/bold green]") 

797 

798 summary_table = Table(show_header=False, box=box.SIMPLE) 

799 summary_table.add_column("Metric", style="cyan") 

800 summary_table.add_column("Count", style="green", justify="right") 

801 

802 summary_table.add_row("Clubs Created", str(stats["clubs_created"])) 

803 summary_table.add_row("Clubs Updated", str(stats["clubs_updated"])) 

804 summary_table.add_row("Clubs Unchanged", str(stats["clubs_unchanged"])) 

805 if stats["logos_uploaded"] > 0: 

806 summary_table.add_row("Logos Uploaded", f"[magenta]{stats['logos_uploaded']}[/magenta]") 

807 summary_table.add_row("Teams Created", str(stats["teams_created"])) 

808 summary_table.add_row("Teams Updated", str(stats["teams_updated"])) 

809 summary_table.add_row("Teams Unchanged", str(stats["teams_unchanged"])) 

810 

811 if stats["errors"] > 0: 

812 summary_table.add_row("Errors", f"[red]{stats['errors']}[/red]") 

813 

814 console.print(summary_table) 

815 

816 if dry_run: 

817 console.print("\n[yellow]💡 Run without --dry-run to apply changes[/yellow]") 

818 

819 

820# ============================================================================ 

821# List Command 

822# ============================================================================ 

823 

824 

825@app.command(name="list") 

826def list_clubs( 

827 show_teams: bool = typer.Option(True, "--show-teams/--no-teams", help="Show teams under each club"), 

828): 

829 """List all clubs and their teams.""" 

830 console.print("[bold cyan]📋 Clubs & Teams List[/bold cyan]\n") 

831 

832 # Authenticate 

833 with console.status("[bold yellow]🔐 Authenticating...", spinner="dots"): 

834 token = get_auth_token() 

835 

836 # Fetch clubs 

837 with console.status("[bold yellow]📡 Fetching clubs...", spinner="dots"): 

838 clubs = get_all_clubs(token) 

839 

840 if not clubs: 

841 console.print("[yellow]No clubs found[/yellow]") 

842 return 

843 

844 # Display clubs 

845 for club in clubs: 

846 club_table = Table(title=f"[bold]{club['name']}[/bold]", box=box.ROUNDED) 

847 club_table.add_column("Property", style="cyan") 

848 club_table.add_column("Value", style="white") 

849 

850 club_table.add_row("ID", str(club["id"])) 

851 club_table.add_row("City", club.get("city", "N/A")) 

852 club_table.add_row("Website", club.get("website", "N/A")) 

853 club_table.add_row("Teams", str(club.get("team_count", 0))) 

854 club_table.add_row("Active", "✅" if club.get("is_active") else "❌") 

855 

856 console.print(club_table) 

857 

858 # Show teams if requested 

859 if show_teams and club.get("teams"): 

860 teams_table = Table(box=box.SIMPLE, show_header=True) 

861 teams_table.add_column("ID", style="dim") 

862 teams_table.add_column("Team Name", style="cyan") 

863 teams_table.add_column("League", style="yellow") 

864 teams_table.add_column("Type", style="magenta") 

865 

866 for team in club["teams"]: 

867 team_type = "Academy" if team.get("academy_team") else "Homegrown" 

868 # Get league name from team_mappings 

869 leagues = [tm.get("league_name", "Unknown") for tm in team.get("team_mappings", [])] 

870 league_str = ", ".join(leagues) if leagues else "N/A" 

871 

872 teams_table.add_row(str(team["id"]), team["name"], league_str, team_type) 

873 

874 console.print(teams_table) 

875 

876 console.print() # Empty line between clubs 

877 

878 

879# ============================================================================ 

880# Delete Commands 

881# ============================================================================ 

882 

883 

884@app.command() 

885def delete_club( 

886 club_id: int = typer.Argument(..., help="Club ID to delete"), 

887 force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"), 

888): 

889 """Delete a club by ID.""" 

890 console.print(f"[bold red]🗑️ Delete Club (ID: {club_id})[/bold red]\n") 

891 

892 # Authenticate 

893 token = get_auth_token() 

894 

895 # Get club details 

896 clubs = get_all_clubs(token) 

897 club = next((c for c in clubs if c["id"] == club_id), None) 

898 

899 if not club: 

900 console.print(f"[red]❌ Club with ID {club_id} not found[/red]") 

901 raise typer.Exit(code=1) 

902 

903 # Show what will be deleted 

904 console.print("[yellow]⚠️ This will delete:[/yellow]") 

905 console.print(f" • Club: {club['name']}") 

906 console.print(f"{club.get('team_count', 0)} associated teams") 

907 console.print() 

908 

909 # Confirm deletion 

910 if not force and not Confirm.ask("Are you sure you want to delete this club?"): 

911 console.print("[dim]Deletion cancelled[/dim]") 

912 raise typer.Exit() 

913 

914 # Delete club 

915 response = api_request("DELETE", f"/api/clubs/{club_id}", token) 

916 

917 if response.status_code == 200: 

918 console.print(f"[green]✅ Club '{club['name']}' deleted successfully[/green]") 

919 else: 

920 console.print(f"[red]❌ Failed to delete club: {response.text}[/red]") 

921 raise typer.Exit(code=1) 

922 

923 

924@app.command() 

925def delete_team( 

926 team_id: int = typer.Argument(..., help="Team ID to delete"), 

927 force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"), 

928): 

929 """Delete a team by ID.""" 

930 console.print(f"[bold red]🗑️ Delete Team (ID: {team_id})[/bold red]\n") 

931 

932 # Authenticate 

933 token = get_auth_token() 

934 

935 # Get team details 

936 teams = get_all_teams(token) 

937 team = next((t for t in teams if t["id"] == team_id), None) 

938 

939 if not team: 

940 console.print(f"[red]❌ Team with ID {team_id} not found[/red]") 

941 raise typer.Exit(code=1) 

942 

943 # Show what will be deleted 

944 console.print("[yellow]⚠️ This will delete:[/yellow]") 

945 console.print(f" • Team: {team['name']}") 

946 console.print() 

947 

948 # Confirm deletion 

949 if not force and not Confirm.ask("Are you sure you want to delete this team?"): 

950 console.print("[dim]Deletion cancelled[/dim]") 

951 raise typer.Exit() 

952 

953 # Delete team 

954 response = api_request("DELETE", f"/api/teams/{team_id}", token) 

955 

956 if response.status_code == 200: 

957 console.print(f"[green]✅ Team '{team['name']}' deleted successfully[/green]") 

958 else: 

959 console.print(f"[red]❌ Failed to delete team: {response.text}[/red]") 

960 raise typer.Exit(code=1) 

961 

962 

963# ============================================================================ 

964# Logo Status Command 

965# ============================================================================ 

966 

967 

968@app.command() 

969def logo_status(): 

970 """Show all clubs with their expected logo filenames and current logo status.""" 

971 console.print("[bold cyan]Club Logo Status[/bold cyan]\n") 

972 

973 # Authenticate 

974 with console.status("[bold yellow]Authenticating...", spinner="dots"): 

975 token = get_auth_token() 

976 

977 # Fetch all clubs from DB 

978 with console.status("[bold yellow]Fetching clubs...", spinner="dots"): 

979 clubs = get_all_clubs(token) 

980 

981 if not clubs: 

982 console.print("[yellow]No clubs found in database[/yellow]") 

983 return 

984 

985 # Check what ready files exist 

986 ready_files = set() 

987 if LOGO_READY_DIR.exists(): 

988 ready_files = {f.stem for f in LOGO_READY_DIR.glob("*.png")} 

989 

990 # Build table 

991 table = Table(title="Club Logo Status", box=box.ROUNDED) 

992 table.add_column("Club Name", style="cyan") 

993 table.add_column("Slug Filename", style="white") 

994 table.add_column("DB Logo", style="white", justify="center") 

995 table.add_column("Local File", style="white", justify="center") 

996 

997 has_logo = 0 

998 missing_logo = 0 

999 

1000 for club in sorted(clubs, key=lambda c: c["name"]): 

1001 slug = club_name_to_slug(club["name"]) 

1002 db_has_logo = bool(club.get("logo_url")) 

1003 local_exists = slug in ready_files 

1004 

1005 if db_has_logo: 

1006 db_status = "[green]Yes[/green]" 

1007 has_logo += 1 

1008 else: 

1009 db_status = "[dim]-[/dim]" 

1010 missing_logo += 1 

1011 

1012 local_status = "[green]Ready[/green]" if local_exists else "[dim]-[/dim]" 

1013 

1014 table.add_row(club["name"], f"{slug}.png", db_status, local_status) 

1015 

1016 console.print(table) 

1017 console.print(f"\n[green]{has_logo}[/green] clubs with logos, [yellow]{missing_logo}[/yellow] without logos") 

1018 console.print(f"\nTo add a logo: place raw image in [bold]club-logos/raw/{'{slug}'}.png[/bold]") 

1019 console.print("Then run: [bold]uv run python ../scripts/prep-logo.py --batch[/bold]") 

1020 console.print("Then run: [bold]uv run python manage_clubs.py upload-logos[/bold]") 

1021 

1022 

1023# ============================================================================ 

1024# Upload Logos Command 

1025# ============================================================================ 

1026 

1027 

1028@app.command() 

1029def upload_logos( 

1030 dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be uploaded without making changes"), 

1031 overwrite: bool = typer.Option(False, "--overwrite", help="Re-upload logos even if club already has one"), 

1032 extract_colors: bool = typer.Option( 

1033 False, "--extract-colors", help="Extract primary/secondary brand colors from logos" 

1034 ), 

1035): 

1036 """ 

1037 Upload prepared logos from club-logos/ready/ to the database. 

1038 

1039 Matches PNG filenames (slugs) to clubs in the database using 

1040 club_name_to_slug(). Works with ALL clubs in the DB, not just 

1041 those in clubs.json. 

1042 

1043 With --extract-colors, also analyzes each logo to detect dominant 

1044 brand colors and updates clubs that don't already have colors set. 

1045 """ 

1046 console.print("[bold cyan]Upload Club Logos[/bold cyan]\n") 

1047 

1048 if dry_run: 

1049 console.print("[yellow]DRY RUN MODE - No changes will be made[/yellow]\n") 

1050 

1051 # Check ready directory 

1052 if not LOGO_READY_DIR.exists(): 

1053 console.print(f"[red]Ready directory not found: {LOGO_READY_DIR}[/red]") 

1054 console.print("Run prep-logo.py --batch first to prepare logos.") 

1055 raise typer.Exit(code=1) 

1056 

1057 # Only process base files, skip _sm/_md size variants 

1058 ready_files = [f for f in LOGO_READY_DIR.glob("*.png") if not f.stem.endswith(("_sm", "_md"))] 

1059 if not ready_files: 

1060 console.print(f"[yellow]No PNG files found in {LOGO_READY_DIR}[/yellow]") 

1061 return 

1062 

1063 console.print(f"Found {len(ready_files)} prepared logo(s)") 

1064 if extract_colors: 

1065 console.print("[cyan]Color extraction enabled[/cyan]") 

1066 console.print() 

1067 

1068 # Authenticate 

1069 with console.status("[bold yellow]Authenticating...", spinner="dots"): 

1070 token = get_auth_token() 

1071 

1072 # Fetch ALL clubs from DB 

1073 with console.status("[bold yellow]Fetching clubs...", spinner="dots"): 

1074 clubs = get_all_clubs(token) 

1075 

1076 if not clubs: 

1077 console.print("[red]No clubs found in database[/red]") 

1078 raise typer.Exit(code=1) 

1079 

1080 # Build slug -> club mapping 

1081 slug_to_club: dict[str, dict] = {} 

1082 for club in clubs: 

1083 slug = club_name_to_slug(club["name"]) 

1084 slug_to_club[slug] = club 

1085 

1086 # Process each ready file 

1087 stats = {"uploaded": 0, "skipped_has_logo": 0, "unmatched": 0, "errors": 0, "colors_set": 0} 

1088 unmatched_files: list[str] = [] 

1089 

1090 for logo_path in sorted(ready_files): 

1091 slug = logo_path.stem 

1092 club = slug_to_club.get(slug) 

1093 

1094 if not club: 

1095 console.print(f" [yellow]No matching club for: {logo_path.name}[/yellow]") 

1096 unmatched_files.append(logo_path.name) 

1097 stats["unmatched"] += 1 

1098 continue 

1099 

1100 # Skip if club already has a logo (unless --overwrite) 

1101 if club.get("logo_url") and not overwrite: 

1102 console.print(f" [dim]Skipped (already has logo): {club['name']}[/dim]") 

1103 stats["skipped_has_logo"] += 1 

1104 # Still extract colors if requested and club has no colors 

1105 if extract_colors and not club.get("primary_color"): 

1106 _apply_extracted_colors(token, club, logo_path, dry_run, stats) 

1107 continue 

1108 

1109 if dry_run: 

1110 action = "Would re-upload" if club.get("logo_url") else "Would upload" 

1111 console.print(f" [green]{action}: {logo_path.name} -> {club['name']}[/green]") 

1112 stats["uploaded"] += 1 

1113 else: 

1114 if upload_club_logo(token, club["id"], logo_path): 

1115 console.print(f" [green]Uploaded: {logo_path.name} -> {club['name']}[/green]") 

1116 stats["uploaded"] += 1 

1117 else: 

1118 stats["errors"] += 1 

1119 

1120 # Extract and set colors if requested 

1121 if extract_colors and not club.get("primary_color"): 

1122 _apply_extracted_colors(token, club, logo_path, dry_run, stats) 

1123 

1124 # Summary 

1125 console.print(f"\n[bold]Summary:[/bold] {stats['uploaded']} uploaded", end="") 

1126 if stats["skipped_has_logo"]: 

1127 console.print(f", {stats['skipped_has_logo']} skipped (already have logo)", end="") 

1128 if stats["colors_set"]: 

1129 console.print(f", {stats['colors_set']} colors extracted", end="") 

1130 if stats["unmatched"]: 

1131 console.print(f", {stats['unmatched']} unmatched", end="") 

1132 if stats["errors"]: 

1133 console.print(f", [red]{stats['errors']} errors[/red]", end="") 

1134 console.print() 

1135 

1136 if unmatched_files: 

1137 console.print("\n[yellow]Unmatched files (no DB club with this slug):[/yellow]") 

1138 for f in unmatched_files: 

1139 console.print(f" {f}") 

1140 console.print("\nRun [bold]logo-status[/bold] to see expected slugs for all clubs.") 

1141 

1142 if dry_run: 

1143 console.print("\n[yellow]Run without --dry-run to apply changes[/yellow]") 

1144 

1145 

1146def _apply_extracted_colors( 

1147 token: str, club: dict, logo_path: Path, dry_run: bool, stats: dict 

1148) -> None: 

1149 """Extract colors from a logo and update the club if it has no colors set.""" 

1150 primary, secondary = extract_brand_colors(logo_path) 

1151 if not primary: 

1152 return 

1153 

1154 if dry_run: 

1155 console.print(f" [magenta]Would set colors: {primary} / {secondary}[/magenta]") 

1156 stats["colors_set"] += 1 

1157 else: 

1158 payload = {"primary_color": primary, "secondary_color": secondary} 

1159 response = api_request("PUT", f"/api/clubs/{club['id']}", token, data=payload) 

1160 if response.status_code == 200: 

1161 console.print(f" [magenta]Set colors: {primary} / {secondary}[/magenta]") 

1162 stats["colors_set"] += 1 

1163 else: 

1164 console.print(f" [yellow]Failed to set colors: {response.text}[/yellow]") 

1165 

1166 

1167# ============================================================================ 

1168# Main Entry Point 

1169# ============================================================================ 

1170 

1171if __name__ == "__main__": 

1172 app()