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:
@@ -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)}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
148
backend/app/bracket.py
Normal 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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
128
backend/app/models.py
Normal 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])
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
16
backend/app/routers/admin.py
Normal file
16
backend/app/routers/admin.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from ..database import get_db
|
||||
from ..models import Base
|
||||
from ..database import engine
|
||||
import subprocess, sys
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.post("/seed")
|
||||
def seed_data(db: Session = Depends(get_db)):
|
||||
"""Re-seed all demo data"""
|
||||
import subprocess, sys
|
||||
result = subprocess.run([sys.executable, "/app/seed.py"], capture_output=True, text=True)
|
||||
return {"ok": result.returncode == 0, "output": result.stdout[-500:], "error": result.stderr[-200:] if result.stderr else None}
|
||||
81
backend/app/routers/courts.py
Normal file
81
backend/app/routers/courts.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from ..database import get_db
|
||||
from ..models import Court, CourtReservation
|
||||
from ..schemas import CourtCreate, CourtStatusUpdate, ReservationCreate, ReservationOut, CourtOut
|
||||
|
||||
router = APIRouter(prefix="/api/courts", tags=["courts"])
|
||||
|
||||
|
||||
@router.get("/", response_model=List[CourtOut])
|
||||
def list_courts(db: Session = Depends(get_db)):
|
||||
courts = db.query(Court).options(joinedload(Court.reservations).joinedload(CourtReservation.player)).all()
|
||||
return courts
|
||||
|
||||
|
||||
@router.post("/", response_model=CourtOut)
|
||||
def create_court(data: CourtCreate, db: Session = Depends(get_db)):
|
||||
court = Court(**data.model_dump())
|
||||
db.add(court)
|
||||
db.commit()
|
||||
db.refresh(court)
|
||||
return court
|
||||
|
||||
|
||||
@router.get("/{court_id}", response_model=CourtOut)
|
||||
def get_court(court_id: int, db: Session = Depends(get_db)):
|
||||
court = db.query(Court).options(joinedload(Court.reservations).joinedload(CourtReservation.player)).filter(Court.id == court_id).first()
|
||||
if not court:
|
||||
raise HTTPException(status_code=404, detail="Court not found")
|
||||
return court
|
||||
|
||||
|
||||
@router.patch("/{court_id}/status")
|
||||
def update_court_status(court_id: int, data: CourtStatusUpdate, db: Session = Depends(get_db)):
|
||||
court = db.query(Court).filter(Court.id == court_id).first()
|
||||
if not court:
|
||||
raise HTTPException(status_code=404, detail="Court not found")
|
||||
valid_statuses = ["available", "in_use", "reserved", "maintenance"]
|
||||
if data.status not in valid_statuses:
|
||||
raise HTTPException(status_code=400, detail=f"Status must be one of {valid_statuses}")
|
||||
court.status = data.status
|
||||
db.commit()
|
||||
return {"ok": True, "status": data.status}
|
||||
|
||||
|
||||
@router.get("/{court_id}/reservations", response_model=List[ReservationOut])
|
||||
def list_reservations(court_id: int, db: Session = Depends(get_db)):
|
||||
return db.query(CourtReservation).filter(CourtReservation.court_id == court_id).order_by(CourtReservation.start_time).all()
|
||||
|
||||
|
||||
@router.post("/{court_id}/reservations", response_model=ReservationOut)
|
||||
def create_reservation(court_id: int, data: ReservationCreate, db: Session = Depends(get_db)):
|
||||
court = db.query(Court).filter(Court.id == court_id).first()
|
||||
if not court:
|
||||
raise HTTPException(status_code=404, detail="Court not found")
|
||||
reservation = CourtReservation(court_id=court_id, **data.model_dump())
|
||||
db.add(reservation)
|
||||
# Update court status to reserved if it's currently available
|
||||
if court.status == "available":
|
||||
court.status = "reserved"
|
||||
db.commit()
|
||||
db.refresh(reservation)
|
||||
return reservation
|
||||
|
||||
|
||||
@router.delete("/reservations/{reservation_id}")
|
||||
def delete_reservation(reservation_id: int, db: Session = Depends(get_db)):
|
||||
reservation = db.query(CourtReservation).filter(CourtReservation.id == reservation_id).first()
|
||||
if not reservation:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
court = db.query(Court).filter(Court.id == reservation.court_id).first()
|
||||
db.delete(reservation)
|
||||
db.commit()
|
||||
# Check if court has any remaining reservations
|
||||
remaining = db.query(CourtReservation).filter(CourtReservation.court_id == reservation.court_id).count()
|
||||
if remaining == 0 and court and court.status == "reserved":
|
||||
court.status = "available"
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
136
backend/app/routers/dashboard.py
Normal file
136
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from ..database import get_db
|
||||
from ..models import Court, Match, Event, Stage, Player, CourtReservation
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_dashboard(db: Session = Depends(get_db)):
|
||||
courts = db.query(Court).all()
|
||||
courts_data = []
|
||||
|
||||
for court in courts:
|
||||
current_match = db.query(Match).filter(
|
||||
Match.court_id == court.id,
|
||||
Match.status == "in_progress"
|
||||
).first()
|
||||
|
||||
match_data = None
|
||||
elapsed = None
|
||||
if current_match:
|
||||
p1 = db.query(Player).filter(Player.id == current_match.player1_id).first() if current_match.player1_id else None
|
||||
p2 = db.query(Player).filter(Player.id == current_match.player2_id).first() if current_match.player2_id else None
|
||||
if current_match.started_at:
|
||||
elapsed = int((datetime.utcnow() - current_match.started_at).total_seconds())
|
||||
match_data = {
|
||||
"id": current_match.id,
|
||||
"player1_id": current_match.player1_id,
|
||||
"player2_id": current_match.player2_id,
|
||||
"player1_name": p1.name if p1 else None,
|
||||
"player2_name": p2.name if p2 else None,
|
||||
"score1": current_match.score1,
|
||||
"score2": current_match.score2,
|
||||
"status": current_match.status,
|
||||
"elapsed_seconds": elapsed,
|
||||
"stage_id": current_match.stage_id,
|
||||
}
|
||||
|
||||
# Next reservation
|
||||
now = datetime.utcnow()
|
||||
next_reservation = db.query(CourtReservation).filter(
|
||||
CourtReservation.court_id == court.id,
|
||||
CourtReservation.start_time > now
|
||||
).order_by(CourtReservation.start_time).first()
|
||||
|
||||
courts_data.append({
|
||||
"id": court.id,
|
||||
"name": court.name,
|
||||
"court_type": court.court_type,
|
||||
"status": court.status,
|
||||
"current_match": match_data,
|
||||
"elapsed_seconds": elapsed,
|
||||
"next_reservation": {
|
||||
"start_time": next_reservation.start_time,
|
||||
"group_name": next_reservation.group_name,
|
||||
} if next_reservation else None,
|
||||
})
|
||||
|
||||
# Upcoming scheduled matches (not yet in progress)
|
||||
upcoming = db.query(Match).filter(
|
||||
Match.status == "scheduled",
|
||||
Match.player1_id.isnot(None),
|
||||
Match.player2_id.isnot(None)
|
||||
).limit(10).all()
|
||||
|
||||
upcoming_data = []
|
||||
for m in upcoming:
|
||||
p1 = db.query(Player).filter(Player.id == m.player1_id).first()
|
||||
p2 = db.query(Player).filter(Player.id == m.player2_id).first()
|
||||
stage = db.query(Stage).filter(Stage.id == m.stage_id).first()
|
||||
event = db.query(Event).filter(Event.id == stage.event_id).first() if stage else None
|
||||
upcoming_data.append({
|
||||
"id": m.id,
|
||||
"player1_name": p1.name if p1 else None,
|
||||
"player2_name": p2.name if p2 else None,
|
||||
"round_number": m.round_number,
|
||||
"event_name": event.name if event else None,
|
||||
"stage_name": stage.name if stage else None,
|
||||
})
|
||||
|
||||
# Active event summary
|
||||
active_event = db.query(Event).filter(Event.status == "active").first()
|
||||
active_event_data = None
|
||||
if active_event:
|
||||
active_stage = None
|
||||
if active_event.current_stage_id:
|
||||
active_stage = db.query(Stage).filter(Stage.id == active_event.current_stage_id).first()
|
||||
active_event_data = {
|
||||
"id": active_event.id,
|
||||
"name": active_event.name,
|
||||
"format": active_event.format,
|
||||
"current_stage": active_stage.name if active_stage else None,
|
||||
"current_stage_id": active_event.current_stage_id,
|
||||
}
|
||||
|
||||
return {
|
||||
"courts": courts_data,
|
||||
"upcoming_matches": upcoming_data,
|
||||
"active_event": active_event_data,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/screen/{court_id}")
|
||||
def screen_view(court_id: int, db: Session = Depends(get_db)):
|
||||
"""TV/screen display for a specific court"""
|
||||
court = db.query(Court).filter(Court.id == court_id).first()
|
||||
if not court:
|
||||
return {"error": "Court not found"}
|
||||
|
||||
current_match = db.query(Match).filter(
|
||||
Match.court_id == court_id,
|
||||
Match.status == "in_progress"
|
||||
).first()
|
||||
|
||||
match_data = None
|
||||
if current_match:
|
||||
p1 = db.query(Player).filter(Player.id == current_match.player1_id).first()
|
||||
p2 = db.query(Player).filter(Player.id == current_match.player2_id).first()
|
||||
elapsed = None
|
||||
if current_match.started_at:
|
||||
elapsed = int((datetime.utcnow() - current_match.started_at).total_seconds())
|
||||
match_data = {
|
||||
"id": current_match.id,
|
||||
"player1_name": p1.name if p1 else "TBD",
|
||||
"player2_name": p2.name if p2 else "TBD",
|
||||
"score1": current_match.score1 if current_match.score1 is not None else 0,
|
||||
"score2": current_match.score2 if current_match.score2 is not None else 0,
|
||||
"elapsed_seconds": elapsed,
|
||||
}
|
||||
|
||||
return {
|
||||
"court": {"id": court.id, "name": court.name, "status": court.status},
|
||||
"current_match": match_data,
|
||||
}
|
||||
294
backend/app/routers/events.py
Normal file
294
backend/app/routers/events.py
Normal file
@@ -0,0 +1,294 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from ..database import get_db
|
||||
from ..models import Event, EventPlayer, Stage, Match, Player, Court
|
||||
from ..schemas import (
|
||||
EventCreate, EventUpdate, EventPlayerAdd, EventPlayerOut,
|
||||
StageCreate, StageUpdate, StageOut, MatchOut
|
||||
)
|
||||
from ..bracket import calculate_stages, generate_single_elim_bracket, generate_open_schedule, generate_round_robin
|
||||
|
||||
router = APIRouter(prefix="/api/events", tags=["events"])
|
||||
|
||||
|
||||
def _event_with_details(event_id: int, db: Session):
|
||||
return db.query(Event).options(
|
||||
joinedload(Event.event_players).joinedload(EventPlayer.player),
|
||||
joinedload(Event.stages).joinedload(Stage.matches)
|
||||
).filter(Event.id == event_id).first()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_events(db: Session = Depends(get_db)):
|
||||
events = db.query(Event).order_by(Event.created_at.desc()).all()
|
||||
return [{"id": e.id, "name": e.name, "format": e.format, "status": e.status,
|
||||
"courts_count": e.courts_count, "created_at": e.created_at,
|
||||
"player_count": len(e.event_players), "stage_count": len(e.stages)} for e in events]
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_event(data: EventCreate, db: Session = Depends(get_db)):
|
||||
event = Event(**data.model_dump())
|
||||
db.add(event)
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return {"id": event.id, "name": event.name, "format": event.format,
|
||||
"status": event.status, "courts_count": event.courts_count}
|
||||
|
||||
|
||||
@router.get("/calculate")
|
||||
def calculate_event(players: int, format: str = "Singles", courts: int = 1):
|
||||
return calculate_stages(players, format, courts)
|
||||
|
||||
|
||||
@router.get("/{event_id}")
|
||||
def get_event(event_id: int, db: Session = Depends(get_db)):
|
||||
event = _event_with_details(event_id, db)
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
stages_data = []
|
||||
for stage in event.stages:
|
||||
matches_data = []
|
||||
for m in stage.matches:
|
||||
p1 = db.query(Player).filter(Player.id == m.player1_id).first() if m.player1_id else None
|
||||
p2 = db.query(Player).filter(Player.id == m.player2_id).first() if m.player2_id else None
|
||||
court = db.query(Court).filter(Court.id == m.court_id).first() if m.court_id else None
|
||||
matches_data.append({
|
||||
"id": m.id, "round_number": m.round_number, "match_number": m.match_number,
|
||||
"player1_id": m.player1_id, "player2_id": m.player2_id,
|
||||
"team1_players": m.team1_players, "team2_players": m.team2_players,
|
||||
"player1_name": p1.name if p1 else None,
|
||||
"player2_name": p2.name if p2 else None,
|
||||
"score1": m.score1, "score2": m.score2,
|
||||
"winner_id": m.winner_id, "winner_team": m.winner_team,
|
||||
"status": m.status, "bracket_position": m.bracket_position,
|
||||
"next_winner_match_id": m.next_winner_match_id,
|
||||
"next_loser_match_id": m.next_loser_match_id,
|
||||
"court_id": m.court_id,
|
||||
"court_name": court.name if court else None,
|
||||
"started_at": m.started_at, "completed_at": m.completed_at,
|
||||
})
|
||||
stages_data.append({
|
||||
"id": stage.id, "name": stage.name, "match_type": stage.match_type,
|
||||
"rounds": stage.rounds, "advance_count": stage.advance_count,
|
||||
"status": stage.status, "order": stage.order,
|
||||
"matches": matches_data,
|
||||
})
|
||||
|
||||
players_data = [{
|
||||
"id": ep.id, "player_id": ep.player_id,
|
||||
"skill_level_override": ep.skill_level_override,
|
||||
"player": {"id": ep.player.id, "name": ep.player.name,
|
||||
"skill_level": ep.player.skill_level, "elo": ep.player.elo,
|
||||
"matches_played": ep.player.matches_played,
|
||||
"wins": ep.player.wins, "losses": ep.player.losses}
|
||||
} for ep in event.event_players]
|
||||
|
||||
return {
|
||||
"id": event.id, "name": event.name, "format": event.format,
|
||||
"courts_count": event.courts_count, "status": event.status,
|
||||
"current_stage_id": event.current_stage_id,
|
||||
"created_at": event.created_at, "started_at": event.started_at,
|
||||
"players": players_data,
|
||||
"stages": stages_data,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{event_id}")
|
||||
def update_event(event_id: int, data: EventUpdate, db: Session = Depends(get_db)):
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
for k, v in data.model_dump(exclude_none=True).items():
|
||||
setattr(event, k, v)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{event_id}/players")
|
||||
def add_player_to_event(event_id: int, data: EventPlayerAdd, db: Session = Depends(get_db)):
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
player = db.query(Player).filter(Player.id == data.player_id).first()
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
existing = db.query(EventPlayer).filter(
|
||||
EventPlayer.event_id == event_id, EventPlayer.player_id == data.player_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Player already in event")
|
||||
ep = EventPlayer(event_id=event_id, player_id=data.player_id,
|
||||
skill_level_override=data.skill_level_override)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
db.refresh(ep)
|
||||
return {"id": ep.id, "player_id": ep.player_id}
|
||||
|
||||
|
||||
@router.delete("/{event_id}/players/{player_id}")
|
||||
def remove_player_from_event(event_id: int, player_id: int, db: Session = Depends(get_db)):
|
||||
ep = db.query(EventPlayer).filter(
|
||||
EventPlayer.event_id == event_id, EventPlayer.player_id == player_id
|
||||
).first()
|
||||
if not ep:
|
||||
raise HTTPException(status_code=404, detail="Player not in event")
|
||||
db.delete(ep)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{event_id}/stages")
|
||||
def add_stage(event_id: int, data: StageCreate, db: Session = Depends(get_db)):
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
stage = Stage(event_id=event_id, **data.model_dump())
|
||||
db.add(stage)
|
||||
db.commit()
|
||||
db.refresh(stage)
|
||||
return {"id": stage.id, "name": stage.name, "match_type": stage.match_type,
|
||||
"rounds": stage.rounds, "advance_count": stage.advance_count, "status": stage.status}
|
||||
|
||||
|
||||
@router.put("/{event_id}/stages/{stage_id}")
|
||||
def update_stage(event_id: int, stage_id: int, data: StageUpdate, db: Session = Depends(get_db)):
|
||||
stage = db.query(Stage).filter(Stage.id == stage_id, Stage.event_id == event_id).first()
|
||||
if not stage:
|
||||
raise HTTPException(status_code=404, detail="Stage not found")
|
||||
for k, v in data.model_dump(exclude_none=True).items():
|
||||
setattr(stage, k, v)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{event_id}/stages/{stage_id}/generate")
|
||||
def generate_stage_matches(event_id: int, stage_id: int, db: Session = Depends(get_db)):
|
||||
"""Generate matches for a stage"""
|
||||
stage = db.query(Stage).filter(Stage.id == stage_id, Stage.event_id == event_id).first()
|
||||
if not stage:
|
||||
raise HTTPException(status_code=404, detail="Stage not found")
|
||||
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
|
||||
# Delete existing matches for this stage
|
||||
db.query(Match).filter(Match.stage_id == stage_id).delete()
|
||||
db.commit()
|
||||
|
||||
# Get players for this event
|
||||
event_players = db.query(EventPlayer).filter(EventPlayer.event_id == event_id).all()
|
||||
player_ids = [ep.player_id for ep in event_players]
|
||||
|
||||
if len(player_ids) < 2:
|
||||
raise HTTPException(status_code=400, detail="Need at least 2 players")
|
||||
|
||||
match_type = stage.match_type
|
||||
matches_to_create = []
|
||||
|
||||
if match_type in ["Open", "Open Match"]:
|
||||
matches_raw = generate_open_schedule(player_ids, stage.rounds, event.courts_count)
|
||||
for m in matches_raw:
|
||||
matches_to_create.append(Match(
|
||||
stage_id=stage_id,
|
||||
round_number=m["round_number"],
|
||||
match_number=m["match_number"],
|
||||
player1_id=m["player1_id"],
|
||||
player2_id=m["player2_id"],
|
||||
status=m["status"],
|
||||
bracket_position=m.get("bracket_position", "main"),
|
||||
))
|
||||
|
||||
elif match_type == "Round Robin":
|
||||
pairings = generate_round_robin(player_ids)
|
||||
for i, (p1, p2) in enumerate(pairings):
|
||||
matches_to_create.append(Match(
|
||||
stage_id=stage_id,
|
||||
round_number=1,
|
||||
match_number=i + 1,
|
||||
player1_id=p1,
|
||||
player2_id=p2,
|
||||
status="scheduled",
|
||||
bracket_position="main",
|
||||
))
|
||||
|
||||
elif match_type in ["Tournament", "Double Elimination"]:
|
||||
bracket_matches = generate_single_elim_bracket(player_ids)
|
||||
# Create matches and track temp IDs
|
||||
created = {}
|
||||
# First pass - create all match objects
|
||||
db_matches = []
|
||||
for bm in bracket_matches:
|
||||
m = Match(
|
||||
stage_id=stage_id,
|
||||
round_number=bm["round_number"],
|
||||
match_number=bm["match_number"],
|
||||
player1_id=bm.get("player1_id"),
|
||||
player2_id=bm.get("player2_id"),
|
||||
status=bm["status"],
|
||||
bracket_position=bm.get("bracket_position", "winners"),
|
||||
)
|
||||
db.add(m)
|
||||
db.flush()
|
||||
created[bm["temp_id"]] = m
|
||||
db_matches.append((bm, m))
|
||||
|
||||
# Second pass - link next_winner_match_id
|
||||
for bm, m in db_matches:
|
||||
if bm.get("next_winner_temp_id") and bm["next_winner_temp_id"] in created:
|
||||
m.next_winner_match_id = created[bm["next_winner_temp_id"]].id
|
||||
|
||||
db.commit()
|
||||
return {"ok": True, "match_count": len(bracket_matches)}
|
||||
|
||||
elif match_type == "Skill-Based":
|
||||
# Group players by skill level and create matches within same skill groups
|
||||
players = db.query(Player).filter(Player.id.in_(player_ids)).all()
|
||||
skill_order = {"Beginner": 0, "Intermediate": 1, "Advanced": 2, "Elite": 3}
|
||||
players_sorted = sorted(players, key=lambda p: skill_order.get(p.skill_level, 0))
|
||||
sorted_ids = [p.id for p in players_sorted]
|
||||
pairings = generate_round_robin(sorted_ids)[:len(sorted_ids)]
|
||||
for i, (p1, p2) in enumerate(pairings):
|
||||
matches_to_create.append(Match(
|
||||
stage_id=stage_id,
|
||||
round_number=1,
|
||||
match_number=i + 1,
|
||||
player1_id=p1,
|
||||
player2_id=p2,
|
||||
status="scheduled",
|
||||
bracket_position="main",
|
||||
))
|
||||
|
||||
for m in matches_to_create:
|
||||
db.add(m)
|
||||
db.commit()
|
||||
return {"ok": True, "match_count": len(matches_to_create)}
|
||||
|
||||
|
||||
@router.post("/{event_id}/start")
|
||||
def start_event(event_id: int, db: Session = Depends(get_db)):
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
event.status = "active"
|
||||
event.started_at = datetime.utcnow()
|
||||
# Activate first stage
|
||||
first_stage = db.query(Stage).filter(Stage.event_id == event_id).order_by(Stage.order).first()
|
||||
if first_stage:
|
||||
first_stage.status = "active"
|
||||
event.current_stage_id = first_stage.id
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{event_id}/complete")
|
||||
def complete_event(event_id: int, db: Session = Depends(get_db)):
|
||||
event = db.query(Event).filter(Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
event.status = "completed"
|
||||
event.completed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
156
backend/app/routers/matches.py
Normal file
156
backend/app/routers/matches.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from ..database import get_db
|
||||
from ..models import Match, Player, Court, Stage
|
||||
from ..schemas import MatchScoreUpdate, MatchCourtAssign
|
||||
|
||||
router = APIRouter(prefix="/api/matches", tags=["matches"])
|
||||
|
||||
|
||||
def _match_detail(match: Match, db: Session) -> dict:
|
||||
p1 = db.query(Player).filter(Player.id == match.player1_id).first() if match.player1_id else None
|
||||
p2 = db.query(Player).filter(Player.id == match.player2_id).first() if match.player2_id else None
|
||||
court = db.query(Court).filter(Court.id == match.court_id).first() if match.court_id else None
|
||||
return {
|
||||
"id": match.id,
|
||||
"stage_id": match.stage_id,
|
||||
"court_id": match.court_id,
|
||||
"court_name": court.name if court else None,
|
||||
"round_number": match.round_number,
|
||||
"match_number": match.match_number,
|
||||
"player1_id": match.player1_id,
|
||||
"player2_id": match.player2_id,
|
||||
"player1_name": p1.name if p1 else None,
|
||||
"player2_name": p2.name if p2 else None,
|
||||
"team1_players": match.team1_players,
|
||||
"team2_players": match.team2_players,
|
||||
"score1": match.score1,
|
||||
"score2": match.score2,
|
||||
"winner_id": match.winner_id,
|
||||
"winner_team": match.winner_team,
|
||||
"status": match.status,
|
||||
"bracket_position": match.bracket_position,
|
||||
"next_winner_match_id": match.next_winner_match_id,
|
||||
"next_loser_match_id": match.next_loser_match_id,
|
||||
"started_at": match.started_at,
|
||||
"completed_at": match.completed_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{match_id}")
|
||||
def get_match(match_id: int, db: Session = Depends(get_db)):
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
return _match_detail(match, db)
|
||||
|
||||
|
||||
@router.post("/{match_id}/start")
|
||||
def start_match(match_id: int, db: Session = Depends(get_db)):
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
|
||||
match.status = "in_progress"
|
||||
match.started_at = datetime.utcnow()
|
||||
|
||||
# Auto-assign available court if none assigned
|
||||
if not match.court_id:
|
||||
stage = db.query(Stage).filter(Stage.id == match.stage_id).first()
|
||||
if stage:
|
||||
# Find available court
|
||||
in_use_court_ids = db.query(Match.court_id).filter(
|
||||
Match.status == "in_progress", Match.court_id.isnot(None)
|
||||
).all()
|
||||
in_use_ids = [r[0] for r in in_use_court_ids]
|
||||
available_court = db.query(Court).filter(
|
||||
Court.status.in_(["available", "reserved"]),
|
||||
Court.id.notin_(in_use_ids)
|
||||
).first()
|
||||
if available_court:
|
||||
match.court_id = available_court.id
|
||||
available_court.status = "in_use"
|
||||
|
||||
db.commit()
|
||||
return _match_detail(match, db)
|
||||
|
||||
|
||||
@router.post("/{match_id}/score")
|
||||
def enter_score(match_id: int, data: MatchScoreUpdate, db: Session = Depends(get_db)):
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
|
||||
match.score1 = data.score1
|
||||
match.score2 = data.score2
|
||||
match.status = "completed"
|
||||
match.completed_at = datetime.utcnow()
|
||||
|
||||
# Determine winner
|
||||
if data.score1 > data.score2:
|
||||
match.winner_id = match.player1_id
|
||||
match.winner_team = 1
|
||||
winner_id = match.player1_id
|
||||
loser_id = match.player2_id
|
||||
else:
|
||||
match.winner_id = match.player2_id
|
||||
match.winner_team = 2
|
||||
winner_id = match.player2_id
|
||||
loser_id = match.player1_id
|
||||
|
||||
# Free up court
|
||||
if match.court_id:
|
||||
court = db.query(Court).filter(Court.id == match.court_id).first()
|
||||
if court:
|
||||
court.status = "available"
|
||||
|
||||
# Update player stats
|
||||
if winner_id:
|
||||
winner = db.query(Player).filter(Player.id == winner_id).first()
|
||||
if winner:
|
||||
winner.wins += 1
|
||||
winner.matches_played += 1
|
||||
winner.elo += 20 # Simple ELO gain
|
||||
if loser_id:
|
||||
loser = db.query(Player).filter(Player.id == loser_id).first()
|
||||
if loser:
|
||||
loser.losses += 1
|
||||
loser.matches_played += 1
|
||||
loser.elo = max(800, loser.elo - 15)
|
||||
|
||||
# Advance bracket: place winner into next match
|
||||
if match.next_winner_match_id:
|
||||
next_match = db.query(Match).filter(Match.id == match.next_winner_match_id).first()
|
||||
if next_match:
|
||||
if next_match.player1_id is None:
|
||||
next_match.player1_id = winner_id
|
||||
elif next_match.player2_id is None:
|
||||
next_match.player2_id = winner_id
|
||||
|
||||
db.commit()
|
||||
return _match_detail(match, db)
|
||||
|
||||
|
||||
@router.patch("/{match_id}/court")
|
||||
def assign_court(match_id: int, data: MatchCourtAssign, db: Session = Depends(get_db)):
|
||||
match = db.query(Match).filter(Match.id == match_id).first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="Match not found")
|
||||
|
||||
# Free old court
|
||||
if match.court_id:
|
||||
old_court = db.query(Court).filter(Court.id == match.court_id).first()
|
||||
if old_court and old_court.status == "in_use":
|
||||
old_court.status = "available"
|
||||
|
||||
match.court_id = data.court_id
|
||||
|
||||
# Mark new court in use if match is in progress
|
||||
if data.court_id and match.status == "in_progress":
|
||||
new_court = db.query(Court).filter(Court.id == data.court_id).first()
|
||||
if new_court:
|
||||
new_court.status = "in_use"
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
52
backend/app/routers/players.py
Normal file
52
backend/app/routers/players.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from ..database import get_db
|
||||
from ..models import Player
|
||||
from ..schemas import PlayerCreate, PlayerUpdate, PlayerOut
|
||||
|
||||
router = APIRouter(prefix="/api/players", tags=["players"])
|
||||
|
||||
|
||||
@router.get("/", response_model=List[PlayerOut])
|
||||
def list_players(db: Session = Depends(get_db)):
|
||||
return db.query(Player).order_by(Player.elo.desc()).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=PlayerOut)
|
||||
def create_player(data: PlayerCreate, db: Session = Depends(get_db)):
|
||||
player = Player(**data.model_dump())
|
||||
db.add(player)
|
||||
db.commit()
|
||||
db.refresh(player)
|
||||
return player
|
||||
|
||||
|
||||
@router.get("/{player_id}", response_model=PlayerOut)
|
||||
def get_player(player_id: int, db: Session = Depends(get_db)):
|
||||
player = db.query(Player).filter(Player.id == player_id).first()
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
return player
|
||||
|
||||
|
||||
@router.put("/{player_id}", response_model=PlayerOut)
|
||||
def update_player(player_id: int, data: PlayerUpdate, db: Session = Depends(get_db)):
|
||||
player = db.query(Player).filter(Player.id == player_id).first()
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
for k, v in data.model_dump(exclude_none=True).items():
|
||||
setattr(player, k, v)
|
||||
db.commit()
|
||||
db.refresh(player)
|
||||
return player
|
||||
|
||||
|
||||
@router.delete("/{player_id}")
|
||||
def delete_player(player_id: int, db: Session = Depends(get_db)):
|
||||
player = db.query(Player).filter(Player.id == player_id).first()
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
db.delete(player)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
163
backend/app/schemas.py
Normal file
163
backend/app/schemas.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
40
backend/app/websocket_manager.py
Normal file
40
backend/app/websocket_manager.py
Normal 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()
|
||||
Reference in New Issue
Block a user