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:
Nemo
2026-03-19 07:35:51 +08:00
parent e455accc7e
commit 4421f61c24
56 changed files with 2949 additions and 4209 deletions

View File

@@ -1,227 +0,0 @@
"""Admin/seed endpoints for demo"""
from fastapi import APIRouter, Depends, BackgroundTasks
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.player import Player, MembershipTier
from app.models.court import Court
from app.models.booking import Booking, BookingStatus
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus
from datetime import datetime, timedelta
import random
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/seed")
def run_seed(db: Session = Depends(get_db)):
"""Run demo seed data"""
try:
import sys
sys.path.insert(0, '/app')
from seed import main
main()
return {"status": "ok", "message": "Seed complete"}
except Exception as e:
return {"status": "error", "message": str(e)}
@router.post("/update-elos")
def update_elos(db: Session = Depends(get_db)):
"""Update demo player ELO ratings to proper values"""
elo_map = {
"marco@demo.com": (1850, MembershipTier.ELITE, 45, 8, "#EF4444"),
"sofia@demo.com": (1720, MembershipTier.ELITE, 38, 12, "#EC4899"),
"carlos@demo.com": (1630, MembershipTier.PLATINUM, 32, 15, "#8B5CF6"),
"ana@demo.com": (1580, MembershipTier.PLATINUM, 28, 18, "#06B6D4"),
"juan@demo.com": (1450, MembershipTier.GOLD, 22, 20, "#F59E0B"),
"maria@demo.com": (1380, MembershipTier.GOLD, 18, 22, "#10B981"),
"pedro@demo.com": (1250, MembershipTier.SILVER, 14, 19, "#3B82F6"),
"rosa@demo.com": (1190, MembershipTier.SILVER, 11, 21, "#F97316"),
"diego@demo.com": (1100, MembershipTier.SILVER, 9, 18, "#84CC16"),
"lucia@demo.com": (980, MembershipTier.BRONZE, 6, 20, "#14B8A6"),
"miguel@demo.com": (920, MembershipTier.BRONZE, 4, 15, "#6366F1"),
"isabella@demo.com": (850, MembershipTier.BRONZE, 2, 12, "#D946EF"),
}
updated = []
for email, (elo, tier, wins, losses, color) in elo_map.items():
p = db.query(Player).filter(Player.email == email).first()
if p:
p.elo_rating = elo
p.membership_tier = tier
p.wins = wins
p.losses = losses
p.total_matches = wins + losses
p.avatar_color = color
updated.append(p.name)
db.commit()
# Create courts if not exist
if db.query(Court).count() == 0:
courts_data = [
("Court 1", 1, 200.0, "Sport Court", "LED Lighting, Pro Net System"),
("Court 2", 2, 200.0, "Sport Court", "LED Lighting, Spectator Seating"),
("Court 3", 3, 250.0, "Cushioned", "Tournament Grade, Scoreboards"),
("Court 4", 4, 250.0, "Cushioned", "Tournament Grade, VIP Lounge Access"),
]
for name, num, rate, surface, features in courts_data:
c = Court(name=name, court_number=num, hourly_rate=rate, surface_type=surface, features=features)
db.add(c)
db.commit()
# Create active matches
courts = db.query(Court).order_by(Court.court_number).all()
players = db.query(Player).order_by(Player.elo_rating.desc()).all()
if db.query(Match).count() == 0 and len(players) >= 4 and len(courts) >= 2:
# Active match 1
m1 = Match(
court_id=courts[0].id,
stage=MatchStage.OPEN,
match_type=MatchType.DOUBLES,
status=MatchStatus.IN_PROGRESS,
title="Open Doubles - Court 1",
max_players=4,
team1_score=7,
team2_score=5,
started_at=datetime.utcnow() - timedelta(minutes=25),
)
db.add(m1)
db.flush()
for pid, team in [(players[0].id, 1), (players[1].id, 1), (players[2].id, 2), (players[3].id, 2)]:
db.add(MatchPlayer(match_id=m1.id, player_id=pid, team=team, elo_before=db.query(Player).filter(Player.id == pid).first().elo_rating))
# Active match 2 (skill-based)
m2 = Match(
court_id=courts[1].id,
stage=MatchStage.SKILL_BASED,
match_type=MatchType.DOUBLES,
status=MatchStatus.IN_PROGRESS,
title="Skill Match - Gold League",
max_players=4,
min_elo=1200,
max_elo=1600,
team1_score=3,
team2_score=6,
started_at=datetime.utcnow() - timedelta(minutes=15),
)
db.add(m2)
db.flush()
for pid, team in [(players[4].id, 1), (players[5].id, 1), (players[6].id, 2), (players[7].id, 2)]:
db.add(MatchPlayer(match_id=m2.id, player_id=pid, team=team, elo_before=db.query(Player).filter(Player.id == pid).first().elo_rating))
# Lobby matches
m3 = Match(stage=MatchStage.OPEN, match_type=MatchType.DOUBLES, status=MatchStatus.LOBBY,
title="Evening Open Game", max_players=4)
db.add(m3)
db.flush()
db.add(MatchPlayer(match_id=m3.id, player_id=players[8].id, team=1, elo_before=players[8].elo_rating))
m4 = Match(stage=MatchStage.SKILL_BASED, match_type=MatchType.DOUBLES, status=MatchStatus.LOBBY,
title="Gold Tier Challenge", max_players=4, min_elo=1200, max_elo=1600)
db.add(m4)
db.flush()
db.add(MatchPlayer(match_id=m4.id, player_id=players[4].id, team=1, elo_before=players[4].elo_rating))
db.commit()
# Create tournament
if db.query(Tournament).count() == 0 and len(players) >= 8 and len(courts) >= 3:
t = Tournament(
name="ServeSync Grand Prix - March 2024",
status=TournamentStatus.IN_PROGRESS,
max_participants=8,
current_round=2,
started_at=datetime.utcnow() - timedelta(hours=2),
)
db.add(t)
db.flush()
tp = players[:8]
entries = []
for i, player in enumerate(tp):
entry = TournamentEntry(tournament_id=t.id, player_id=player.id, seed=i+1, wins=0, losses=0)
db.add(entry)
entries.append(entry)
db.flush()
r1 = [(tp[0].id, tp[7].id, 11, 5), (tp[1].id, tp[6].id, 11, 7),
(tp[2].id, tp[5].id, 8, 11), (tp[3].id, tp[4].id, 11, 9)]
winners = []
losers = []
for i, (p1, p2, s1, s2) in enumerate(r1):
tm = TournamentMatch(tournament_id=t.id, round_number=1, match_number=i+1,
bracket_type=BracketType.WINNERS, status=TournamentMatchStatus.COMPLETED,
player1_id=p1, player2_id=p2, team1_score=s1, team2_score=s2,
winner_team=1 if s1>s2 else 2,
completed_at=datetime.utcnow() - timedelta(hours=1, minutes=30))
db.add(tm)
w = p1 if s1>s2 else p2
l = p2 if s1>s2 else p1
winners.append(w)
losers.append(l)
for e in entries:
if e.player_id == w: e.wins += 1
elif e.player_id == l:
e.losses += 1
e.is_in_losers = True
# R2 winners - 1 completed, 1 active
wm1 = TournamentMatch(tournament_id=t.id, round_number=2, match_number=1,
bracket_type=BracketType.WINNERS, status=TournamentMatchStatus.COMPLETED,
player1_id=winners[0], player2_id=winners[1], team1_score=11, team2_score=8,
winner_team=1, completed_at=datetime.utcnow() - timedelta(minutes=45))
db.add(wm1)
for e in entries:
if e.player_id == winners[0]: e.wins += 1
elif e.player_id == winners[1]:
e.losses += 1
e.is_in_losers = True
wm2 = TournamentMatch(tournament_id=t.id, round_number=2, match_number=2,
bracket_type=BracketType.WINNERS, status=TournamentMatchStatus.IN_PROGRESS,
player1_id=winners[2], player2_id=winners[3], team1_score=6, team2_score=7)
db.add(wm2)
# Losers bracket
lb1 = TournamentMatch(tournament_id=t.id, round_number=1, match_number=1,
bracket_type=BracketType.LOSERS, status=TournamentMatchStatus.COMPLETED,
player1_id=losers[0], player2_id=losers[1], team1_score=9, team2_score=11,
winner_team=2, completed_at=datetime.utcnow() - timedelta(minutes=90))
db.add(lb1)
lb2 = TournamentMatch(tournament_id=t.id, round_number=1, match_number=2,
bracket_type=BracketType.LOSERS, status=TournamentMatchStatus.COMPLETED,
player1_id=losers[2], player2_id=losers[3], team1_score=11, team2_score=6,
winner_team=1, completed_at=datetime.utcnow() - timedelta(minutes=60))
db.add(lb2)
for e in entries:
if e.player_id == losers[0]:
e.losses += 1
e.is_eliminated = True
e.final_rank = 8
elif e.player_id == losers[3]:
e.losses += 1
e.is_eliminated = True
e.final_rank = 7
# Tournament match on court 3
p_name_2 = db.query(Player).filter(Player.id == winners[2]).first().name
p_name_3 = db.query(Player).filter(Player.id == winners[3]).first().name
m_tour = Match(
court_id=courts[2].id, stage=MatchStage.TOURNAMENT, match_type=MatchType.SINGLES,
status=MatchStatus.IN_PROGRESS,
title=f"Tournament R2 - {p_name_2} vs {p_name_3}",
max_players=2, team1_score=6, team2_score=7,
started_at=datetime.utcnow() - timedelta(minutes=20),
)
db.add(m_tour)
db.flush()
for pid, team in [(winners[2], 1), (winners[3], 2)]:
db.add(MatchPlayer(match_id=m_tour.id, player_id=pid, team=team,
elo_before=db.query(Player).filter(Player.id == pid).first().elo_rating))
db.commit()
return {"status": "ok", "updated": updated, "players": len(updated)}

