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 # backend/app/crud.py
from sqlalchemy.orm import Session
from uuid import uuid4 from uuid import uuid4
from . import models, schemas, logic
from sqlalchemy.orm import Session
from . import logic, models, schemas
# --- HELPER --- def _rebuild_structure(db: Session, t: models.Tournament):
def _rebuild_bracket(db: Session, t: models.Tournament):
""" """
Internal helper to regenerate matches, refresh the bracket logic, Nukes existing nodes/matches and regenerates them based on current teams.
and update the schedule. Used whenever teams or type changes. 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) 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.initialize_seeding(db, t)
logic.refresh_bracket(t) logic.update_schedule_times(db, t)
logic.update_schedule(t)
# --- TOURNAMENTS ---
def get_tournaments(db: Session): def get_tournaments(db: Session):
return db.query(models.Tournament).all() 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): def create_tournament(db: Session, data: schemas.TournamentCreate):
t_id = str(uuid4())[:8] t_id = str(uuid4())[:8]
new_t = models.Tournament( new_t = models.Tournament(
id=t_id, id=t_id,
name=data.name, name=data.name,
@@ -44,17 +50,21 @@ def create_tournament(db: Session, data: schemas.TournamentCreate):
duration=data.duration, duration=data.duration,
type=data.type, type=data.type,
) )
new_t.teams = [models.Team(name=n) for n in data.teams] 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 = [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.add(new_t)
db.commit() 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) db.refresh(new_t)
return new_t return new_t
@@ -63,7 +73,6 @@ def delete_tournament(db: Session, tournament_id: str) -> bool:
t = get_tournament(db, tournament_id) t = get_tournament(db, tournament_id)
if not t: if not t:
return False return False
db.delete(t) db.delete(t)
db.commit() db.commit()
return True return True
@@ -84,75 +93,15 @@ def update_tournament_details(
setattr(t, key, value) setattr(t, key, value)
if type_changed: if type_changed:
_rebuild_bracket(db, t) _rebuild_structure(db, t)
else: else:
logic.update_schedule(t) logic.update_schedule_times(db, t)
db.commit() db.commit()
db.refresh(t) db.refresh(t)
return 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): def get_teams(db: Session, tournament_id: str):
return ( return (
db.query(models.Team).filter(models.Team.tournament_id == tournament_id).all() 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.add(new_team)
db.flush() db.flush()
_rebuild_bracket(db, t) _rebuild_structure(db, t)
db.commit() db.commit()
db.refresh(new_team) db.refresh(new_team)
return 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) t = get_tournament(db, tournament_id)
if not t: if not t:
return None 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) team = db.get(models.Team, team_id)
if not team or team.tournament_id != tournament_id: if not team or team.tournament_id != tournament_id:
return None return None
@@ -187,13 +150,12 @@ def delete_team(db: Session, tournament_id: str, team_id: int):
db.delete(team) db.delete(team)
db.flush() db.flush()
_rebuild_bracket(db, t) _rebuild_structure(db, t)
db.commit() db.commit()
return True return True
# --- COURTS ---
def get_courts(db: Session, tournament_id: str): def get_courts(db: Session, tournament_id: str):
return ( return (
db.query(models.Court).filter(models.Court.tournament_id == tournament_id).all() 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.add(new_court)
db.flush() db.flush()
db.refresh(t) logic.update_schedule_times(db, t)
logic.update_schedule(t)
db.commit() db.commit()
db.refresh(new_court) 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) t = get_tournament(db, tournament_id)
if not t: if not t:
return None return None
court = db.get(models.Court, court_id) court = db.get(models.Court, court_id)
if not court or court.tournament_id != tournament_id: if not court or court.tournament_id != tournament_id:
return None return None
@@ -229,8 +189,38 @@ def delete_court(db: Session, tournament_id: str, court_id: int):
db.delete(court) db.delete(court)
db.flush() db.flush()
db.refresh(t) logic.update_schedule_times(db, t)
logic.update_schedule(t)
db.commit() db.commit()
return True 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()
)
+252 -260
View File
@@ -1,10 +1,19 @@
# backend/app/logic.py # backend/app/logic.py
import math import math
from datetime import datetime, timedelta 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 sqlalchemy.orm import Session
from .models import Tournament
from . import models
from .constants import (
BracketType,
MatchSourceType,
MatchStatus,
TournamentTypes,
WinnerSide,
)
def get_seeded_positions(num_slots, teams): 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(s)
next_seeds.append(2 * len(seeds) + 1 - s) next_seeds.append(2 * len(seeds) + 1 - s)
seeds = next_seeds 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( def generate_bracket_nodes(t: models.Tournament) -> list[dict]:
teams: List[str], type: TournamentTypes = TournamentTypes.DOUBLE """
) -> List[Dict[str, Any]]: Generates the skeleton (BracketNodes). Does NOT create Matches.
"""
teams = t.teams
count = len(teams) count = len(teams)
if count < 2: if count < 2:
return [] return []
power = math.ceil(math.log2(count)) if count > 0 else 1 power = math.ceil(math.log2(count)) if count > 0 else 1
size = 2**power size = 2**power
seeded_teams = get_seeded_positions(size, teams)
class Node: nodes = []
def __init__(self, id, bracket: BracketType, round_n: int): display_counter = 1
self.id = str(id)
def make_id():
return str(uuid4())
class NodeRef:
def __init__(self, bracket, round_n):
self.id = make_id()
self.bracket = bracket self.bracket = bracket
self.round = round_n self.round = round_n
self.p1: str | None = None self.display_num = 0
self.p2: str | None = None
self.winner_next_match_id: str | None = None self.next_win: Optional["NodeRef"] = None
self.loser_next_match_id: str | None = None self.next_loss: Optional["NodeRef"] = None
self.previous_match_p1_id: str | None = None self.src_p1: Optional["NodeRef"] = None
self.previous_match_p2_id: str | None = None self.src_p2: Optional["NodeRef"] = None
self.source_p1_type: MatchSourceType | None = None self.src_p1_type: Optional[MatchSourceType] = None
self.source_p2_type: MatchSourceType | None = None self.src_p2_type: Optional[MatchSourceType] = None
def to_dict(self): def to_dict(self):
return { return {
"id": self.id, "id": self.id,
# Pass Enum OBJECTS, not strings. SQLAlchemy handles the rest. "bracket_type": self.bracket,
"bracket": self.bracket, "round_number": self.round,
"round": self.round, "display_number": self.display_num,
"p1_name": self.p1, "winner_next_node_id": self.next_win.id if self.next_win else None,
"p2_name": self.p2, "loser_next_node_id": self.next_loss.id if self.next_loss else None,
"status": MatchStatus.PENDING, "source_p1_node_id": self.src_p1.id if self.src_p1 else None,
"previous_match_p1_id": self.previous_match_p1_id, "source_p2_node_id": self.src_p2.id if self.src_p2 else None,
"previous_match_p2_id": self.previous_match_p2_id, "source_p1_type": self.src_p1_type,
"source_p1_type": self.source_p1_type, "source_p2_type": self.src_p2_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,
} }
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_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 r in range(1, wb_rounds + 1):
for _ in range(size // (2**r)): 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 # Link Winners
for r in range(1, wb_rounds): for r in range(1, wb_rounds):
for i, m in enumerate(wb_matches[r]): for i, node in enumerate(wb_layers[r]):
target = wb_matches[r + 1][i // 2] target = wb_layers[r + 1][i // 2]
node.next_win = target
m.winner_next_match_id = target.id
if i % 2 == 0: if i % 2 == 0:
target.previous_match_p1_id = m.id target.src_p1 = node
target.source_p1_type = MatchSourceType.WINNER target.src_p1_type = MatchSourceType.WINNER
else: else:
target.previous_match_p2_id = m.id target.src_p2 = node
target.source_p2_type = MatchSourceType.WINNER target.src_p2_type = MatchSourceType.WINNER
for i, m in enumerate(wb_matches[1]): lb_layers = {}
m.p1 = seeded_teams[i * 2] if t.type == TournamentTypes.DOUBLE and size >= 4:
m.p2 = seeded_teams[i * 2 + 1]
# --- Losers Bracket ---
if type == TournamentTypes.DOUBLE and size >= 4:
lb_rounds = (wb_rounds - 1) * 2 lb_rounds = (wb_rounds - 1) * 2
lb_matches = {r: [] for r in range(1, lb_rounds + 1)} lb_layers = {r: [] for r in range(1, lb_rounds + 1)}
current_count = size // 4
current_count = size // 4
for r in range(1, lb_rounds + 1): for r in range(1, lb_rounds + 1):
for _ in range(current_count): 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: if r % 2 == 0:
current_count //= 2 current_count //= 2
# Link Losers Internal
for r in range(1, lb_rounds): for r in range(1, lb_rounds):
for i, m in enumerate(lb_matches[r]): for i, node in enumerate(lb_layers[r]):
target = ( target = lb_layers[r + 1][i] if r % 2 != 0 else lb_layers[r + 1][i // 2]
lb_matches[r + 1][i] if r % 2 != 0 else lb_matches[r + 1][i // 2] node.next_win = target
)
m.winner_next_match_id = target.id
if r % 2 != 0: if r % 2 != 0:
target.previous_match_p1_id = m.id target.src_p1 = node
target.source_p1_type = MatchSourceType.WINNER target.src_p1_type = MatchSourceType.WINNER
else: else:
if i % 2 == 0: if i % 2 == 0:
target.previous_match_p1_id = m.id target.src_p1 = node
target.source_p1_type = MatchSourceType.WINNER target.src_p1_type = MatchSourceType.WINNER
else: else:
target.previous_match_p2_id = m.id target.src_p2 = node
target.source_p2_type = MatchSourceType.WINNER target.src_p2_type = MatchSourceType.WINNER
# Link Losers Drop-down # Link Drop-down (Winners -> Losers)
for r in range(1, wb_rounds): for r in range(1, wb_rounds):
drop_round = 1 if r == 1 else (r - 1) * 2 drop_round = 1 if r == 1 else (r - 1) * 2
wb_layer = wb_matches[r] wb_layer_nodes = wb_layers[r]
lb_layer = lb_matches[drop_round] lb_layer_nodes = lb_layers[drop_round]
for i, wb_m in enumerate(wb_layer): for i, wb_node in enumerate(wb_layer_nodes):
target = ( target = None
lb_layer[i // 2] if r == 1:
if r == 1 target = lb_layer_nodes[i // 2]
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
else: else:
target.previous_match_p2_id = wb_m.id if i < len(lb_layer_nodes):
target.source_p2_type = MatchSourceType.LOSER target = lb_layer_nodes[i]
# Finals Linking
wb_final = wb_matches[wb_rounds][0]
lb_final = lb_matches[lb_rounds][0]
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
else: else:
m.winner = m.p1_name target = lb_layer_nodes[-1]
m.status = MatchStatus.FINISHED
# Reset Logic wb_node.next_loss = target
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 = []
# Numbering slot = WinnerSide.P1 if (r == 1 and i % 2 == 0) else WinnerSide.P2
display_counter = 1 if slot == WinnerSide.P1:
sorted_matches = sorted( target.src_p1 = wb_node
t_obj.matches, key=lambda x: int(x.id) if x.id.isdigit() else 999 target.src_p1_type = MatchSourceType.LOSER
)
for m in sorted_matches:
if m.winner == "BYE" or m.p1_name == "BYE" or m.p2_name == "BYE":
m.number = None
else: else:
m.number = display_counter target.src_p2 = wb_node
target.src_p2_type = MatchSourceType.LOSER
# Finals
final_node = NodeRef(BracketType.FINALS, 1)
wb_final = wb_layers[wb_rounds][0]
lb_final = lb_layers[lb_rounds][0]
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 display_counter += 1
nodes.append(n.to_dict())
# NO LABEL GENERATION HERE - FRONTEND HANDLES IT return nodes
def update_schedule(t_obj: Tournament): def initialize_seeding(db: Session, t: models.Tournament):
match_map = {m.id: m for m in t_obj.matches} seeded_teams = get_seeded_positions(
depth_cache = {} 2 ** math.ceil(math.log2(t.team_count)), t.teams
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
criticality_map = {} r1_nodes = [
for m in t_obj.matches: n
criticality_map[m.id] = get_depth(m.id) 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)
start_time = t_obj.timestamp for i, node in enumerate(r1_nodes):
duration = t_obj.duration t1 = seeded_teams[i * 2]
t2 = seeded_teams[i * 2 + 1]
finish_times: Dict[str, datetime] = {} node.p1_team_id = t1.id if t1 else None
court_timers: Dict[str, datetime] = {c.name: start_time for c in t_obj.courts} node.p2_team_id = t2.id if t2 else None
unscheduled = [] advance_flow(db, t)
# 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)
if not court_timers: def advance_flow(db: Session, t: models.Tournament):
changes = True
while changes:
changes = False
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
# 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
# 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
)
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
def _move_team(db, t, team_id, target_node_id, source_type):
if not target_node_id or not team_id:
return False
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 return
# 2. Schedule court_timers: dict[int, datetime] = {c.id: current_time for c in courts}
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]
ready = [] for node in nodes:
for m in unscheduled: if node.planned_start_time:
p1_r = ( continue
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
)
if max(p1_r, p2_r) <= current_time_slot: best_court_id = min(court_timers, key=lambda k: court_timers[k])
ready.append(m) start = court_timers[best_court_id]
if ready: node.planned_court_id = best_court_id
ready.sort(key=lambda x: (-criticality_map.get(x.id, 0), x.round)) node.planned_start_time = start
cand = ready[0]
cand.court_name = best_court
cand.timestamp = current_time_slot
cand.status = MatchStatus.SCHEDULED
fin = current_time_slot + timedelta(minutes=duration) court_timers[best_court_id] = start + timedelta(minutes=t.duration)
finish_times[cand.id] = fin
court_timers[best_court] = fin if node.match and node.match.status == MatchStatus.PENDING:
unscheduled.remove(cand) node.match.court_id = best_court_id
else: node.match.timestamp = start
next_wake = None
for m in unscheduled: db.commit()
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
+78 -49
View File
@@ -7,13 +7,7 @@ from sqlalchemy import Enum as SqlEnum
from sqlalchemy import ForeignKey, Integer, String from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from .constants import ( from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes
TournamentTypes,
MatchSourceType,
MatchStatus,
WinnerSide,
BracketType,
)
from .database import Base from .database import Base
@@ -33,11 +27,17 @@ class Tournament(Base):
courts: Mapped[list["Court"]] = relationship( courts: Mapped[list["Court"]] = relationship(
"Court", back_populates="tournament", cascade="all, delete-orphan" "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( matches: Mapped[list["Match"]] = relationship(
"Match", back_populates="tournament", cascade="all, delete-orphan" "Match", back_populates="tournament", cascade="all, delete-orphan"
) )
# --- ADD THESE PROPERTIES ---
@property @property
def team_count(self) -> int: def team_count(self) -> int:
return len(self.teams) return len(self.teams)
@@ -65,57 +65,86 @@ class Court(Base):
tournament: Mapped["Tournament"] = relationship(back_populates="courts") 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): class Match(Base):
__tablename__ = "matches" __tablename__ = "matches"
id: Mapped[str] = mapped_column(String, primary_key=True) id: Mapped[str] = mapped_column(String, primary_key=True)
tournament_id: Mapped[str] = mapped_column( tournament_id: Mapped[str] = mapped_column(ForeignKey("tournaments.id"))
ForeignKey("tournaments.id"), primary_key=True
node_id: Mapped[str] = mapped_column(ForeignKey("bracket_nodes.id"), unique=True)
court_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("courts.id"), nullable=True
) )
start_time: Mapped[Optional[datetime]] = mapped_column(DateTime, 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)
status: Mapped[MatchStatus] = mapped_column( 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) sets: Mapped[list[dict]] = mapped_column(JSON, default=list)
winner_team_id: Mapped[Optional[int]] = mapped_column(
previous_match_p1_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) ForeignKey("teams.id"), 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_next_match_id: Mapped[Optional[str]] = mapped_column(String, nullable=True) node: Mapped["BracketNode"] = relationship(back_populates="match")
loser_next_match_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
tournament: Mapped["Tournament"] = relationship(back_populates="matches") tournament: Mapped["Tournament"] = relationship(back_populates="matches")
court: Mapped[Optional["Court"]] = relationship()
@property p1_team: Mapped["Team"] = relationship("Team", foreign_keys=[p1_team_id])
def winner_side(self) -> WinnerSide: p2_team: Mapped["Team"] = relationship("Team", foreign_keys=[p2_team_id])
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
+1 -1
View File
@@ -3,4 +3,4 @@ from fastapi import APIRouter
router = APIRouter(prefix="/tournaments", tags=["Tournaments"]) 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 # backend/app/routes/tournaments/courts.py
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ... import crud, schemas from ... import crud, schemas
from ...constants import SUCCESS from ...constants import SUCCESS
from ...core.websocket_manager import send_ws_update
from ...core.auth import get_current_user from ...core.auth import get_current_user
from ...core.websocket_manager import send_ws_update
from ...database import get_db from ...database import get_db
from . import router from . import router
+13 -17
View File
@@ -13,7 +13,6 @@ from ...database import get_db
from . import router from . import router
# --- Helper: Centralize Auth Logic ---
def _check_auth(t: models.Tournament, user: Optional[str], code: Optional[str]): def _check_auth(t: models.Tournament, user: Optional[str], code: Optional[str]):
is_admin = user is not None is_admin = user is not None
code_matches = code is not None and str(code).strip() == str(t.code).strip() 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") raise HTTPException(400, "No sets submitted")
_apply_score(match, report.sets) _apply_score(match, report.sets)
flag_modified(match, "sets") flag_modified(match, "sets")
logic.refresh_bracket(t)
logic.update_schedule(t)
db.commit() db.commit()
logic.advance_flow(db, t)
await send_ws_update(id) await send_ws_update(id)
return SUCCESS return SUCCESS
@@ -63,8 +60,9 @@ async def edit_score(
user: Optional[str] = Depends(get_optional_user), user: Optional[str] = Depends(get_optional_user),
): ):
""" """
Allows correcting a score without resetting the match status logic entirely, Allows correcting a score.
or just re-applying the new sets. 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) t = crud.get_tournament(db, id)
if not t: if not t:
@@ -80,9 +78,8 @@ async def edit_score(
_apply_score(match, report.sets) _apply_score(match, report.sets)
flag_modified(match, "sets") flag_modified(match, "sets")
logic.refresh_bracket(t)
logic.update_schedule(t)
db.commit() db.commit()
logic.advance_flow(db, t)
await send_ws_update(id) await send_ws_update(id)
return SUCCESS return SUCCESS
@@ -106,14 +103,11 @@ async def clear_score(
if not match: if not match:
raise HTTPException(404, "Match not found") raise HTTPException(404, "Match not found")
match.winner = None match.winner_team_id = None
match.status = MatchStatus.PENDING.value match.status = MatchStatus.PENDING
match.sets = [] match.sets = []
flag_modified(match, "sets") flag_modified(match, "sets")
logic.refresh_bracket(t)
logic.update_schedule(t)
db.commit() db.commit()
await send_ws_update(id) 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) p2_wins = sum(1 for s in sets if s.p2 > s.p1)
if p1_wins > p2_wins: if p1_wins > p2_wins:
match.winner = match.p1_name match.winner_team_id = match.p1_team_id
elif p2_wins > p1_wins: elif p2_wins > p1_wins:
match.winner = match.p2_name match.winner_team_id = match.p2_team_id
else: else:
p1_points = sum(s.p1 for s in sets) p1_points = sum(s.p1 for s in sets)
p2_points = sum(s.p2 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: if p1_points == p2_points:
raise HTTPException(400, "Absolute tie: Sets and Points are equal.") 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] match.sets = [s.model_dump() for s in sets]
+1 -2
View File
@@ -1,12 +1,11 @@
# backend/app/routes/tournaments/teams.py # backend/app/routes/tournaments/teams.py
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ... import crud, schemas from ... import crud, schemas
from ...constants import SUCCESS from ...constants import SUCCESS
from ...core.websocket_manager import send_ws_update
from ...core.auth import get_current_user from ...core.auth import get_current_user
from ...core.websocket_manager import send_ws_update
from ...database import get_db from ...database import get_db
from . import router from . import router
@@ -10,7 +10,7 @@ from ...core.auth import get_current_user
from . import router from . import router
@router.post("", response_model=schemas.TournamentOut) @router.post("", response_model=schemas.TournamentUpdateResponse)
async def create_tournament( async def create_tournament(
data: schemas.TournamentCreate, data: schemas.TournamentCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
+36 -56
View File
@@ -1,15 +1,9 @@
# backend/app/schemas.py # backend/app/schemas.py
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict
from .constants import ( from .constants import BracketType, MatchSourceType, MatchStatus, TournamentTypes
BracketType,
MatchSourceType,
MatchStatus,
TournamentTypes,
WinnerSide,
)
class TeamSchema(BaseModel): class TeamSchema(BaseModel):
@@ -38,53 +32,43 @@ class SetScore(BaseModel):
class ScoreReport(BaseModel): class ScoreReport(BaseModel):
id: str
code: str | None = None code: str | None = None
sets: list[SetScore] sets: list[SetScore]
class MatchOut(BaseModel): class MatchOut(BaseModel):
id: str id: str
number: int | None = None node_id: str
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"
)
status: MatchStatus 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] = [] sets: list[SetScore] = []
previous_match_p1_id: str | None = Field( model_config = ConfigDict(from_attributes=True)
default=None, serialization_alias="source_p1"
)
previous_match_p2_id: str | None = Field(
default=None, serialization_alias="source_p2"
)
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_p1_type: MatchSourceType | None = None
source_p2_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( model_config = ConfigDict(from_attributes=True)
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)
class TournamentCreate(BaseModel): class TournamentCreate(BaseModel):
@@ -97,19 +81,7 @@ class TournamentCreate(BaseModel):
courts: list[str] 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): class TournamentUpdate(BaseModel):
name: str | None = None name: str | None = None
code: str | None = None code: str | None = None
timestamp: datetime | None = None timestamp: datetime | None = None
@@ -117,15 +89,23 @@ class TournamentUpdate(BaseModel):
type: TournamentTypes | None = None 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): class TournamentDetail(BaseModel):
id: str id: str
name: str name: str
code: str
timestamp: datetime timestamp: datetime
type: TournamentTypes type: TournamentTypes
teams: list[TeamSchema] teams: list[TeamSchema]
courts: list[CourtSchema] courts: list[CourtSchema]
matches: list[MatchOut]
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+34 -33
View File
@@ -15,21 +15,22 @@ async def test_scoring_flow(
t_id = res.json()["id"] t_id = res.json()["id"]
t_code = valid_tournament_payload["code"] t_code = valid_tournament_payload["code"]
# 2. Get Matches to find a Round 1 match # 2. Get Bracket Nodes (Updated Endpoint)
matches_res = await client.get(f"/tournaments/{t_id}/matches") bracket_res = await client.get(f"/tournaments/{t_id}/bracket")
matches = matches_res.json() nodes = bracket_res.json()
# Find a match that has real players (not BYE) # Find active node
# In double elim, Round 1 matches usually have seeds. active_node = next(n for n in nodes if n.get("match") is not None)
target_match = next(m for m in matches if m["p1"] and m["p2"])
match_id = target_match["id"]
next_match_id = target_match["next_win"] # Note: using alias from schema
# 3. Report Score WITHOUT Auth Header (Public user with Code) match_data = active_node["match"]
match_id = match_data["id"]
next_node_id = active_node["winner_next_node_id"]
# 3. Report Score
score_payload = { score_payload = {
"id": match_id, "id": match_id,
"code": t_code, "code": t_code,
"sets": [{"p1": 21, "p2": 19}, {"p1": 21, "p2": 15}], # P1 Wins "sets": [{"p1": 21, "p2": 19}, {"p1": 21, "p2": 15}],
} }
report_res = await client.post( report_res = await client.post(
@@ -37,15 +38,17 @@ async def test_scoring_flow(
) )
assert report_res.status_code == 200 assert report_res.status_code == 200
# 4. Verify Winner Advanced # 4. Verify Winner Advanced (Fetch bracket again)
# Fetch the *Next* match updated_res = await client.get(f"/tournaments/{t_id}/bracket")
next_match_res = await client.get(f"/tournaments/{t_id}/matches/{next_match_id}") updated_nodes = updated_res.json()
next_match = next_match_res.json()
# Assert P1 from previous match is now in the next match target_node = next(n for n in updated_nodes if n["id"] == next_node_id)
# Note: We check if the name matches the winner
winner_name = target_match["p1"] winner_id = match_data["p1_team"]["id"]
assert (next_match["p1"] == winner_name) or (next_match["p2"] == winner_name) p1_in_target = target_node["p1_team"]["id"] if target_node["p1_team"] else None
p2_in_target = target_node["p2_team"]["id"] if target_node["p2_team"] else None
assert winner_id in [p1_in_target, p2_in_target]
# 5. Test Invalid Code # 5. Test Invalid Code
bad_payload = score_payload.copy() bad_payload = score_payload.copy()
@@ -57,38 +60,36 @@ async def test_scoring_flow(
async def test_clear_score(client: AsyncClient, auth_headers, valid_tournament_payload): async def test_clear_score(client: AsyncClient, auth_headers, valid_tournament_payload):
# Setup: Create & Score # Setup
res = await client.post( res = await client.post(
"/tournaments", json=valid_tournament_payload, headers=auth_headers "/tournaments", json=valid_tournament_payload, headers=auth_headers
) )
t_id = res.json()["id"] t_id = res.json()["id"]
matches = (await client.get(f"/tournaments/{t_id}/matches")).json()
target = next(m for m in matches if m["p1"] and m["p2"])
score_payload = {"id": target["id"], "code": "1234", "sets": [{"p1": 25, "p2": 0}]} # Get active match
bracket = (await client.get(f"/tournaments/{t_id}/bracket")).json()
active_node = next(n for n in bracket if n.get("match"))
match_id = active_node["match"]["id"]
score_payload = {"id": match_id, "code": "1234", "sets": [{"p1": 25, "p2": 0}]}
await client.post( await client.post(
f"/tournaments/{t_id}/matches/{target['id']}/score", json=score_payload f"/tournaments/{t_id}/matches/{match_id}/score", json=score_payload
) )
# Verify Finished # Verify Finished
check_res = await client.get(f"/tournaments/{t_id}/matches/{target['id']}") check_res = await client.get(f"/tournaments/{t_id}/matches/{match_id}")
assert check_res.json()["status"] == "Finished" assert check_res.json()["status"] == "Finished"
# Action: Clear Score # Action: Clear Score
clear_res = await client.delete( clear_res = await client.delete(
f"/tournaments/{t_id}/matches/{target['id']}/score", headers=auth_headers f"/tournaments/{t_id}/matches/{match_id}/score", headers=auth_headers
) )
assert clear_res.status_code == 200 assert clear_res.status_code == 200
# Verify Reset # Verify Reset
final_res = await client.get(f"/tournaments/{t_id}/matches/{target['id']}") final_res = await client.get(f"/tournaments/{t_id}/matches/{match_id}")
data = final_res.json() data = final_res.json()
# 1. We already fixed this to expect 'Scheduled' assert data["status"] == "Pending"
assert data["status"] == "Scheduled" assert data["winner_team_id"] is None
# 2. FIX: Check 'winner_side' instead of 'winner'
# Use the Enum value "none"
assert data["winner_side"] == "none"
assert len(data["sets"]) == 0 assert len(data["sets"]) == 0
+10 -9
View File
@@ -14,7 +14,7 @@ async def test_manage_teams(
) )
t_id = res.json()["id"] t_id = res.json()["id"]
# 1. Add a Team via POST # 1. Add a Team
new_team = {"name": "Team E"} new_team = {"name": "Team E"}
post_res = await client.post( post_res = await client.post(
f"/tournaments/{t_id}/teams", json=new_team, headers=auth_headers f"/tournaments/{t_id}/teams", json=new_team, headers=auth_headers
@@ -22,12 +22,14 @@ async def test_manage_teams(
assert post_res.status_code == 200 assert post_res.status_code == 200
assert post_res.json()["name"] == "Team E" assert post_res.json()["name"] == "Team E"
# 2. Verify Bracket Regenerated (Match count should likely change or re-seed) # 2. Verify Bracket Regenerated (Call new endpoint)
matches_res = await client.get(f"/tournaments/{t_id}/matches") bracket_res = await client.get(f"/tournaments/{t_id}/bracket")
# With 4 teams -> ~6 matches. With 5 teams -> ~8-10 matches in Double Elim. nodes = bracket_res.json()
assert len(matches_res.json()) > 0
# 3. Bulk Update via PATCH (Replace all teams) # With 5 teams -> size 8 bracket
assert len(nodes) > 4
# 3. Bulk Update
new_team_list = ["Team X", "Team Y"] new_team_list = ["Team X", "Team Y"]
patch_res = await client.patch( patch_res = await client.patch(
f"/tournaments/{t_id}/teams", json=new_team_list, headers=auth_headers f"/tournaments/{t_id}/teams", json=new_team_list, headers=auth_headers
@@ -35,7 +37,6 @@ async def test_manage_teams(
assert patch_res.status_code == 200 assert patch_res.status_code == 200
data = patch_res.json() data = patch_res.json()
assert len(data) == 2 assert len(data) == 2
assert data[0]["name"] in ["Team X", "Team Y"]
async def test_manage_courts( async def test_manage_courts(
@@ -51,14 +52,14 @@ async def test_manage_courts(
initial_courts = courts_res.json() initial_courts = courts_res.json()
assert len(initial_courts) == 2 assert len(initial_courts) == 2
# Delete a court # Delete
court_id = initial_courts[0]["id"] court_id = initial_courts[0]["id"]
del_res = await client.delete( del_res = await client.delete(
f"/tournaments/{t_id}/courts/{court_id}", headers=auth_headers f"/tournaments/{t_id}/courts/{court_id}", headers=auth_headers
) )
assert del_res.status_code == 200 assert del_res.status_code == 200
# Create a court # Create
create_res = await client.post( create_res = await client.post(
f"/tournaments/{t_id}/courts", json={"name": "New Court"}, headers=auth_headers f"/tournaments/{t_id}/courts", json={"name": "New Court"}, headers=auth_headers
) )
+17 -9
View File
@@ -2,7 +2,6 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
# Mark all tests in this file as async
pytestmark = pytest.mark.anyio pytestmark = pytest.mark.anyio
@@ -15,15 +14,14 @@ async def test_create_tournament(
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["name"] == "Test Tournament" assert data["name"] == "Test Tournament"
assert data["team_count"] == 4 assert len(data["teams"]) == 4
assert data["court_count"] == 2 assert len(data["courts"]) == 2
assert "id" in data assert "id" in data
async def test_list_tournaments( async def test_list_tournaments(
client: AsyncClient, auth_headers, valid_tournament_payload client: AsyncClient, auth_headers, valid_tournament_payload
): ):
# Create one first
await client.post( await client.post(
"/tournaments", json=valid_tournament_payload, headers=auth_headers "/tournaments", json=valid_tournament_payload, headers=auth_headers
) )
@@ -35,7 +33,7 @@ async def test_list_tournaments(
assert data[0]["name"] == "Test Tournament" assert data[0]["name"] == "Test Tournament"
async def test_get_tournament_detail( async def test_get_tournament_detail_and_bracket(
client: AsyncClient, auth_headers, valid_tournament_payload client: AsyncClient, auth_headers, valid_tournament_payload
): ):
create_res = await client.post( create_res = await client.post(
@@ -43,12 +41,24 @@ async def test_get_tournament_detail(
) )
t_id = create_res.json()["id"] t_id = create_res.json()["id"]
# 1. Test Light Detail Endpoint
response = await client.get(f"/tournaments/{t_id}") response = await client.get(f"/tournaments/{t_id}")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Check deeply nested fields
assert len(data["matches"]) > 0 # Logic should have generated matches # Should HAVE metadata
assert len(data["teams"]) == 4 assert len(data["teams"]) == 4
# Should NOT have heavy bracket data
assert "nodes" not in data
# 2. Test New Bracket Endpoint
bracket_res = await client.get(f"/tournaments/{t_id}/bracket")
assert bracket_res.status_code == 200
nodes = bracket_res.json()
assert len(nodes) > 0
first_node = nodes[0]
assert "display_number" in first_node
async def test_update_settings( async def test_update_settings(
@@ -64,7 +74,6 @@ async def test_update_settings(
f"/tournaments/{t_id}", json=update_payload, headers=auth_headers f"/tournaments/{t_id}", json=update_payload, headers=auth_headers
) )
# This will now succeed because we updated the response_model!
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["name"] == "Updated Name" assert data["name"] == "Updated Name"
@@ -82,6 +91,5 @@ async def test_delete_tournament(
del_res = await client.delete(f"/tournaments/{t_id}", headers=auth_headers) del_res = await client.delete(f"/tournaments/{t_id}", headers=auth_headers)
assert del_res.status_code == 200 assert del_res.status_code == 200
# Verify it's gone
get_res = await client.get(f"/tournaments/{t_id}") get_res = await client.get(f"/tournaments/{t_id}")
assert get_res.status_code == 404 assert get_res.status_code == 404