Coverage for manage_clubs.py: 0.00%
579 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-13 00:07 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-13 00:07 +0000
1#!/usr/bin/env python3
2"""
3Club and Team Management CLI Tool
5This tool manages clubs and teams based on clubs.json file.
6Uses the backend API to create, update, and delete clubs and teams.
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"""
17import colorsys
18import json
19import os
20from collections import Counter
21from pathlib import Path
22from typing import Any
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
33from models.clubs import ClubData, TeamData, club_name_to_slug, load_clubs_from_json
35# Initialize Typer app and Rich console
36app = typer.Typer(help="Club and Team Management CLI Tool")
37console = Console()
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"
46# ============================================================================
47# Authentication & API Helper Functions
48# ============================================================================
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")
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)
61 # Try to login as admin user
62 username = "tom"
63 password = os.getenv("TEST_USER_PASSWORD_TOM", "admin123")
65 response = requests.post(
66 f"{API_URL}/api/auth/login",
67 json={"username": username, "password": password},
68 headers={"Content-Type": "application/json"},
69 )
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)
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"}
83 url = f"{API_URL}{endpoint}"
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}")
96 return response
99# ============================================================================
100# Data Loading Functions
101# ============================================================================
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)
110 with open(CLUBS_JSON_PATH) as f:
111 clubs_raw = json.load(f)
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
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)
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"]
131 return None
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.
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
142 Returns:
143 Division ID if found, None otherwise
144 """
145 response = api_request("GET", f"/api/divisions?league_id={league_id}", token)
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"]
153 return None
156def get_age_group_ids_by_names(token: str, age_group_names: list[str]) -> list[int]:
157 """Get age group IDs by names.
159 Args:
160 token: Authentication token
161 age_group_names: List of age group names (e.g., ["U13", "U14", "U15"])
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)
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}
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]")
182 return ids
184 return []
187# ============================================================================
188# Club Management Functions
189# ============================================================================
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)
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 []
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
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 }
243 response = api_request("POST", "/api/clubs", token, data=payload)
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
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 }
270 response = api_request("PUT", f"/api/clubs/{club_id}", token, data=payload)
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
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.
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
296def extract_brand_colors(logo_path: Path) -> tuple[str | None, str | None]:
297 """Extract primary and secondary brand colors from a logo image.
299 Analyzes opaque, saturated pixels to find the dominant brand colors,
300 filtering out transparent, near-white, near-black, and gray pixels.
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
308 img = Image.open(logo_path).convert("RGBA")
309 pixels = list(img.getdata())
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))
326 if not filtered:
327 return None, None
329 counts = Counter(filtered).most_common(20)
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)
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
344 def to_hex(c: tuple[int, int, int]) -> str:
345 return f"#{c[0]:02x}{c[1]:02x}{c[2]:02x}"
347 return to_hex(primary), to_hex(secondary) if secondary else to_hex(primary)
350# ============================================================================
351# Team Management Functions
352# ============================================================================
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)
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 []
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.
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)
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
386 return None
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.
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)
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
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
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
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
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
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 }
444 response = api_request("POST", "/api/teams", token, data=payload)
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
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.
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 }
480 response = api_request("PUT", f"/api/teams/{team_id}", token, data=payload)
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
489# ============================================================================
490# Sync Command - Main Logic
491# ============================================================================
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.
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}")
510 if dry_run:
511 console.print("[yellow]🏃 DRY RUN MODE - No changes will be made[/yellow]\n")
513 # Load clubs data
514 clubs_data = load_clubs_json()
515 console.print(f"✅ Loaded {len(clubs_data)} clubs from JSON\n")
517 # Authenticate
518 with console.status("[bold yellow]🔐 Authenticating...", spinner="dots"):
519 token = get_auth_token()
520 console.print("✅ Authenticated as admin\n")
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)
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}
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 []
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}
541 console.print(
542 f"✅ Found {len(all_clubs)} clubs, {len(all_teams)} teams, {len(all_leagues)} leagues, {len(all_divisions)} divisions\n"
543 )
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 }
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))
561 for club in clubs_data:
562 progress.update(task, description=f"[cyan]Processing {club.club_name}...")
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)
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 )
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
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
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
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
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
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
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
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
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
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 )
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
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]")
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
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 }
740 response = api_request("POST", "/api/teams", token, data=payload)
741 new_team = response.json() if response.status_code == 200 else None
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}
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]")
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
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
791 progress.update(task, advance=1)
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]")
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")
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"]))
811 if stats["errors"] > 0:
812 summary_table.add_row("Errors", f"[red]{stats['errors']}[/red]")
814 console.print(summary_table)
816 if dry_run:
817 console.print("\n[yellow]💡 Run without --dry-run to apply changes[/yellow]")
820# ============================================================================
821# List Command
822# ============================================================================
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")
832 # Authenticate
833 with console.status("[bold yellow]🔐 Authenticating...", spinner="dots"):
834 token = get_auth_token()
836 # Fetch clubs
837 with console.status("[bold yellow]📡 Fetching clubs...", spinner="dots"):
838 clubs = get_all_clubs(token)
840 if not clubs:
841 console.print("[yellow]No clubs found[/yellow]")
842 return
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")
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 "❌")
856 console.print(club_table)
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")
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"
872 teams_table.add_row(str(team["id"]), team["name"], league_str, team_type)
874 console.print(teams_table)
876 console.print() # Empty line between clubs
879# ============================================================================
880# Delete Commands
881# ============================================================================
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")
892 # Authenticate
893 token = get_auth_token()
895 # Get club details
896 clubs = get_all_clubs(token)
897 club = next((c for c in clubs if c["id"] == club_id), None)
899 if not club:
900 console.print(f"[red]❌ Club with ID {club_id} not found[/red]")
901 raise typer.Exit(code=1)
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()
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()
914 # Delete club
915 response = api_request("DELETE", f"/api/clubs/{club_id}", token)
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)
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")
932 # Authenticate
933 token = get_auth_token()
935 # Get team details
936 teams = get_all_teams(token)
937 team = next((t for t in teams if t["id"] == team_id), None)
939 if not team:
940 console.print(f"[red]❌ Team with ID {team_id} not found[/red]")
941 raise typer.Exit(code=1)
943 # Show what will be deleted
944 console.print("[yellow]⚠️ This will delete:[/yellow]")
945 console.print(f" • Team: {team['name']}")
946 console.print()
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()
953 # Delete team
954 response = api_request("DELETE", f"/api/teams/{team_id}", token)
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)
963# ============================================================================
964# Logo Status Command
965# ============================================================================
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")
973 # Authenticate
974 with console.status("[bold yellow]Authenticating...", spinner="dots"):
975 token = get_auth_token()
977 # Fetch all clubs from DB
978 with console.status("[bold yellow]Fetching clubs...", spinner="dots"):
979 clubs = get_all_clubs(token)
981 if not clubs:
982 console.print("[yellow]No clubs found in database[/yellow]")
983 return
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")}
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")
997 has_logo = 0
998 missing_logo = 0
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
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
1012 local_status = "[green]Ready[/green]" if local_exists else "[dim]-[/dim]"
1014 table.add_row(club["name"], f"{slug}.png", db_status, local_status)
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]")
1023# ============================================================================
1024# Upload Logos Command
1025# ============================================================================
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.
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.
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")
1048 if dry_run:
1049 console.print("[yellow]DRY RUN MODE - No changes will be made[/yellow]\n")
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)
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
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()
1068 # Authenticate
1069 with console.status("[bold yellow]Authenticating...", spinner="dots"):
1070 token = get_auth_token()
1072 # Fetch ALL clubs from DB
1073 with console.status("[bold yellow]Fetching clubs...", spinner="dots"):
1074 clubs = get_all_clubs(token)
1076 if not clubs:
1077 console.print("[red]No clubs found in database[/red]")
1078 raise typer.Exit(code=1)
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
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] = []
1090 for logo_path in sorted(ready_files):
1091 slug = logo_path.stem
1092 club = slug_to_club.get(slug)
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
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
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
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)
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()
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.")
1142 if dry_run:
1143 console.print("\n[yellow]Run without --dry-run to apply changes[/yellow]")
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
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]")
1167# ============================================================================
1168# Main Entry Point
1169# ============================================================================
1171if __name__ == "__main__":
1172 app()