Added seperate bracket/node logic
This commit is contained in:
+82
-92
@@ -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()
|
||||||
|
)
|
||||||
|
|||||||
+251
-259
@@ -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]
|
||||||
|
else:
|
||||||
|
target = lb_layer_nodes[-1]
|
||||||
|
|
||||||
# Finals Linking
|
wb_node.next_loss = target
|
||||||
wb_final = wb_matches[wb_rounds][0]
|
|
||||||
lb_final = lb_matches[lb_rounds][0]
|
|
||||||
|
|
||||||
wb_final.loser_next_match_id = lb_final.id
|
slot = WinnerSide.P1 if (r == 1 and i % 2 == 0) else WinnerSide.P2
|
||||||
lb_final.previous_match_p2_id = wb_final.id
|
if slot == WinnerSide.P1:
|
||||||
lb_final.source_p2_type = MatchSourceType.LOSER
|
target.src_p1 = wb_node
|
||||||
|
target.src_p1_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.src_p2 = wb_node
|
||||||
m.status = MatchStatus.FINISHED
|
target.src_p2_type = MatchSourceType.LOSER
|
||||||
|
|
||||||
# Reset Logic
|
# Finals
|
||||||
if m.status == MatchStatus.FINISHED and m.winner != "BYE":
|
final_node = NodeRef(BracketType.FINALS, 1)
|
||||||
has_p1 = bool(m.p1_name)
|
wb_final = wb_layers[wb_rounds][0]
|
||||||
has_p2 = bool(m.p2_name)
|
lb_final = lb_layers[lb_rounds][0]
|
||||||
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
|
wb_final.next_loss = lb_final
|
||||||
display_counter = 1
|
wb_final.next_win = final_node
|
||||||
sorted_matches = sorted(
|
lb_final.next_win = final_node
|
||||||
t_obj.matches, key=lambda x: int(x.id) if x.id.isdigit() else 999
|
|
||||||
|
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):
|
def advance_flow(db: Session, t: models.Tournament):
|
||||||
match_map = {m.id: m for m in t_obj.matches}
|
changes = True
|
||||||
depth_cache = {}
|
while changes:
|
||||||
|
changes = False
|
||||||
|
|
||||||
def get_depth(mid):
|
for node in t.nodes:
|
||||||
if mid not in match_map:
|
if node.round_number == 1 and node.bracket_type == BracketType.WINNERS:
|
||||||
return 0
|
if node.p1_team_id and not node.p2_team_id:
|
||||||
if mid in depth_cache:
|
if _move_team(
|
||||||
return depth_cache[mid]
|
db,
|
||||||
m = match_map[mid]
|
t,
|
||||||
d = 1 + max(
|
node.p1_team_id,
|
||||||
get_depth(m.winner_next_match_id) if m.winner_next_match_id else 0,
|
node.winner_next_node_id,
|
||||||
get_depth(m.loser_next_match_id) if m.loser_next_match_id else 0,
|
MatchSourceType.WINNER,
|
||||||
)
|
):
|
||||||
depth_cache[mid] = d
|
pass
|
||||||
return d
|
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 = {}
|
# 2. MATCH CREATION
|
||||||
for m in t_obj.matches:
|
if node.p1_team_id and node.p2_team_id:
|
||||||
criticality_map[m.id] = get_depth(m.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
|
# 3. MATCH RESULT PROPAGATION
|
||||||
duration = t_obj.duration
|
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] = {}
|
if _move_team(
|
||||||
court_timers: Dict[str, datetime] = {c.name: start_time for c in t_obj.courts}
|
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
|
def _move_team(db, t, team_id, target_node_id, source_type):
|
||||||
for m in t_obj.matches:
|
if not target_node_id or not team_id:
|
||||||
if m.winner == "BYE" or m.p1_name == "BYE" or m.p2_name == "BYE":
|
return False
|
||||||
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:
|
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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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,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,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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user