Added seperate bracket/node logic

This commit is contained in:
2026-02-12 16:03:10 +01:00 Verified
parent 7cbb7faca8
commit bb68e326cd
13 changed files with 540 additions and 530 deletions
+82 -92
View File
@@ -1,26 +1,33 @@
# backend/app/crud.py
from sqlalchemy.orm import Session
from uuid import uuid4
from . import models, schemas, logic
from sqlalchemy.orm import Session
from . import logic, models, schemas
# --- HELPER ---
def _rebuild_bracket(db: Session, t: models.Tournament):
def _rebuild_structure(db: Session, t: models.Tournament):
"""
Internal helper to regenerate matches, refresh the bracket logic,
and update the schedule. Used whenever teams or type changes.
Nukes existing nodes/matches and regenerates them based on current teams.
Used when teams are added/removed.
"""
t.matches = []
db.flush()
t.nodes = []
db.flush()
node_data = logic.generate_bracket_nodes(t)
for nd in node_data:
db.add(models.BracketNode(**nd, tournament_id=t.id))
db.flush()
db.refresh(t)
current_team_names = [team.name for team in t.teams]
match_data = logic.generate_structure(current_team_names, t.type)
t.matches = [models.Match(**m, tournament_id=t.id) for m in match_data]
# 4. Run Logic
logic.refresh_bracket(t)
logic.update_schedule(t)
logic.initialize_seeding(db, t)
logic.update_schedule_times(db, t)
# --- TOURNAMENTS ---
def get_tournaments(db: Session):
return db.query(models.Tournament).all()
@@ -35,7 +42,6 @@ def get_tournament(db: Session, tournament_id: str):
def create_tournament(db: Session, data: schemas.TournamentCreate):
t_id = str(uuid4())[:8]
new_t = models.Tournament(
id=t_id,
name=data.name,
@@ -44,17 +50,21 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
duration=data.duration,
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]
match_data = logic.generate_structure(data.teams, data.type)
new_t.matches = [models.Match(**m, tournament_id=t_id) for m in match_data]
logic.refresh_bracket(new_t)
logic.update_schedule(new_t)
db.add(new_t)
db.commit()
node_data = logic.generate_bracket_nodes(new_t)
for nd in node_data:
db.add(models.BracketNode(**nd, tournament_id=t_id))
db.commit()
db.refresh(new_t)
logic.initialize_seeding(db, new_t)
logic.update_schedule_times(db, new_t)
db.refresh(new_t)
return new_t
@@ -63,7 +73,6 @@ def delete_tournament(db: Session, tournament_id: str) -> bool:
t = get_tournament(db, tournament_id)
if not t:
return False
db.delete(t)
db.commit()
return True
@@ -84,75 +93,15 @@ def update_tournament_details(
setattr(t, key, value)
if type_changed:
_rebuild_bracket(db, t)
_rebuild_structure(db, t)
else:
logic.update_schedule(t)
logic.update_schedule_times(db, t)
db.commit()
db.refresh(t)
return t
def update_tournament_teams(db: Session, tournament_id: str, new_team_names: list[str]):
t = get_tournament(db, tournament_id)
if not t:
return None
current_team_names = [team.name for team in t.teams]
if new_team_names == current_team_names:
return t
t.teams = [models.Team(name=n, tournament_id=t.id) for n in new_team_names]
db.flush()
_rebuild_bracket(db, t)
db.commit()
db.refresh(t)
return t
def update_tournament_courts(
db: Session, tournament_id: str, new_court_names: list[str]
):
t = get_tournament(db, tournament_id)
if not t:
return None
current_court_names = [c.name for c in t.courts]
if set(new_court_names) == set(current_court_names):
return t
t.courts = [models.Court(name=c, tournament_id=t.id) for c in new_court_names]
logic.update_schedule(t)
db.commit()
db.refresh(t)
return t
def get_tournament_matches(db: Session, tournament_id: str):
return (
db.query(models.Match)
.filter(models.Match.tournament_id == tournament_id)
.order_by(models.Match.timestamp, models.Match.court_name)
.all()
)
def get_match(db: Session, tournament_id: str, match_id: str):
return (
db.query(models.Match)
.filter(models.Match.tournament_id == tournament_id)
.filter(models.Match.id == match_id)
.first()
)
# --- TEAMS ---
def get_teams(db: Session, tournament_id: str):
return (
db.query(models.Team).filter(models.Team.tournament_id == tournament_id).all()
@@ -168,18 +117,32 @@ def create_team(db: Session, tournament_id: str, team_data: schemas.TeamCreate):
db.add(new_team)
db.flush()
_rebuild_bracket(db, t)
_rebuild_structure(db, t)
db.commit()
db.refresh(new_team)
return new_team
def delete_team(db: Session, tournament_id: str, team_id: int):
def update_tournament_teams(db: Session, tournament_id: str, new_team_names: list[str]):
t = get_tournament(db, tournament_id)
if not t:
return None
t.teams = [models.Team(name=n, tournament_id=t.id) for n in new_team_names]
db.flush()
_rebuild_structure(db, t)
db.commit()
db.refresh(t)
return t
def delete_team(db: Session, tournament_id: str, team_id: int):
t = get_tournament(db, tournament_id)
if not t:
return None
team = db.get(models.Team, team_id)
if not team or team.tournament_id != tournament_id:
return None
@@ -187,13 +150,12 @@ def delete_team(db: Session, tournament_id: str, team_id: int):
db.delete(team)
db.flush()
_rebuild_bracket(db, t)
_rebuild_structure(db, t)
db.commit()
return True
# --- COURTS ---
def get_courts(db: Session, tournament_id: str):
return (
db.query(models.Court).filter(models.Court.tournament_id == tournament_id).all()
@@ -209,8 +171,7 @@ def create_court(db: Session, tournament_id: str, court_data: schemas.CourtCreat
db.add(new_court)
db.flush()
db.refresh(t)
logic.update_schedule(t)
logic.update_schedule_times(db, t)
db.commit()
db.refresh(new_court)
@@ -221,7 +182,6 @@ def delete_court(db: Session, tournament_id: str, court_id: int):
t = get_tournament(db, tournament_id)
if not t:
return None
court = db.get(models.Court, court_id)
if not court or court.tournament_id != tournament_id:
return None
@@ -229,8 +189,38 @@ def delete_court(db: Session, tournament_id: str, court_id: int):
db.delete(court)
db.flush()
db.refresh(t)
logic.update_schedule(t)
logic.update_schedule_times(db, t)
db.commit()
return True
def update_tournament_courts(
db: Session, tournament_id: str, new_court_names: list[str]
):
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]
db.flush()
logic.update_schedule_times(db, t)
db.commit()
db.refresh(t)
return t
def get_tournament_matches(db: Session, tournament_id: str):
return (
db.query(models.Match).filter(models.Match.tournament_id == tournament_id).all()
)
def get_match(db: Session, tournament_id: str, match_id: str):
return (
db.query(models.Match)
.filter(models.Match.tournament_id == tournament_id)
.filter(models.Match.id == match_id)
.first()
)
+251 -259
View File
@@ -1,10 +1,19 @@
# backend/app/logic.py
import math
from datetime import datetime, timedelta
from typing import List, Dict, Any
from typing import Optional
from uuid import uuid4
from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes
from .models import Tournament
from sqlalchemy.orm import Session
from . import models
from .constants import (
BracketType,
MatchSourceType,
MatchStatus,
TournamentTypes,
WinnerSide,
)
def get_seeded_positions(num_slots, teams):
@@ -15,327 +24,310 @@ def get_seeded_positions(num_slots, teams):
next_seeds.append(s)
next_seeds.append(2 * len(seeds) + 1 - s)
seeds = next_seeds
return [teams[s - 1] if s <= len(teams) else "BYE" for s in seeds]
return [teams[s - 1] if s <= len(teams) else None for s in seeds]
def generate_structure(
teams: List[str], type: TournamentTypes = TournamentTypes.DOUBLE
) -> List[Dict[str, Any]]:
def generate_bracket_nodes(t: models.Tournament) -> list[dict]:
"""
Generates the skeleton (BracketNodes). Does NOT create Matches.
"""
teams = t.teams
count = len(teams)
if count < 2:
return []
power = math.ceil(math.log2(count)) if count > 0 else 1
size = 2**power
seeded_teams = get_seeded_positions(size, teams)
class Node:
def __init__(self, id, bracket: BracketType, round_n: int):
self.id = str(id)
nodes = []
display_counter = 1
def make_id():
return str(uuid4())
class NodeRef:
def __init__(self, bracket, round_n):
self.id = make_id()
self.bracket = bracket
self.round = round_n
self.p1: str | None = None
self.p2: str | None = None
self.display_num = 0
self.winner_next_match_id: str | None = None
self.loser_next_match_id: str | None = None
self.previous_match_p1_id: str | None = None
self.previous_match_p2_id: str | None = None
self.next_win: Optional["NodeRef"] = None
self.next_loss: Optional["NodeRef"] = None
self.src_p1: Optional["NodeRef"] = None
self.src_p2: Optional["NodeRef"] = None
self.source_p1_type: MatchSourceType | None = None
self.source_p2_type: MatchSourceType | None = None
self.src_p1_type: Optional[MatchSourceType] = None
self.src_p2_type: Optional[MatchSourceType] = None
def to_dict(self):
return {
"id": self.id,
# Pass Enum OBJECTS, not strings. SQLAlchemy handles the rest.
"bracket": self.bracket,
"round": self.round,
"p1_name": self.p1,
"p2_name": self.p2,
"status": MatchStatus.PENDING,
"previous_match_p1_id": self.previous_match_p1_id,
"previous_match_p2_id": self.previous_match_p2_id,
"source_p1_type": self.source_p1_type,
"source_p2_type": self.source_p2_type,
"winner_next_match_id": self.winner_next_match_id,
"loser_next_match_id": self.loser_next_match_id,
"bracket_type": self.bracket,
"round_number": self.round,
"display_number": self.display_num,
"winner_next_node_id": self.next_win.id if self.next_win else None,
"loser_next_node_id": self.next_loss.id if self.next_loss else None,
"source_p1_node_id": self.src_p1.id if self.src_p1 else None,
"source_p2_node_id": self.src_p2.id if self.src_p2 else None,
"source_p1_type": self.src_p1_type,
"source_p2_type": self.src_p2_type,
}
nodes: List[Node] = []
match_counter = 1
def create_node(bracket: BracketType, round_n: int):
nonlocal match_counter
n = Node(match_counter, bracket, round_n)
match_counter += 1
nodes.append(n)
return n
# --- Winners Bracket ---
wb_rounds = power
wb_matches = {r: [] for r in range(1, wb_rounds + 1)}
wb_layers = {r: [] for r in range(1, wb_rounds + 1)}
for r in range(1, wb_rounds + 1):
for _ in range(size // (2**r)):
wb_matches[r].append(create_node(BracketType.WINNERS, r))
n = NodeRef(BracketType.WINNERS, r)
wb_layers[r].append(n)
# Link Winners
for r in range(1, wb_rounds):
for i, m in enumerate(wb_matches[r]):
target = wb_matches[r + 1][i // 2]
m.winner_next_match_id = target.id
for i, node in enumerate(wb_layers[r]):
target = wb_layers[r + 1][i // 2]
node.next_win = target
if i % 2 == 0:
target.previous_match_p1_id = m.id
target.source_p1_type = MatchSourceType.WINNER
target.src_p1 = node
target.src_p1_type = MatchSourceType.WINNER
else:
target.previous_match_p2_id = m.id
target.source_p2_type = MatchSourceType.WINNER
target.src_p2 = node
target.src_p2_type = MatchSourceType.WINNER
for i, m in enumerate(wb_matches[1]):
m.p1 = seeded_teams[i * 2]
m.p2 = seeded_teams[i * 2 + 1]
# --- Losers Bracket ---
if type == TournamentTypes.DOUBLE and size >= 4:
lb_layers = {}
if t.type == TournamentTypes.DOUBLE and size >= 4:
lb_rounds = (wb_rounds - 1) * 2
lb_matches = {r: [] for r in range(1, lb_rounds + 1)}
current_count = size // 4
lb_layers = {r: [] for r in range(1, lb_rounds + 1)}
current_count = size // 4
for r in range(1, lb_rounds + 1):
for _ in range(current_count):
lb_matches[r].append(create_node(BracketType.LOSERS, r))
n = NodeRef(BracketType.LOSERS, r)
lb_layers[r].append(n)
if r % 2 == 0:
current_count //= 2
# Link Losers Internal
for r in range(1, lb_rounds):
for i, m in enumerate(lb_matches[r]):
target = (
lb_matches[r + 1][i] if r % 2 != 0 else lb_matches[r + 1][i // 2]
)
m.winner_next_match_id = target.id
for i, node in enumerate(lb_layers[r]):
target = lb_layers[r + 1][i] if r % 2 != 0 else lb_layers[r + 1][i // 2]
node.next_win = target
if r % 2 != 0:
target.previous_match_p1_id = m.id
target.source_p1_type = MatchSourceType.WINNER
target.src_p1 = node
target.src_p1_type = MatchSourceType.WINNER
else:
if i % 2 == 0:
target.previous_match_p1_id = m.id
target.source_p1_type = MatchSourceType.WINNER
target.src_p1 = node
target.src_p1_type = MatchSourceType.WINNER
else:
target.previous_match_p2_id = m.id
target.source_p2_type = MatchSourceType.WINNER
target.src_p2 = node
target.src_p2_type = MatchSourceType.WINNER
# Link Losers Drop-down
# Link Drop-down (Winners -> Losers)
for r in range(1, wb_rounds):
drop_round = 1 if r == 1 else (r - 1) * 2
wb_layer = wb_matches[r]
lb_layer = lb_matches[drop_round]
wb_layer_nodes = wb_layers[r]
lb_layer_nodes = lb_layers[drop_round]
for i, wb_m in enumerate(wb_layer):
target = (
lb_layer[i // 2]
if r == 1
else (lb_layer[i] if i < len(lb_layer) else lb_layer[-1])
)
slot = "p1" if (r == 1 and i % 2 == 0) else "p2"
wb_m.loser_next_match_id = target.id
if slot == "p1":
target.previous_match_p1_id = wb_m.id
target.source_p1_type = MatchSourceType.LOSER
for i, wb_node in enumerate(wb_layer_nodes):
target = None
if r == 1:
target = lb_layer_nodes[i // 2]
else:
target.previous_match_p2_id = wb_m.id
target.source_p2_type = MatchSourceType.LOSER
if i < len(lb_layer_nodes):
target = lb_layer_nodes[i]
else:
target = lb_layer_nodes[-1]
# Finals Linking
wb_final = wb_matches[wb_rounds][0]
lb_final = lb_matches[lb_rounds][0]
wb_node.next_loss = target
wb_final.loser_next_match_id = lb_final.id
lb_final.previous_match_p2_id = wb_final.id
lb_final.source_p2_type = MatchSourceType.LOSER
final = create_node(BracketType.FINALS, 1)
wb_final.winner_next_match_id = final.id
lb_final.winner_next_match_id = final.id
final.previous_match_p1_id = wb_final.id
final.source_p1_type = MatchSourceType.WINNER
final.previous_match_p2_id = lb_final.id
final.source_p2_type = MatchSourceType.WINNER
return [n.to_dict() for n in nodes]
def refresh_bracket(t_obj: Tournament):
matches_map = {m.id: m for m in t_obj.matches}
for _ in range(20):
for m in t_obj.matches:
def resolve(src_id, type_):
if not src_id or src_id not in matches_map:
return None
src = matches_map[src_id]
if type_ == MatchSourceType.WINNER:
return src.winner
if type_ == MatchSourceType.LOSER:
if src.winner == "BYE":
return "BYE"
if src.winner:
return src.p1_name if src.winner == src.p2_name else src.p2_name
return None
return None
if m.previous_match_p1_id:
m.p1_name = resolve(m.previous_match_p1_id, m.source_p1_type)
if m.previous_match_p2_id:
m.p2_name = resolve(m.previous_match_p2_id, m.source_p2_type)
# BYE Auto-Win
if not m.winner and (m.p1_name == "BYE" or m.p2_name == "BYE"):
if m.p1_name == "BYE" and m.p2_name == "BYE":
m.winner = "BYE"
elif m.p1_name == "BYE":
m.winner = m.p2_name
slot = WinnerSide.P1 if (r == 1 and i % 2 == 0) else WinnerSide.P2
if slot == WinnerSide.P1:
target.src_p1 = wb_node
target.src_p1_type = MatchSourceType.LOSER
else:
m.winner = m.p1_name
m.status = MatchStatus.FINISHED
target.src_p2 = wb_node
target.src_p2_type = MatchSourceType.LOSER
# Reset Logic
if m.status == MatchStatus.FINISHED and m.winner != "BYE":
has_p1 = bool(m.p1_name)
has_p2 = bool(m.p2_name)
if (
not has_p1
or not has_p2
or (m.winner != m.p1_name and m.winner != m.p2_name)
):
m.winner = None
m.status = MatchStatus.PENDING
m.sets = []
# Finals
final_node = NodeRef(BracketType.FINALS, 1)
wb_final = wb_layers[wb_rounds][0]
lb_final = lb_layers[lb_rounds][0]
# Numbering
display_counter = 1
sorted_matches = sorted(
t_obj.matches, key=lambda x: int(x.id) if x.id.isdigit() else 999
wb_final.next_loss = lb_final
wb_final.next_win = final_node
lb_final.next_win = final_node
final_node.src_p1 = wb_final
final_node.src_p1_type = MatchSourceType.WINNER
final_node.src_p2 = lb_final
final_node.src_p2_type = MatchSourceType.WINNER
all_nodes = []
for r in sorted(wb_layers.keys()):
all_nodes.extend(wb_layers[r])
for r in sorted(lb_layers.keys()):
all_nodes.extend(lb_layers[r])
all_nodes.append(final_node)
else:
all_nodes = []
for r in sorted(wb_layers.keys()):
all_nodes.extend(wb_layers[r])
for n in all_nodes:
n.display_num = display_counter
display_counter += 1
nodes.append(n.to_dict())
return nodes
def initialize_seeding(db: Session, t: models.Tournament):
seeded_teams = get_seeded_positions(
2 ** math.ceil(math.log2(t.team_count)), t.teams
)
for m in sorted_matches:
if m.winner == "BYE" or m.p1_name == "BYE" or m.p2_name == "BYE":
m.number = None
else:
m.number = display_counter
display_counter += 1
# NO LABEL GENERATION HERE - FRONTEND HANDLES IT
r1_nodes = [
n
for n in t.nodes
if n.bracket_type == BracketType.WINNERS and n.round_number == 1
]
r1_nodes.sort(key=lambda x: x.display_number)
for i, node in enumerate(r1_nodes):
t1 = seeded_teams[i * 2]
t2 = seeded_teams[i * 2 + 1]
node.p1_team_id = t1.id if t1 else None
node.p2_team_id = t2.id if t2 else None
advance_flow(db, t)
def update_schedule(t_obj: Tournament):
match_map = {m.id: m for m in t_obj.matches}
depth_cache = {}
def advance_flow(db: Session, t: models.Tournament):
changes = True
while changes:
changes = False
def get_depth(mid):
if mid not in match_map:
return 0
if mid in depth_cache:
return depth_cache[mid]
m = match_map[mid]
d = 1 + max(
get_depth(m.winner_next_match_id) if m.winner_next_match_id else 0,
get_depth(m.loser_next_match_id) if m.loser_next_match_id else 0,
)
depth_cache[mid] = d
return d
for node in t.nodes:
if node.round_number == 1 and node.bracket_type == BracketType.WINNERS:
if node.p1_team_id and not node.p2_team_id:
if _move_team(
db,
t,
node.p1_team_id,
node.winner_next_node_id,
MatchSourceType.WINNER,
):
pass
elif node.p2_team_id and not node.p1_team_id:
if _move_team(
db,
t,
node.p2_team_id,
node.winner_next_node_id,
MatchSourceType.WINNER,
):
pass
criticality_map = {}
for m in t_obj.matches:
criticality_map[m.id] = get_depth(m.id)
# 2. MATCH CREATION
if node.p1_team_id and node.p2_team_id:
if not node.match:
m_id = str(uuid4())
new_match = models.Match(
id=m_id,
tournament_id=t.id,
node_id=node.id,
p1_team_id=node.p1_team_id,
p2_team_id=node.p2_team_id,
status=MatchStatus.PENDING,
court_id=node.planned_court_id,
start_time=node.planned_start_time,
)
db.add(new_match)
db.commit()
db.refresh(node)
changes = True
start_time = t_obj.timestamp
duration = t_obj.duration
# 3. MATCH RESULT PROPAGATION
elif (
node.match.status == MatchStatus.FINISHED
and node.match.winner_team_id
):
winner_id = node.match.winner_team_id
loser_id = (
node.match.p1_team_id
if winner_id == node.match.p2_team_id
else node.match.p2_team_id
)
finish_times: Dict[str, datetime] = {}
court_timers: Dict[str, datetime] = {c.name: start_time for c in t_obj.courts}
if _move_team(
db,
t,
winner_id,
node.winner_next_node_id,
MatchSourceType.WINNER,
):
changes = True
if _move_team(
db, t, loser_id, node.loser_next_node_id, MatchSourceType.LOSER
):
changes = True
unscheduled = []
# 1. Initialize
for m in t_obj.matches:
if m.winner == "BYE" or m.p1_name == "BYE" or m.p2_name == "BYE":
finish_times[m.id] = start_time
m.status = MatchStatus.FINISHED
elif m.status == MatchStatus.FINISHED:
match_start = m.timestamp if m.timestamp else start_time
fin = match_start + timedelta(minutes=duration)
finish_times[m.id] = fin
if m.court_name and m.court_name in court_timers:
if fin > court_timers[m.court_name]:
court_timers[m.court_name] = fin
else:
m.timestamp = None
m.court_name = None
m.status = MatchStatus.PENDING
unscheduled.append(m)
def _move_team(db, t, team_id, target_node_id, source_type):
if not target_node_id or not team_id:
return False
if not court_timers:
target = next((n for n in t.nodes if n.id == target_node_id), None)
if not target:
return False
updated = False
if target.source_p1_type == source_type and not target.p1_team_id:
target.p1_team_id = team_id
updated = True
elif target.source_p2_type == source_type and not target.p2_team_id:
target.p2_team_id = team_id
updated = True
elif not target.p1_team_id:
target.p1_team_id = team_id
updated = True
elif not target.p2_team_id:
target.p2_team_id = team_id
updated = True
if updated:
db.add(target)
db.commit()
return updated
def update_schedule_times(db: Session, t: models.Tournament):
nodes = sorted(t.nodes, key=lambda n: n.display_number)
current_time = t.timestamp
courts = t.courts
if not courts:
return
# 2. Schedule
loop = len(t_obj.matches) * 2
while unscheduled and loop > 0:
loop -= 1
best_court = min(court_timers, key=lambda k: court_timers[k])
current_time_slot = court_timers[best_court]
court_timers: dict[int, datetime] = {c.id: current_time for c in courts}
ready = []
for m in unscheduled:
p1_r = (
finish_times.get(m.previous_match_p1_id, start_time)
if m.previous_match_p1_id
else start_time
)
p2_r = (
finish_times.get(m.previous_match_p2_id, start_time)
if m.previous_match_p2_id
else start_time
)
for node in nodes:
if node.planned_start_time:
continue
if max(p1_r, p2_r) <= current_time_slot:
ready.append(m)
best_court_id = min(court_timers, key=lambda k: court_timers[k])
start = court_timers[best_court_id]
if ready:
ready.sort(key=lambda x: (-criticality_map.get(x.id, 0), x.round))
cand = ready[0]
cand.court_name = best_court
cand.timestamp = current_time_slot
cand.status = MatchStatus.SCHEDULED
node.planned_court_id = best_court_id
node.planned_start_time = start
fin = current_time_slot + timedelta(minutes=duration)
finish_times[cand.id] = fin
court_timers[best_court] = fin
unscheduled.remove(cand)
else:
next_wake = None
for m in unscheduled:
p1_r = (
finish_times.get(m.previous_match_p1_id, start_time)
if m.previous_match_p1_id
else start_time
)
p2_r = (
finish_times.get(m.previous_match_p2_id, start_time)
if m.previous_match_p2_id
else start_time
)
ready_at = max(p1_r, p2_r)
if ready_at > current_time_slot:
if next_wake is None or ready_at < next_wake:
next_wake = ready_at
if next_wake:
court_timers[best_court] = next_wake
else:
break
court_timers[best_court_id] = start + timedelta(minutes=t.duration)
if node.match and node.match.status == MatchStatus.PENDING:
node.match.court_id = best_court_id
node.match.timestamp = start
db.commit()
+78 -49
View File
@@ -7,13 +7,7 @@ from sqlalchemy import Enum as SqlEnum
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .constants import (
TournamentTypes,
MatchSourceType,
MatchStatus,
WinnerSide,
BracketType,
)
from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes
from .database import Base
@@ -33,11 +27,17 @@ class Tournament(Base):
courts: Mapped[list["Court"]] = relationship(
"Court", back_populates="tournament", cascade="all, delete-orphan"
)
# The Structure (Skeleton)
nodes: Mapped[list["BracketNode"]] = relationship(
"BracketNode", back_populates="tournament", cascade="all, delete-orphan"
)
# The Events (Real Games)
matches: Mapped[list["Match"]] = relationship(
"Match", back_populates="tournament", cascade="all, delete-orphan"
)
# --- ADD THESE PROPERTIES ---
@property
def team_count(self) -> int:
return len(self.teams)
@@ -65,57 +65,86 @@ class Court(Base):
tournament: Mapped["Tournament"] = relationship(back_populates="courts")
class BracketNode(Base):
__tablename__ = "bracket_nodes"
id: Mapped[str] = mapped_column(String, primary_key=True)
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
bracket_type: Mapped[BracketType] = mapped_column(SqlEnum(BracketType))
round_number: Mapped[int] = mapped_column(Integer)
display_number: Mapped[int] = mapped_column(Integer)
# --- Planning / Scheduling ---
planned_court_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("courts.id"), nullable=True
)
planned_start_time: Mapped[Optional[datetime]] = mapped_column(
DateTime, nullable=True
)
# --- Flow Logic ---
source_p1_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
source_p2_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
source_p1_type: Mapped[Optional[MatchSourceType]] = mapped_column(
SqlEnum(MatchSourceType), nullable=True
)
source_p2_type: Mapped[Optional[MatchSourceType]] = mapped_column(
SqlEnum(MatchSourceType), nullable=True
)
# --- Next Step ---
winner_next_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
loser_next_node_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# --- Current State ---
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
)
# Link to actual match (only exists if active)
match: Mapped[Optional["Match"]] = relationship(
"Match", back_populates="node", uselist=False
)
tournament: Mapped["Tournament"] = relationship(back_populates="nodes")
court: Mapped[Optional["Court"]] = relationship()
# Helper to get team names quickly
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
class Match(Base):
__tablename__ = "matches"
id: Mapped[str] = mapped_column(String, primary_key=True)
tournament_id: Mapped[str] = mapped_column(
ForeignKey("tournaments.id"), primary_key=True
tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
node_id: Mapped[str] = mapped_column(ForeignKey("bracket_nodes.id"), unique=True)
court_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("courts.id"), nullable=True
)
# --- Structural Info ---
bracket: Mapped[BracketType] = mapped_column(
SqlEnum(BracketType, native_enum=False)
)
round: Mapped[int] = mapped_column(Integer)
number: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# --- Scheduling ---
timestamp: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
court_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# --- Player Info ---
p1_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
p2_name: Mapped[Optional[str]] = mapped_column(String, nullable=True)
winner: Mapped[Optional[str]] = mapped_column(String, nullable=True)
start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
status: Mapped[MatchStatus] = mapped_column(
SqlEnum(MatchStatus, native_enum=False), default=MatchStatus.PENDING
SqlEnum(MatchStatus), default=MatchStatus.PENDING
)
p1_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"))
p2_team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"))
sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
previous_match_p1_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
previous_match_p2_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
source_p1_type: Mapped[Optional[MatchSourceType]] = mapped_column(
SqlEnum(MatchSourceType, native_enum=False), nullable=True
)
source_p2_type: Mapped[Optional[MatchSourceType]] = mapped_column(
SqlEnum(MatchSourceType, native_enum=False), nullable=True
winner_team_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("teams.id"), nullable=True
)
winner_next_match_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
loser_next_match_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
node: Mapped["BracketNode"] = relationship(back_populates="match")
tournament: Mapped["Tournament"] = relationship(back_populates="matches")
court: Mapped[Optional["Court"]] = relationship()
@property
def winner_side(self) -> WinnerSide:
if not self.winner:
return WinnerSide.NONE
if self.winner == self.p1_name:
return WinnerSide.P1
if self.winner == self.p2_name:
return WinnerSide.P2
return WinnerSide.NONE
p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
+1 -1
View File
@@ -3,4 +3,4 @@ from fastapi import APIRouter
router = APIRouter(prefix="/tournaments", tags=["Tournaments"])
from . import report, courts, matches, teams, tournaments
from . import tournaments, teams, courts, matches, report, bracket
+15
View File
@@ -0,0 +1,15 @@
# backend/app/routes/tournaments/bracket.py
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import crud, schemas
from ...database import get_db
from . import router
@router.get("/{id}/bracket", response_model=list[schemas.BracketNodeOut])
def get_tournament_bracket(id: str, db: Session = Depends(get_db)):
t = crud.get_tournament(db, id)
if not t:
raise HTTPException(404, "Tournament not found")
return t.nodes
+1 -2
View File
@@ -1,12 +1,11 @@
# 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.websocket_manager import send_ws_update
from ...core.auth import get_current_user
from ...core.websocket_manager import send_ws_update
from ...database import get_db
from . import router
+13 -17
View File
@@ -13,7 +13,6 @@ from ...database import get_db
from . import router
# --- Helper: Centralize Auth Logic ---
def _check_auth(t: models.Tournament, user: Optional[str], code: Optional[str]):
is_admin = user is not None
code_matches = code is not None and str(code).strip() == str(t.code).strip()
@@ -44,11 +43,9 @@ async def report_score(
raise HTTPException(400, "No sets submitted")
_apply_score(match, report.sets)
flag_modified(match, "sets")
logic.refresh_bracket(t)
logic.update_schedule(t)
db.commit()
logic.advance_flow(db, t)
await send_ws_update(id)
return SUCCESS
@@ -63,8 +60,9 @@ async def edit_score(
user: Optional[str] = Depends(get_optional_user),
):
"""
Allows correcting a score without resetting the match status logic entirely,
or just re-applying the new sets.
Allows correcting a score.
Note: If the winner changes, 'advance_flow' might need to handle
undoing previous advancements, but for now we just re-run the flow.
"""
t = crud.get_tournament(db, id)
if not t:
@@ -80,9 +78,8 @@ async def edit_score(
_apply_score(match, report.sets)
flag_modified(match, "sets")
logic.refresh_bracket(t)
logic.update_schedule(t)
db.commit()
logic.advance_flow(db, t)
await send_ws_update(id)
return SUCCESS
@@ -106,14 +103,11 @@ async def clear_score(
if not match:
raise HTTPException(404, "Match not found")
match.winner = None
match.status = MatchStatus.PENDING.value
match.winner_team_id = None
match.status = MatchStatus.PENDING
match.sets = []
flag_modified(match, "sets")
logic.refresh_bracket(t)
logic.update_schedule(t)
db.commit()
await send_ws_update(id)
@@ -129,9 +123,9 @@ def _apply_score(match: models.Match, sets: List[schemas.SetScore]):
p2_wins = sum(1 for s in sets if s.p2 > s.p1)
if p1_wins > p2_wins:
match.winner = match.p1_name
match.winner_team_id = match.p1_team_id
elif p2_wins > p1_wins:
match.winner = match.p2_name
match.winner_team_id = match.p2_team_id
else:
p1_points = sum(s.p1 for s in sets)
p2_points = sum(s.p2 for s in sets)
@@ -139,7 +133,9 @@ def _apply_score(match: models.Match, sets: List[schemas.SetScore]):
if p1_points == p2_points:
raise HTTPException(400, "Absolute tie: Sets and Points are equal.")
match.winner = match.p1_name if p1_points > p2_points else match.p2_name
match.winner_team_id = (
match.p1_team_id if p1_points > p2_points else match.p2_team_id
)
match.status = MatchStatus.FINISHED.value
match.status = MatchStatus.FINISHED
match.sets = [s.model_dump() for s in sets]
+1 -2
View File
@@ -1,12 +1,11 @@
# backend/app/routes/tournaments/teams.py
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from ... import crud, schemas
from ...constants import SUCCESS
from ...core.websocket_manager import send_ws_update
from ...core.auth import get_current_user
from ...core.websocket_manager import send_ws_update
from ...database import get_db
from . import router
@@ -10,7 +10,7 @@ from ...core.auth import get_current_user
from . import router
@router.post("", response_model=schemas.TournamentOut)
@router.post("", response_model=schemas.TournamentUpdateResponse)
async def create_tournament(
data: schemas.TournamentCreate,
db: Session = Depends(get_db),
+36 -56
View File
@@ -1,15 +1,9 @@
# backend/app/schemas.py
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict
from .constants import (
BracketType,
MatchSourceType,
MatchStatus,
TournamentTypes,
WinnerSide,
)
from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes
class TeamSchema(BaseModel):
@@ -38,53 +32,43 @@ class SetScore(BaseModel):
class ScoreReport(BaseModel):
id: str
code: str | None = None
sets: list[SetScore]
class MatchOut(BaseModel):
id: str
number: int | None = None
timestamp: datetime | None = None
court: str | None = Field(
default=None, validation_alias="court_name", serialization_alias="court"
)
bracket: BracketType
round: int
p1: str | None = Field(
default=None, validation_alias="p1_name", serialization_alias="p1"
)
p2: str | None = Field(
default=None, validation_alias="p2_name", serialization_alias="p2"
)
node_id: str
status: MatchStatus
timestamp: datetime | None = None
court: CourtSchema | None = None
p1_team: TeamSchema | None = None
p2_team: TeamSchema | None = None
winner_team_id: int | None = None
sets: list[SetScore] = []
previous_match_p1_id: str | None = Field(
default=None, serialization_alias="source_p1"
)
previous_match_p2_id: str | None = Field(
default=None, serialization_alias="source_p2"
)
model_config = ConfigDict(from_attributes=True)
class BracketNodeOut(BaseModel):
id: str
display_number: int
bracket_type: BracketType
round_number: int
planned_start_time: datetime | None = None
planned_court_id: int | None = None
source_p1_node_id: str | None = None
source_p2_node_id: str | None = None
source_p1_type: MatchSourceType | None = None
source_p2_type: MatchSourceType | None = None
winner_next_node_id: str | None = None
loser_next_node_id: str | None = None
p1_team: TeamSchema | None = None
p2_team: TeamSchema | None = None
match: MatchOut | None = None
winner_next_match_id: str | None = Field(
default=None, serialization_alias="next_win"
)
loser_next_match_id: str | None = Field(
default=None, serialization_alias="next_loss"
)
winner_side: WinnerSide = WinnerSide.NONE
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
model_config = ConfigDict(from_attributes=True)
class TournamentCreate(BaseModel):
@@ -97,19 +81,7 @@ class TournamentCreate(BaseModel):
courts: list[str]
class TournamentOut(BaseModel):
id: str
name: str
timestamp: datetime
type: TournamentTypes
team_count: int
court_count: int
model_config = ConfigDict(from_attributes=True)
class TournamentUpdate(BaseModel):
name: str | None = None
code: str | None = None
timestamp: datetime | None = None
@@ -117,15 +89,23 @@ class TournamentUpdate(BaseModel):
type: TournamentTypes | None = None
class TournamentOut(BaseModel):
id: str
name: str
timestamp: datetime
type: TournamentTypes
team_count: int
court_count: int
model_config = ConfigDict(from_attributes=True)
class TournamentDetail(BaseModel):
id: str
name: str
code: str
timestamp: datetime
type: TournamentTypes
teams: list[TeamSchema]
courts: list[CourtSchema]
matches: list[MatchOut]
model_config = ConfigDict(from_attributes=True)