View File

@@ -1,218 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.database import get_db
from app.models.court import Court
from app.models.booking import Booking, BookingStatus
from app.models.player import Player
from app.models.match import Match, MatchStatus
from pydantic import BaseModel
from datetime import datetime, timedelta
from typing import Optional, List
router = APIRouter(prefix="/courts", tags=["courts"])
class BookingCreate(BaseModel):
player_id: int
court_id: int
start_time: datetime
duration_hours: float = 1.0
class CourtResponse(BaseModel):
id: int
name: str
court_number: int
hourly_rate: float
is_active: bool
surface_type: str
features: str
current_status: str
current_match: Optional[dict] = None
class Config:
from_attributes = True
class BookingResponse(BaseModel):
id: int
player_id: int
player_name: str
court_id: int
court_name: str
start_time: datetime
end_time: datetime
duration_hours: float
total_cost: float
status: str
class Config:
from_attributes = True
def get_court_status(court: Court, db: Session) -> tuple:
"""Get current status of a court"""
# Check if match is in progress
match = db.query(Match).filter(
Match.court_id == court.id,
Match.status == MatchStatus.IN_PROGRESS
).first()
if match:
team1 = [mp.player.name for mp in match.match_players if mp.team == 1]
team2 = [mp.player.name for mp in match.match_players if mp.team == 2]
return "occupied", {
"match_id": match.id,
"team1": team1,
"team2": team2,
"team1_score": match.team1_score,
"team2_score": match.team2_score,
"stage": match.stage.value,
"started_at": match.started_at.isoformat() if match.started_at else None,
}
# Check if there's a booking now
now = datetime.utcnow()
booking = db.query(Booking).filter(
Booking.court_id == court.id,
Booking.start_time <= now,
Booking.end_time >= now,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
).first()
if booking:
return "booked", None
return "available", None
@router.get("/", response_model=List[CourtResponse])
def get_courts(db: Session = Depends(get_db)):
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
result = []
for court in courts:
status, match_info = get_court_status(court, db)
result.append(CourtResponse(
id=court.id,
name=court.name,
court_number=court.court_number,
hourly_rate=court.hourly_rate,
is_active=court.is_active,
surface_type=court.surface_type,
features=court.features,
current_status=status,
current_match=match_info,
))
return result
@router.get("/{court_id}")
def get_court(court_id: int, 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")
status, match_info = get_court_status(court, db)
return {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"hourly_rate": court.hourly_rate,
"surface_type": court.surface_type,
"features": court.features,
"current_status": status,
"current_match": match_info,
}
@router.get("/{court_id}/schedule")
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
"""Get bookings for a court for the next 7 days"""
now = datetime.utcnow()
end = now + timedelta(days=7)
bookings = db.query(Booking).filter(
Booking.court_id == court_id,
Booking.start_time >= now,
Booking.end_time <= end,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS])
).order_by(Booking.start_time).all()
return [
{
"id": b.id,
"player_name": b.player.name,
"start_time": b.start_time.isoformat(),
"end_time": b.end_time.isoformat(),
"duration_hours": b.duration_hours,
"is_match_booking": b.is_match_booking,
}
for b in bookings
]
@router.post("/book")
def book_court(booking_data: BookingCreate, db: Session = Depends(get_db)):
court = db.query(Court).filter(Court.id == booking_data.court_id).first()
if not court:
raise HTTPException(status_code=404, detail="Court not found")
player = db.query(Player).filter(Player.id == booking_data.player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
end_time = booking_data.start_time + timedelta(hours=booking_data.duration_hours)
# Check for conflicts
conflict = db.query(Booking).filter(
Booking.court_id == booking_data.court_id,
Booking.status.in_([BookingStatus.CONFIRMED, BookingStatus.IN_PROGRESS]),
and_(Booking.start_time < end_time, Booking.end_time > booking_data.start_time)
).first()
if conflict:
raise HTTPException(status_code=400, detail="Court is already booked for this time slot")
total_cost = court.hourly_rate * booking_data.duration_hours
booking = Booking(
player_id=booking_data.player_id,
court_id=booking_data.court_id,
start_time=booking_data.start_time,
end_time=end_time,
duration_hours=booking_data.duration_hours,
total_cost=total_cost,
status=BookingStatus.CONFIRMED,
)
db.add(booking)
db.commit()
db.refresh(booking)
return {
"id": booking.id,
"court_name": court.name,
"player_name": player.name,
"start_time": booking.start_time.isoformat(),
"end_time": booking.end_time.isoformat(),
"total_cost": booking.total_cost,
"status": booking.status.value,
}
@router.get("/bookings/upcoming")
def get_upcoming_bookings(db: Session = Depends(get_db)):
now = datetime.utcnow()
bookings = db.query(Booking).filter(
Booking.start_time >= now,
Booking.status == BookingStatus.CONFIRMED
).order_by(Booking.start_time).limit(20).all()
return [
{
"id": b.id,
"player_name": b.player.name,
"court_name": b.court.name,
"start_time": b.start_time.isoformat(),
"end_time": b.end_time.isoformat(),
"total_cost": b.total_cost,
}
for b in bookings
]

View File

@@ -1,160 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.models.player import Player
from app.services import matchmaking
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/matches", tags=["matches"])
class CreateMatchRequest(BaseModel):
title: str
stage: str = "open"
match_type: str = "doubles"
creator_player_id: int
elo_tolerance: float = 200
class JoinMatchRequest(BaseModel):
player_id: int
team: int
class ScoreUpdateRequest(BaseModel):
team1_score: int
team2_score: int
def match_to_dict(match: Match, db: Session) -> dict:
players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match.id).all()
team1 = []
team2 = []
for mp in players:
p = db.query(Player).filter(Player.id == mp.player_id).first()
if p:
pdata = {
"id": p.id,
"name": p.name,
"elo": round(p.elo_rating, 1),
"avatar_color": p.avatar_color,
"elo_change": mp.elo_change,
}
if mp.team == 1:
team1.append(pdata)
else:
team2.append(pdata)
return {
"id": match.id,
"title": match.title,
"stage": match.stage.value,
"match_type": match.match_type.value,
"status": match.status.value,
"team1": team1,
"team2": team2,
"team1_score": match.team1_score,
"team2_score": match.team2_score,
"max_players": match.max_players,
"current_players": len(players),
"min_elo": match.min_elo,
"max_elo": match.max_elo,
"court_id": match.court_id,
"court_name": match.court.name if match.court else None,
"started_at": match.started_at.isoformat() if match.started_at else None,
"ended_at": match.ended_at.isoformat() if match.ended_at else None,
"created_at": match.created_at.isoformat() if match.created_at else None,
}
@router.get("/")
def get_matches(status: Optional[str] = None, stage: Optional[str] = None, db: Session = Depends(get_db)):
query = db.query(Match)
if status:
query = query.filter(Match.status == status)
if stage:
query = query.filter(Match.stage == stage)
matches = query.order_by(Match.created_at.desc()).limit(50).all()
return [match_to_dict(m, db) for m in matches]
@router.get("/lobby")
def get_lobby(db: Session = Depends(get_db)):
matches = matchmaking.get_lobby_matches(db)
return [match_to_dict(m, db) for m in matches]
@router.get("/active")
def get_active_matches(db: Session = Depends(get_db)):
matches = db.query(Match).filter(Match.status == MatchStatus.IN_PROGRESS).all()
return [match_to_dict(m, db) for m in matches]
@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_to_dict(match, db)
@router.post("/")
def create_match(req: CreateMatchRequest, db: Session = Depends(get_db)):
player = db.query(Player).filter(Player.id == req.creator_player_id).first()
if not player:
raise HTTPException(status_code=404, detail="Player not found")
match_type = MatchType.DOUBLES if req.match_type == "doubles" else MatchType.SINGLES
if req.stage == "open":
match = matchmaking.create_open_match(db, req.title, match_type)
elif req.stage == "skill_based":
match = matchmaking.create_skill_match(db, req.title, player.elo_rating, match_type, req.elo_tolerance)
else:
raise HTTPException(status_code=400, detail="Invalid stage. Use 'open' or 'skill_based'")
# Auto-join creator to team 1
matchmaking.join_match(db, match.id, player.id, 1)
return match_to_dict(match, db)
@router.post("/{match_id}/join")
def join_match(match_id: int, req: JoinMatchRequest, db: Session = Depends(get_db)):
result = matchmaking.join_match(db, match_id, req.player_id, req.team)
if not result:
raise HTTPException(status_code=400, detail="Cannot join match (full, ELO constraint, or already joined)")
match = db.query(Match).filter(Match.id == match_id).first()
return match_to_dict(match, db)
@router.post("/{match_id}/start")
def start_match(match_id: int, db: Session = Depends(get_db)):
match = matchmaking.start_match(db, match_id)
if not match:
raise HTTPException(status_code=400, detail="Cannot start match (not enough players or already started)")
return match_to_dict(match, db)
@router.post("/{match_id}/score")
def update_score(match_id: int, req: ScoreUpdateRequest, 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")
if match.status != MatchStatus.IN_PROGRESS:
raise HTTPException(status_code=400, detail="Match not in progress")
match.team1_score = req.team1_score
match.team2_score = req.team2_score
db.commit()
return match_to_dict(match, db)
@router.post("/{match_id}/complete")
def complete_match(match_id: int, req: ScoreUpdateRequest, db: Session = Depends(get_db)):
match = matchmaking.complete_match(db, match_id, req.team1_score, req.team2_score)
if not match:
raise HTTPException(status_code=400, detail="Cannot complete match")
return match_to_dict(match, db)

View File

@@ -1,94 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.player import Player, MembershipTier
from app.services.elo import get_tier_from_elo
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/players", tags=["players"])
class PlayerCreate(BaseModel):
name: str
email: str
phone: Optional[str] = None
class PlayerUpdate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
class PlayerResponse(BaseModel):
id: int
name: str
email: str
phone: Optional[str]
elo_rating: float
membership_tier: str
wins: int
losses: int
total_matches: int
win_rate: float
avatar_color: str
class Config:
from_attributes = True
@classmethod
def from_orm(cls, player: Player):
return cls(
id=player.id,
name=player.name,
email=player.email,
phone=player.phone,
elo_rating=round(player.elo_rating, 1),
membership_tier=player.membership_tier.value,
wins=player.wins,
losses=player.losses,
total_matches=player.total_matches,
win_rate=player.win_rate,
avatar_color=player.avatar_color,
)
@router.get("/", response_model=List[PlayerResponse])
def get_players(db: Session = Depends(get_db)):
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).all()
return [PlayerResponse.from_orm(p) for p in players]
@router.get("/leaderboard", response_model=List[PlayerResponse])
def get_leaderboard(limit: int = 10, db: Session = Depends(get_db)):
players = db.query(Player).filter(Player.is_active == True).order_by(Player.elo_rating.desc()).limit(limit).all()
return [PlayerResponse.from_orm(p) for p in players]
@router.get("/{player_id}", response_model=PlayerResponse)
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 PlayerResponse.from_orm(player)
@router.post("/", response_model=PlayerResponse)
def create_player(player_data: PlayerCreate, db: Session = Depends(get_db)):
existing = db.query(Player).filter(Player.email == player_data.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"]
color = colors[db.query(Player).count() % len(colors)]
player = Player(
name=player_data.name,
email=player_data.email,
phone=player_data.phone,
avatar_color=color,
)
db.add(player)
db.commit()
db.refresh(player)
return PlayerResponse.from_orm(player)

View File

@@ -1,163 +0,0 @@
"""Screen Display API - for TV/display views"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import desc
from app.database import get_db
from app.models.court import Court
from app.models.match import Match, MatchPlayer, MatchStatus
from app.models.player import Player
from app.models.tournament import Tournament, TournamentStatus
from app.services.tournament import get_bracket_data
from datetime import datetime, timezone
router = APIRouter(prefix="/screen", tags=["screen"])
@router.get("/court/{court_id}")
def get_court_display(court_id: int, db: Session = Depends(get_db)):
"""Full court display data for TV screen (court_id = court_number)"""
# Try by court_number first (friendlier URLs), then by id
court = db.query(Court).filter(Court.court_number == court_id).first()
if not court:
court = db.query(Court).filter(Court.id == court_id).first()
if not court:
return {"error": "Court not found"}
# Current active match
active_match = db.query(Match).filter(
Match.court_id == court_id,
Match.status == MatchStatus.IN_PROGRESS
).first()
match_data = None
if active_match:
team1_players = []
team2_players = []
for mp in active_match.match_players:
p = db.query(Player).filter(Player.id == mp.player_id).first()
if p:
pd = {
"id": p.id,
"name": p.name,
"elo": round(p.elo_rating, 1),
"avatar_color": p.avatar_color,
"membership_tier": p.membership_tier.value,
}
if mp.team == 1:
team1_players.append(pd)
else:
team2_players.append(pd)
elapsed = None
if active_match.started_at:
now = datetime.now(timezone.utc)
started = active_match.started_at
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
elapsed = int((now - started).total_seconds())
match_data = {
"id": active_match.id,
"stage": active_match.stage.value,
"match_type": active_match.match_type.value,
"team1": team1_players,
"team2": team2_players,
"team1_score": active_match.team1_score,
"team2_score": active_match.team2_score,
"elapsed_seconds": elapsed,
}
# Recent completed matches on this court
recent_matches = db.query(Match).filter(
Match.court_id == court_id,
Match.status == MatchStatus.COMPLETED
).order_by(desc(Match.ended_at)).limit(3).all()
recent = []
for m in recent_matches:
team1 = [db.query(Player).filter(Player.id == mp.player_id).first().name
for mp in m.match_players if mp.team == 1]
team2 = [db.query(Player).filter(Player.id == mp.player_id).first().name
for mp in m.match_players if mp.team == 2]
recent.append({
"team1": team1,
"team2": team2,
"team1_score": m.team1_score,
"team2_score": m.team2_score,
"winner": "team1" if m.team1_score > m.team2_score else "team2",
})
return {
"court": {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"surface_type": court.surface_type,
},
"active_match": match_data,
"recent_matches": recent,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@router.get("/overview")
def get_overview(db: Session = Depends(get_db)):
"""All courts overview for main display"""
courts = db.query(Court).filter(Court.is_active == True).order_by(Court.court_number).all()
result = []
for court in courts:
active_match = db.query(Match).filter(
Match.court_id == court.id,
Match.status == MatchStatus.IN_PROGRESS
).first()
court_data = {
"id": court.id,
"name": court.name,
"court_number": court.court_number,
"status": "occupied" if active_match else "available",
}
if active_match:
team1 = [mp.player.name for mp in active_match.match_players if mp.team == 1]
team2 = [mp.player.name for mp in active_match.match_players if mp.team == 2]
court_data["match"] = {
"team1_names": team1,
"team2_names": team2,
"team1_score": active_match.team1_score,
"team2_score": active_match.team2_score,
"stage": active_match.stage.value,
}
result.append(court_data)
# Active tournament
tournament_data = None
active_tournament = db.query(Tournament).filter(
Tournament.status == TournamentStatus.IN_PROGRESS
).first()
if active_tournament:
tournament_data = get_bracket_data(db, active_tournament.id)
# Leaderboard top 5
top_players = db.query(Player).filter(Player.is_active == True).order_by(
Player.elo_rating.desc()
).limit(5).all()
return {
"courts": result,
"tournament": tournament_data,
"leaderboard": [
{
"rank": i + 1,
"name": p.name,
"elo": round(p.elo_rating, 1),
"tier": p.membership_tier.value,
"wins": p.wins,
"losses": p.losses,
"avatar_color": p.avatar_color,
}
for i, p in enumerate(top_players)
],
"timestamp": datetime.now(timezone.utc).isoformat(),
}

View File

@@ -1,82 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus
from app.services import tournament as tournament_service
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(prefix="/tournaments", tags=["tournaments"])
class CreateTournamentRequest(BaseModel):
name: str
max_participants: int = 8
class RegisterPlayerRequest(BaseModel):
player_id: int
class MatchScoreRequest(BaseModel):
team1_score: int
team2_score: int
@router.get("/")
def get_tournaments(db: Session = Depends(get_db)):
tournaments = db.query(Tournament).order_by(Tournament.created_at.desc()).all()
return [
{
"id": t.id,
"name": t.name,
"status": t.status.value,
"max_participants": t.max_participants,
"current_participants": len(t.entries),
"started_at": t.started_at.isoformat() if t.started_at else None,
}
for t in tournaments
]
@router.get("/{tournament_id}")
def get_tournament(tournament_id: int, db: Session = Depends(get_db)):
return tournament_service.get_bracket_data(db, tournament_id)
@router.post("/")
def create_tournament(req: CreateTournamentRequest, db: Session = Depends(get_db)):
t = tournament_service.create_tournament(db, req.name, req.max_participants)
return {"id": t.id, "name": t.name, "status": t.status.value}
@router.post("/{tournament_id}/register")
def register_player(tournament_id: int, req: RegisterPlayerRequest, db: Session = Depends(get_db)):
entry = tournament_service.register_player(db, tournament_id, req.player_id)
if not entry:
raise HTTPException(status_code=400, detail="Cannot register player (tournament full or already registered)")
return {"message": "Registered successfully", "player_id": req.player_id}
@router.post("/{tournament_id}/start")
def start_tournament(tournament_id: int, db: Session = Depends(get_db)):
t = tournament_service.start_tournament(db, tournament_id)
if not t:
raise HTTPException(status_code=400, detail="Cannot start tournament (need at least 4 players)")
return tournament_service.get_bracket_data(db, tournament_id)
@router.post("/{tournament_id}/matches/{match_id}/score")
def complete_tournament_match(
tournament_id: int, match_id: int, req: MatchScoreRequest, db: Session = Depends(get_db)
):
tm = tournament_service.complete_tournament_match(db, match_id, req.team1_score, req.team2_score)
if not tm:
raise HTTPException(status_code=400, detail="Cannot complete match")
return tournament_service.get_bracket_data(db, tournament_id)
@router.get("/{tournament_id}/leaderboard")
def get_leaderboard(tournament_id: int, db: Session = Depends(get_db)):
data = tournament_service.get_bracket_data(db, tournament_id)
return data.get("leaderboard", [])

148
backend/app/bracket.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Bracket and schedule generation logic for ServeSync
"""
import math
from typing import List, Optional, Tuple
def calculate_stages(num_players: int, format: str, courts: int) -> dict:
"""Calculate suggested stages and rounds for an event"""
if format == "Doubles":
teams = num_players // 2
else:
teams = num_players
matches_per_round = courts
if teams <= 4:
suggested_stages = 2
stage_names = ["Open Stage", "Tournament"]
elif teams <= 8:
suggested_stages = 2
stage_names = ["Open Stage", "Tournament"]
else:
suggested_stages = 3
stage_names = ["Open Stage", "Skill Stage", "Tournament"]
rounds_single_elim = math.ceil(math.log2(max(teams, 2)))
rounds_double_elim = rounds_single_elim * 2
return {
"teams": teams,
"matches_per_round": matches_per_round,
"rounds_single_elim": rounds_single_elim,
"rounds_double_elim": rounds_double_elim,
"suggested_stages": suggested_stages,
"stage_names": stage_names,
}
def generate_round_robin(player_ids: List[int]) -> List[Tuple[int, int]]:
"""Generate all round-robin pairings"""
pairings = []
n = len(player_ids)
players = list(player_ids)
if n % 2 == 1:
players.append(None) # bye
for round_num in range(len(players) - 1):
for i in range(len(players) // 2):
p1 = players[i]
p2 = players[len(players) - 1 - i]
if p1 is not None and p2 is not None:
pairings.append((p1, p2))
# Rotate: keep first fixed, rotate the rest
players = [players[0]] + [players[-1]] + players[1:-1]
return pairings
def generate_single_elim_bracket(player_ids: List[int]) -> List[dict]:
"""
Generate single elimination bracket matches.
Returns list of match dicts with round, match_number, player1, player2, next_winner_match_id
"""
n = len(player_ids)
# Pad to power of 2
bracket_size = 2 ** math.ceil(math.log2(max(n, 2)))
players = list(player_ids) + [None] * (bracket_size - n)
matches = []
match_id_counter = 1
# First round
round1_matches = []
for i in range(0, len(players), 2):
match = {
"temp_id": match_id_counter,
"round_number": 1,
"match_number": i // 2 + 1,
"player1_id": players[i],
"player2_id": players[i + 1],
"next_winner_temp_id": None,
"bracket_position": "winners",
"status": "scheduled",
}
# Handle byes
if players[i] is None or players[i + 1] is None:
match["status"] = "bye"
match["winner_temp"] = players[i] if players[i] is not None else players[i + 1]
round1_matches.append(match)
matches.append(match)
match_id_counter += 1
# Subsequent rounds
current_round_matches = round1_matches
round_num = 2
while len(current_round_matches) > 1:
next_round_matches = []
for i in range(0, len(current_round_matches), 2):
match = {
"temp_id": match_id_counter,
"round_number": round_num,
"match_number": i // 2 + 1,
"player1_id": None,
"player2_id": None,
"next_winner_temp_id": None,
"bracket_position": "winners",
"status": "scheduled",
}
# Set next_winner_temp_id for previous round matches
current_round_matches[i]["next_winner_temp_id"] = match_id_counter
if i + 1 < len(current_round_matches):
current_round_matches[i + 1]["next_winner_temp_id"] = match_id_counter
next_round_matches.append(match)
matches.append(match)
match_id_counter += 1
current_round_matches = next_round_matches
round_num += 1
return matches
def generate_open_schedule(player_ids: List[int], rounds: int, courts: int) -> List[dict]:
"""
Generate open match schedule - round robin up to 'rounds' rounds.
"""
all_pairings = generate_round_robin(player_ids)
matches = []
match_num = 1
round_num = 1
court_idx = 0
for round_round in range(min(rounds, math.ceil(len(all_pairings) / max(courts, 1)))):
start = round_round * courts
end = start + courts
round_pairings = all_pairings[start:end]
for i, (p1, p2) in enumerate(round_pairings):
matches.append({
"round_number": round_num,
"match_number": i + 1,
"player1_id": p1,
"player2_id": p2,
"status": "scheduled",
"bracket_position": "main",
})
round_num += 1
return matches

View File

@@ -1,15 +0,0 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql://servesync:servesync@db:5432/servesync"
REDIS_URL: str = "redis://redis:6379"
SECRET_KEY: str = "servesync-demo-secret-key-2024"
DEBUG: bool = True
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -1,12 +1,14 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
from sqlalchemy.orm import sessionmaker, DeclarativeBase
engine = create_engine(settings.DATABASE_URL)
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://servesync:servesync@localhost:5432/servesync")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()

View File

@@ -1,27 +1,15 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .database import engine
from .models import Base
from .routers import players, courts, events, matches, dashboard, admin
from .websocket_manager import manager
import asyncio
import json
from typing import List, Dict
from app.database import engine, SessionLocal
from app.models import player, court, booking, match, tournament
Base.metadata.create_all(bind=engine)
# Create all tables
player.Base.metadata.create_all(bind=engine)
court.Base.metadata.create_all(bind=engine)
booking.Base.metadata.create_all(bind=engine)
match.Base.metadata.create_all(bind=engine)
tournament.Base.metadata.create_all(bind=engine)
from app.api import players, courts, matches, tournaments, screen, admin
app = FastAPI(
title="ServeSync API",
description="Pickleball Court Management System",
version="1.0.0",
)
app = FastAPI(title="ServeSync API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
@@ -31,112 +19,37 @@ app.add_middleware(
allow_headers=["*"],
)
# Include routers
app.include_router(players.router, prefix="/api")
app.include_router(courts.router, prefix="/api")
app.include_router(matches.router, prefix="/api")
app.include_router(tournaments.router, prefix="/api")
app.include_router(screen.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
app.include_router(players.router)
app.include_router(courts.router)
app.include_router(events.router)
app.include_router(matches.router)
app.include_router(dashboard.router)
app.include_router(admin.router)
# WebSocket connection manager
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {}
async def connect(self, websocket: WebSocket, channel: str):
await websocket.accept()
if channel not in self.active_connections:
self.active_connections[channel] = []
self.active_connections[channel].append(websocket)
def disconnect(self, websocket: WebSocket, channel: str):
if channel in self.active_connections:
self.active_connections[channel].remove(websocket)
async def broadcast(self, channel: str, data: dict):
if channel in self.active_connections:
disconnected = []
for connection in self.active_connections[channel]:
try:
await connection.send_text(json.dumps(data))
except Exception:
disconnected.append(connection)
for conn in disconnected:
self.active_connections[channel].remove(conn)
manager = ConnectionManager()
@app.websocket("/ws/court/{court_id}")
async def websocket_court(websocket: WebSocket, court_id: int):
"""WebSocket for real-time court display updates"""
channel = f"court_{court_id}"
await manager.connect(websocket, channel)
try:
while True:
# Send court data every 3 seconds
db = SessionLocal()
try:
from app.api.screen import get_court_display
data = get_court_display(court_id, db)
await websocket.send_text(json.dumps(data))
finally:
db.close()
await asyncio.sleep(3)
except WebSocketDisconnect:
manager.disconnect(websocket, channel)
@app.websocket("/ws/overview")
async def websocket_overview(websocket: WebSocket):
"""WebSocket for real-time overview display"""
channel = "overview"
await manager.connect(websocket, channel)
try:
while True:
db = SessionLocal()
try:
from app.api.screen import get_overview
data = get_overview(db)
await websocket.send_text(json.dumps(data))
finally:
db.close()
await asyncio.sleep(3)
except WebSocketDisconnect:
manager.disconnect(websocket, channel)
@app.websocket("/ws/matches")
async def websocket_matches(websocket: WebSocket):
"""WebSocket for real-time match lobby updates"""
channel = "matches"
await manager.connect(websocket, channel)
try:
while True:
db = SessionLocal()
try:
from app.api.matches import get_lobby
from app.api.matches import get_active_matches
data = {
"lobby": get_lobby(db),
"active": get_active_matches(db),
}
await websocket.send_text(json.dumps(data))
finally:
db.close()
await asyncio.sleep(2)
except WebSocketDisconnect:
manager.disconnect(websocket, channel)
@app.get("/")
def root():
return {"message": "ServeSync API v1.0", "status": "running"}
@app.get("/health")
@app.get("/api/health")
def health():
return {"status": "healthy"}
return {"status": "ok", "version": "2.0.0"}
@app.websocket("/ws/dashboard")
async def ws_dashboard(websocket: WebSocket):
await manager.connect(websocket, "dashboard")
try:
while True:
await asyncio.sleep(3)
await manager.broadcast("dashboard", {"type": "ping"})
except WebSocketDisconnect:
manager.disconnect(websocket, "dashboard")
@app.websocket("/ws/screen/{court_id}")
async def ws_screen(websocket: WebSocket, court_id: int):
channel = f"screen_{court_id}"
await manager.connect(websocket, channel)
try:
while True:
await asyncio.sleep(5)
await manager.broadcast(channel, {"type": "ping", "court_id": court_id})
except WebSocketDisconnect:
manager.disconnect(websocket, channel)

128
backend/app/models.py Normal file
View File

@@ -0,0 +1,128 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, JSON, Text
from sqlalchemy.orm import relationship
from .database import Base
class Player(Base):
__tablename__ = "players"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
contact = Column(String(100), nullable=True)
skill_level = Column(String(20), nullable=False, default="Beginner") # Beginner/Intermediate/Advanced/Elite
matches_played = Column(Integer, default=0)
wins = Column(Integer, default=0)
losses = Column(Integer, default=0)
elo = Column(Float, default=1000.0)
created_at = Column(DateTime, default=datetime.utcnow)
event_players = relationship("EventPlayer", back_populates="player")
class Court(Base):
__tablename__ = "courts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
court_type = Column(String(100), default="Sport Court")
status = Column(String(20), default="available") # available/in_use/reserved/maintenance
created_at = Column(DateTime, default=datetime.utcnow)
reservations = relationship("CourtReservation", back_populates="court")
matches = relationship("Match", back_populates="court")
class CourtReservation(Base):
__tablename__ = "court_reservations"
id = Column(Integer, primary_key=True, index=True)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=True)
group_name = Column(String(100), nullable=True)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
court = relationship("Court", back_populates="reservations")
player = relationship("Player")
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
format = Column(String(20), default="Singles") # Singles/Doubles
courts_count = Column(Integer, default=1)
status = Column(String(20), default="setup") # setup/active/completed
current_stage_id = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
event_players = relationship("EventPlayer", back_populates="event", cascade="all, delete-orphan")
stages = relationship("Stage", back_populates="event", order_by="Stage.order", cascade="all, delete-orphan")
class EventPlayer(Base):
__tablename__ = "event_players"
id = Column(Integer, primary_key=True, index=True)
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
skill_level_override = Column(String(20), nullable=True)
event = relationship("Event", back_populates="event_players")
player = relationship("Player", back_populates="event_players")
class Stage(Base):
__tablename__ = "stages"
id = Column(Integer, primary_key=True, index=True)
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
name = Column(String(100), nullable=False)
match_type = Column(String(30), nullable=False) # Open/Skill-Based/Round Robin/Tournament
rounds = Column(Integer, default=1)
advance_count = Column(Integer, default=0) # 0 = all advance
status = Column(String(20), default="pending") # pending/active/completed
order = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
event = relationship("Event", back_populates="stages")
matches = relationship("Match", back_populates="stage", cascade="all, delete-orphan")
class Match(Base):
__tablename__ = "matches"
id = Column(Integer, primary_key=True, index=True)
stage_id = Column(Integer, ForeignKey("stages.id"), nullable=False)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=True)
round_number = Column(Integer, default=1)
match_number = Column(Integer, default=1)
# Singles: player1_id, player2_id
# Doubles: team1_players and team2_players are JSON lists
player1_id = Column(Integer, ForeignKey("players.id"), nullable=True)
player2_id = Column(Integer, ForeignKey("players.id"), nullable=True)
team1_players = Column(JSON, nullable=True) # [player_id, player_id]
team2_players = Column(JSON, nullable=True)
score1 = Column(Integer, nullable=True)
score2 = Column(Integer, nullable=True)
winner_id = Column(Integer, ForeignKey("players.id"), nullable=True) # for singles
winner_team = Column(Integer, nullable=True) # 1 or 2 for doubles
status = Column(String(20), default="scheduled") # scheduled/in_progress/completed/bye
bracket_position = Column(String(20), nullable=True) # winners/losers
next_winner_match_id = Column(Integer, nullable=True)
next_loser_match_id = Column(Integer, nullable=True)
scheduled_time = Column(DateTime, nullable=True)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
stage = relationship("Stage", back_populates="matches")
court = relationship("Court", back_populates="matches")
player1 = relationship("Player", foreign_keys=[player1_id])
player2 = relationship("Player", foreign_keys=[player2_id])
winner = relationship("Player", foreign_keys=[winner_id])

View File

@@ -1,5 +0,0 @@
from app.models.player import Player
from app.models.court import Court
from app.models.booking import Booking
from app.models.match import Match, MatchPlayer
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch

View File

@@ -1,33 +0,0 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class BookingStatus(str, enum.Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class Booking(Base):
__tablename__ = "bookings"
id = Column(Integer, primary_key=True, index=True)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=False)
duration_hours = Column(Float, default=1.0)
total_cost = Column(Float, default=0.0)
status = Column(Enum(BookingStatus), default=BookingStatus.CONFIRMED)
is_match_booking = Column(Boolean, default=False)
notes = Column(String(500))
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
player = relationship("Player", back_populates="bookings")
court = relationship("Court", back_populates="bookings")

View File

@@ -1,22 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class Court(Base):
__tablename__ = "courts"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False)
court_number = Column(Integer, unique=True, nullable=False)
hourly_rate = Column(Float, default=200.0)
is_active = Column(Boolean, default=True)
surface_type = Column(String(50), default="Sport Court")
features = Column(String(500), default="LED Lighting, Spectator Seating")
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
bookings = relationship("Booking", back_populates="court")
matches = relationship("Match", back_populates="court")

View File

@@ -1,73 +0,0 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class MatchStage(str, enum.Enum):
OPEN = "open"
SKILL_BASED = "skill_based"
TOURNAMENT = "tournament"
class MatchStatus(str, enum.Enum):
LOBBY = "lobby"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
class MatchType(str, enum.Enum):
SINGLES = "singles"
DOUBLES = "doubles"
class Match(Base):
__tablename__ = "matches"
id = Column(Integer, primary_key=True, index=True)
court_id = Column(Integer, ForeignKey("courts.id"), nullable=True)
stage = Column(Enum(MatchStage), default=MatchStage.OPEN)
match_type = Column(Enum(MatchType), default=MatchType.DOUBLES)
status = Column(Enum(MatchStatus), default=MatchStatus.LOBBY)
# Scores
team1_score = Column(Integer, default=0)
team2_score = Column(Integer, default=0)
team1_games = Column(Integer, default=0)
team2_games = Column(Integer, default=0)
# Game details
max_players = Column(Integer, default=4)
min_elo = Column(Float, nullable=True)
max_elo = Column(Float, nullable=True)
# Match metadata
title = Column(String(200))
started_at = Column(DateTime(timezone=True), nullable=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
court = relationship("Court", back_populates="matches")
match_players = relationship("MatchPlayer", back_populates="match", cascade="all, delete-orphan")
class MatchPlayer(Base):
__tablename__ = "match_players"
id = Column(Integer, primary_key=True, index=True)
match_id = Column(Integer, ForeignKey("matches.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
team = Column(Integer, nullable=False) # 1 or 2
elo_before = Column(Float, nullable=True)
elo_after = Column(Float, nullable=True)
elo_change = Column(Float, default=0.0)
is_winner = Column(Boolean, nullable=True)
joined_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
match = relationship("Match", back_populates="match_players")
player = relationship("Player", back_populates="match_players")

View File

@@ -1,46 +0,0 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Enum, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class MembershipTier(str, enum.Enum):
BRONZE = "bronze"
SILVER = "silver"
GOLD = "gold"
PLATINUM = "platinum"
ELITE = "elite"
class Player(Base):
__tablename__ = "players"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
email = Column(String(255), unique=True, nullable=False)
phone = Column(String(20))
elo_rating = Column(Float, default=1000.0)
membership_tier = Column(Enum(MembershipTier), default=MembershipTier.BRONZE)
wins = Column(Integer, default=0)
losses = Column(Integer, default=0)
total_matches = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
avatar_color = Column(String(7), default="#3B82F6")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
bookings = relationship("Booking", back_populates="player")
match_players = relationship("MatchPlayer", back_populates="player")
tournament_entries = relationship("TournamentEntry", back_populates="player")
@property
def win_rate(self):
if self.total_matches == 0:
return 0
return round(self.wins / self.total_matches * 100, 1)
@property
def tier_label(self):
return self.membership_tier.value.capitalize()

View File

@@ -1,93 +0,0 @@
from sqlalchemy import Column, Integer, ForeignKey, DateTime, Float, String, Enum, Boolean, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class TournamentStatus(str, enum.Enum):
REGISTRATION = "registration"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class BracketType(str, enum.Enum):
WINNERS = "winners"
LOSERS = "losers"
GRAND_FINAL = "grand_final"
class TournamentMatchStatus(str, enum.Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
class Tournament(Base):
__tablename__ = "tournaments"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False)
status = Column(Enum(TournamentStatus), default=TournamentStatus.REGISTRATION)
max_participants = Column(Integer, default=8)
current_round = Column(Integer, default=1)
bracket_data = Column(JSON, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
entries = relationship("TournamentEntry", back_populates="tournament")
tournament_matches = relationship("TournamentMatch", back_populates="tournament")
class TournamentEntry(Base):
__tablename__ = "tournament_entries"
id = Column(Integer, primary_key=True, index=True)
tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False)
player_id = Column(Integer, ForeignKey("players.id"), nullable=False)
seed = Column(Integer, nullable=True)
final_rank = Column(Integer, nullable=True)
is_eliminated = Column(Boolean, default=False)
losses = Column(Integer, default=0)
wins = Column(Integer, default=0)
is_in_losers = Column(Boolean, default=False)
registered_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
tournament = relationship("Tournament", back_populates="entries")
player = relationship("Player", back_populates="tournament_entries")
class TournamentMatch(Base):
__tablename__ = "tournament_matches"
id = Column(Integer, primary_key=True, index=True)
tournament_id = Column(Integer, ForeignKey("tournaments.id"), nullable=False)
match_id = Column(Integer, ForeignKey("matches.id"), nullable=True)
round_number = Column(Integer, nullable=False)
match_number = Column(Integer, nullable=False)
bracket_type = Column(Enum(BracketType), default=BracketType.WINNERS)
status = Column(Enum(TournamentMatchStatus), default=TournamentMatchStatus.PENDING)
# Players
player1_id = Column(Integer, ForeignKey("players.id"), nullable=True)
player2_id = Column(Integer, ForeignKey("players.id"), nullable=True)
player3_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p1
player4_id = Column(Integer, ForeignKey("players.id"), nullable=True) # doubles team2 p2
# Scores
team1_score = Column(Integer, default=0)
team2_score = Column(Integer, default=0)
winner_team = Column(Integer, nullable=True)
# Next match routing
winner_next_match = Column(Integer, nullable=True)
loser_next_match = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
tournament = relationship("Tournament", back_populates="tournament_matches")

View 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}

View 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}

View 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,
}

View 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}

