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:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
16
backend/app/routers/admin.py
Normal file
16
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from ..database import get_db
|
||||
from ..models import Base
|
||||
from ..database import engine
|
||||
import subprocess, sys
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
def seed_data(db: Session = Depends(get_db)):
|
||||
"""Re-seed all demo data"""
|
||||
import subprocess, sys
|
||||
result = subprocess.run([sys.executable, "/app/seed.py"], capture_output=True, text=True)
|
||||
return {"ok": result.returncode == 0, "output": result.stdout[-500:], "error": result.stderr[-200:] if result.stderr else None}
|
||||
81
backend/app/routers/courts.py
Normal file
81
backend/app/routers/courts.py
Normal file
@@ -0,0 +1,81 @@
|
||||
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 Court, CourtReservation
|
||||
from ..schemas import CourtCreate, CourtStatusUpdate, ReservationCreate, ReservationOut, CourtOut
|
||||
|
||||
router = APIRouter(prefix="/api/courts", tags=["courts"])
|
||||
|
||||
|
||||
@router.get("/", response_model=List[CourtOut])
|
||||
def list_courts(db: Session = Depends(get_db)):
|
||||
courts = db.query(Court).options(joinedload(Court.reservations).joinedload(CourtReservation.player)).all()
|
||||
return courts
|
||||
|
||||
|
||||
@router.post("/", response_model=CourtOut)
|
||||
def create_court(data: CourtCreate, db: Session = Depends(get_db)):
|
||||
court = Court(**data.model_dump())
|
||||
db.add(court)
|
||||
db.commit()
|
||||
db.refresh(court)
|
||||
return court
|
||||
|
||||
|
||||
@router.get("/{court_id}", response_model=CourtOut)
|
||||
def get_court(court_id: int, db: Session = Depends(get_db)):
|
||||
court = db.query(Court).options(joinedload(Court.reservations).joinedload(CourtReservation.player)).filter(Court.id == court_id).first()
|
||||
if not court:
|
||||
raise HTTPException(status_code=404, detail="Court not found")
|
||||
return court
|
||||
|
||||
|
||||
@router.patch("/{court_id}/status")
|
||||
def update_court_status(court_id: int, data: CourtStatusUpdate, db: Session = Depends(get_db)):
|
||||
court = db.query(Court).filter(Court.id == court_id).first()
|
||||
if not court:
|
||||
raise HTTPException(status_code=404, detail="Court not found")
|
||||
valid_statuses = ["available", "in_use", "reserved", "maintenance"]
|
||||
if data.status not in valid_statuses:
|
||||
raise HTTPException(status_code=400, detail=f"Status must be one of {valid_statuses}")
|
||||
court.status = data.status
|
||||
db.commit()
|
||||
return {"ok": True, "status": data.status}
|
||||
|
||||
|
||||
@router.get("/{court_id}/reservations", response_model=List[ReservationOut])
|
||||
def list_reservations(court_id: int, db: Session = Depends(get_db)):
|
||||
return db.query(CourtReservation).filter(CourtReservation.court_id == court_id).order_by(CourtReservation.start_time).all()
|
||||
|
||||
|
||||
@router.post("/{court_id}/reservations", response_model=ReservationOut)
|
||||
def create_reservation(court_id: int, data: ReservationCreate, db: Session = Depends(get_db)):
|
||||
court = db.query(Court).filter(Court.id == court_id).first()
|
||||
if not court:
|
||||
raise HTTPException(status_code=404, detail="Court not found")
|
||||
reservation = CourtReservation(court_id=court_id, **data.model_dump())
|
||||
db.add(reservation)
|
||||
# Update court status to reserved if it's currently available
|
||||
if court.status == "available":
|
||||
court.status = "reserved"
|
||||
db.commit()
|
||||
db.refresh(reservation)
|
||||
return reservation
|
||||
|
||||
|
||||
@router.delete("/reservations/{reservation_id}")
|
||||
def delete_reservation(reservation_id: int, db: Session = Depends(get_db)):
|
||||
reservation = db.query(CourtReservation).filter(CourtReservation.id == reservation_id).first()
|
||||
if not reservation:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
court = db.query(Court).filter(Court.id == reservation.court_id).first()
|
||||
db.delete(reservation)
|
||||
db.commit()
|
||||
# Check if court has any remaining reservations
|
||||
remaining = db.query(CourtReservation).filter(CourtReservation.court_id == reservation.court_id).count()
|
||||
if remaining == 0 and court and court.status == "reserved":
|
||||
court.status = "available"
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
136
backend/app/routers/dashboard.py
Normal file
136
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from ..database import get_db
|
||||
from ..models import Court, Match, Event, Stage, Player, CourtReservation
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_dashboard(db: Session = Depends(get_db)):
|
||||
courts = db.query(Court).all()
|
||||
courts_data = []
|
||||
|
||||
for court in courts:
|
||||
current_match = db.query(Match).filter(
|
||||
Match.court_id == court.id,
|
||||
Match.status == "in_progress"
|
||||
).first()
|
||||
|
||||
match_data = None
|
||||
elapsed = None
|
||||
if current_match:
|
||||
p1 = db.query(Player).filter(Player.id == current_match.player1_id).first() if current_match.player1_id else None
|
||||
p2 = db.query(Player).filter(Player.id == current_match.player2_id).first() if current_match.player2_id else None
|
||||
if current_match.started_at:
|
||||
elapsed = int((datetime.utcnow() - current_match.started_at).total_seconds())
|
||||
match_data = {
|
||||
"id": current_match.id,
|
||||
"player1_id": current_match.player1_id,
|
||||
"player2_id": current_match.player2_id,
|
||||
"player1_name": p1.name if p1 else None,
|
||||
"player2_name": p2.name if p2 else None,
|
||||
"score1": current_match.score1,
|
||||
"score2": current_match.score2,
|
||||
"status": current_match.status,
|
||||
"elapsed_seconds": elapsed,
|
||||
"stage_id": current_match.stage_id,
|
||||
}
|
||||
|
||||
# Next reservation
|
||||
now = datetime.utcnow()
|
||||
next_reservation = db.query(CourtReservation).filter(
|
||||
CourtReservation.court_id == court.id,
|
||||
CourtReservation.start_time > now
|
||||
).order_by(CourtReservation.start_time).first()
|
||||
|
||||
courts_data.append({
|
||||
"id": court.id,
|
||||
"name": court.name,
|
||||
"court_type": court.court_type,
|
||||
"status": court.status,
|
||||
"current_match": match_data,
|
||||
"elapsed_seconds": elapsed,
|
||||
"next_reservation": {
|
||||
"start_time": next_reservation.start_time,
|
||||
"group_name": next_reservation.group_name,
|
||||
} if next_reservation else None,
|
||||
})
|
||||
|
||||
# Upcoming scheduled matches (not yet in progress)
|
||||
upcoming = db.query(Match).filter(
|
||||
Match.status == "scheduled",
|
||||
Match.player1_id.isnot(None),
|
||||
Match.player2_id.isnot(None)
|
||||
).limit(10).all()
|
||||
|
||||
upcoming_data = []
|
||||
for m in upcoming:
|
||||
p1 = db.query(Player).filter(Player.id == m.player1_id).first()
|
||||
p2 = db.query(Player).filter(Player.id == m.player2_id).first()
|
||||
stage = db.query(Stage).filter(Stage.id == m.stage_id).first()
|
||||
event = db.query(Event).filter(Event.id == stage.event_id).first() if stage else None
|
||||
upcoming_data.append({
|
||||
"id": m.id,
|
||||
"player1_name": p1.name if p1 else None,
|
||||
"player2_name": p2.name if p2 else None,
|
||||
"round_number": m.round_number,
|
||||
"event_name": event.name if event else None,
|
||||
"stage_name": stage.name if stage else None,
|
||||
})
|
||||
|
||||
# Active event summary
|
||||
active_event = db.query(Event).filter(Event.status == "active").first()
|
||||
active_event_data = None
|
||||
if active_event:
|
||||
active_stage = None
|
||||
if active_event.current_stage_id:
|
||||
active_stage = db.query(Stage).filter(Stage.id == active_event.current_stage_id).first()
|
||||
active_event_data = {
|
||||
"id": active_event.id,
|
||||
"name": active_event.name,
|
||||
"format": active_event.format,
|
||||
"current_stage": active_stage.name if active_stage else None,
|
||||
"current_stage_id": active_event.current_stage_id,
|
||||
}
|
||||
|
||||
return {
|
||||
"courts": courts_data,
|
||||
"upcoming_matches": upcoming_data,
|
||||
"active_event": active_event_data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/screen/{court_id}")
|
||||
def screen_view(court_id: int, db: Session = Depends(get_db)):
|
||||
"""TV/screen display for a specific court"""
|
||||
court = db.query(Court).filter(Court.id == court_id).first()
|
||||
if not court:
|
||||
return {"error": "Court not found"}
|
||||
|
||||
current_match = db.query(Match).filter(
|
||||
Match.court_id == court_id,
|
||||
Match.status == "in_progress"
|
||||
).first()
|
||||
|
||||
match_data = None
|
||||
if current_match:
|
||||
p1 = db.query(Player).filter(Player.id == current_match.player1_id).first()
|
||||
p2 = db.query(Player).filter(Player.id == current_match.player2_id).first()
|
||||
elapsed = None
|
||||
if current_match.started_at:
|
||||
elapsed = int((datetime.utcnow() - current_match.started_at).total_seconds())
|
||||
match_data = {
|
||||
"id": current_match.id,
|
||||
"player1_name": p1.name if p1 else "TBD",
|
||||
"player2_name": p2.name if p2 else "TBD",
|
||||
"score1": current_match.score1 if current_match.score1 is not None else 0,
|
||||
"score2": current_match.score2 if current_match.score2 is not None else 0,
|
||||
"elapsed_seconds": elapsed,
|
||||
}
|
||||
|
||||
return {
|
||||
"court": {"id": court.id, "name": court.name, "status": court.status},
|
||||
"current_match": match_data,
|
||||
}
|
||||
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}
|
||||
156
backend/app/routers/matches.py
Normal file
156
backend/app/routers/matches.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from ..database import get_db
|
||||
from ..models import Match, Player, Court, Stage
|
||||
from ..schemas import MatchScoreUpdate, MatchCourtAssign
|
||||
|
||||
router = APIRouter(prefix="/api/matches", tags=["matches"])
|
||||
|
||||
|
||||
def _match_detail(match: Match, db: Session) -> dict:
|
||||
p1 = db.query(Player).filter(Player.id == match.player1_id).first() if match.player1_id else None
|
||||
p2 = db.query(Player).filter(Player.id == match.player2_id).first() if match.player2_id else None
|
||||
court = db.query(Court).filter(Court.id == match.court_id).first() if match.court_id else None
|
||||
return {
|
||||
"id": match.id,
|
||||
"stage_id": match.stage_id,
|
||||
"court_id": match.court_id,
|
||||
"court_name": court.name if court else None,
|
||||
"round_number": match.round_number,
|
||||
"match_number": match.match_number,
|
||||
"player1_id": match.player1_id,
|
||||
"player2_id": match.player2_id,
|
||||
"player1_name": p1.name if p1 else None,
|
||||
"player2_name": p2.name if p2 else None,
|
||||
"team1_players": match.team1_players,
|
||||
"team2_players": match.team2_players,
|
||||
"score1": match.score1,
|
||||
"score2": match.score2,
|
||||
"winner_id": match.winner_id,
|
||||
"winner_team": match.winner_team,
|
||||
"status": match.status,
|
||||
"bracket_position": match.bracket_position,
|
||||
"next_winner_match_id": match.next_winner_match_id,
|
||||
"next_loser_match_id": match.next_loser_match_id,
|
||||
"started_at": match.started_at,
|
||||
"completed_at": match.completed_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{match_id}")
|
||||
def get_match(match_id: int, db: Session = Depends(get_db)):
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
return _match_detail(match, db)
|
||||
|
||||
|
||||
@router.post("/{match_id}/start")
|
||||
def start_match(match_id: int, db: Session = Depends(get_db)):
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
|
||||
match.status = "in_progress"
|
||||
match.started_at = datetime.utcnow()
|
||||
|
||||
# Auto-assign available court if none assigned
|
||||
if not match.court_id:
|
||||
stage = db.query(Stage).filter(Stage.id == match.stage_id).first()
|
||||
if stage:
|
||||
# Find available court
|
||||
in_use_court_ids = db.query(Match.court_id).filter(
|
||||
Match.status == "in_progress", Match.court_id.isnot(None)
|
||||
).all()
|
||||
in_use_ids = [r[0] for r in in_use_court_ids]
|
||||
available_court = db.query(Court).filter(
|
||||
Court.status.in_(["available", "reserved"]),
|
||||
Court.id.notin_(in_use_ids)
|
||||
).first()
|
||||
if available_court:
|
||||
match.court_id = available_court.id
|
||||
available_court.status = "in_use"
|
||||
|
||||
db.commit()
|
||||
return _match_detail(match, db)
|
||||
|
||||
|
||||
@router.post("/{match_id}/score")
|
||||
def enter_score(match_id: int, data: MatchScoreUpdate, db: Session = Depends(get_db)):
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
|
||||
match.score1 = data.score1
|
||||
match.score2 = data.score2
|
||||
match.status = "completed"
|
||||
match.completed_at = datetime.utcnow()
|
||||
|
||||
# Determine winner
|
||||
if data.score1 > data.score2:
|
||||
match.winner_id = match.player1_id
|
||||
match.winner_team = 1
|
||||
winner_id = match.player1_id
|
||||
loser_id = match.player2_id
|
||||
else:
|
||||
match.winner_id = match.player2_id
|
||||
match.winner_team = 2
|
||||
winner_id = match.player2_id
|
||||
loser_id = match.player1_id
|
||||
|
||||
# Free up court
|
||||
if match.court_id:
|
||||
court = db.query(Court).filter(Court.id == match.court_id).first()
|
||||
if court:
|
||||
court.status = "available"
|
||||
|
||||
# Update player stats
|
||||
if winner_id:
|
||||
winner = db.query(Player).filter(Player.id == winner_id).first()
|
||||
if winner:
|
||||
winner.wins += 1
|
||||
winner.matches_played += 1
|
||||
winner.elo += 20 # Simple ELO gain
|
||||
if loser_id:
|
||||
loser = db.query(Player).filter(Player.id == loser_id).first()
|
||||
if loser:
|
||||
loser.losses += 1
|
||||
loser.matches_played += 1
|
||||
loser.elo = max(800, loser.elo - 15)
|
||||
|
||||
# Advance bracket: place winner into next match
|
||||
if match.next_winner_match_id:
|
||||
next_match = db.query(Match).filter(Match.id == match.next_winner_match_id).first()
|
||||
if next_match:
|
||||
if next_match.player1_id is None:
|
||||
next_match.player1_id = winner_id
|
||||
elif next_match.player2_id is None:
|
||||
next_match.player2_id = winner_id
|
||||
|
||||
db.commit()
|
||||
return _match_detail(match, db)
|
||||
|
||||
|
||||
@router.patch("/{match_id}/court")
|
||||
def assign_court(match_id: int, data: MatchCourtAssign, db: Session = Depends(get_db)):
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
|
||||
# Free old court
|
||||
if match.court_id:
|
||||
old_court = db.query(Court).filter(Court.id == match.court_id).first()
|
||||
if old_court and old_court.status == "in_use":
|
||||
old_court.status = "available"
|
||||
|
||||
match.court_id = data.court_id
|
||||
|
||||
# Mark new court in use if match is in progress
|
||||
if data.court_id and match.status == "in_progress":
|
||||
new_court = db.query(Court).filter(Court.id == data.court_id).first()
|
||||
if new_court:
|
||||
new_court.status = "in_use"
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
52
backend/app/routers/players.py
Normal file
52
backend/app/routers/players.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import Player
|
||||
from ..schemas import PlayerCreate, PlayerUpdate, PlayerOut
|
||||
|
||||
router = APIRouter(prefix="/api/players", tags=["players"])
|
||||
|
||||
|
||||
@router.get("/", response_model=List[PlayerOut])
|
||||
def list_players(db: Session = Depends(get_db)):
|
||||
return db.query(Player).order_by(Player.elo.desc()).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=PlayerOut)
|
||||
def create_player(data: PlayerCreate, db: Session = Depends(get_db)):
|
||||
player = Player(**data.model_dump())
|
||||
db.add(player)
|
||||
db.commit()
|
||||
db.refresh(player)
|
||||
return player
|
||||
|
||||
|
||||
@router.get("/{player_id}", response_model=PlayerOut)
|
||||
def get_player(player_id: int, db: Session = Depends(get_db)):
|
||||
player = db.query(Player).filter(Player.id == player_id).first()
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
return player
|
||||
|
||||
|
||||
@router.put("/{player_id}", response_model=PlayerOut)
|
||||
def update_player(player_id: int, data: PlayerUpdate, db: Session = Depends(get_db)):
|
||||
player = db.query(Player).filter(Player.id == player_id).first()
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
for k, v in data.model_dump(exclude_none=True).items():
|
||||
setattr(player, k, v)
|
||||
db.commit()
|
||||
db.refresh(player)
|
||||
return player
|
||||
|
||||
|
||||
@router.delete("/{player_id}")
|
||||
def delete_player(player_id: int, db: Session = Depends(get_db)):
|
||||
player = db.query(Player).filter(Player.id == player_id).first()
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
db.delete(player)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
Reference in New Issue
Block a user