🏓 Initial ServeSync demo build
- FastAPI backend with PostgreSQL + Redis - 4 core features: Registration, Courts, Matchmaking, Tournament - Double elimination tournament with bracket visualization - ELO-based matchmaking (Stage 1 Open, Stage 2 Skill-Based) - Real-time WebSocket updates - TV Screen display for courts - Vue 3 + Tailwind CSS frontend - Seed data: 12 players, 4 courts, active matches, tournament in progress - Docker compose stack with Nginx reverse proxy
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
218
backend/app/api/courts.py
Normal file
218
backend/app/api/courts.py
Normal file
@@ -0,0 +1,218 @@
|
||||
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
|
||||
]
|
||||
160
backend/app/api/matches.py
Normal file
160
backend/app/api/matches.py
Normal file
@@ -0,0 +1,160 @@
|
||||
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)
|
||||
94
backend/app/api/players.py
Normal file
94
backend/app/api/players.py
Normal file
@@ -0,0 +1,94 @@
|
||||
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)
|
||||
156
backend/app/api/screen.py
Normal file
156
backend/app/api/screen.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""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
|
||||
|
||||
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 = 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:
|
||||
elapsed = int((datetime.utcnow() - active_match.started_at).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.utcnow().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.utcnow().isoformat(),
|
||||
}
|
||||
82
backend/app/api/tournaments.py
Normal file
82
backend/app/api/tournaments.py
Normal file
@@ -0,0 +1,82 @@
|
||||
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", [])
|
||||
Reference in New Issue
Block a user