View 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}

View 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}

163
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,163 @@
from typing import Optional, List
from pydantic import BaseModel
from datetime import datetime
# Player schemas
class PlayerCreate(BaseModel):
name: str
contact: Optional[str] = None
skill_level: str = "Beginner"
class PlayerUpdate(BaseModel):
name: Optional[str] = None
contact: Optional[str] = None
skill_level: Optional[str] = None
class PlayerOut(BaseModel):
id: int
name: str
contact: Optional[str]
skill_level: str
matches_played: int
wins: int
losses: int
elo: float
created_at: datetime
class Config:
from_attributes = True
# Court schemas
class CourtCreate(BaseModel):
name: str
court_type: str = "Sport Court"
class CourtStatusUpdate(BaseModel):
status: str # available/in_use/reserved/maintenance
class ReservationCreate(BaseModel):
player_id: Optional[int] = None
group_name: Optional[str] = None
start_time: datetime
end_time: datetime
notes: Optional[str] = None
class ReservationOut(BaseModel):
id: int
court_id: int
player_id: Optional[int]
group_name: Optional[str]
start_time: datetime
end_time: datetime
notes: Optional[str]
player: Optional[PlayerOut] = None
class Config:
from_attributes = True
class CourtOut(BaseModel):
id: int
name: str
court_type: str
status: str
reservations: List[ReservationOut] = []
class Config:
from_attributes = True
# Event schemas
class EventCreate(BaseModel):
name: str
format: str = "Singles"
courts_count: int = 1
class EventUpdate(BaseModel):
name: Optional[str] = None
format: Optional[str] = None
courts_count: Optional[int] = None
class EventPlayerAdd(BaseModel):
player_id: int
skill_level_override: Optional[str] = None
class EventPlayerOut(BaseModel):
id: int
player_id: int
skill_level_override: Optional[str]
player: PlayerOut
class Config:
from_attributes = True
# Stage schemas
class StageCreate(BaseModel):
name: str
match_type: str # Open/Skill-Based/Round Robin/Tournament
rounds: int = 1
advance_count: int = 0
order: int = 0
class StageUpdate(BaseModel):
name: Optional[str] = None
match_type: Optional[str] = None
rounds: Optional[int] = None
advance_count: Optional[int] = None
class StageOut(BaseModel):
id: int
event_id: int
name: str
match_type: str
rounds: int
advance_count: int
status: str
order: int
class Config:
from_attributes = True
# Match schemas
class MatchScoreUpdate(BaseModel):
score1: int
score2: int
class MatchCourtAssign(BaseModel):
court_id: Optional[int]
class MatchOut(BaseModel):
id: int
stage_id: int
court_id: Optional[int]
round_number: int
match_number: int
player1_id: Optional[int]
player2_id: Optional[int]
team1_players: Optional[List[int]]
team2_players: Optional[List[int]]
score1: Optional[int]
score2: Optional[int]
winner_id: Optional[int]
winner_team: Optional[int]
status: str
bracket_position: Optional[str]
next_winner_match_id: Optional[int]
next_loser_match_id: Optional[int]
scheduled_time: Optional[datetime]
started_at: Optional[datetime]
completed_at: Optional[datetime]
player1: Optional[PlayerOut] = None
player2: Optional[PlayerOut] = None
class Config:
from_attributes = True
# Dashboard schema
class DashboardCourtData(BaseModel):
court: CourtOut
current_match: Optional[MatchOut] = None
elapsed_seconds: Optional[int] = None
class DashboardData(BaseModel):
courts: List[DashboardCourtData]
upcoming_matches: List[MatchOut]
active_event: Optional[dict] = None

