diff --git a/backend/app/crud.py b/backend/app/crud.py index af99277..37ac5ce 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -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() + ) diff --git a/backend/app/logic.py b/backend/app/logic.py index 241c0de..1ce8e55 100644 --- a/backend/app/logic.py +++ b/backend/app/logic.py @@ -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() diff --git a/backend/app/models.py b/backend/app/models.py index b75bb76..925e8ae 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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]) diff --git a/backend/app/routes/tournaments/__init__.py b/backend/app/routes/tournaments/__init__.py index 8f73fc9..89ec6bf 100644 --- a/backend/app/routes/tournaments/__init__.py +++ b/backend/app/routes/tournaments/__init__.py @@ -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 diff --git a/backend/app/routes/tournaments/bracket.py b/backend/app/routes/tournaments/bracket.py new file mode 100644 index 0000000..e190d3d --- /dev/null +++ b/backend/app/routes/tournaments/bracket.py @@ -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 diff --git a/backend/app/routes/tournaments/courts.py b/backend/app/routes/tournaments/courts.py index 6f95034..479e279 100644 --- a/backend/app/routes/tournaments/courts.py +++ b/backend/app/routes/tournaments/courts.py @@ -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 diff --git a/backend/app/routes/tournaments/report.py b/backend/app/routes/tournaments/report.py index 450de30..d107ae5 100644 --- a/backend/app/routes/tournaments/report.py +++ b/backend/app/routes/tournaments/report.py @@ -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] diff --git a/backend/app/routes/tournaments/teams.py b/backend/app/routes/tournaments/teams.py index 4102ce3..a4f7492 100644 --- a/backend/app/routes/tournaments/teams.py +++ b/backend/app/routes/tournaments/teams.py @@ -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 diff --git a/backend/app/routes/tournaments/tournaments.py b/backend/app/routes/tournaments/tournaments.py index 93bcdd6..fc55f9f 100644 --- a/backend/app/routes/tournaments/tournaments.py +++ b/backend/app/routes/tournaments/tournaments.py @@ -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), diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 18b7b3c..080905e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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) diff --git a/backend/tests/test_scoring.py b/backend/tests/test_scoring.py index 4de047a..13b62a5 100644 --- a/backend/tests/test_scoring.py +++ b/backend/tests/test_scoring.py @@ -15,21 +15,22 @@ async def test_scoring_flow( t_id = res.json()["id"] t_code = valid_tournament_payload["code"] - # 2. Get Matches to find a Round 1 match - matches_res = await client.get(f"/tournaments/{t_id}/matches") - matches = matches_res.json() + # 2. Get Bracket Nodes (Updated Endpoint) + bracket_res = await client.get(f"/tournaments/{t_id}/bracket") + nodes = bracket_res.json() - # Find a match that has real players (not BYE) - # In double elim, Round 1 matches usually have seeds. - 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 + # Find active node + active_node = next(n for n in nodes if n.get("match") is not None) - # 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 = { "id": match_id, "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( @@ -37,15 +38,17 @@ async def test_scoring_flow( ) assert report_res.status_code == 200 - # 4. Verify Winner Advanced - # Fetch the *Next* match - next_match_res = await client.get(f"/tournaments/{t_id}/matches/{next_match_id}") - next_match = next_match_res.json() + # 4. Verify Winner Advanced (Fetch bracket again) + updated_res = await client.get(f"/tournaments/{t_id}/bracket") + updated_nodes = updated_res.json() - # Assert P1 from previous match is now in the next match - # Note: We check if the name matches the winner - winner_name = target_match["p1"] - assert (next_match["p1"] == winner_name) or (next_match["p2"] == winner_name) + target_node = next(n for n in updated_nodes if n["id"] == next_node_id) + + winner_id = match_data["p1_team"]["id"] + 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 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): - # Setup: Create & Score + # Setup res = await client.post( "/tournaments", json=valid_tournament_payload, headers=auth_headers ) 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( - f"/tournaments/{t_id}/matches/{target['id']}/score", json=score_payload + f"/tournaments/{t_id}/matches/{match_id}/score", json=score_payload ) # 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" # Action: Clear Score 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 # 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() - # 1. We already fixed this to expect 'Scheduled' - assert data["status"] == "Scheduled" - - # 2. FIX: Check 'winner_side' instead of 'winner' - # Use the Enum value "none" - assert data["winner_side"] == "none" - + assert data["status"] == "Pending" + assert data["winner_team_id"] is None assert len(data["sets"]) == 0 diff --git a/backend/tests/test_structure.py b/backend/tests/test_structure.py index 44c6346..7787ae0 100644 --- a/backend/tests/test_structure.py +++ b/backend/tests/test_structure.py @@ -14,7 +14,7 @@ async def test_manage_teams( ) t_id = res.json()["id"] - # 1. Add a Team via POST + # 1. Add a Team new_team = {"name": "Team E"} post_res = await client.post( 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.json()["name"] == "Team E" - # 2. Verify Bracket Regenerated (Match count should likely change or re-seed) - matches_res = await client.get(f"/tournaments/{t_id}/matches") - # With 4 teams -> ~6 matches. With 5 teams -> ~8-10 matches in Double Elim. - assert len(matches_res.json()) > 0 + # 2. Verify Bracket Regenerated (Call new endpoint) + bracket_res = await client.get(f"/tournaments/{t_id}/bracket") + nodes = bracket_res.json() - # 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"] patch_res = await client.patch( 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 data = patch_res.json() assert len(data) == 2 - assert data[0]["name"] in ["Team X", "Team Y"] async def test_manage_courts( @@ -51,14 +52,14 @@ async def test_manage_courts( initial_courts = courts_res.json() assert len(initial_courts) == 2 - # Delete a court + # Delete court_id = initial_courts[0]["id"] del_res = await client.delete( f"/tournaments/{t_id}/courts/{court_id}", headers=auth_headers ) assert del_res.status_code == 200 - # Create a court + # Create create_res = await client.post( f"/tournaments/{t_id}/courts", json={"name": "New Court"}, headers=auth_headers ) diff --git a/backend/tests/test_tournaments.py b/backend/tests/test_tournaments.py index e129c8a..2e1b373 100644 --- a/backend/tests/test_tournaments.py +++ b/backend/tests/test_tournaments.py @@ -2,7 +2,6 @@ import pytest from httpx import AsyncClient -# Mark all tests in this file as async pytestmark = pytest.mark.anyio @@ -15,15 +14,14 @@ async def test_create_tournament( assert response.status_code == 200 data = response.json() assert data["name"] == "Test Tournament" - assert data["team_count"] == 4 - assert data["court_count"] == 2 + assert len(data["teams"]) == 4 + assert len(data["courts"]) == 2 assert "id" in data async def test_list_tournaments( client: AsyncClient, auth_headers, valid_tournament_payload ): - # Create one first await client.post( "/tournaments", json=valid_tournament_payload, headers=auth_headers ) @@ -35,7 +33,7 @@ async def test_list_tournaments( 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 ): create_res = await client.post( @@ -43,12 +41,24 @@ async def test_get_tournament_detail( ) t_id = create_res.json()["id"] + # 1. Test Light Detail Endpoint response = await client.get(f"/tournaments/{t_id}") assert response.status_code == 200 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 + # 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( @@ -64,7 +74,6 @@ async def test_update_settings( 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 data = response.json() 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) assert del_res.status_code == 200 - # Verify it's gone get_res = await client.get(f"/tournaments/{t_id}") assert get_res.status_code == 404