Added seperate courts

This commit is contained in:
2026-05-24 15:53:54 +02:00 Verified
parent b2d341642e
commit 33e623b337
18 changed files with 1787 additions and 1282 deletions
+28 -29
View File
@@ -28,7 +28,7 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
type=data.type,
)
new_t.teams = [models.Team(name=n) for n in data.teams]
new_t.courts = [models.Court(name=n) for n in data.courts]
new_t.courts = db.query(models.Court).filter(models.Court.id.in_(data.courts)).all()
db.add(new_t)
db.commit()
@@ -141,53 +141,52 @@ def delete_team(db: Session, tournament_id: str, team_id: int):
return True
def get_courts(db: Session, tournament_id: str):
return (
db.query(models.Court).filter(models.Court.tournament_id == tournament_id).all()
)
def get_courts(db: Session):
return db.query(models.Court).all()
def create_court(db: Session, tournament_id: str, court_data: schemas.CourtCreate):
t = get_tournament(db, tournament_id)
if not t:
return None
new_court = models.Court(name=court_data.name, tournament_id=tournament_id)
def create_court(db: Session, court_data: schemas.CourtCreate):
new_court = models.Court(name=court_data.name)
db.add(new_court)
db.flush()
logic.update_schedule_times(db, t)
db.commit()
db.refresh(new_court)
return new_court
def delete_court(db: Session, tournament_id: str, court_id: int):
t = get_tournament(db, tournament_id)
if not t:
return None
def delete_court(db: Session, court_id: int):
court = db.get(models.Court, court_id)
if not court or court.tournament_id != tournament_id:
return None
if not court:
return False
affected_tournaments = list(court.tournaments)
matches = db.query(models.Match).filter(models.Match.court_id == court_id).all()
for m in matches:
if m.status == models.MatchStatus.FINISHED:
m.court_id = None
else:
m.court_id = None
m.start_time = None
db.delete(court)
db.flush()
logic.update_schedule_times(db, t)
db.commit()
processed_days = set()
for t in affected_tournaments:
day = t.timestamp.date() if t.timestamp else None
if day and day not in processed_days:
logic.update_schedule_times(db, t)
processed_days.add(day)
return True
def update_tournament_courts(
db: Session, tournament_id: str, new_court_names: list[str]
):
def update_tournament_courts(db: Session, tournament_id: str, new_court_ids: list[int]):
t = get_tournament(db, tournament_id)
if not t:
return None
t.courts = [models.Court(name=c, tournament_id=t.id) for c in new_court_names]
t.courts = db.query(models.Court).filter(models.Court.id.in_(new_court_ids)).all()
db.flush()
logic.update_schedule_times(db, t)
+130 -55
View File
@@ -1,6 +1,6 @@
# backend/app/logic.py
from collections import defaultdict
from datetime import timedelta
from datetime import datetime, timedelta
from uuid import uuid4
from sqlalchemy.orm import Session
@@ -79,59 +79,108 @@ def generate_bracket(db: Session, t: models.Tournament):
def update_schedule_times(db: Session, t: models.Tournament):
if not t.courts or not t.matches:
if not t.timestamp:
return
matches = t.matches
match_map = {m.id: m for m in matches}
# 1. Grab the entire day of tournaments
target_date = t.timestamp.date()
start_of_day = datetime.combine(target_date, datetime.min.time())
end_of_day = start_of_day + timedelta(days=1)
tournaments = (
db.query(models.Tournament)
.filter(
models.Tournament.timestamp >= start_of_day,
models.Tournament.timestamp < end_of_day,
)
.all()
)
if not tournaments:
return
all_matches: list[models.Match] = []
match_map: dict[str, models.Match] = {}
prereqs = defaultdict(list)
for m in matches:
if m.winner_next_match_id:
prereqs[m.winner_next_match_id].append(m.id)
if m.loser_next_match_id:
prereqs[m.loser_next_match_id].append(m.id)
depth_cache = {}
# 2. Gather all matches and prereqs globally
for t_item in tournaments:
for m in t_item.matches:
all_matches.append(m)
match_map[m.id] = m
if m.winner_next_match_id:
prereqs[m.winner_next_match_id].append(m.id)
if m.loser_next_match_id:
prereqs[m.loser_next_match_id].append(m.id)
# 3. Calculate depths
def get_depth(m_id):
if m_id not in match_map:
return 0
if m_id in depth_cache:
return depth_cache[m_id]
m = match_map[m_id]
child_depths = [0]
if m.winner_next_match_id:
child_depths.append(get_depth(m.winner_next_match_id))
if m.loser_next_match_id:
child_depths.append(get_depth(m.loser_next_match_id))
depth = 1 + max(child_depths)
depth_cache[m_id] = depth
return depth
for m in matches:
for m in all_matches:
get_depth(m.id)
tournament_start = (
t.timestamp.replace(tzinfo=None) if t.timestamp.tzinfo else t.timestamp
)
court_timers = {c.id: tournament_start for c in t.courts}
# 4. Initialize timers & lock finished matches
all_courts: set[models.Court] = set()
court_sharing_count = defaultdict(
int
) # NEW: Track how highly contested each court is
for t_item in tournaments:
for c in t_item.courts:
all_courts.add(c)
court_sharing_count[c.id] += 1 # NEW
court_timers = {c.id: start_of_day for c in all_courts}
planned_finish_times = {}
unscheduled = list(matches)
unscheduled: list[models.Match] = []
loop_limit = len(matches) * 2
for m in all_matches:
if m.status == MatchStatus.FINISHED and m.start_time and m.court_id:
fin = m.start_time + timedelta(minutes=m.tournament.duration)
planned_finish_times[m.id] = fin
if court_timers.get(m.court_id, start_of_day) < fin:
court_timers[m.court_id] = fin
else:
unscheduled.append(m)
tournament_match_counts = {t_item.id: 0 for t_item in tournaments}
# 5. Global Interleaving Schedule Loop
loop_limit = len(unscheduled) * 3
while unscheduled and loop_limit > 0:
loop_limit -= 1
best_court_id = min(court_timers, key=lambda k: court_timers[k])
best_court_id = min(
court_timers.keys(), key=lambda k: (court_timers[k], court_sharing_count[k])
)
current_time = court_timers[best_court_id]
ready: list[models.Match] = []
for m in unscheduled:
if best_court_id not in [c.id for c in m.tournament.courts]:
continue
is_ready = True
max_prereq_time = tournament_start
max_prereq_time = (
m.tournament.timestamp.replace(tzinfo=None)
if m.tournament.timestamp.tzinfo
else m.tournament.timestamp
)
for p_id in prereqs[m.id]:
if p_id not in planned_finish_times:
is_ready = False
@@ -143,27 +192,41 @@ def update_schedule_times(db: Session, t: models.Tournament):
if ready:
ready.sort(
key=lambda x: (-depth_cache[x.id], x.round_number, x.match_number)
key=lambda x: (
len(x.tournament.courts),
tournament_match_counts[x.tournament_id],
-depth_cache[x.id],
x.round_number,
x.match_number,
)
)
cand = ready[0]
cand.court_id = best_court_id
cand.start_time = current_time
fin = current_time + timedelta(minutes=t.duration)
fin = current_time + timedelta(minutes=cand.tournament.duration)
planned_finish_times[cand.id] = fin
court_timers[best_court_id] = fin
tournament_match_counts[cand.tournament_id] += 1
unscheduled.remove(cand)
else:
next_wake = None
for m in unscheduled:
if best_court_id not in [c.id for c in m.tournament.courts]:
continue
is_ready = True
max_prereq_time = tournament_start
max_prereq_time = (
m.tournament.timestamp.replace(tzinfo=None)
if m.tournament.timestamp.tzinfo
else m.tournament.timestamp
)
for p_id in prereqs[m.id]:
if p_id not in planned_finish_times:
is_ready = False
break
max_prereq_time = max(max_prereq_time, planned_finish_times[p_id])
if is_ready and max_prereq_time > current_time:
if next_wake is None or max_prereq_time < next_wake:
next_wake = max_prereq_time
@@ -171,22 +234,26 @@ def update_schedule_times(db: Session, t: models.Tournament):
if next_wake:
court_timers[best_court_id] = next_wake
else:
break
court_timers[best_court_id] += timedelta(minutes=5)
# ==========================================
# --- DYNAMIC REFEREE ASSIGNMENT ---
# ==========================================
all_matches = sorted(list(matches), key=lambda x: (x.start_time, x.court_id))
# 6. Dynamic Referee Assignment (Runs globally across all interleaved matches!)
all_matches = sorted(
all_matches, key=lambda x: (x.start_time or start_of_day, x.court_id or 0)
)
duty_counts = defaultdict(int)
active_refs = []
def is_busy(outcome_tuple, source_m, start, end):
next_m_id = source_m.winner_next_match_id if outcome_tuple[0] == "W" else source_m.loser_next_match_id
def is_busy(outcome_tuple, source_m: models.Match, start, end):
next_m_id = (
source_m.winner_next_match_id
if outcome_tuple[0] == "W"
else source_m.loser_next_match_id
)
if next_m_id:
next_m = match_map[next_m_id]
next_start = next_m.start_time
if next_start:
next_end = next_start + timedelta(minutes=t.duration)
next_end = next_start + timedelta(minutes=next_m.tournament.duration)
if not (next_end <= start or next_start >= end):
return True
for r_outcome, r_start, r_end in active_refs:
@@ -198,29 +265,37 @@ def update_schedule_times(db: Session, t: models.Tournament):
for m in all_matches:
if not m.start_time:
continue
m_start = m.start_time
m_end = m_start + timedelta(minutes=t.duration)
m_end = m_start + timedelta(minutes=m.tournament.duration)
if m.bracket_type == BracketTypes.FINALS:
prev_final = next((p for p in all_matches if p.bracket_type == BracketTypes.FINALS and p.winner_next_match_id == m.id), None)
prev_final = next(
(
p
for p in all_matches
if p.bracket_type == BracketTypes.FINALS
and p.winner_next_match_id == m.id
),
None,
)
if prev_final:
m.ref_team_id = prev_final.ref_team_id
m.ref_label = prev_final.ref_label
identifier = f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
identifier = (
f"TEAM_{m.ref_team_id}" if m.ref_team_id else prev_final.ref_label
)
active_refs.append((identifier, m_start, m_end))
continue
best_outcome = None
best_score = float('inf')
best_score = float("inf")
for prev_m in all_matches:
if not prev_m.start_time:
continue
prev_end = prev_m.start_time + timedelta(minutes=t.duration)
prev_end = prev_m.start_time + timedelta(minutes=prev_m.tournament.duration)
if prev_end <= m_start:
l_outcome = ("L", prev_m.id)
if not is_busy(l_outcome, prev_m, m_start, m_end):
@@ -231,17 +306,17 @@ def update_schedule_times(db: Session, t: models.Tournament):
if score < best_score:
best_score = score
best_outcome = (l_outcome, prev_m)
w_outcome = ("W", prev_m.id)
if not is_busy(w_outcome, prev_m, m_start, m_end):
wait_mins = (m_start - prev_end).total_seconds() / 60.0
score = (duty_counts[w_outcome] * 120) + wait_mins + 60
score = (duty_counts[w_outcome] * 120) + wait_mins + 60
if prev_m.court_id == m.court_id:
score -= 30
if score < best_score:
best_score = score
best_outcome = (w_outcome, prev_m)
if best_outcome:
outcome_tuple, prev_m = best_outcome
duty_counts[outcome_tuple] += 1
@@ -249,20 +324,18 @@ def update_schedule_times(db: Session, t: models.Tournament):
role = "Winner" if outcome_tuple[0] == "W" else "Loser"
m.ref_label = f"{role} of #{prev_m.match_number}"
else:
m.ref_label = "Staff / Volunteers"
m.ref_label = "Staff / Volunteers"
for future_m in all_matches:
if future_m.start_time and future_m.start_time >= m_end:
assigned = False
for t_id in [future_m.p1_team_id, future_m.p2_team_id]:
if t_id:
team_identifier = f"TEAM_{t_id}"
is_team_busy = any(
r_outcome == team_identifier and not (r_end <= m_start or r_start >= m_end)
r_outcome == team_identifier
and not (r_end <= m_start or r_start >= m_end)
for r_outcome, r_start, r_end in active_refs
)
if not is_team_busy:
m.ref_team_id = t_id
m.ref_label = None
@@ -271,7 +344,6 @@ def update_schedule_times(db: Session, t: models.Tournament):
break
if assigned:
break
# ----------------------------------
db.commit()
@@ -355,7 +427,10 @@ def undo_advancement(db: Session, match: models.Match):
return
for m in match.tournament.matches:
if m.ref_label == f"Loser of #{match.match_number}" or m.ref_label == f"Winner of #{match.match_number}":
if (
m.ref_label == f"Loser of #{match.match_number}"
or m.ref_label == f"Winner of #{match.match_number}"
):
m.ref_team_id = None
db.add(m)
@@ -385,5 +460,5 @@ def undo_advancement(db: Session, match: models.Match):
clear_from_next(match.winner_next_match, match.winner_next_match_slot)
if match.loser_next_match and loser_id:
clear_from_next(match.loser_next_match, match.loser_next_match_slot)
db.commit()
db.commit()
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .database import Base, engine
from .routes import auth, tournaments, websocket
from .routes import auth, tournaments, websocket, courts
@asynccontextmanager
@@ -15,6 +15,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Tournament Bracket API", lifespan=lifespan, root_path="/api")
app.include_router(tournaments.router)
app.include_router(courts.router)
app.include_router(auth.router)
app.include_router(websocket.router)
app.add_middleware(
+68 -26
View File
@@ -2,7 +2,7 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import JSON, DateTime
from sqlalchemy import JSON, DateTime, Table, Column
from sqlalchemy import Enum as SqlEnum
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -10,6 +10,33 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from .constants import MatchStatus, TournamentTypes, BracketTypes
from .database import Base
tournament_courts = Table(
"tournament_courts",
Base.metadata,
Column(
"tournament_id",
String(8),
ForeignKey("tournaments.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"court_id",
Integer,
ForeignKey("courts.id", ondelete="CASCADE"),
primary_key=True,
),
)
class Court(Base):
__tablename__ = "courts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False, unique=True)
tournaments: Mapped[list["Tournament"]] = relationship(
"Tournament", secondary=tournament_courts, back_populates="courts"
)
class Tournament(Base):
__tablename__ = "tournaments"
@@ -25,7 +52,7 @@ class Tournament(Base):
"Team", back_populates="tournament", cascade="all, delete-orphan"
)
courts: Mapped[list["Court"]] = relationship(
"Court", back_populates="tournament", cascade="all, delete-orphan"
"Court", secondary=tournament_courts, back_populates="tournaments"
)
matches: Mapped[list["Match"]] = relationship(
@@ -50,15 +77,6 @@ class Team(Base):
tournament: Mapped["Tournament"] = relationship(back_populates="teams")
class Court(Base):
__tablename__ = "courts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
tournament: Mapped["Tournament"] = relationship(back_populates="courts")
class Match(Base):
__tablename__ = "matches"
@@ -70,32 +88,56 @@ class Match(Base):
bracket_type: Mapped[BracketTypes] = mapped_column(SqlEnum(BracketTypes))
court_id: Mapped[Optional[int]] = mapped_column(ForeignKey("courts.id"), nullable=True)
court_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("courts.id"), nullable=True
)
start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
status: Mapped[MatchStatus] = mapped_column(SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED)
status: Mapped[MatchStatus] = mapped_column(
SqlEnum(MatchStatus), default=MatchStatus.SCHEDULED
)
p1_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
p2_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
ref_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
p1_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
p2_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
ref_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
ref_label: Mapped[Optional[str]] = mapped_column(String, nullable=True)
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
winner_team_id: Mapped[Optional[int]] = mapped_column(ForeignKey("teams.id"), nullable=True)
winner_next_match_id: Mapped[Optional[str]] = mapped_column(ForeignKey("matches.id"), nullable=True)
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
winner_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
loser_next_match_id: Mapped[Optional[str]] = mapped_column(ForeignKey("matches.id"), nullable=True)
winner_next_match_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("matches.id"), nullable=True
)
winner_next_match_slot: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True
)
loser_next_match_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("matches.id"), nullable=True
)
loser_next_match_slot: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
tournament: Mapped["Tournament"] = relationship(back_populates="matches")
court: Mapped[Optional["Court"]] = relationship()
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
ref_team: Mapped[Optional["Team"]] = relationship("Team", foreign_keys=[ref_team_id])
winner_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[winner_next_match_id])
loser_next_match: Mapped["Match"] = relationship("Match", remote_side=[id], foreign_keys=[loser_next_match_id])
ref_team: Mapped[Optional["Team"]] = relationship(
"Team", foreign_keys=[ref_team_id]
)
winner_next_match: Mapped["Match"] = relationship(
"Match", remote_side=[id], foreign_keys=[winner_next_match_id]
)
loser_next_match: Mapped["Match"] = relationship(
"Match", remote_side=[id], foreign_keys=[loser_next_match_id]
)
+80
View File
@@ -0,0 +1,80 @@
# backend/app/routes/courts.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, date
from .. import crud, schemas, models
from ..constants import SUCCESS
from ..core.auth import get_admin_user
from ..database import get_db
router = APIRouter(prefix="/courts", tags=["Courts"])
@router.get("", response_model=list[schemas.CourtSchema])
def get_all_courts(db: Session = Depends(get_db)):
"""Public route to list all global courts"""
return db.query(models.Court).all()
@router.post("", response_model=schemas.CourtSchema)
def create_global_court(
data: schemas.CourtCreate,
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
"""Admin route to register a new physical court"""
return crud.create_court(db, data)
@router.delete("/{court_id}")
def delete_global_court(
court_id: int, db: Session = Depends(get_db), user: dict = Depends(get_admin_user)
):
success = crud.delete_court(db, court_id)
if not success:
raise HTTPException(404, "Court not found")
return SUCCESS
@router.get("/{court_id}/schedule")
def get_court_schedule(court_id: int, db: Session = Depends(get_db)):
court = db.query(models.Court).filter(models.Court.id == court_id).first()
if not court:
raise HTTPException(404, "Court not found")
today = date.today()
matches = (
db.query(models.Match)
.join(models.Tournament)
.filter(models.Match.court_id == court_id)
.filter(
models.Tournament.timestamp >= datetime.combine(today, datetime.min.time())
)
.filter(
models.Tournament.timestamp < datetime.combine(today, datetime.max.time())
)
.order_by(models.Match.start_time)
.all()
)
schedule = []
for m in matches:
schedule.append(
{
"id": m.id,
"tournament_id": m.tournament_id,
"tournament_name": m.tournament.name,
"time": m.start_time.strftime("%H:%M") if m.start_time else "TBD",
"status": m.status,
"match_number": m.match_number,
"p1": m.p1_team.name if m.p1_team else "TBD",
"p2": m.p2_team.name if m.p2_team else "TBD",
"p1_sets": sum(1 for s in m.sets if s["p1"] > s["p2"]) if m.sets else 0,
"p2_sets": sum(1 for s in m.sets if s["p2"] > s["p1"]) if m.sets else 0,
"ref_name": m.ref_team.name if m.ref_team else m.ref_label,
}
)
return {"court": court.name, "matches": schedule}
+1 -1
View File
@@ -3,4 +3,4 @@ from fastapi import APIRouter
router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
from . import tournaments, settings, teams, courts, matches, report
from . import tournaments, settings, teams, matches, report
-60
View File
@@ -1,60 +0,0 @@
# backend/app/routes/tournaments/courts.py
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import crud, schemas
from ...constants import SUCCESS
from ...core.auth import get_admin_user
from ...core.websocket_manager import send_ws_update
from ...database import get_db
from . import router
@router.get("/{id}/courts", response_model=list[schemas.CourtSchema])
def get_courts(id: str, db: Session = Depends(get_db)):
return crud.get_courts(db, id)
@router.post("/{id}/courts", response_model=schemas.CourtSchema)
async def create_court(
id: str,
court: schemas.CourtCreate,
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
new_court = crud.create_court(db, id, court)
if not new_court:
raise HTTPException(404, "Tournament not found")
await send_ws_update(id)
return new_court
@router.patch("/{id}/courts", response_model=schemas.TournamentDetail)
async def update_courts(
id: str,
courts: list[str],
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
t = crud.update_tournament_courts(db, id, courts)
if not t:
raise HTTPException(404, "Not found")
await send_ws_update(id)
return t
@router.delete("/{id}/courts/{court_id}")
async def delete_court(
id: str,
court_id: int,
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
success = crud.delete_court(db, id, court_id)
if not success:
raise HTTPException(404, "Court or Tournament not found")
await send_ws_update(id)
return SUCCESS
@@ -60,3 +60,17 @@ async def delete_tournament(
await send_ws_update(id)
return SUCCESS
@router.patch("/{id}/courts", response_model=schemas.TournamentOut)
def update_tournament_courts(
id: str,
court_ids: list[int],
db: Session = Depends(get_db),
user: dict = Depends(get_admin_user),
):
"""Updates the global courts assigned to a specific tournament"""
t = crud.update_tournament_courts(db, id, court_ids)
if not t:
raise HTTPException(404, "Tournament not found")
return t
+2 -2
View File
@@ -49,7 +49,7 @@ class MatchOut(BaseModel):
p1_team_id: int | None = None
p2_team_id: int | None = None
winner_team_id: int | None = None
ref_team_id: int | None = None
ref_label: str | None = None
ref_team: TeamSchema | None = None
@@ -71,7 +71,7 @@ class TournamentCreate(BaseModel):
timestamp: datetime
duration: int
teams: list[str]
courts: list[str]
courts: list[int]
class TournamentUpdate(BaseModel):