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
This commit is contained in:
294
backend/app/routers/events.py
Normal file
294
backend/app/routers/events.py
Normal file
@@ -0,0 +1,294 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user