View File

@@ -1,53 +0,0 @@
"""ELO Rating System for ServeSync"""
K_FACTOR = 32 # Standard K-factor
def expected_score(rating_a: float, rating_b: float) -> float:
"""Calculate expected score for player A vs player B"""
return 1 / (1 + 10 ** ((rating_b - rating_a) / 400))
def calculate_elo_change(winner_rating: float, loser_rating: float) -> tuple[float, float]:
"""
Calculate ELO changes after a match.
Returns (winner_change, loser_change)
"""
expected_winner = expected_score(winner_rating, loser_rating)
expected_loser = expected_score(loser_rating, winner_rating)
winner_change = K_FACTOR * (1 - expected_winner)
loser_change = K_FACTOR * (0 - expected_loser)
return round(winner_change, 2), round(loser_change, 2)
def calculate_team_elo(player1_rating: float, player2_rating: float = None) -> float:
"""Calculate team ELO (average for doubles)"""
if player2_rating is None:
return player1_rating
return (player1_rating + player2_rating) / 2
def get_tier_from_elo(elo: float) -> str:
"""Get membership tier based on ELO rating"""
if elo < 1000:
return "bronze"
elif elo < 1200:
return "silver"
elif elo < 1500:
return "gold"
elif elo < 1800:
return "platinum"
else:
return "elite"
def is_valid_match(player_elo: float, match_min_elo: float, match_max_elo: float) -> bool:
"""Check if player is within ELO range for skill-based match"""
return match_min_elo <= player_elo <= match_max_elo
def get_elo_range(player_elo: float, tolerance: float = 200) -> tuple[float, float]:
"""Get valid ELO range for matching"""
return (max(0, player_elo - tolerance), player_elo + tolerance)

