Files
Nemo 4421f61c24 feat: Rebuild as Court Manager Dashboard v2
- Complete rewrite for single Court Manager user
- Feature 1: Player Management (CRUD, ELO, skill level)
- Feature 2: Court Reservation (timeline, override)
- Feature 3: Match Event Builder (3-step wizard)
  - Step 1: Event setup with player selection + stage calculator
  - Step 2: Stage configurator (Open/Skill/RR/Tournament)
  - Step 3: Auto bracket generation
- Feature 4: Live Court View (main dashboard, 2x2 grid)
- Event Control: score entry, bracket advancement, court assignment
- Screen view: TV display per court (/screen/:id)
- Seed: 8 players, 4 courts, 1 completed + 1 active event
- Routes: / players courts events events/new events/:id screen/:id
2026-03-19 07:35:51 +08:00

295 lines
12 KiB
Python

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from typing import List
from datetime import datetime
from ..database import get_db
from ..models import Event, EventPlayer, Stage, Match, Player, Court
from ..schemas import (
EventCreate, EventUpdate, EventPlayerAdd, EventPlayerOut,
StageCreate, StageUpdate, StageOut, MatchOut
)
from ..bracket import calculate_stages, generate_single_elim_bracket, generate_open_schedule, generate_round_robin
router = APIRouter(prefix="/api/events", tags=["events"])
def _event_with_details(event_id: int, db: Session):
return db.query(Event).options(
joinedload(Event.event_players).joinedload(EventPlayer.player),
joinedload(Event.stages).joinedload(Stage.matches)
).filter(Event.id == event_id).first()
@router.get("/")
def list_events(db: Session = Depends(get_db)):
events = db.query(Event).order_by(Event.created_at.desc()).all()
return [{"id": e.id, "name": e.name, "format": e.format, "status": e.status,
"courts_count": e.courts_count, "created_at": e.created_at,
"player_count": len(e.event_players), "stage_count": len(e.stages)} for e in events]
@router.post("/")
def create_event(data: EventCreate, db: Session = Depends(get_db)):
event = Event(**data.model_dump())
db.add(event)
db.commit()
db.refresh(event)
return {"id": event.id, "name": event.name, "format": event.format,
"status": event.status, "courts_count": event.courts_count}
@router.get("/calculate")
def calculate_event(players: int, format: str = "Singles", courts: int = 1):
return calculate_stages(players, format, courts)
@router.get("/{event_id}")
def get_event(event_id: int, db: Session = Depends(get_db)):
event = _event_with_details(event_id, db)
if not event:
raise HTTPException(status_code=404, detail="Event not found")
stages_data = []
for stage in event.stages:
matches_data = []
for m in stage.matches:
p1 = db.query(Player).filter(Player.id == m.player1_id).first() if m.player1_id else None
p2 = db.query(Player).filter(Player.id == m.player2_id).first() if m.player2_id else None
court = db.query(Court).filter(Court.id == m.court_id).first() if m.court_id else None
matches_data.append({
"id": m.id, "round_number": m.round_number, "match_number": m.match_number,
"player1_id": m.player1_id, "player2_id": m.player2_id,
"team1_players": m.team1_players, "team2_players": m.team2_players,
"player1_name": p1.name if p1 else None,
"player2_name": p2.name if p2 else None,
"score1": m.score1, "score2": m.score2,
"winner_id": m.winner_id, "winner_team": m.winner_team,
"status": m.status, "bracket_position": m.bracket_position,
"next_winner_match_id": m.next_winner_match_id,
"next_loser_match_id": m.next_loser_match_id,
"court_id": m.court_id,
"court_name": court.name if court else None,
"started_at": m.started_at, "completed_at": m.completed_at,
})
stages_data.append({
"id": stage.id, "name": stage.name, "match_type": stage.match_type,
"rounds": stage.rounds, "advance_count": stage.advance_count,
"status": stage.status, "order": stage.order,
"matches": matches_data,
})
players_data = [{
"id": ep.id, "player_id": ep.player_id,
"skill_level_override": ep.skill_level_override,
"player": {"id": ep.player.id, "name": ep.player.name,
"skill_level": ep.player.skill_level, "elo": ep.player.elo,
"matches_played": ep.player.matches_played,
"wins": ep.player.wins, "losses": ep.player.losses}
} for ep in event.event_players]
return {
"id": event.id, "name": event.name, "format": event.format,
"courts_count": event.courts_count, "status": event.status,
"current_stage_id": event.current_stage_id,
"created_at": event.created_at, "started_at": event.started_at,
"players": players_data,
"stages": stages_data,
}
@router.put("/{event_id}")
def update_event(event_id: int, data: EventUpdate, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
for k, v in data.model_dump(exclude_none=True).items():
setattr(event, k, v)
db.commit()
return {"ok": True}
@router.post("/{event_id}/players")
def add_player_to_event(event_id: int, data: EventPlayerAdd, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
player = db.query(Player).filter(Player.id == data.player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
existing = db.query(EventPlayer).filter(
EventPlayer.event_id == event_id, EventPlayer.player_id == data.player_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Player already in event")
ep = EventPlayer(event_id=event_id, player_id=data.player_id,
skill_level_override=data.skill_level_override)
db.add(ep)
db.commit()
db.refresh(ep)
return {"id": ep.id, "player_id": ep.player_id}
@router.delete("/{event_id}/players/{player_id}")
def remove_player_from_event(event_id: int, player_id: int, db: Session = Depends(get_db)):
ep = db.query(EventPlayer).filter(
EventPlayer.event_id == event_id, EventPlayer.player_id == player_id
).first()
if not ep:
raise HTTPException(status_code=404, detail="Player not in event")
db.delete(ep)
db.commit()
return {"ok": True}
@router.post("/{event_id}/stages")
def add_stage(event_id: int, data: StageCreate, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
stage = Stage(event_id=event_id, **data.model_dump())
db.add(stage)
db.commit()
db.refresh(stage)
return {"id": stage.id, "name": stage.name, "match_type": stage.match_type,
"rounds": stage.rounds, "advance_count": stage.advance_count, "status": stage.status}
@router.put("/{event_id}/stages/{stage_id}")
def update_stage(event_id: int, stage_id: int, data: StageUpdate, db: Session = Depends(get_db)):
stage = db.query(Stage).filter(Stage.id == stage_id, Stage.event_id == event_id).first()
if not stage:
raise HTTPException(status_code=404, detail="Stage not found")
for k, v in data.model_dump(exclude_none=True).items():
setattr(stage, k, v)
db.commit()
return {"ok": True}
@router.post("/{event_id}/stages/{stage_id}/generate")
def generate_stage_matches(event_id: int, stage_id: int, db: Session = Depends(get_db)):
"""Generate matches for a stage"""
stage = db.query(Stage).filter(Stage.id == stage_id, Stage.event_id == event_id).first()
if not stage:
raise HTTPException(status_code=404, detail="Stage not found")
event = db.query(Event).filter(Event.id == event_id).first()
# Delete existing matches for this stage
db.query(Match).filter(Match.stage_id == stage_id).delete()
db.commit()
# Get players for this event
event_players = db.query(EventPlayer).filter(EventPlayer.event_id == event_id).all()
player_ids = [ep.player_id for ep in event_players]
if len(player_ids) < 2:
raise HTTPException(status_code=400, detail="Need at least 2 players")
match_type = stage.match_type
matches_to_create = []
if match_type in ["Open", "Open Match"]:
matches_raw = generate_open_schedule(player_ids, stage.rounds, event.courts_count)
for m in matches_raw:
matches_to_create.append(Match(
stage_id=stage_id,
round_number=m["round_number"],
match_number=m["match_number"],
player1_id=m["player1_id"],
player2_id=m["player2_id"],
status=m["status"],
bracket_position=m.get("bracket_position", "main"),
))
elif match_type == "Round Robin":
pairings = generate_round_robin(player_ids)
for i, (p1, p2) in enumerate(pairings):
matches_to_create.append(Match(
stage_id=stage_id,
round_number=1,
match_number=i + 1,
player1_id=p1,
player2_id=p2,
status="scheduled",
bracket_position="main",
))
elif match_type in ["Tournament", "Double Elimination"]:
bracket_matches = generate_single_elim_bracket(player_ids)
# Create matches and track temp IDs
created = {}
# First pass - create all match objects
db_matches = []
for bm in bracket_matches:
m = Match(
stage_id=stage_id,
round_number=bm["round_number"],
match_number=bm["match_number"],
player1_id=bm.get("player1_id"),
player2_id=bm.get("player2_id"),
status=bm["status"],
bracket_position=bm.get("bracket_position", "winners"),
)
db.add(m)
db.flush()
created[bm["temp_id"]] = m
db_matches.append((bm, m))
# Second pass - link next_winner_match_id
for bm, m in db_matches:
if bm.get("next_winner_temp_id") and bm["next_winner_temp_id"] in created:
m.next_winner_match_id = created[bm["next_winner_temp_id"]].id
db.commit()
return {"ok": True, "match_count": len(bracket_matches)}
elif match_type == "Skill-Based":
# Group players by skill level and create matches within same skill groups
players = db.query(Player).filter(Player.id.in_(player_ids)).all()
skill_order = {"Beginner": 0, "Intermediate": 1, "Advanced": 2, "Elite": 3}
players_sorted = sorted(players, key=lambda p: skill_order.get(p.skill_level, 0))
sorted_ids = [p.id for p in players_sorted]
pairings = generate_round_robin(sorted_ids)[:len(sorted_ids)]
for i, (p1, p2) in enumerate(pairings):
matches_to_create.append(Match(
stage_id=stage_id,
round_number=1,
match_number=i + 1,
player1_id=p1,
player2_id=p2,
status="scheduled",
bracket_position="main",
))
for m in matches_to_create:
db.add(m)
db.commit()
return {"ok": True, "match_count": len(matches_to_create)}
@router.post("/{event_id}/start")
def start_event(event_id: int, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
event.status = "active"
event.started_at = datetime.utcnow()
# Activate first stage
first_stage = db.query(Stage).filter(Stage.event_id == event_id).order_by(Stage.order).first()
if first_stage:
first_stage.status = "active"
event.current_stage_id = first_stage.id
db.commit()
return {"ok": True}
@router.post("/{event_id}/complete")
def complete_event(event_id: int, db: Session = Depends(get_db)):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
event.status = "completed"
event.completed_at = datetime.utcnow()
db.commit()
return {"ok": True}