View File

@@ -1,214 +0,0 @@
"""Matchmaking Engine for ServeSync"""
from sqlalchemy.orm import Session
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.models.court import Court
from app.models.player import Player
from app.models.booking import Booking, BookingStatus
from app.services.elo import calculate_elo_change, get_elo_range, is_valid_match
from datetime import datetime, timedelta
from typing import Optional, List
def create_open_match(db: Session, title: str, match_type: MatchType = MatchType.DOUBLES) -> Match:
"""Stage 1: Create an open match lobby - no restrictions"""
max_players = 4 if match_type == MatchType.DOUBLES else 2
match = Match(
stage=MatchStage.OPEN,
match_type=match_type,
status=MatchStatus.LOBBY,
title=title,
max_players=max_players,
)
db.add(match)
db.commit()
db.refresh(match)
return match
def create_skill_match(
db: Session,
title: str,
creator_elo: float,
match_type: MatchType = MatchType.DOUBLES,
tolerance: float = 200,
) -> Match:
"""Stage 2: Create a skill-based match with ELO constraints"""
min_elo, max_elo = get_elo_range(creator_elo, tolerance)
max_players = 4 if match_type == MatchType.DOUBLES else 2
match = Match(
stage=MatchStage.SKILL_BASED,
match_type=match_type,
status=MatchStatus.LOBBY,
title=title,
max_players=max_players,
min_elo=min_elo,
max_elo=max_elo,
)
db.add(match)
db.commit()
db.refresh(match)
return match
def join_match(db: Session, match_id: int, player_id: int, team: int) -> Optional[MatchPlayer]:
"""Add a player to a match lobby"""
match = db.query(Match).filter(Match.id == match_id).first()
if not match or match.status != MatchStatus.LOBBY:
return None
player = db.query(Player).filter(Player.id == player_id).first()
if not player:
return None
# Check player count
current_players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count()
if current_players >= match.max_players:
return None
# Check if already joined
existing = db.query(MatchPlayer).filter(
MatchPlayer.match_id == match_id,
MatchPlayer.player_id == player_id
).first()
if existing:
return existing
# For skill-based matches, check ELO constraint
if match.stage == MatchStage.SKILL_BASED:
if match.min_elo and match.max_elo:
if not is_valid_match(player.elo_rating, match.min_elo, match.max_elo):
return None
mp = MatchPlayer(
match_id=match_id,
player_id=player_id,
team=team,
elo_before=player.elo_rating,
)
db.add(mp)
db.commit()
db.refresh(mp)
return mp
def start_match(db: Session, match_id: int) -> Optional[Match]:
"""Start a match - auto-assign court if available"""
match = db.query(Match).filter(Match.id == match_id).first()
if not match:
return None
players = db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).count()
if players < match.max_players:
return None
# Auto-assign an available court
if not match.court_id:
court = find_available_court(db)
if court:
match.court_id = court.id
# Create booking
now = datetime.utcnow()
booking = Booking(
player_id=db.query(MatchPlayer).filter(MatchPlayer.match_id == match_id).first().player_id,
court_id=court.id,
start_time=now,
end_time=now + timedelta(hours=1),
duration_hours=1.0,
total_cost=court.hourly_rate,
status=BookingStatus.IN_PROGRESS,
is_match_booking=True,
)
db.add(booking)
match.status = MatchStatus.IN_PROGRESS
match.started_at = datetime.utcnow()
db.commit()
db.refresh(match)
return match
def find_available_court(db: Session) -> Optional[Court]:
"""Find an available court not currently in use"""
from sqlalchemy import and_
busy_court_ids = db.query(Match.court_id).filter(
Match.status == MatchStatus.IN_PROGRESS,
Match.court_id.isnot(None)
).all()
busy_ids = [c[0] for c in busy_court_ids]
court = db.query(Court).filter(
Court.is_active == True,
Court.id.notin_(busy_ids)
).first()
return court
def complete_match(db: Session, match_id: int, team1_score: int, team2_score: int) -> Optional[Match]:
"""Complete a match and update ELO ratings"""
match = db.query(Match).filter(Match.id == match_id).first()
if not match or match.status != MatchStatus.IN_PROGRESS:
return None
match.status = MatchStatus.COMPLETED
match.team1_score = team1_score
match.team2_score = team2_score
match.ended_at = datetime.utcnow()
winner_team = 1 if team1_score > team2_score else 2
# Get players
team1_players = db.query(MatchPlayer).filter(
MatchPlayer.match_id == match_id, MatchPlayer.team == 1
).all()
team2_players = db.query(MatchPlayer).filter(
MatchPlayer.match_id == match_id, MatchPlayer.team == 2
).all()
# Calculate average ELO
team1_avg = sum(p.elo_before or p.player.elo_rating for p in team1_players) / len(team1_players)
team2_avg = sum(p.elo_before or p.player.elo_rating for p in team2_players) / len(team2_players)
if winner_team == 1:
w_change, l_change = calculate_elo_change(team1_avg, team2_avg)
else:
l_change, w_change = calculate_elo_change(team2_avg, team1_avg)
w_change, l_change = -l_change, -w_change
# Update player ELOs
for mp in team1_players:
is_winner = (winner_team == 1)
change = w_change if is_winner else l_change
mp.is_winner = is_winner
mp.elo_change = change
mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change
mp.player.elo_rating += change
if is_winner:
mp.player.wins += 1
else:
mp.player.losses += 1
mp.player.total_matches += 1
for mp in team2_players:
is_winner = (winner_team == 2)
change = w_change if is_winner else l_change
mp.is_winner = is_winner
mp.elo_change = change
mp.elo_after = (mp.elo_before or mp.player.elo_rating) + change
mp.player.elo_rating += change
if is_winner:
mp.player.wins += 1
else:
mp.player.losses += 1
mp.player.total_matches += 1
db.commit()
db.refresh(match)
return match
def get_lobby_matches(db: Session, stage: Optional[MatchStage] = None) -> List[Match]:
"""Get all lobby matches, optionally filtered by stage"""
query = db.query(Match).filter(Match.status == MatchStatus.LOBBY)
if stage:
query = query.filter(Match.stage == stage)
return query.order_by(Match.created_at.desc()).all()

View File

@@ -1,281 +0,0 @@
"""Double Elimination Tournament Engine for ServeSync"""
from sqlalchemy.orm import Session
from app.models.tournament import Tournament, TournamentEntry, TournamentMatch, TournamentStatus, BracketType, TournamentMatchStatus
from app.models.player import Player
from app.models.court import Court
from app.models.match import Match, MatchPlayer, MatchStage, MatchStatus, MatchType
from app.services.matchmaking import find_available_court
from datetime import datetime
from typing import Optional, List
import math
def create_tournament(db: Session, name: str, max_participants: int = 8) -> Tournament:
"""Create a new tournament"""
t = Tournament(name=name, max_participants=max_participants)
db.add(t)
db.commit()
db.refresh(t)
return t
def register_player(db: Session, tournament_id: int, player_id: int) -> Optional[TournamentEntry]:
"""Register a player for the tournament"""
t = db.query(Tournament).filter(Tournament.id == tournament_id).first()
if not t or t.status != TournamentStatus.REGISTRATION:
return None
current_count = db.query(TournamentEntry).filter(TournamentEntry.tournament_id == tournament_id).count()
if current_count >= t.max_participants:
return None
existing = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id,
TournamentEntry.player_id == player_id
).first()
if existing:
return existing
entry = TournamentEntry(tournament_id=tournament_id, player_id=player_id)
db.add(entry)
db.commit()
db.refresh(entry)
return entry
def start_tournament(db: Session, tournament_id: int) -> Optional[Tournament]:
"""Start tournament and generate initial bracket"""
t = db.query(Tournament).filter(Tournament.id == tournament_id).first()
if not t or t.status != TournamentStatus.REGISTRATION:
return None
entries = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id
).join(Player).order_by(Player.elo_rating.desc()).all()
if len(entries) < 4:
return None
# Seed players by ELO
for i, entry in enumerate(entries):
entry.seed = i + 1
# Generate bracket matches
_generate_winners_bracket(db, t, entries)
t.status = TournamentStatus.IN_PROGRESS
t.started_at = datetime.utcnow()
db.commit()
db.refresh(t)
return t
def _generate_winners_bracket(db: Session, tournament: Tournament, entries: List[TournamentEntry]):
"""Generate the initial winners bracket matches"""
n = len(entries)
# Pair up by seeding (1 vs n, 2 vs n-1, etc.)
match_num = 1
pairs = []
for i in range(n // 2):
p1 = entries[i]
p2 = entries[n - 1 - i]
pairs.append((p1, p2))
for p1, p2 in pairs:
tm = TournamentMatch(
tournament_id=tournament.id,
round_number=1,
match_number=match_num,
bracket_type=BracketType.WINNERS,
status=TournamentMatchStatus.PENDING,
player1_id=p1.player_id,
player2_id=p2.player_id,
)
db.add(tm)
match_num += 1
db.flush()
def complete_tournament_match(
db: Session,
tournament_match_id: int,
team1_score: int,
team2_score: int
) -> Optional[TournamentMatch]:
"""Complete a tournament match and advance bracket"""
tm = db.query(TournamentMatch).filter(TournamentMatch.id == tournament_match_id).first()
if not tm:
return None
tm.team1_score = team1_score
tm.team2_score = team2_score
tm.winner_team = 1 if team1_score > team2_score else 2
tm.status = TournamentMatchStatus.COMPLETED
tm.completed_at = datetime.utcnow()
winner_player_id = tm.player1_id if tm.winner_team == 1 else tm.player2_id
loser_player_id = tm.player2_id if tm.winner_team == 1 else tm.player1_id
# Update entry stats
winner_entry = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tm.tournament_id,
TournamentEntry.player_id == winner_player_id
).first()
loser_entry = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tm.tournament_id,
TournamentEntry.player_id == loser_player_id
).first()
if winner_entry:
winner_entry.wins += 1
if loser_entry:
loser_entry.losses += 1
# Double elimination logic
if loser_entry and loser_entry.losses < 2:
# Move to losers bracket
loser_entry.is_in_losers = True
_create_losers_match(db, tm, loser_player_id)
elif loser_entry:
# Eliminated
loser_entry.is_eliminated = True
_assign_final_ranks(db, tm.tournament_id)
db.commit()
db.refresh(tm)
return tm
def _create_losers_match(db: Session, completed_match: TournamentMatch, loser_id: int):
"""Route loser to losers bracket"""
# Find existing pending losers match in next round
next_round = completed_match.round_number + 1
existing = db.query(TournamentMatch).filter(
TournamentMatch.tournament_id == completed_match.tournament_id,
TournamentMatch.bracket_type == BracketType.LOSERS,
TournamentMatch.round_number == next_round,
TournamentMatch.player2_id.is_(None),
).first()
if existing:
existing.player2_id = loser_id
else:
# Create a new losers match
match_count = db.query(TournamentMatch).filter(
TournamentMatch.tournament_id == completed_match.tournament_id,
TournamentMatch.bracket_type == BracketType.LOSERS,
TournamentMatch.round_number == next_round,
).count()
tm = TournamentMatch(
tournament_id=completed_match.tournament_id,
round_number=next_round,
match_number=match_count + 1,
bracket_type=BracketType.LOSERS,
status=TournamentMatchStatus.PENDING,
player1_id=loser_id,
)
db.add(tm)
def _assign_final_ranks(db: Session, tournament_id: int):
"""Assign final ranks to eliminated players"""
entries = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id,
TournamentEntry.is_eliminated == True,
TournamentEntry.final_rank.is_(None)
).order_by(TournamentEntry.losses.desc()).all()
remaining = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id,
TournamentEntry.is_eliminated == False
).count()
base_rank = remaining + 1
for i, entry in enumerate(entries):
entry.final_rank = base_rank + i
def get_bracket_data(db: Session, tournament_id: int) -> dict:
"""Get structured bracket data for frontend visualization"""
tournament = db.query(Tournament).filter(Tournament.id == tournament_id).first()
if not tournament:
return {}
matches = db.query(TournamentMatch).filter(
TournamentMatch.tournament_id == tournament_id
).order_by(TournamentMatch.round_number, TournamentMatch.match_number).all()
entries = db.query(TournamentEntry).filter(
TournamentEntry.tournament_id == tournament_id
).all()
players_map = {}
for entry in entries:
player = db.query(Player).filter(Player.id == entry.player_id).first()
if player:
players_map[player.id] = {
"id": player.id,
"name": player.name,
"elo": player.elo_rating,
"seed": entry.seed,
"wins": entry.wins,
"losses": entry.losses,
"is_eliminated": entry.is_eliminated,
"is_in_losers": entry.is_in_losers,
"final_rank": entry.final_rank,
}
winners_bracket = []
losers_bracket = []
grand_final = []
for m in matches:
match_data = {
"id": m.id,
"round": m.round_number,
"match_number": m.match_number,
"status": m.status.value,
"player1": players_map.get(m.player1_id),
"player2": players_map.get(m.player2_id),
"team1_score": m.team1_score,
"team2_score": m.team2_score,
"winner_team": m.winner_team,
}
if m.bracket_type == BracketType.WINNERS:
winners_bracket.append(match_data)
elif m.bracket_type == BracketType.LOSERS:
losers_bracket.append(match_data)
else:
grand_final.append(match_data)
# Build leaderboard
leaderboard = sorted(
[
{
"player": players_map[e.player_id],
"wins": e.wins,
"losses": e.losses,
"is_eliminated": e.is_eliminated,
"is_in_losers": e.is_in_losers,
"final_rank": e.final_rank,
}
for e in entries
if e.player_id in players_map
],
key=lambda x: (x["final_rank"] or 999, -x["wins"]),
)
return {
"tournament": {
"id": tournament.id,
"name": tournament.name,
"status": tournament.status.value,
"current_round": tournament.current_round,
},
"winners_bracket": winners_bracket,
"losers_bracket": losers_bracket,
"grand_final": grand_final,
"leaderboard": leaderboard,
}

View File

@@ -0,0 +1,40 @@
from typing import Dict, List
from fastapi import WebSocket
import json
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {}
async def connect(self, websocket: WebSocket, channel: str):
await websocket.accept()
if channel not in self.active_connections:
self.active_connections[channel] = []
self.active_connections[channel].append(websocket)
def disconnect(self, websocket: WebSocket, channel: str):
if channel in self.active_connections:
try:
self.active_connections[channel].remove(websocket)
except ValueError:
pass
async def broadcast(self, channel: str, data: dict):
if channel not in self.active_connections:
return
dead = []
for ws in self.active_connections[channel]:
try:
await ws.send_json(data)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(ws, channel)
async def broadcast_all(self, data: dict):
for channel in list(self.active_connections.keys()):
await self.broadcast(channel, data)
manager = ConnectionManager()