diff --git a/backend/alembic/versions/6458e0bc3e9d_add_stages_table.py b/backend/alembic/versions/6458e0bc3e9d_add_stages_table.py
index 25bf37f6..243898cc 100644
--- a/backend/alembic/versions/6458e0bc3e9d_add_stages_table.py
+++ b/backend/alembic/versions/6458e0bc3e9d_add_stages_table.py
@@ -30,7 +30,6 @@ def upgrade() -> None:
'type',
ENUM(
'SINGLE_ELIMINATION',
- 'DOUBLE_ELIMINATION',
'SWISS',
'SWISS_DYNAMIC_TEAMS',
'ROUND_ROBIN',
diff --git a/backend/bracket/app.py b/backend/bracket/app.py
index 5fd1451d..bec69ca9 100644
--- a/backend/bracket/app.py
+++ b/backend/bracket/app.py
@@ -1,3 +1,6 @@
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
from fastapi import FastAPI
from starlette.exceptions import HTTPException
from starlette.middleware.cors import CORSMiddleware
@@ -14,6 +17,7 @@ from bracket.routes import (
matches,
players,
rounds,
+ stage_items,
stages,
teams,
tournaments,
@@ -23,10 +27,23 @@ from bracket.utils.db_init import init_db_when_empty
init_sentry()
+
+@asynccontextmanager
+async def lifespan(_: FastAPI) -> AsyncIterator[None]:
+ await database.connect()
+ await init_db_when_empty()
+
+ yield
+
+ if environment != Environment.CI:
+ await database.disconnect()
+
+
app = FastAPI(
title="Bracket API",
docs_url="/docs",
version="1.0.0",
+ lifespan=lifespan,
)
origins = ["http://localhost", "http://localhost:3000", *config.cors_origins.split(',')]
@@ -41,20 +58,6 @@ app.add_middleware(
)
-@app.on_event("startup")
-async def startup() -> None:
- await database.connect()
- await init_db_when_empty()
-
-
-@app.on_event("shutdown")
-async def shutdown() -> None:
- # On CI, we need first to clean up db data in conftest.py before we can disconnect the
- # db connections.
- if environment != Environment.CI:
- await database.disconnect()
-
-
@app.get('/ping', summary="Healthcheck ping")
async def ping() -> str:
return 'ping'
@@ -74,11 +77,12 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
app.include_router(auth.router, tags=['auth'])
app.include_router(clubs.router, tags=['clubs'])
-app.include_router(tournaments.router, tags=['tournaments'])
+app.include_router(courts.router, tags=['courts'])
+app.include_router(matches.router, tags=['matches'])
app.include_router(players.router, tags=['players'])
app.include_router(rounds.router, tags=['rounds'])
-app.include_router(matches.router, tags=['matches'])
+app.include_router(stage_items.router, tags=['stage_items'])
app.include_router(stages.router, tags=['stages'])
app.include_router(teams.router, tags=['teams'])
-app.include_router(courts.router, tags=['courts'])
+app.include_router(tournaments.router, tags=['tournaments'])
app.include_router(users.router, tags=['users'])
diff --git a/backend/bracket/logic/elo.py b/backend/bracket/logic/elo.py
index 62a2a410..e072befd 100644
--- a/backend/bracket/logic/elo.py
+++ b/backend/bracket/logic/elo.py
@@ -2,28 +2,18 @@ import math
from collections import defaultdict
from decimal import Decimal
-from pydantic import BaseModel
-
from bracket.database import database
-from bracket.models.db.round import RoundWithMatches, StageWithRounds
+from bracket.models.db.players import START_ELO, PlayerStatistics
+from bracket.models.db.util import RoundWithMatches
from bracket.schema import players
-from bracket.sql.players import get_all_players_in_tournament
-from bracket.sql.stages import get_stages_with_rounds_and_matches
+from bracket.sql.players import get_all_players_in_tournament, update_player_stats
+from bracket.sql.stages import get_full_tournament_details
from bracket.utils.types import assert_some
-START_ELO: int = 1200
K = 32
D = 400
-class PlayerStatistics(BaseModel):
- wins: int = 0
- draws: int = 0
- losses: int = 0
- elo_score: int = START_ELO
- swiss_score: Decimal = Decimal('0.00')
-
-
def calculate_elo_per_player(rounds: list[RoundWithMatches]) -> defaultdict[int, PlayerStatistics]:
player_x_elo: defaultdict[int, PlayerStatistics] = defaultdict(PlayerStatistics)
@@ -83,21 +73,21 @@ def calculate_elo_per_player(rounds: list[RoundWithMatches]) -> defaultdict[int,
async def recalculate_elo_for_tournament_id(tournament_id: int) -> None:
- stages = await get_stages_with_rounds_and_matches(tournament_id)
- for stage in stages:
- await recalculate_elo_for_stage(tournament_id, stage)
+ stages = await get_full_tournament_details(tournament_id)
+ rounds = [
+ round_
+ for stage in stages
+ for stage_item in stage.stage_items
+ for round_ in stage_item.rounds
+ ]
+ await recalculate_elo_for_stage(tournament_id, rounds)
-async def recalculate_elo_for_stage(tournament_id: int, stage: StageWithRounds) -> None:
- elo_per_player = calculate_elo_per_player(stage.rounds)
+async def recalculate_elo_for_stage(tournament_id: int, rounds: list[RoundWithMatches]) -> None:
+ elo_per_player = calculate_elo_per_player(rounds)
for player_id, statistics in elo_per_player.items():
- await database.execute(
- query=players.update().where(
- (players.c.id == player_id) & (players.c.tournament_id == tournament_id)
- ),
- values=statistics.dict(),
- )
+ await update_player_stats(tournament_id, player_id, statistics)
all_players = await get_all_players_in_tournament(tournament_id)
for player in all_players:
diff --git a/backend/bracket/logic/matches.py b/backend/bracket/logic/matches.py
new file mode 100644
index 00000000..38ec8017
--- /dev/null
+++ b/backend/bracket/logic/matches.py
@@ -0,0 +1,20 @@
+from bracket.models.db.match import Match, MatchCreateBody
+from bracket.sql.courts import get_all_free_courts_in_round
+from bracket.sql.matches import sql_create_match
+from bracket.sql.tournaments import sql_get_tournament
+
+
+async def create_match_and_assign_free_court(
+ tournament_id: int,
+ match_body: MatchCreateBody,
+) -> Match:
+ tournament = await sql_get_tournament(tournament_id)
+ next_free_court_id = None
+
+ if tournament.auto_assign_courts and match_body.court_id is None:
+ free_courts = await get_all_free_courts_in_round(tournament_id, match_body.round_id)
+ if len(free_courts) > 0:
+ next_free_court_id = free_courts[0].id
+
+ match_body = match_body.copy(update={'court_id': next_free_court_id})
+ return await sql_create_match(match_body)
diff --git a/backend/bracket/logic/scheduling/builder.py b/backend/bracket/logic/scheduling/builder.py
new file mode 100644
index 00000000..d12ebe0c
--- /dev/null
+++ b/backend/bracket/logic/scheduling/builder.py
@@ -0,0 +1,112 @@
+from fastapi import HTTPException
+from heliclockter import datetime_utc
+
+from bracket.database import database
+from bracket.logic.scheduling.elimination import (
+ build_single_elimination_stage_item,
+ get_number_of_rounds_to_create_single_elimination,
+)
+from bracket.logic.scheduling.round_robin import (
+ build_round_robin_stage_item,
+ get_number_of_rounds_to_create_round_robin,
+)
+from bracket.models.db.match import SuggestedMatch, SuggestedVirtualMatch
+from bracket.models.db.round import RoundToInsert
+from bracket.models.db.stage_item import StageItem, StageType
+from bracket.models.db.stage_item_inputs import (
+ StageItemInputOptionFinal,
+ StageItemInputOptionTentative,
+)
+from bracket.models.db.team import FullTeamWithPlayers
+from bracket.models.db.util import StageWithStageItems
+from bracket.schema import rounds
+from bracket.sql.rounds import get_next_round_name
+from bracket.sql.stage_items import get_stage_item
+from bracket.utils.types import assert_some
+
+
+async def create_rounds_for_new_stage_item(tournament_id: int, stage_item: StageItem) -> None:
+ rounds_count: int
+ match stage_item.type:
+ case StageType.ROUND_ROBIN:
+ rounds_count = get_number_of_rounds_to_create_round_robin(stage_item.team_count)
+ case StageType.SINGLE_ELIMINATION:
+ rounds_count = get_number_of_rounds_to_create_single_elimination(stage_item.team_count)
+ case other:
+ raise NotImplementedError(f'No round creation implementation for {other}')
+
+ now = datetime_utc.now()
+ for _ in range(rounds_count):
+ await database.execute(
+ query=rounds.insert(),
+ values=RoundToInsert(
+ created=now,
+ stage_item_id=assert_some(stage_item.id),
+ name=await get_next_round_name(tournament_id, assert_some(stage_item.id)),
+ ).dict(),
+ )
+
+
+async def build_matches_for_stage_item(
+ stage_item: StageItem, tournament_id: int
+) -> list[SuggestedMatch | SuggestedVirtualMatch]:
+ await create_rounds_for_new_stage_item(tournament_id, stage_item)
+ stage_item_with_rounds = await get_stage_item(tournament_id, assert_some(stage_item.id))
+
+ if stage_item_with_rounds is None:
+ raise ValueError(
+ f'Could not find stage item with id {stage_item.id} for tournament {tournament_id}'
+ )
+
+ match stage_item.type:
+ case StageType.ROUND_ROBIN:
+ upcoming_matches = await build_round_robin_stage_item(
+ tournament_id, stage_item_with_rounds
+ )
+ case StageType.SINGLE_ELIMINATION:
+ upcoming_matches = await build_single_elimination_stage_item(
+ tournament_id,
+ stage_item_with_rounds,
+ )
+
+ case _:
+ raise HTTPException(
+ 400, f'Cannot automatically create matches for stage type {stage_item.type}'
+ )
+
+ return upcoming_matches
+
+
+def determine_available_inputs(
+ stage_id: int,
+ teams: list[FullTeamWithPlayers],
+ stages: list[StageWithStageItems],
+) -> list[StageItemInputOptionTentative | StageItemInputOptionFinal]:
+ results_team_ids = [assert_some(team.id) for team in teams]
+ results_tentative = []
+
+ for stage in stages:
+ if stage_id == stage.id:
+ break
+
+ for stage_item in stage.stage_items:
+ item_team_id_inputs = [
+ input.team_id for input in stage_item.inputs if input.team_id is not None
+ ]
+ for input_ in item_team_id_inputs:
+ if input_ in results_team_ids:
+ results_team_ids.remove(input_)
+
+ results_tentative.extend(
+ [
+ StageItemInputOptionTentative(
+ team_stage_item_id=stage_item.id, team_position_in_group=1
+ ),
+ StageItemInputOptionTentative(
+ team_stage_item_id=stage_item.id, team_position_in_group=2
+ ),
+ ]
+ )
+
+ results_final = [StageItemInputOptionFinal(team_id=team_id) for team_id in results_team_ids]
+ return results_final + results_tentative
diff --git a/backend/bracket/logic/scheduling/elimination.py b/backend/bracket/logic/scheduling/elimination.py
new file mode 100644
index 00000000..e485d53f
--- /dev/null
+++ b/backend/bracket/logic/scheduling/elimination.py
@@ -0,0 +1,96 @@
+from bracket.logic.scheduling.shared import get_suggested_match
+from bracket.models.db.match import SuggestedMatch, SuggestedVirtualMatch
+from bracket.models.db.team import FullTeamWithPlayers, TeamWithPlayers
+from bracket.models.db.util import StageItemWithRounds
+from bracket.sql.rounds import get_rounds_for_stage_item
+from bracket.sql.teams import get_teams_with_members
+from bracket.utils.types import assert_some
+
+
+def determine_matches_first_round(
+ stage_item: StageItemWithRounds, teams_sorted: list[FullTeamWithPlayers]
+) -> list[SuggestedMatch | SuggestedVirtualMatch]:
+ suggestions: list[SuggestedMatch | SuggestedVirtualMatch] = []
+
+ # for i in range(0, stage.team_count, 2):
+ # match = SuggestedVirtualMatch(
+ # team1_group_id=
+ # )
+ # suggestions.append(get_suggested_match(team1, team2))
+
+ return suggestions
+
+
+def todo_determine_matches_other_round(
+ stage_item: StageItemWithRounds, teams_sorted: list[TeamWithPlayers]
+) -> list[SuggestedMatch | SuggestedVirtualMatch]:
+ suggestions: list[SuggestedMatch | SuggestedVirtualMatch] = []
+ # previous_round = sorted(
+ # [round_ for round_ in rounds if assert_some(round_.id) < round_id],
+ # key=lambda round_: assert_some(round_.id),
+ # reverse=True,*
+ # )[0]
+
+ # winners = []
+ # for match in previous_round.matches:
+ # winner = match.get_winner()
+ # assert winner is not None
+ # winners.append(winner)
+ #
+ # assert len(winners) % 2 == 0
+ # for i in range(0, len(winners), 2):
+ # team1, team2 = teams_sorted[i + 0], teams_sorted[i + 1]
+ # suggestions.append(get_suggested_match(team1, team2))
+ return suggestions
+
+
+async def build_single_elimination_stage_item(
+ tournament_id: int, stage_item: StageItemWithRounds
+) -> list[SuggestedMatch | SuggestedVirtualMatch]:
+ stage_id = assert_some(stage_item.stage_id)
+ suggestions: list[SuggestedMatch | SuggestedVirtualMatch] = []
+ rounds = await get_rounds_for_stage_item(tournament_id, stage_id)
+ assert len(rounds) > 0
+
+ for j, round_ in enumerate(stage_item.rounds):
+ first_round_id = min(assert_some(round_.id) for round_ in rounds)
+ first_round = round_.id == first_round_id
+
+ teams = await get_teams_with_members(tournament_id, only_active_teams=True)
+ teams_sorted = sorted(teams, key=lambda team: team.elo_score, reverse=True)
+
+ assert stage_item.team_count % 2 == 0
+ assert stage_item.team_count % 2 == 0
+
+ if first_round:
+ return determine_matches_first_round(stage_item, teams_sorted)
+
+ previous_round = stage_item.rounds[j - 1]
+
+ winners = []
+ for match in previous_round.matches:
+ winner = match.get_winner()
+ assert winner is not None
+ winners.append(winner)
+
+ assert len(winners) % 2 == 0
+ for i in range(0, len(winners), 2):
+ team1, team2 = teams_sorted[i + 0], teams_sorted[i + 1]
+ suggestions.append(get_suggested_match(team1, team2))
+
+ return suggestions
+
+
+def get_number_of_rounds_to_create_single_elimination(team_count: int) -> int:
+ if team_count < 1:
+ return 0
+
+ assert team_count % 2 == 0
+
+ game_count_lookup = {
+ 2: 1,
+ 4: 2,
+ 8: 3,
+ 16: 4,
+ }
+ return game_count_lookup[team_count]
diff --git a/backend/bracket/logic/scheduling/ladder_players_iter.py b/backend/bracket/logic/scheduling/ladder_players_iter.py
index 4b6fd8c3..12701a69 100644
--- a/backend/bracket/logic/scheduling/ladder_players_iter.py
+++ b/backend/bracket/logic/scheduling/ladder_players_iter.py
@@ -1,16 +1,17 @@
import random
from collections import defaultdict
from functools import lru_cache
+from typing import cast
from fastapi import HTTPException
from bracket.logic.scheduling.shared import check_team_combination_adheres_to_filter
-from bracket.models.db.match import MatchFilter, SuggestedMatch
+from bracket.models.db.match import MatchFilter, SuggestedMatch, SuggestedVirtualMatch
from bracket.models.db.player import Player
-from bracket.models.db.round import RoundWithMatches
from bracket.models.db.team import TeamWithPlayers
+from bracket.models.db.util import RoundWithMatches
from bracket.sql.players import get_active_players_in_tournament
-from bracket.sql.stages import get_stages_with_rounds_and_matches
+from bracket.sql.stage_items import get_stage_item
from bracket.utils.types import assert_some
@@ -19,18 +20,19 @@ def player_already_scheduled(player: Player, draft_round: RoundWithMatches) -> b
async def get_possible_upcoming_matches_for_players(
- tournament_id: int, filter_: MatchFilter, stage_id: int, round_id: int
-) -> list[SuggestedMatch]:
+ tournament_id: int, filter_: MatchFilter, stage_item_id: int, round_id: int
+) -> list[SuggestedMatch | SuggestedVirtualMatch]:
random.seed(10)
suggestions: set[SuggestedMatch] = set()
- stages = await get_stages_with_rounds_and_matches(tournament_id, stage_id=stage_id)
+ stage_item = await get_stage_item(tournament_id, stage_item_id)
- if len(stages) < 1:
- raise HTTPException(400, 'There is no active stage, so no matches can be scheduled.')
+ if stage_item is None:
+ raise ValueError(
+ f'Could not find stage item with id {stage_item_id} for tournament {tournament_id}'
+ )
- [active_stage] = stages
- draft_round = next((round_ for round_ in active_stage.rounds if round_.id == round_id), None)
- other_rounds = [round_ for round_ in active_stage.rounds if not round_.is_draft]
+ draft_round = next((round_ for round_ in stage_item.rounds if round_.id == round_id), None)
+ other_rounds = [round_ for round_ in stage_item.rounds if not round_.is_draft]
max_matches_per_round = (
max(len(other_round.matches) for other_round in other_rounds)
if len(other_rounds) > 0
@@ -108,4 +110,4 @@ async def get_possible_upcoming_matches_for_players(
result[i].is_recommended = True
team_already_scheduled_before.cache_clear()
- return result[: filter_.limit]
+ return cast(list[SuggestedMatch | SuggestedVirtualMatch], result[: filter_.limit])
diff --git a/backend/bracket/logic/scheduling/ladder_teams.py b/backend/bracket/logic/scheduling/ladder_teams.py
index bc69ab99..3c4c821d 100644
--- a/backend/bracket/logic/scheduling/ladder_teams.py
+++ b/backend/bracket/logic/scheduling/ladder_teams.py
@@ -1,16 +1,16 @@
from fastapi import HTTPException
from bracket.logic.scheduling.shared import check_team_combination_adheres_to_filter
-from bracket.models.db.match import MatchFilter, SuggestedMatch
-from bracket.sql.rounds import get_rounds_for_stage
+from bracket.models.db.match import MatchFilter, SuggestedMatch, SuggestedVirtualMatch
+from bracket.sql.rounds import get_rounds_for_stage_item
from bracket.sql.teams import get_teams_with_members
async def todo_get_possible_upcoming_matches_for_teams(
- tournament_id: int, stage_id: int, filter_: MatchFilter
-) -> list[SuggestedMatch]:
- suggestions: list[SuggestedMatch] = []
- rounds = await get_rounds_for_stage(tournament_id, stage_id)
+ tournament_id: int, filter_: MatchFilter, stage_id: int
+) -> list[SuggestedMatch | SuggestedVirtualMatch]:
+ suggestions: list[SuggestedMatch | SuggestedVirtualMatch] = []
+ rounds = await get_rounds_for_stage_item(tournament_id, stage_id)
draft_round = next((round_ for round_ in rounds if round_.is_draft), None)
if draft_round is None:
raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
diff --git a/backend/bracket/logic/scheduling/round_robin.py b/backend/bracket/logic/scheduling/round_robin.py
index 091255a5..522a97f9 100644
--- a/backend/bracket/logic/scheduling/round_robin.py
+++ b/backend/bracket/logic/scheduling/round_robin.py
@@ -1,27 +1,57 @@
+import math
+from typing import cast
+
+from bracket.logic.matches import create_match_and_assign_free_court
from bracket.logic.scheduling.shared import get_suggested_match
-from bracket.models.db.match import SuggestedMatch
-from bracket.sql.rounds import get_rounds_for_stage
+from bracket.models.db.match import (
+ MatchCreateBody,
+ SuggestedMatch,
+ SuggestedVirtualMatch,
+)
+from bracket.models.db.util import StageItemWithRounds
from bracket.sql.teams import get_teams_with_members
+from bracket.utils.types import assert_some
-async def get_possible_upcoming_matches_round_robin(
- tournament_id: int, stage_id: int, round_id: int
-) -> list[SuggestedMatch]:
+async def build_round_robin_stage_item(
+ tournament_id: int, stage_item: StageItemWithRounds
+) -> list[SuggestedMatch | SuggestedVirtualMatch]:
suggestions: list[SuggestedMatch] = []
- rounds = await get_rounds_for_stage(tournament_id, stage_id)
- draft_round = next(round_ for round_ in rounds if round_.id == round_id)
-
teams = await get_teams_with_members(tournament_id, only_active_teams=True)
- for i, team1 in enumerate(teams):
- for _, team2 in enumerate(teams[i + 1 :]):
- team_already_scheduled = any(
- team1.id in match.team_ids or team2.id in match.team_ids
- for match in draft_round.matches
- )
- if team_already_scheduled:
- continue
+ for round_ in stage_item.rounds:
+ round_suggestions: list[SuggestedMatch] = []
- suggestions.append(get_suggested_match(team1, team2))
+ for i, team1 in enumerate(teams):
+ for _, team2 in enumerate(teams[i + 1 :]):
+ match_already_scheduled = any(
+ team1.id in match.team_ids and team2.id in match.team_ids
+ for match in suggestions
+ ) or any(
+ team1.id in match.team_ids or team2.id in match.team_ids
+ for match in round_suggestions
+ )
+ if match_already_scheduled:
+ continue
- return suggestions
+ suggestions.append(get_suggested_match(team1, team2))
+ round_suggestions.append(get_suggested_match(team1, team2))
+
+ match = MatchCreateBody(
+ round_id=assert_some(round_.id),
+ team1_id=assert_some(team1.id),
+ team2_id=assert_some(team2.id),
+ court_id=None,
+ )
+ await create_match_and_assign_free_court(tournament_id, match)
+
+ return cast(list[SuggestedMatch | SuggestedVirtualMatch], suggestions)
+
+
+def get_number_of_rounds_to_create_round_robin(team_count: int) -> int:
+ if team_count < 1:
+ return 0
+
+ concurrency = team_count // 2
+ number_of_games = (team_count - 1) * team_count / 2
+ return math.ceil(number_of_games / concurrency)
diff --git a/backend/bracket/logic/scheduling/upcoming_matches.py b/backend/bracket/logic/scheduling/upcoming_matches.py
new file mode 100644
index 00000000..94067a79
--- /dev/null
+++ b/backend/bracket/logic/scheduling/upcoming_matches.py
@@ -0,0 +1,25 @@
+from fastapi import HTTPException
+
+from bracket.logic.scheduling.ladder_players_iter import get_possible_upcoming_matches_for_players
+from bracket.models.db.match import MatchFilter, SuggestedMatch, SuggestedVirtualMatch
+from bracket.models.db.round import Round
+from bracket.models.db.stage_item import StageType
+from bracket.sql.stages import get_full_tournament_details
+from bracket.utils.types import assert_some
+
+
+async def get_upcoming_matches_for_swiss_round(
+ match_filter: MatchFilter, round_: Round, tournament_id: int
+) -> list[SuggestedMatch | SuggestedVirtualMatch]:
+ [stage] = await get_full_tournament_details(tournament_id, stage_item_id=round_.stage_item_id)
+ assert len(stage.stage_items) == 1
+ [stage_item] = stage.stage_items
+
+ if stage_item.type is not StageType.SWISS:
+ raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
+
+ upcoming_matches = await get_possible_upcoming_matches_for_players(
+ tournament_id, match_filter, assert_some(stage_item.id), assert_some(round_.id)
+ )
+
+ return upcoming_matches
diff --git a/backend/bracket/models/db/match.py b/backend/bracket/models/db/match.py
index 4c90ef1f..8c88747e 100644
--- a/backend/bracket/models/db/match.py
+++ b/backend/bracket/models/db/match.py
@@ -9,17 +9,20 @@ from bracket.models.db.team import FullTeamWithPlayers, TeamWithPlayers
from bracket.utils.types import assert_some
-class Match(BaseModelORM):
+class MatchBase(BaseModelORM):
id: int | None = None
created: datetime_utc
round_id: int
- team1_id: int
- team2_id: int
team1_score: int
team2_score: int
court_id: int | None
+class Match(MatchBase):
+ team1_id: int
+ team2_id: int
+
+
class MatchWithDetails(Match):
team1: FullTeamWithPlayers
team2: FullTeamWithPlayers
@@ -37,6 +40,11 @@ class MatchWithDetails(Match):
def player_ids(self) -> list[int]:
return self.team1.player_ids + self.team2.player_ids
+ def get_winner(self) -> FullTeamWithPlayers | None:
+ if self.team1.elo_score == self.team2.elo_score:
+ return None
+ return self.team1 if self.team1.elo_score > self.team2.elo_score else self.team2
+
class MatchBody(BaseModelORM):
round_id: int
@@ -52,6 +60,15 @@ class MatchCreateBody(BaseModelORM):
court_id: int | None
+class MatchVirtualCreateBody(BaseModelORM):
+ round_id: int
+ court_id: int | None
+ team1_stage_item_id: int
+ team1_position_in_group: int
+ team2_stage_item_id: int
+ team2_position_in_group: int
+
+
class MatchFilter(BaseModel):
elo_diff_threshold: int
only_behind_schedule: bool
@@ -59,6 +76,13 @@ class MatchFilter(BaseModel):
iterations: int
+class SuggestedVirtualMatch(BaseModel):
+ team1_group_id: int
+ team1_position_in_group: int
+ team2_group_id: int
+ team2_position_in_group: int
+
+
class SuggestedMatch(BaseModel):
team1: TeamWithPlayers
team2: TeamWithPlayers
@@ -67,6 +91,10 @@ class SuggestedMatch(BaseModel):
is_recommended: bool
player_behind_schedule_count: int
+ @property
+ def team_ids(self) -> list[int]:
+ return [assert_some(self.team1.id), assert_some(self.team2.id)]
+
def __hash__(self) -> int:
return sum(
pow(100, i) + player.id
diff --git a/backend/bracket/models/db/players.py b/backend/bracket/models/db/players.py
new file mode 100644
index 00000000..c8bfbec6
--- /dev/null
+++ b/backend/bracket/models/db/players.py
@@ -0,0 +1,13 @@
+from decimal import Decimal
+
+from pydantic import BaseModel
+
+START_ELO: int = 1200
+
+
+class PlayerStatistics(BaseModel):
+ wins: int = 0
+ draws: int = 0
+ losses: int = 0
+ elo_score: int = START_ELO
+ swiss_score: Decimal = Decimal('0.00')
diff --git a/backend/bracket/models/db/round.py b/backend/bracket/models/db/round.py
index 2de05c21..41943b20 100644
--- a/backend/bracket/models/db/round.py
+++ b/backend/bracket/models/db/round.py
@@ -1,62 +1,17 @@
-import json
-from typing import Any
-
from heliclockter import datetime_utc
-from pydantic import root_validator, validator
-from bracket.models.db.match import Match, MatchWithDetails
from bracket.models.db.shared import BaseModelORM
-from bracket.models.db.stage import Stage, StageType
-from bracket.utils.types import assert_some
class Round(BaseModelORM):
id: int | None = None
- stage_id: int
+ stage_item_id: int
created: datetime_utc
is_draft: bool
is_active: bool = False
name: str
-class RoundWithMatches(Round):
- matches: list[MatchWithDetails]
-
- @validator('matches', pre=True)
- def handle_matches(values: list[Match]) -> list[Match]: # type: ignore[misc]
- if values == [None]:
- return []
- return values
-
- def get_team_ids(self) -> set[int]:
- return {assert_some(team.id) for match in self.matches for team in match.teams}
-
-
-class StageWithRounds(Stage):
- rounds: list[RoundWithMatches]
- type_name: str
-
- @root_validator(pre=True)
- def fill_type_name(cls, values: Any) -> Any:
- match values['type']:
- case str() as type_:
- values['type_name'] = type_.lower().capitalize().replace('_', ' ')
- case StageType() as type_:
- values['type_name'] = type_.value.lower().capitalize().replace('_', ' ')
-
- return values
-
- @validator('rounds', pre=True)
- def handle_rounds(values: list[Round]) -> list[Round]: # type: ignore[misc]
- if isinstance(values, str):
- values_json = json.loads(values)
- if values_json == [None]:
- return []
- return values_json
-
- return values
-
-
class RoundUpdateBody(BaseModelORM):
name: str
is_draft: bool
@@ -65,11 +20,11 @@ class RoundUpdateBody(BaseModelORM):
class RoundCreateBody(BaseModelORM):
name: str | None
- stage_id: int
+ stage_item_id: int
class RoundToInsert(RoundUpdateBody):
created: datetime_utc
- stage_id: int
+ stage_item_id: int
is_draft: bool = False
is_active: bool = False
diff --git a/backend/bracket/models/db/stage.py b/backend/bracket/models/db/stage.py
index 6ad50a9e..46f77031 100644
--- a/backend/bracket/models/db/stage.py
+++ b/backend/bracket/models/db/stage.py
@@ -1,35 +1,21 @@
-from enum import auto
from typing import Literal
from heliclockter import datetime_utc
from bracket.models.db.shared import BaseModelORM
-from bracket.utils.types import EnumAutoStr
-
-
-class StageType(EnumAutoStr):
- DOUBLE_ELIMINATION = auto()
- ROUND_ROBIN = auto()
- SINGLE_ELIMINATION = auto()
- SWISS = auto()
- SWISS_DYNAMIC_TEAMS = auto()
class Stage(BaseModelORM):
id: int | None = None
tournament_id: int
+ name: str
created: datetime_utc
- type: StageType
is_active: bool
class StageUpdateBody(BaseModelORM):
- is_active: bool
+ name: str
class StageActivateBody(BaseModelORM):
direction: Literal['next', 'previous'] = 'next'
-
-
-class StageCreateBody(BaseModelORM):
- type: StageType
diff --git a/backend/bracket/models/db/stage_item.py b/backend/bracket/models/db/stage_item.py
new file mode 100644
index 00000000..fb5f1308
--- /dev/null
+++ b/backend/bracket/models/db/stage_item.py
@@ -0,0 +1,52 @@
+from enum import auto
+from typing import Any
+
+from heliclockter import datetime_utc
+from pydantic import Field, root_validator
+
+from bracket.models.db.shared import BaseModelORM
+from bracket.models.db.stage_item_inputs import StageItemInputCreateBody
+from bracket.utils.types import EnumAutoStr
+
+
+class StageType(EnumAutoStr):
+ ROUND_ROBIN = auto()
+ SINGLE_ELIMINATION = auto()
+ SWISS = auto()
+
+ @property
+ def supports_dynamic_number_of_rounds(self) -> bool:
+ return self in [StageType.SWISS]
+
+
+class StageItemToInsert(BaseModelORM):
+ id: int | None = None
+ stage_id: int
+ name: str
+ created: datetime_utc
+ type: StageType
+ team_count: int = Field(ge=2, le=16)
+
+
+class StageItem(StageItemToInsert):
+ id: int
+
+
+class StageItemUpdateBody(BaseModelORM):
+ name: str
+
+
+class StageItemCreateBody(BaseModelORM):
+ stage_id: int
+ name: str | None
+ type: StageType
+ team_count: int = Field(ge=2, le=16)
+ inputs: list[StageItemInputCreateBody]
+
+ def get_name_or_default_name(self) -> str:
+ return self.name if self.name is not None else self.type.value.replace('_', ' ').title()
+
+ @root_validator(pre=True)
+ def handle_inputs_length(cls, values: Any) -> Any:
+ assert len(values['inputs']) == values['team_count']
+ return values
diff --git a/backend/bracket/models/db/stage_item_inputs.py b/backend/bracket/models/db/stage_item_inputs.py
new file mode 100644
index 00000000..836f3085
--- /dev/null
+++ b/backend/bracket/models/db/stage_item_inputs.py
@@ -0,0 +1,48 @@
+from pydantic import BaseModel, Field
+
+from bracket.models.db.shared import BaseModelORM
+
+
+class StageItemInputBase(BaseModelORM):
+ id: int | None
+ slot: int
+ tournament_id: int
+ stage_item_id: int | None
+
+
+class StageItemInputTentative(StageItemInputBase):
+ team_id: None = None
+ team_stage_item_id: int
+ team_position_in_group: int = Field(ge=1)
+
+
+class StageItemInputFinal(StageItemInputBase):
+ team_id: int
+ team_stage_item_id: None = None
+ team_position_in_group: None = None
+
+
+StageItemInput = StageItemInputTentative | StageItemInputFinal
+
+
+class StageItemInputCreateBodyTentative(BaseModel):
+ slot: int
+ team_stage_item_id: int
+ team_position_in_group: int = Field(ge=1)
+
+
+class StageItemInputCreateBodyFinal(BaseModel):
+ slot: int
+ team_id: int
+
+
+StageItemInputCreateBody = StageItemInputCreateBodyTentative | StageItemInputCreateBodyFinal
+
+
+class StageItemInputOptionFinal(BaseModel):
+ team_id: int
+
+
+class StageItemInputOptionTentative(BaseModel):
+ team_stage_item_id: int
+ team_position_in_group: int
diff --git a/backend/bracket/models/db/team.py b/backend/bracket/models/db/team.py
index 4617c31d..055f17d4 100644
--- a/backend/bracket/models/db/team.py
+++ b/backend/bracket/models/db/team.py
@@ -25,6 +25,9 @@ class TeamWithPlayers(BaseModel):
players: list[Player]
swiss_score: Decimal
elo_score: Decimal
+ wins: int
+ draws: int
+ losses: int
@classmethod
def from_players(cls, players: list[Player]) -> TeamWithPlayers:
@@ -32,6 +35,9 @@ class TeamWithPlayers(BaseModel):
players=players,
elo_score=Decimal(sum(p.elo_score for p in players) / len(players)),
swiss_score=Decimal(sum(p.swiss_score for p in players) / len(players)),
+ wins=sum(p.wins for p in players) // len(players),
+ draws=sum(p.draws for p in players) // len(players),
+ losses=sum(p.losses for p in players) // len(players),
)
@property
diff --git a/backend/bracket/models/db/util.py b/backend/bracket/models/db/util.py
new file mode 100644
index 00000000..63b9c1f0
--- /dev/null
+++ b/backend/bracket/models/db/util.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+# ruff: noqa: TCH001,TCH002
+import json
+from typing import Any
+
+from pydantic import root_validator, validator
+
+from bracket.models.db.match import Match, MatchWithDetails
+from bracket.models.db.round import Round
+from bracket.models.db.stage import Stage
+from bracket.models.db.stage_item import StageItem, StageType
+from bracket.models.db.stage_item_inputs import StageItemInput
+from bracket.utils.types import assert_some
+
+
+class RoundWithMatches(Round):
+ matches: list[MatchWithDetails]
+
+ @validator('matches', pre=True)
+ def handle_matches(values: list[Match]) -> list[Match]: # type: ignore[misc]
+ if values == [None]:
+ return []
+ return values
+
+ def get_team_ids(self) -> set[int]:
+ return {assert_some(team.id) for match in self.matches for team in match.teams}
+
+
+class StageItemWithRounds(StageItem):
+ rounds: list[RoundWithMatches]
+ inputs: list[StageItemInput]
+ type_name: str
+
+ @root_validator(pre=True)
+ def fill_type_name(cls, values: Any) -> Any:
+ match values['type']:
+ case str() as type_:
+ values['type_name'] = type_.lower().capitalize().replace('_', ' ')
+ case StageType() as type_:
+ values['type_name'] = type_.value.lower().capitalize().replace('_', ' ')
+
+ return values
+
+ @validator('rounds', 'inputs', pre=True)
+ def handle_empty_list_elements(values: list[Any] | None) -> list[Any]: # type: ignore[misc]
+ if values is None:
+ return []
+ return [value for value in values if value is not None]
+
+
+class StageWithStageItems(Stage):
+ stage_items: list[StageItemWithRounds]
+
+ @validator('stage_items', pre=True)
+ def handle_stage_items(values: list[StageItemWithRounds]) -> list[StageItemWithRounds]: # type: ignore[misc]
+ if isinstance(values, str):
+ values_json = json.loads(values)
+ if values_json == [None]:
+ return []
+ return values_json
+
+ return values
diff --git a/backend/bracket/routes/auth.py b/backend/bracket/routes/auth.py
index 574907b3..ec7bc50d 100644
--- a/backend/bracket/routes/auth.py
+++ b/backend/bracket/routes/auth.py
@@ -13,6 +13,7 @@ from bracket.database import database
from bracket.models.db.tournament import Tournament
from bracket.models.db.user import UserInDB, UserPublic
from bracket.schema import tournaments
+from bracket.sql.tournaments import sql_get_tournament_by_endpoint_name
from bracket.sql.users import get_user, get_user_access_to_club, get_user_access_to_tournament
from bracket.utils.db import fetch_all_parsed
from bracket.utils.security import pwd_context
@@ -156,6 +157,21 @@ async def user_authenticated_or_public_dashboard(
return None
+async def user_authenticated_or_public_dashboard_by_endpoint_name(
+ token: str = Depends(oauth2_scheme), endpoint_name: str | None = None
+) -> UserPublic | None:
+ if endpoint_name is not None:
+ if await sql_get_tournament_by_endpoint_name(endpoint_name) is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ return None
+
+ return await user_authenticated(token)
+
+
@router.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()) -> Token:
user = await authenticate_user(form_data.username, form_data.password)
diff --git a/backend/bracket/routes/clubs.py b/backend/bracket/routes/clubs.py
index 365acd72..c66dcbc6 100644
--- a/backend/bracket/routes/clubs.py
+++ b/backend/bracket/routes/clubs.py
@@ -30,7 +30,7 @@ async def delete_club(
return SuccessResponse()
-@router.patch("/clubs/{club_id}", response_model=ClubResponse)
+@router.put("/clubs/{club_id}", response_model=ClubResponse)
async def update_club(
club_id: int, club: ClubUpdateBody, _: UserPublic = Depends(user_authenticated_for_club)
) -> ClubResponse:
diff --git a/backend/bracket/routes/courts.py b/backend/bracket/routes/courts.py
index 7bd4bce0..2ac6200e 100644
--- a/backend/bracket/routes/courts.py
+++ b/backend/bracket/routes/courts.py
@@ -5,11 +5,14 @@ from starlette import status
from bracket.database import database
from bracket.models.db.court import Court, CourtBody, CourtToInsert
from bracket.models.db.user import UserPublic
-from bracket.routes.auth import user_authenticated_for_tournament
+from bracket.routes.auth import (
+ user_authenticated_for_tournament,
+ user_authenticated_or_public_dashboard,
+)
from bracket.routes.models import CourtsResponse, SingleCourtResponse, SuccessResponse
from bracket.schema import courts
from bracket.sql.courts import get_all_courts_in_tournament, update_court
-from bracket.sql.stages import get_stages_with_rounds_and_matches
+from bracket.sql.stages import get_full_tournament_details
from bracket.utils.db import fetch_one_parsed
from bracket.utils.types import assert_some
@@ -19,12 +22,12 @@ router = APIRouter()
@router.get("/tournaments/{tournament_id}/courts", response_model=CourtsResponse)
async def get_courts(
tournament_id: int,
- _: UserPublic = Depends(user_authenticated_for_tournament),
+ _: UserPublic = Depends(user_authenticated_or_public_dashboard),
) -> CourtsResponse:
return CourtsResponse(data=await get_all_courts_in_tournament(tournament_id))
-@router.patch("/tournaments/{tournament_id}/courts/{court_id}", response_model=SingleCourtResponse)
+@router.put("/tournaments/{tournament_id}/courts/{court_id}", response_model=SingleCourtResponse)
async def update_court_by_id(
tournament_id: int,
court_id: int,
@@ -53,13 +56,14 @@ async def update_court_by_id(
async def delete_court(
tournament_id: int, court_id: int, _: UserPublic = Depends(user_authenticated_for_tournament)
) -> SuccessResponse:
- stages = await get_stages_with_rounds_and_matches(tournament_id, no_draft_rounds=False)
+ stages = await get_full_tournament_details(tournament_id, no_draft_rounds=False)
used_in_matches_count = 0
for stage in stages:
- for round_ in stage.rounds:
- for match in round_.matches:
- if match.court_id == court_id:
- used_in_matches_count += 1
+ for stage_item in stage.stage_items:
+ for round_ in stage_item.rounds:
+ for match in round_.matches:
+ if match.court_id == court_id:
+ used_in_matches_count += 1
if used_in_matches_count > 0:
raise HTTPException(
diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py
index 7a0c4c93..b55c2a4b 100644
--- a/backend/bracket/routes/matches.py
+++ b/backend/bracket/routes/matches.py
@@ -1,19 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException
from bracket.logic.elo import recalculate_elo_for_tournament_id
-from bracket.logic.scheduling.ladder_players_iter import get_possible_upcoming_matches_for_players
-from bracket.logic.scheduling.round_robin import get_possible_upcoming_matches_round_robin
-from bracket.models.db.match import Match, MatchBody, MatchCreateBody, MatchFilter
+from bracket.logic.matches import create_match_and_assign_free_court
+from bracket.logic.scheduling.upcoming_matches import (
+ get_upcoming_matches_for_swiss_round,
+)
+from bracket.models.db.match import Match, MatchBody, MatchCreateBody, MatchFilter, SuggestedMatch
from bracket.models.db.round import Round
-from bracket.models.db.stage import StageType
from bracket.models.db.user import UserPublic
from bracket.routes.auth import user_authenticated_for_tournament
from bracket.routes.models import SingleMatchResponse, SuccessResponse, UpcomingMatchesResponse
from bracket.routes.util import match_dependency, round_dependency
-from bracket.sql.courts import get_all_free_courts_in_round
-from bracket.sql.matches import sql_create_match, sql_delete_match, sql_update_match
-from bracket.sql.stages import get_stages_with_rounds_and_matches
-from bracket.sql.tournaments import sql_get_tournament
+from bracket.sql.matches import sql_delete_match, sql_update_match
from bracket.utils.types import assert_some
router = APIRouter()
@@ -42,22 +40,9 @@ async def get_matches_to_schedule(
if not round_.is_draft:
raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
- [stage] = await get_stages_with_rounds_and_matches(tournament_id, stage_id=round_.stage_id)
- match stage.type:
- case StageType.ROUND_ROBIN:
- upcoming_matches = await get_possible_upcoming_matches_round_robin(
- tournament_id, assert_some(stage.id), assert_some(round_.id)
- )
-
- case StageType.SWISS:
- upcoming_matches = await get_possible_upcoming_matches_for_players(
- tournament_id, match_filter, assert_some(stage.id), assert_some(round_.id)
- )
-
- case _:
- raise NotImplementedError(f'Cannot suggest matches for stage type {stage.type}')
-
- return UpcomingMatchesResponse(data=upcoming_matches)
+ return UpcomingMatchesResponse(
+ data=await get_upcoming_matches_for_swiss_round(match_filter, round_, tournament_id)
+ )
@router.delete("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse)
@@ -77,20 +62,59 @@ async def create_match(
match_body: MatchCreateBody,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SingleMatchResponse:
- tournament = await sql_get_tournament(tournament_id)
- next_free_court_id = None
-
- if tournament.auto_assign_courts:
- free_courts = await get_all_free_courts_in_round(tournament_id, match_body.round_id)
- if len(free_courts) > 0:
- next_free_court_id = free_courts[0].id
-
- match_body = match_body.copy(update={'court_id': next_free_court_id})
- match = await sql_create_match(match_body)
- return SingleMatchResponse(data=match)
+ return SingleMatchResponse(
+ data=await create_match_and_assign_free_court(tournament_id, match_body)
+ )
-@router.patch("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse)
+@router.post(
+ "/tournaments/{tournament_id}/rounds/{round_id}/schedule_auto",
+ response_model=SuccessResponse,
+)
+async def create_matches_automatically(
+ tournament_id: int,
+ elo_diff_threshold: int = 100,
+ iterations: int = 200,
+ only_behind_schedule: bool = False,
+ _: UserPublic = Depends(user_authenticated_for_tournament),
+ round_: Round = Depends(round_dependency),
+) -> SuccessResponse:
+ if not round_.is_draft:
+ raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
+
+ match_filter = MatchFilter(
+ elo_diff_threshold=elo_diff_threshold,
+ only_behind_schedule=only_behind_schedule,
+ limit=1,
+ iterations=iterations,
+ )
+
+ limit = 15
+ for __ in range(limit):
+ all_matches_to_schedule = await get_upcoming_matches_for_swiss_round(
+ match_filter, round_, tournament_id
+ )
+ if len(all_matches_to_schedule) < 1:
+ break
+
+ match = all_matches_to_schedule[0]
+ assert isinstance(match, SuggestedMatch)
+
+ assert round_.id and match.team1.id and match.team2.id
+ await create_match_and_assign_free_court(
+ tournament_id,
+ MatchCreateBody(
+ round_id=round_.id,
+ team1_id=match.team1.id,
+ team2_id=match.team2.id,
+ court_id=None,
+ ),
+ )
+
+ return SuccessResponse()
+
+
+@router.put("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse)
async def update_match_by_id(
tournament_id: int,
match_body: MatchBody,
diff --git a/backend/bracket/routes/models.py b/backend/bracket/routes/models.py
index 7e2cb4fd..0b0c38b6 100644
--- a/backend/bracket/routes/models.py
+++ b/backend/bracket/routes/models.py
@@ -5,12 +5,16 @@ from pydantic.generics import GenericModel
from bracket.models.db.club import Club
from bracket.models.db.court import Court
-from bracket.models.db.match import Match, SuggestedMatch
+from bracket.models.db.match import Match, SuggestedMatch, SuggestedVirtualMatch
from bracket.models.db.player import Player
-from bracket.models.db.round import StageWithRounds
+from bracket.models.db.stage_item_inputs import (
+ StageItemInputOptionFinal,
+ StageItemInputOptionTentative,
+)
from bracket.models.db.team import FullTeamWithPlayers, Team
from bracket.models.db.tournament import Tournament
from bracket.models.db.user import UserPublic
+from bracket.models.db.util import StageWithStageItems
from bracket.routes.auth import Token
DataT = TypeVar('DataT')
@@ -48,11 +52,11 @@ class SinglePlayerResponse(DataResponse[Player]):
pass
-class RoundsWithMatchesResponse(DataResponse[list[StageWithRounds]]):
+class StagesWithStageItemsResponse(DataResponse[list[StageWithStageItems]]):
pass
-class UpcomingMatchesResponse(DataResponse[list[SuggestedMatch]]):
+class UpcomingMatchesResponse(DataResponse[list[SuggestedMatch | SuggestedVirtualMatch]]):
pass
@@ -82,3 +86,9 @@ class CourtsResponse(DataResponse[list[Court]]):
class SingleCourtResponse(DataResponse[Court]):
pass
+
+
+class StageItemInputOptionsResponse(
+ DataResponse[list[StageItemInputOptionTentative | StageItemInputOptionFinal]]
+):
+ pass
diff --git a/backend/bracket/routes/players.py b/backend/bracket/routes/players.py
index 16bf4062..f312a3bc 100644
--- a/backend/bracket/routes/players.py
+++ b/backend/bracket/routes/players.py
@@ -28,9 +28,7 @@ async def get_players(
return PlayersResponse(data=await fetch_all_parsed(database, Player, query))
-@router.patch(
- "/tournaments/{tournament_id}/players/{player_id}", response_model=SinglePlayerResponse
-)
+@router.put("/tournaments/{tournament_id}/players/{player_id}", response_model=SinglePlayerResponse)
async def update_player_by_id(
tournament_id: int,
player_id: int,
diff --git a/backend/bracket/routes/rounds.py b/backend/bracket/routes/rounds.py
index 53724b4a..49ed9907 100644
--- a/backend/bracket/routes/rounds.py
+++ b/backend/bracket/routes/rounds.py
@@ -9,14 +9,15 @@ from bracket.models.db.round import (
RoundCreateBody,
RoundToInsert,
RoundUpdateBody,
- RoundWithMatches,
)
from bracket.models.db.user import UserPublic
+from bracket.models.db.util import RoundWithMatches
from bracket.routes.auth import user_authenticated_for_tournament
from bracket.routes.models import SuccessResponse
from bracket.routes.util import round_dependency, round_with_matches_dependency
from bracket.schema import rounds
from bracket.sql.rounds import get_next_round_name
+from bracket.sql.stage_items import get_stage_item
router = APIRouter()
@@ -49,18 +50,32 @@ async def create_round(
round_body: RoundCreateBody,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
+ stage_item = await get_stage_item(tournament_id, stage_item_id=round_body.stage_item_id)
+
+ if stage_item is None:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Stage item doesn't exist",
+ )
+
+ if not stage_item.type.supports_dynamic_number_of_rounds:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Stage type {stage_item.type} doesn't support manual creation of rounds",
+ )
+
await database.execute(
query=rounds.insert(),
values=RoundToInsert(
created=datetime_utc.now(),
- stage_id=round_body.stage_id,
- name=await get_next_round_name(tournament_id, round_body.stage_id),
+ stage_item_id=round_body.stage_item_id,
+ name=await get_next_round_name(tournament_id, round_body.stage_item_id),
).dict(),
)
return SuccessResponse()
-@router.patch("/tournaments/{tournament_id}/rounds/{round_id}", response_model=SuccessResponse)
+@router.put("/tournaments/{tournament_id}/rounds/{round_id}", response_model=SuccessResponse)
async def update_round_by_id(
tournament_id: int,
round_id: int,
@@ -83,7 +98,8 @@ async def update_round_by_id(
WHERE rounds.id IN (
SELECT rounds.id
FROM rounds
- JOIN stages s on s.id = rounds.stage_id
+ JOIN stage_items ON rounds.stage_item_id = stage_items.id
+ JOIN stages s on s.id = stage_items.stage_id
WHERE s.tournament_id = :tournament_id
)
'''
@@ -97,7 +113,8 @@ async def update_round_by_id(
WHERE rounds.id IN (
SELECT rounds.id
FROM rounds
- JOIN stages s on s.id = rounds.stage_id
+ JOIN stage_items ON rounds.stage_item_id = stage_items.id
+ JOIN stages s on s.id = stage_items.stage_id
WHERE s.tournament_id = :tournament_id
)
AND rounds.id = :round_id
diff --git a/backend/bracket/routes/stage_items.py b/backend/bracket/routes/stage_items.py
new file mode 100644
index 00000000..ddc366a4
--- /dev/null
+++ b/backend/bracket/routes/stage_items.py
@@ -0,0 +1,80 @@
+from fastapi import APIRouter, Depends, HTTPException
+from starlette import status
+
+from bracket.database import database
+from bracket.logic.elo import recalculate_elo_for_tournament_id
+from bracket.logic.scheduling.builder import (
+ build_matches_for_stage_item,
+)
+from bracket.models.db.stage_item import StageItemCreateBody, StageItemUpdateBody
+from bracket.models.db.user import UserPublic
+from bracket.models.db.util import StageItemWithRounds
+from bracket.routes.auth import (
+ user_authenticated_for_tournament,
+)
+from bracket.routes.models import SuccessResponse
+from bracket.routes.util import stage_item_dependency
+from bracket.sql.matches import sql_delete_matches_for_stage_item_id
+from bracket.sql.rounds import sql_delete_rounds_for_stage_item_id
+from bracket.sql.stage_item_inputs import sql_delete_stage_item_inputs
+from bracket.sql.stage_items import sql_create_stage_item, sql_delete_stage_item
+
+router = APIRouter()
+
+
+@router.delete(
+ "/tournaments/{tournament_id}/stage_items/{stage_item_id}", response_model=SuccessResponse
+)
+async def delete_stage_item(
+ tournament_id: int,
+ stage_item_id: int,
+ _: UserPublic = Depends(user_authenticated_for_tournament),
+ stage_item: StageItemWithRounds = Depends(stage_item_dependency),
+) -> SuccessResponse:
+ async with database.transaction():
+ await sql_delete_matches_for_stage_item_id(stage_item_id)
+ await sql_delete_rounds_for_stage_item_id(stage_item_id)
+ await sql_delete_stage_item_inputs(stage_item_id)
+ await sql_delete_stage_item(stage_item_id)
+
+ await recalculate_elo_for_tournament_id(tournament_id)
+ return SuccessResponse()
+
+
+@router.post("/tournaments/{tournament_id}/stage_items", response_model=SuccessResponse)
+async def create_stage_item(
+ tournament_id: int,
+ stage_body: StageItemCreateBody,
+ _: UserPublic = Depends(user_authenticated_for_tournament),
+) -> SuccessResponse:
+ if stage_body.team_count != len(stage_body.inputs):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Team count doesn't match number of inputs",
+ )
+
+ stage_item = await sql_create_stage_item(tournament_id, stage_body)
+ await build_matches_for_stage_item(stage_item, tournament_id)
+ return SuccessResponse()
+
+
+@router.put(
+ "/tournaments/{tournament_id}/stage_items/{stage_item_id}", response_model=SuccessResponse
+)
+async def update_stage_item(
+ tournament_id: int,
+ stage_item_id: int,
+ stage_item_body: StageItemUpdateBody,
+ _: UserPublic = Depends(user_authenticated_for_tournament),
+ stage_item: StageItemWithRounds = Depends(stage_item_dependency),
+) -> SuccessResponse:
+ query = '''
+ UPDATE stage_items
+ SET name = :name
+ WHERE stage_items.id = :stage_item_id
+ '''
+ await database.execute(
+ query=query,
+ values={'stage_item_id': stage_item_id, 'name': stage_item_body.name},
+ )
+ return SuccessResponse()
diff --git a/backend/bracket/routes/stages.py b/backend/bracket/routes/stages.py
index f9035520..9702ad27 100644
--- a/backend/bracket/routes/stages.py
+++ b/backend/bracket/routes/stages.py
@@ -3,42 +3,46 @@ from starlette import status
from bracket.database import database
from bracket.logic.elo import recalculate_elo_for_tournament_id
-from bracket.models.db.round import StageWithRounds
-from bracket.models.db.stage import Stage, StageActivateBody, StageCreateBody, StageUpdateBody
+from bracket.logic.scheduling.builder import determine_available_inputs
+from bracket.models.db.stage import Stage, StageActivateBody, StageUpdateBody
from bracket.models.db.user import UserPublic
+from bracket.models.db.util import StageWithStageItems
from bracket.routes.auth import (
user_authenticated_for_tournament,
user_authenticated_or_public_dashboard,
)
-from bracket.routes.models import RoundsWithMatchesResponse, SuccessResponse
+from bracket.routes.models import (
+ StageItemInputOptionsResponse,
+ StagesWithStageItemsResponse,
+ SuccessResponse,
+)
from bracket.routes.util import stage_dependency
from bracket.sql.stages import (
+ get_full_tournament_details,
get_next_stage_in_tournament,
- get_stages_with_rounds_and_matches,
sql_activate_next_stage,
sql_create_stage,
sql_delete_stage,
)
+from bracket.sql.teams import get_teams_with_members
router = APIRouter()
-@router.get("/tournaments/{tournament_id}/stages", response_model=RoundsWithMatchesResponse)
+@router.get("/tournaments/{tournament_id}/stages", response_model=StagesWithStageItemsResponse)
async def get_stages(
tournament_id: int,
user: UserPublic = Depends(user_authenticated_or_public_dashboard),
no_draft_rounds: bool = False,
-) -> RoundsWithMatchesResponse:
+) -> StagesWithStageItemsResponse:
if no_draft_rounds is False and user is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Can't view draft rounds when not authorized",
)
- stages_ = await get_stages_with_rounds_and_matches(
- tournament_id, no_draft_rounds=no_draft_rounds
- )
- return RoundsWithMatchesResponse(data=stages_)
+ stages_ = await get_full_tournament_details(tournament_id, no_draft_rounds=no_draft_rounds)
+ return StagesWithStageItemsResponse(data=stages_)
@router.delete("/tournaments/{tournament_id}/stages/{stage_id}", response_model=SuccessResponse)
@@ -46,12 +50,12 @@ async def delete_stage(
tournament_id: int,
stage_id: int,
_: UserPublic = Depends(user_authenticated_for_tournament),
- stage: StageWithRounds = Depends(stage_dependency),
+ stage: StageWithStageItems = Depends(stage_dependency),
) -> SuccessResponse:
- if len(stage.rounds) > 0:
+ if len(stage.stage_items) > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail="Stage contains rounds, please delete those first",
+ detail="Stage contains stage items, please delete those first",
)
if stage.is_active:
@@ -69,14 +73,13 @@ async def delete_stage(
@router.post("/tournaments/{tournament_id}/stages", response_model=SuccessResponse)
async def create_stage(
tournament_id: int,
- stage_body: StageCreateBody,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
- await sql_create_stage(stage_body, tournament_id)
+ await sql_create_stage(tournament_id)
return SuccessResponse()
-@router.patch("/tournaments/{tournament_id}/stages/{stage_id}", response_model=SuccessResponse)
+@router.put("/tournaments/{tournament_id}/stages/{stage_id}", response_model=SuccessResponse)
async def update_stage(
tournament_id: int,
stage_id: int,
@@ -87,13 +90,13 @@ async def update_stage(
values = {'tournament_id': tournament_id, 'stage_id': stage_id}
query = '''
UPDATE stages
- SET is_active = :is_active
+ SET name = :name
WHERE stages.id = :stage_id
AND stages.tournament_id = :tournament_id
'''
await database.execute(
query=query,
- values={**values, 'is_active': stage_body.is_active},
+ values={**values, 'name': stage_body.name},
)
return SuccessResponse()
@@ -108,8 +111,24 @@ async def activate_next_stage(
if new_active_stage_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail="There is no next stage",
+ detail=f"There is no {stage_body.direction} stage",
)
await sql_activate_next_stage(new_active_stage_id, tournament_id)
return SuccessResponse()
+
+
+@router.get(
+ "/tournaments/{tournament_id}/stages/{stage_id}/available_inputs",
+ response_model=StageItemInputOptionsResponse,
+)
+async def get_available_inputs(
+ tournament_id: int,
+ stage_id: int,
+ _: UserPublic = Depends(user_authenticated_for_tournament),
+ stage: Stage = Depends(stage_dependency),
+) -> StageItemInputOptionsResponse:
+ stages = await get_full_tournament_details(tournament_id)
+ teams = await get_teams_with_members(tournament_id)
+ available_inputs = determine_available_inputs(stage_id, teams, stages)
+ return StageItemInputOptionsResponse(data=available_inputs)
diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py
index 85464984..aa328d29 100644
--- a/backend/bracket/routes/teams.py
+++ b/backend/bracket/routes/teams.py
@@ -6,11 +6,14 @@ from bracket.database import database
from bracket.logic.elo import recalculate_elo_for_tournament_id
from bracket.models.db.team import FullTeamWithPlayers, Team, TeamBody, TeamToInsert
from bracket.models.db.user import UserPublic
-from bracket.routes.auth import user_authenticated_for_tournament
+from bracket.routes.auth import (
+ user_authenticated_for_tournament,
+ user_authenticated_or_public_dashboard,
+)
from bracket.routes.models import SingleTeamResponse, SuccessResponse, TeamsWithPlayersResponse
from bracket.routes.util import team_dependency, team_with_players_dependency
from bracket.schema import players_x_teams, teams
-from bracket.sql.stages import get_stages_with_rounds_and_matches
+from bracket.sql.stages import get_full_tournament_details
from bracket.sql.teams import get_team_by_id, get_teams_with_members
from bracket.utils.db import fetch_one_parsed
from bracket.utils.types import assert_some
@@ -41,12 +44,12 @@ async def update_team_members(team_id: int, tournament_id: int, player_ids: list
@router.get("/tournaments/{tournament_id}/teams", response_model=TeamsWithPlayersResponse)
async def get_teams(
- tournament_id: int, _: UserPublic = Depends(user_authenticated_for_tournament)
+ tournament_id: int, _: UserPublic = Depends(user_authenticated_or_public_dashboard)
) -> TeamsWithPlayersResponse:
return TeamsWithPlayersResponse.parse_obj({'data': await get_teams_with_members(tournament_id)})
-@router.patch("/tournaments/{tournament_id}/teams/{team_id}", response_model=SingleTeamResponse)
+@router.put("/tournaments/{tournament_id}/teams/{team_id}", response_model=SingleTeamResponse)
async def update_team_by_id(
tournament_id: int,
team_body: TeamBody,
@@ -60,6 +63,7 @@ async def update_team_by_id(
values=team_body.dict(exclude={'player_ids'}),
)
await update_team_members(assert_some(team.id), tournament_id, team_body.player_ids)
+ await recalculate_elo_for_tournament_id(tournament_id)
return SingleTeamResponse(
data=assert_some(
@@ -80,14 +84,15 @@ async def delete_team(
_: UserPublic = Depends(user_authenticated_for_tournament),
team: FullTeamWithPlayers = Depends(team_with_players_dependency),
) -> SuccessResponse:
- stages = await get_stages_with_rounds_and_matches(tournament_id, no_draft_rounds=False)
+ stages = await get_full_tournament_details(tournament_id, no_draft_rounds=False)
for stage in stages:
- for round_ in stage.rounds:
- if team.id in round_.get_team_ids():
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Could not delete team that participates in matches in the tournament",
- )
+ for stage_item in stage.stage_items:
+ for round_ in stage_item.rounds:
+ if team.id in round_.get_team_ids():
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Could not delete team that participates in matches",
+ )
if len(team.players):
raise HTTPException(
@@ -112,7 +117,7 @@ async def create_team(
) -> SingleTeamResponse:
tournament_teams = await get_teams_with_members(tournament_id)
for team in tournament_teams:
- if sorted(team.player_ids) == sorted(team_to_insert.player_ids):
+ if team.player_ids != [] and sorted(team.player_ids) == sorted(team_to_insert.player_ids):
return SingleTeamResponse(data=team)
last_record_id = await database.execute(
diff --git a/backend/bracket/routes/tournaments.py b/backend/bracket/routes/tournaments.py
index dc84c55d..7f5d0ba5 100644
--- a/backend/bracket/routes/tournaments.py
+++ b/backend/bracket/routes/tournaments.py
@@ -14,15 +14,21 @@ from bracket.routes.auth import (
user_authenticated,
user_authenticated_for_tournament,
user_authenticated_or_public_dashboard,
+ user_authenticated_or_public_dashboard_by_endpoint_name,
)
from bracket.routes.models import SuccessResponse, TournamentResponse, TournamentsResponse
from bracket.schema import tournaments
-from bracket.sql.tournaments import sql_get_tournaments
+from bracket.sql.tournaments import sql_get_tournament_by_endpoint_name, sql_get_tournaments
from bracket.sql.users import get_user_access_to_club, get_which_clubs_has_user_access_to
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.types import assert_some
router = APIRouter()
+unauthorized_exception = HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="You don't have access to this tournament",
+ headers={"WWW-Authenticate": "Bearer"},
+)
@router.get("/tournaments/{tournament_id}", response_model=TournamentResponse)
@@ -33,24 +39,35 @@ async def get_tournament(
database, Tournament, tournaments.select().where(tournaments.c.id == tournament_id)
)
if user is None and not tournament.dashboard_public:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="You don't have access to this tournament",
- headers={"WWW-Authenticate": "Bearer"},
- )
+ raise unauthorized_exception
return TournamentResponse(data=tournament)
@router.get("/tournaments", response_model=TournamentsResponse)
async def get_tournaments(
- user: UserPublic = Depends(user_authenticated), endpoint_name: str | None = None
+ user: UserPublic | None = Depends(user_authenticated_or_public_dashboard_by_endpoint_name),
+ endpoint_name: str | None = None,
) -> TournamentsResponse:
- user_club_ids = await get_which_clubs_has_user_access_to(assert_some(user.id))
- return TournamentsResponse(data=await sql_get_tournaments(tuple(user_club_ids), endpoint_name))
+ match user, endpoint_name:
+ case None, None:
+ raise unauthorized_exception
+
+ case _, str(endpoint_name):
+ return TournamentsResponse(
+ data=[await sql_get_tournament_by_endpoint_name(endpoint_name)]
+ )
+
+ case _, _ if isinstance(user, UserPublic):
+ user_club_ids = await get_which_clubs_has_user_access_to(assert_some(user.id))
+ return TournamentsResponse(
+ data=await sql_get_tournaments(tuple(user_club_ids), endpoint_name)
+ )
+
+ raise RuntimeError()
-@router.patch("/tournaments/{tournament_id}", response_model=SuccessResponse)
+@router.put("/tournaments/{tournament_id}", response_model=SuccessResponse)
async def update_tournament_by_id(
tournament_id: int,
tournament_body: TournamentUpdateBody,
diff --git a/backend/bracket/routes/users.py b/backend/bracket/routes/users.py
index 7735f64d..0c1596bc 100644
--- a/backend/bracket/routes/users.py
+++ b/backend/bracket/routes/users.py
@@ -40,8 +40,8 @@ async def get_user(
return UserPublicResponse(data=user_public)
-@router.patch("/users/{user_id}", response_model=UserPublicResponse)
-async def patch_user(
+@router.put("/users/{user_id}", response_model=UserPublicResponse)
+async def put_user(
user_id: int,
user_to_update: UserToUpdate,
user_public: UserPublic = Depends(user_authenticated),
@@ -54,8 +54,8 @@ async def patch_user(
return UserPublicResponse(data=assert_some(user_updated))
-@router.patch("/users/{user_id}/password", response_model=SuccessResponse)
-async def patch_user_password(
+@router.put("/users/{user_id}/password", response_model=SuccessResponse)
+async def put_user_password(
user_id: int,
user_to_update: UserPasswordToUpdate,
user_public: UserPublic = Depends(user_authenticated),
diff --git a/backend/bracket/routes/util.py b/backend/bracket/routes/util.py
index ebc6564d..ac6609ad 100644
--- a/backend/bracket/routes/util.py
+++ b/backend/bracket/routes/util.py
@@ -3,10 +3,12 @@ from starlette import status
from bracket.database import database
from bracket.models.db.match import Match
-from bracket.models.db.round import Round, RoundWithMatches, StageWithRounds
+from bracket.models.db.round import Round
from bracket.models.db.team import FullTeamWithPlayers, Team
+from bracket.models.db.util import RoundWithMatches, StageItemWithRounds, StageWithStageItems
from bracket.schema import matches, rounds, teams
-from bracket.sql.stages import get_stages_with_rounds_and_matches
+from bracket.sql.stage_items import get_stage_item
+from bracket.sql.stages import get_full_tournament_details
from bracket.sql.teams import get_teams_with_members
from bracket.utils.db import fetch_one_parsed
@@ -28,14 +30,15 @@ async def round_dependency(tournament_id: int, round_id: int) -> Round:
async def round_with_matches_dependency(tournament_id: int, round_id: int) -> RoundWithMatches:
- stages = await get_stages_with_rounds_and_matches(
+ stages = await get_full_tournament_details(
tournament_id, no_draft_rounds=False, round_id=round_id
)
for stage in stages:
- for round_ in stage.rounds:
- if round_ is not None:
- return round_
+ for stage_item in stage.stage_items:
+ for round_ in stage_item.rounds:
+ if round_ is not None:
+ return round_
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -43,8 +46,8 @@ async def round_with_matches_dependency(tournament_id: int, round_id: int) -> Ro
)
-async def stage_dependency(tournament_id: int, stage_id: int) -> StageWithRounds:
- stages = await get_stages_with_rounds_and_matches(
+async def stage_dependency(tournament_id: int, stage_id: int) -> StageWithStageItems:
+ stages = await get_full_tournament_details(
tournament_id, no_draft_rounds=False, stage_id=stage_id
)
@@ -57,6 +60,18 @@ async def stage_dependency(tournament_id: int, stage_id: int) -> StageWithRounds
return stages[0]
+async def stage_item_dependency(tournament_id: int, stage_item_id: int) -> StageItemWithRounds:
+ stage_item = await get_stage_item(tournament_id, stage_item_id=stage_item_id)
+
+ if stage_item is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Could not find stage item with id {stage_item_id}",
+ )
+
+ return stage_item
+
+
async def match_dependency(tournament_id: int, match_id: int) -> Match:
match = await fetch_one_parsed(
database,
diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py
index 36569bca..a26bff59 100644
--- a/backend/bracket/schema.py
+++ b/backend/bracket/schema.py
@@ -20,7 +20,7 @@ tournaments = Table(
Column('id', BigInteger, primary_key=True, index=True),
Column('name', String, nullable=False, index=True),
Column('created', DateTimeTZ, nullable=False),
- Column('club_id', BigInteger, ForeignKey('clubs.id'), nullable=False),
+ Column('club_id', BigInteger, ForeignKey('clubs.id'), index=True, nullable=False),
Column('dashboard_public', Boolean, nullable=False),
Column('logo_path', String, nullable=True),
Column('dashboard_endpoint', String, nullable=True),
@@ -32,16 +32,25 @@ stages = Table(
'stages',
metadata,
Column('id', BigInteger, primary_key=True, index=True),
+ Column('name', String, nullable=False, index=True),
Column('created', DateTimeTZ, nullable=False),
- Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), nullable=False),
+ Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), index=True, nullable=False),
Column('is_active', Boolean, nullable=False, server_default='false'),
+)
+
+stage_items = Table(
+ 'stage_items',
+ metadata,
+ Column('id', BigInteger, primary_key=True, index=True),
+ Column('name', Text, nullable=False),
+ Column('created', DateTimeTZ, nullable=False, server_default='now()'),
+ Column('stage_id', BigInteger, ForeignKey('stages.id'), index=True, nullable=False),
+ Column('team_count', Integer, nullable=False),
Column(
'type',
Enum(
'SINGLE_ELIMINATION',
- 'DOUBLE_ELIMINATION',
'SWISS',
- 'SWISS_DYNAMIC_TEAMS',
'ROUND_ROBIN',
name='stage_type',
),
@@ -49,6 +58,24 @@ stages = Table(
),
)
+stage_item_inputs = Table(
+ 'stage_item_inputs',
+ metadata,
+ Column('id', BigInteger, primary_key=True, index=True),
+ Column('slot', Integer, nullable=False),
+ Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), index=True, nullable=False),
+ Column(
+ 'stage_item_id',
+ BigInteger,
+ ForeignKey('stage_items.id', ondelete="CASCADE"),
+ index=True,
+ nullable=False,
+ ),
+ Column('team_id', BigInteger, ForeignKey('teams.id'), nullable=True),
+ Column('team_stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=True),
+ Column('team_position_in_group', Integer, nullable=True),
+)
+
rounds = Table(
'rounds',
metadata,
@@ -57,17 +84,22 @@ rounds = Table(
Column('created', DateTimeTZ, nullable=False),
Column('is_draft', Boolean, nullable=False),
Column('is_active', Boolean, nullable=False, server_default='false'),
- Column('stage_id', BigInteger, ForeignKey('stages.id'), nullable=False),
+ Column('stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=False),
)
+
matches = Table(
'matches',
metadata,
Column('id', BigInteger, primary_key=True, index=True),
Column('created', DateTimeTZ, nullable=False),
Column('round_id', BigInteger, ForeignKey('rounds.id'), nullable=False),
- Column('team1_id', BigInteger, ForeignKey('teams.id'), nullable=False),
- Column('team2_id', BigInteger, ForeignKey('teams.id'), nullable=False),
+ Column('team1_id', BigInteger, ForeignKey('teams.id'), nullable=True),
+ Column('team2_id', BigInteger, ForeignKey('teams.id'), nullable=True),
+ Column('team1_stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=True),
+ Column('team2_stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=True),
+ Column('team1_position_in_group', Integer, nullable=True),
+ Column('team2_position_in_group', Integer, nullable=True),
Column('court_id', BigInteger, ForeignKey('courts.id'), nullable=True),
Column('team1_score', Integer, nullable=False),
Column('team2_score', Integer, nullable=False),
@@ -79,7 +111,7 @@ teams = Table(
Column('id', BigInteger, primary_key=True, index=True),
Column('name', String, nullable=False, index=True),
Column('created', DateTimeTZ, nullable=False),
- Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), nullable=False),
+ Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), index=True, nullable=False),
Column('active', Boolean, nullable=False, index=True, server_default='t'),
)
@@ -89,7 +121,7 @@ players = Table(
Column('id', BigInteger, primary_key=True, index=True),
Column('name', String, nullable=False, index=True, unique=True),
Column('created', DateTimeTZ, nullable=False),
- Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), nullable=False),
+ Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), index=True, nullable=False),
Column('elo_score', Float, nullable=False),
Column('swiss_score', Float, nullable=False),
Column('wins', Integer, nullable=False),
diff --git a/backend/bracket/sql/matches.py b/backend/bracket/sql/matches.py
index f9553064..d5980644 100644
--- a/backend/bracket/sql/matches.py
+++ b/backend/bracket/sql/matches.py
@@ -1,5 +1,5 @@
from bracket.database import database
-from bracket.models.db.match import Match, MatchBody, MatchCreateBody
+from bracket.models.db.match import Match, MatchBody, MatchCreateBody, MatchVirtualCreateBody
async def sql_delete_match(match_id: int) -> None:
@@ -10,6 +10,19 @@ async def sql_delete_match(match_id: int) -> None:
await database.execute(query=query, values={'match_id': match_id})
+async def sql_delete_matches_for_stage_item_id(stage_item_id: int) -> None:
+ query = '''
+ DELETE FROM matches
+ WHERE matches.id IN (
+ SELECT matches.id
+ FROM matches
+ LEFT JOIN rounds ON matches.round_id = rounds.id
+ WHERE rounds.stage_item_id = :stage_item_id
+ )
+ '''
+ await database.execute(query=query, values={'stage_item_id': stage_item_id})
+
+
async def sql_create_match(match: MatchCreateBody) -> Match:
query = '''
INSERT INTO matches (
@@ -23,7 +36,41 @@ async def sql_create_match(match: MatchCreateBody) -> Match:
)
VALUES (:round_id, :team1_id, :team2_id, 0, 0, :court_id, NOW())
RETURNING *
- '''
+ '''
+ result = await database.fetch_one(query=query, values=match.dict())
+
+ if result is None:
+ raise ValueError('Could not create stage')
+
+ return Match.parse_obj(result._mapping)
+
+
+async def todo_sql_create_virtual_match(match: MatchVirtualCreateBody) -> Match:
+ query = '''
+ INSERT INTO matches (
+ round_id,
+ court_id,
+ team1_stage_item_id,
+ team2_stage_item_id,
+ team1_position_in_group,
+ team2_position_in_group,
+ team1_score,
+ team2_score,
+ created
+ )
+ VALUES (
+ :round_id,
+ :court_id,
+ :team1_stage_item_id,
+ :team2_stage_item_id,
+ :team1_position_in_group,
+ :team2_position_in_group,
+ 0,
+ 0,
+ NOW()
+ )
+ RETURNING *
+ '''
result = await database.fetch_one(query=query, values=match.dict())
if result is None:
diff --git a/backend/bracket/sql/players.py b/backend/bracket/sql/players.py
index 2ecc17bf..f4f9fb7d 100644
--- a/backend/bracket/sql/players.py
+++ b/backend/bracket/sql/players.py
@@ -1,5 +1,6 @@
from bracket.database import database
from bracket.models.db.player import Player
+from bracket.models.db.players import PlayerStatistics
async def get_all_players_in_tournament(tournament_id: int) -> list[Player]:
@@ -21,3 +22,31 @@ async def get_active_players_in_tournament(tournament_id: int) -> list[Player]:
'''
result = await database.fetch_all(query=query, values={'tournament_id': tournament_id})
return [Player.parse_obj(x._mapping) for x in result]
+
+
+async def update_player_stats(
+ tournament_id: int, player_id: int, player_statistics: PlayerStatistics
+) -> None:
+ query = '''
+ UPDATE players
+ SET
+ wins = :wins,
+ draws = :draws,
+ losses = :losses,
+ elo_score = :elo_score,
+ swiss_score = :swiss_score
+ WHERE players.tournament_id = :tournament_id
+ AND players.id = :player_id
+ '''
+ await database.execute(
+ query=query,
+ values={
+ 'tournament_id': tournament_id,
+ 'player_id': player_id,
+ 'wins': player_statistics.wins,
+ 'draws': player_statistics.draws,
+ 'losses': player_statistics.losses,
+ 'elo_score': player_statistics.elo_score,
+ 'swiss_score': float(player_statistics.swiss_score),
+ },
+ )
diff --git a/backend/bracket/sql/rounds.py b/backend/bracket/sql/rounds.py
index 7f95fd91..7729d638 100644
--- a/backend/bracket/sql/rounds.py
+++ b/backend/bracket/sql/rounds.py
@@ -1,27 +1,39 @@
from bracket.database import database
-from bracket.models.db.round import RoundWithMatches
-from bracket.sql.stages import get_stages_with_rounds_and_matches
+from bracket.models.db.util import RoundWithMatches
+from bracket.sql.stage_items import get_stage_item
-async def get_rounds_for_stage(tournament_id: int, stage_id: int) -> list[RoundWithMatches]:
- stages = await get_stages_with_rounds_and_matches(tournament_id)
- result_stage = next((stage for stage in stages if stage.id == stage_id), None)
- if result_stage is None:
- raise ValueError(f'Could not find stage with id {stage_id} for tournament {tournament_id}')
+async def get_rounds_for_stage_item(
+ tournament_id: int, stage_item_id: int
+) -> list[RoundWithMatches]:
+ stage_item = await get_stage_item(tournament_id, stage_item_id)
- return result_stage.rounds
+ if stage_item is None:
+ raise ValueError(
+ f'Could not find stage item with id {stage_item_id} for tournament {tournament_id}'
+ )
+
+ return stage_item.rounds
-async def get_next_round_name(tournament_id: int, stage_id: int) -> str:
+async def get_next_round_name(tournament_id: int, stage_item_id: int) -> str:
query = '''
SELECT count(*) FROM rounds
- JOIN stages s on s.id = rounds.stage_id
+ JOIN stages s on s.id = rounds.stage_item_id
WHERE s.tournament_id = :tournament_id
- AND rounds.stage_id = :stage_id
+ AND rounds.stage_item_id = :stage_item_id
'''
round_count = int(
await database.fetch_val(
- query=query, values={'tournament_id': tournament_id, 'stage_id': stage_id}
+ query=query, values={'tournament_id': tournament_id, 'stage_item_id': stage_item_id}
)
)
return f'Round {round_count + 1}'
+
+
+async def sql_delete_rounds_for_stage_item_id(stage_item_id: int) -> None:
+ query = '''
+ DELETE FROM rounds
+ WHERE rounds.stage_item_id = :stage_item_id
+ '''
+ await database.execute(query=query, values={'stage_item_id': stage_item_id})
diff --git a/backend/bracket/sql/stage_item_inputs.py b/backend/bracket/sql/stage_item_inputs.py
new file mode 100644
index 00000000..3ffe845f
--- /dev/null
+++ b/backend/bracket/sql/stage_item_inputs.py
@@ -0,0 +1,64 @@
+from bracket.database import database
+from bracket.models.db.stage_item_inputs import (
+ StageItemInputBase,
+ StageItemInputCreateBody,
+ StageItemInputCreateBodyFinal,
+ StageItemInputCreateBodyTentative,
+)
+
+
+async def sql_delete_stage_item_inputs(stage_item_id: int) -> None:
+ query = '''
+ DELETE FROM stage_item_inputs
+ WHERE stage_item_id = :stage_item_id OR team_stage_item_id = :stage_item_id
+ '''
+ await database.execute(query=query, values={'stage_item_id': stage_item_id})
+
+
+async def sql_create_stage_item_input(
+ tournament_id: int, stage_item_id: int, stage_item_input: StageItemInputCreateBody
+) -> StageItemInputBase:
+ async with database.transaction():
+ query = '''
+ INSERT INTO stage_item_inputs
+ (
+ slot,
+ tournament_id,
+ stage_item_id,
+ team_id,
+ team_stage_item_id,
+ team_position_in_group
+ )
+ VALUES
+ (
+ :slot,
+ :tournament_id,
+ :stage_item_id,
+ :team_id,
+ :team_stage_item_id,
+ :team_position_in_group
+ )
+ RETURNING *
+ '''
+ result = await database.fetch_one(
+ query=query,
+ values={
+ 'slot': stage_item_input.slot,
+ 'tournament_id': tournament_id,
+ 'stage_item_id': stage_item_id,
+ 'team_id': stage_item_input.team_id
+ if isinstance(stage_item_input, StageItemInputCreateBodyFinal)
+ else None,
+ 'team_stage_item_id': stage_item_input.team_stage_item_id
+ if isinstance(stage_item_input, StageItemInputCreateBodyTentative)
+ else None,
+ 'team_position_in_group': stage_item_input.team_position_in_group
+ if isinstance(stage_item_input, StageItemInputCreateBodyTentative)
+ else None,
+ },
+ )
+
+ if result is None:
+ raise ValueError('Could not create stage')
+
+ return StageItemInputBase.parse_obj(result._mapping)
diff --git a/backend/bracket/sql/stage_items.py b/backend/bracket/sql/stage_items.py
new file mode 100644
index 00000000..51ccea70
--- /dev/null
+++ b/backend/bracket/sql/stage_items.py
@@ -0,0 +1,49 @@
+from bracket.database import database
+from bracket.models.db.stage_item import StageItem, StageItemCreateBody
+from bracket.models.db.util import StageItemWithRounds
+from bracket.sql.stage_item_inputs import sql_create_stage_item_input
+from bracket.sql.stages import get_full_tournament_details
+
+
+async def sql_create_stage_item(tournament_id: int, stage_item: StageItemCreateBody) -> StageItem:
+ async with database.transaction():
+ query = '''
+ INSERT INTO stage_items (type, stage_id, name, team_count)
+ VALUES (:stage_item_type, :stage_id, :name, :team_count)
+ RETURNING *
+ '''
+ result = await database.fetch_one(
+ query=query,
+ values={
+ 'stage_item_type': stage_item.type.value,
+ 'stage_id': stage_item.stage_id,
+ 'name': stage_item.get_name_or_default_name(),
+ 'team_count': stage_item.team_count,
+ },
+ )
+
+ if result is None:
+ raise ValueError('Could not create stage')
+
+ stage_item_result = StageItem.parse_obj(result._mapping)
+
+ for input_ in stage_item.inputs:
+ await sql_create_stage_item_input(tournament_id, stage_item_result.id, input_)
+
+ return stage_item_result
+
+
+async def sql_delete_stage_item(stage_item_id: int) -> None:
+ query = '''
+ DELETE FROM stage_items
+ WHERE stage_items.id = :stage_item_id
+ '''
+ await database.execute(query=query, values={'stage_item_id': stage_item_id})
+
+
+async def get_stage_item(tournament_id: int, stage_item_id: int) -> StageItemWithRounds | None:
+ stages = await get_full_tournament_details(tournament_id, stage_item_id=stage_item_id)
+ if len(stages) < 1 or len(stages[0].stage_items) < 1:
+ return None
+
+ return stages[0].stage_items[0]
diff --git a/backend/bracket/sql/stages.py b/backend/bracket/sql/stages.py
index 9677fa8a..6c89ced3 100644
--- a/backend/bracket/sql/stages.py
+++ b/backend/bracket/sql/stages.py
@@ -1,21 +1,29 @@
from typing import Literal, cast
from bracket.database import database
-from bracket.models.db.round import StageWithRounds
-from bracket.models.db.stage import Stage, StageCreateBody
+from bracket.models.db.stage import Stage
+from bracket.models.db.util import StageWithStageItems
from bracket.utils.types import dict_without_none
-async def get_stages_with_rounds_and_matches(
+async def get_full_tournament_details(
tournament_id: int,
round_id: int | None = None,
stage_id: int | None = None,
+ stage_item_id: int | None = None,
*,
no_draft_rounds: bool = False,
-) -> list[StageWithRounds]:
+) -> list[StageWithStageItems]:
draft_filter = 'AND rounds.is_draft IS FALSE' if no_draft_rounds else ''
round_filter = 'AND rounds.id = :round_id' if round_id is not None else ''
stage_filter = 'AND stages.id = :stage_id' if stage_id is not None else ''
+ stage_item_filter = 'AND stage_items.id = :stage_item_id' if stage_item_id is not None else ''
+ stage_item_filter_join = (
+ 'LEFT JOIN stage_items on stages.id = stage_items.stage_id'
+ if stage_item_id is not None
+ else ''
+ )
+
query = f'''
WITH teams_with_players AS (
SELECT DISTINCT ON (teams.id)
@@ -28,7 +36,19 @@ async def get_stages_with_rounds_and_matches(
(
SELECT COALESCE(avg(elo_score), 0.0)
FROM players
- ) AS elo_score
+ ) AS elo_score,
+ (
+ SELECT COALESCE(avg(wins), 0.0)
+ FROM players
+ ) AS wins,
+ (
+ SELECT COALESCE(avg(draws), 0.0)
+ FROM players
+ ) AS draws,
+ (
+ SELECT COALESCE(avg(losses), 0.0)
+ FROM players
+ ) AS losses
FROM teams
LEFT JOIN players_x_teams pt on pt.team_id = teams.id
LEFT JOIN players p on pt.player_id = p.id
@@ -44,56 +64,99 @@ async def get_stages_with_rounds_and_matches(
LEFT JOIN teams_with_players t1 on t1.id = matches.team1_id
LEFT JOIN teams_with_players t2 on t2.id = matches.team2_id
LEFT JOIN rounds r on matches.round_id = r.id
- LEFT JOIN stages s2 on r.stage_id = s2.id
+ LEFT JOIN stages st on r.stage_item_id = st.id
+ LEFT JOIN stage_items si on st.id = si.stage_id
LEFT JOIN courts c on matches.court_id = c.id
- WHERE s2.tournament_id = :tournament_id
+ WHERE st.tournament_id = :tournament_id
), rounds_with_matches AS (
SELECT DISTINCT ON (rounds.id)
rounds.*,
to_json(array_agg(m.*)) AS matches
FROM rounds
LEFT JOIN matches_with_teams m on m.round_id = rounds.id
- LEFT JOIN stages s2 on rounds.stage_id = s2.id
+ LEFT JOIN stages s2 on rounds.stage_item_id = s2.id
WHERE s2.tournament_id = :tournament_id
{draft_filter}
{round_filter}
GROUP BY rounds.id
+ ), stage_items_with_rounds AS (
+ SELECT DISTINCT ON (stage_items.id)
+ stage_items.*,
+ to_json(array_agg(r.*)) AS rounds
+ FROM stage_items
+ JOIN stages st on stage_items.stage_id = st.id
+ LEFT JOIN rounds_with_matches r on r.stage_item_id = stage_items.id
+ WHERE st.tournament_id = :tournament_id
+ {stage_item_filter}
+ GROUP BY stage_items.id
+ ), stage_items_with_inputs AS (
+ SELECT DISTINCT ON (stage_items.id)
+ stage_items.id,
+ to_json(array_agg(sii)) AS inputs
+ FROM stage_items
+ LEFT JOIN stage_item_inputs sii ON stage_items.id = sii.stage_item_id
+ WHERE sii.tournament_id = :tournament_id
+ {stage_item_filter}
+ GROUP BY stage_items.id
+ ORDER BY stage_items.id
+ ), stage_items_with_rounds_and_inputs AS (
+ SELECT stage_items.*, stage_items_with_inputs.inputs, stage_items_with_rounds.rounds
+ FROM stage_items
+ JOIN stage_items_with_rounds ON stage_items_with_rounds.id = stage_items.id
+ LEFT JOIN stage_items_with_inputs
+ ON stage_items_with_inputs.id = stage_items_with_rounds.id
)
- SELECT stages.*, to_json(array_agg(r.*)) AS rounds FROM stages
- LEFT JOIN rounds_with_matches r on stages.id = r.stage_id
+ SELECT stages.*, to_json(array_agg(r.*)) AS stage_items
+ FROM stages
+ LEFT JOIN stage_items_with_rounds_and_inputs r on stages.id = r.stage_id
+ {stage_item_filter_join}
WHERE stages.tournament_id = :tournament_id
{stage_filter}
+ {stage_item_filter}
GROUP BY stages.id
+ ORDER BY stages.id
'''
values = dict_without_none(
- {'tournament_id': tournament_id, 'round_id': round_id, 'stage_id': stage_id}
+ {
+ 'tournament_id': tournament_id,
+ 'round_id': round_id,
+ 'stage_id': stage_id,
+ 'stage_item_id': stage_item_id,
+ }
)
result = await database.fetch_all(query=query, values=values)
- return [StageWithRounds.parse_obj(x._mapping) for x in result]
+ return [StageWithStageItems.parse_obj(x._mapping) for x in result]
async def sql_delete_stage(tournament_id: int, stage_id: int) -> None:
- query = '''
- DELETE FROM stages
- WHERE stages.id = :stage_id
- AND stages.tournament_id = :tournament_id
- '''
- await database.fetch_one(
- query=query, values={'stage_id': stage_id, 'tournament_id': tournament_id}
- )
-
-
-async def sql_create_stage(stage: StageCreateBody, tournament_id: int) -> Stage:
async with database.transaction():
query = '''
- INSERT INTO stages (type, created, is_active, tournament_id)
- VALUES (:stage_type, NOW(), false, :tournament_id)
- RETURNING *
+ DELETE FROM stage_items
+ WHERE stage_items.stage_id = :stage_id
'''
- result = await database.fetch_one(
- query=query, values={'stage_type': stage.type.value, 'tournament_id': tournament_id}
+ await database.execute(query=query, values={'stage_id': stage_id})
+
+ query = '''
+ DELETE FROM stages
+ WHERE stages.id = :stage_id
+ AND stages.tournament_id = :tournament_id
+ '''
+ await database.execute(
+ query=query, values={'stage_id': stage_id, 'tournament_id': tournament_id}
)
+
+async def sql_create_stage(tournament_id: int) -> Stage:
+ query = '''
+ INSERT INTO stages (created, is_active, name, tournament_id)
+ VALUES (NOW(), false, :name, :tournament_id)
+ RETURNING *
+ '''
+ result = await database.fetch_one(
+ query=query,
+ values={'tournament_id': tournament_id, 'name': 'Stage'},
+ )
+
if result is None:
raise ValueError('Could not create stage')
diff --git a/backend/bracket/sql/teams.py b/backend/bracket/sql/teams.py
index fbe70abf..d87721f0 100644
--- a/backend/bracket/sql/teams.py
+++ b/backend/bracket/sql/teams.py
@@ -25,15 +25,19 @@ async def get_teams_with_members(
SELECT
teams.*,
to_json(array_agg(p.*)) AS players,
- COALESCE(avg(p.elo_score), 0.0) AS elo_score,
- COALESCE(avg(p.swiss_score), 0.0) AS swiss_score
+ COALESCE(avg(p.elo_score), 1200.0) AS elo_score,
+ COALESCE(avg(p.swiss_score), 0.0) AS swiss_score,
+ COALESCE(avg(p.wins), 0.0) AS wins,
+ COALESCE(avg(p.draws), 0.0) AS draws,
+ COALESCE(avg(p.losses), 0.0) AS losses
FROM teams
LEFT JOIN players_x_teams pt on pt.team_id = teams.id
LEFT JOIN players p on pt.player_id = p.id
WHERE teams.tournament_id = :tournament_id
{active_team_filter}
{team_id_filter}
- GROUP BY teams.id;
+ GROUP BY teams.id
+ ORDER BY elo_score DESC, wins DESC, name ASC
'''
values = dict_without_none({'tournament_id': tournament_id, 'team_id': team_id})
result = await database.fetch_all(query=query, values=values)
diff --git a/backend/bracket/sql/tournaments.py b/backend/bracket/sql/tournaments.py
index baedcbd8..7510284b 100644
--- a/backend/bracket/sql/tournaments.py
+++ b/backend/bracket/sql/tournaments.py
@@ -15,6 +15,18 @@ async def sql_get_tournament(tournament_id: int) -> Tournament:
return Tournament.parse_obj(result._mapping)
+async def sql_get_tournament_by_endpoint_name(endpoint_name: str) -> Tournament:
+ query = '''
+ SELECT *
+ FROM tournaments
+ WHERE dashboard_endpoint = :endpoint_name
+ AND dashboard_public IS TRUE
+ '''
+ result = await database.fetch_one(query=query, values={'endpoint_name': endpoint_name})
+ assert result is not None
+ return Tournament.parse_obj(result._mapping)
+
+
async def sql_get_tournaments(
club_ids: tuple[int, ...], endpoint_name: str | None
) -> list[Tournament]:
diff --git a/backend/bracket/utils/db_init.py b/backend/bracket/utils/db_init.py
index c7719bdf..e3cd3c32 100644
--- a/backend/bracket/utils/db_init.py
+++ b/backend/bracket/utils/db_init.py
@@ -5,6 +5,7 @@ from heliclockter import datetime_utc
from bracket.config import Environment, config, environment
from bracket.database import database, engine
from bracket.logic.elo import recalculate_elo_for_tournament_id
+from bracket.logic.scheduling.builder import build_matches_for_stage_item
from bracket.models.db.club import Club
from bracket.models.db.court import Court
from bracket.models.db.match import Match
@@ -12,6 +13,11 @@ from bracket.models.db.player import Player
from bracket.models.db.player_x_team import PlayerXTeam
from bracket.models.db.round import Round
from bracket.models.db.stage import Stage
+from bracket.models.db.stage_item import StageItem, StageItemCreateBody
+from bracket.models.db.stage_item_inputs import (
+ StageItemInputCreateBodyFinal,
+ StageItemInputCreateBodyTentative,
+)
from bracket.models.db.team import Team
from bracket.models.db.tournament import Tournament
from bracket.models.db.user import User
@@ -24,22 +30,20 @@ from bracket.schema import (
players,
players_x_teams,
rounds,
+ stage_items,
stages,
teams,
tournaments,
users,
users_x_clubs,
)
+from bracket.sql.stage_items import sql_create_stage_item
from bracket.sql.users import get_user
from bracket.utils.db import insert_generic
from bracket.utils.dummy_records import (
DUMMY_CLUB,
DUMMY_COURT1,
DUMMY_COURT2,
- DUMMY_MATCH1,
- DUMMY_MATCH2,
- DUMMY_MATCH3,
- DUMMY_MATCH4,
DUMMY_PLAYER1,
DUMMY_PLAYER2,
DUMMY_PLAYER3,
@@ -50,11 +54,10 @@ from bracket.utils.dummy_records import (
DUMMY_PLAYER8,
DUMMY_PLAYER9,
DUMMY_PLAYER_X_TEAM,
- DUMMY_ROUND1,
- DUMMY_ROUND2,
- DUMMY_ROUND3,
DUMMY_STAGE1,
DUMMY_STAGE2,
+ DUMMY_STAGE_ITEM1,
+ DUMMY_STAGE_ITEM2,
DUMMY_TEAM1,
DUMMY_TEAM2,
DUMMY_TEAM3,
@@ -123,6 +126,7 @@ async def sql_create_dev_db() -> None:
Match: matches,
Tournament: tournaments,
Court: courts,
+ StageItem: stage_items,
}
async def insert_dummy(obj_to_insert: BaseModelT) -> int:
@@ -133,6 +137,7 @@ async def sql_create_dev_db() -> None:
user_id_1 = await insert_dummy(DUMMY_USER)
club_id_1 = await insert_dummy(DUMMY_CLUB)
+
await insert_dummy(UserXClub(user_id=user_id_1, club_id=club_id_1))
if real_user_id is not None:
@@ -141,6 +146,7 @@ async def sql_create_dev_db() -> None:
tournament_id_1 = await insert_dummy(DUMMY_TOURNAMENT.copy(update={'club_id': club_id_1}))
stage_id_1 = await insert_dummy(DUMMY_STAGE1.copy(update={'tournament_id': tournament_id_1}))
stage_id_2 = await insert_dummy(DUMMY_STAGE2.copy(update={'tournament_id': tournament_id_1}))
+
team_id_1 = await insert_dummy(DUMMY_TEAM1.copy(update={'tournament_id': tournament_id_1}))
team_id_2 = await insert_dummy(DUMMY_TEAM2.copy(update={'tournament_id': tournament_id_1}))
team_id_3 = await insert_dummy(DUMMY_TEAM3.copy(update={'tournament_id': tournament_id_1}))
@@ -181,48 +187,60 @@ async def sql_create_dev_db() -> None:
DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_8, 'team_id': team_id_4})
)
- round_id_1 = await insert_dummy(DUMMY_ROUND1.copy(update={'stage_id': stage_id_1}))
- round_id_2 = await insert_dummy(DUMMY_ROUND2.copy(update={'stage_id': stage_id_1}))
- round_id_3 = await insert_dummy(DUMMY_ROUND3.copy(update={'stage_id': stage_id_2}))
+ await insert_dummy(DUMMY_COURT1.copy(update={'tournament_id': tournament_id_1}))
+ await insert_dummy(DUMMY_COURT2.copy(update={'tournament_id': tournament_id_1}))
- court_id_1 = await insert_dummy(DUMMY_COURT1.copy(update={'tournament_id': tournament_id_1}))
- court_id_2 = await insert_dummy(DUMMY_COURT2.copy(update={'tournament_id': tournament_id_1}))
+ stage_item_1 = await sql_create_stage_item(
+ tournament_id_1,
+ StageItemCreateBody(
+ stage_id=stage_id_1,
+ name=DUMMY_STAGE_ITEM1.name,
+ team_count=DUMMY_STAGE_ITEM1.team_count,
+ type=DUMMY_STAGE_ITEM1.type,
+ inputs=[
+ StageItemInputCreateBodyFinal(
+ slot=1,
+ team_id=team_id_1,
+ ),
+ StageItemInputCreateBodyFinal(
+ slot=2,
+ team_id=team_id_2,
+ ),
+ StageItemInputCreateBodyFinal(
+ slot=3,
+ team_id=team_id_3,
+ ),
+ StageItemInputCreateBodyFinal(
+ slot=4,
+ team_id=team_id_4,
+ ),
+ ],
+ ),
+ )
+ stage_item_2 = await sql_create_stage_item(
+ tournament_id_1,
+ StageItemCreateBody(
+ stage_id=stage_id_2,
+ name=DUMMY_STAGE_ITEM2.name,
+ team_count=DUMMY_STAGE_ITEM2.team_count,
+ type=DUMMY_STAGE_ITEM2.type,
+ inputs=[
+ StageItemInputCreateBodyTentative(
+ slot=1,
+ team_stage_item_id=stage_item_1.id,
+ team_position_in_group=1,
+ ),
+ StageItemInputCreateBodyTentative(
+ slot=2,
+ team_stage_item_id=stage_item_1.id,
+ team_position_in_group=2,
+ ),
+ ],
+ ),
+ )
- await insert_dummy(
- DUMMY_MATCH1.copy(
- update={
- 'round_id': round_id_1,
- 'team1_id': team_id_1,
- 'team2_id': team_id_2,
- 'court_id': court_id_1,
- }
- ),
- )
- await insert_dummy(
- DUMMY_MATCH2.copy(
- update={
- 'round_id': round_id_1,
- 'team1_id': team_id_3,
- 'team2_id': team_id_4,
- 'court_id': court_id_2,
- }
- ),
- )
- await insert_dummy(
- DUMMY_MATCH3.copy(
- update={
- 'round_id': round_id_2,
- 'team1_id': team_id_2,
- 'team2_id': team_id_4,
- 'court_id': court_id_1,
- }
- ),
- )
- await insert_dummy(
- DUMMY_MATCH4.copy(
- update={'round_id': round_id_3, 'team1_id': team_id_3, 'team2_id': team_id_1}
- ),
- )
+ await build_matches_for_stage_item(stage_item_1, tournament_id_1)
+ await build_matches_for_stage_item(stage_item_2, tournament_id_1)
for tournament in await database.fetch_all(tournaments.select()):
await recalculate_elo_for_tournament_id(tournament.id) # type: ignore[attr-defined]
diff --git a/backend/bracket/utils/dummy_records.py b/backend/bracket/utils/dummy_records.py
index e0218d85..eba0a3d4 100644
--- a/backend/bracket/utils/dummy_records.py
+++ b/backend/bracket/utils/dummy_records.py
@@ -8,7 +8,8 @@ from bracket.models.db.match import Match
from bracket.models.db.player import Player
from bracket.models.db.player_x_team import PlayerXTeam
from bracket.models.db.round import Round
-from bracket.models.db.stage import Stage, StageType
+from bracket.models.db.stage import Stage
+from bracket.models.db.stage_item import StageItemToInsert, StageType
from bracket.models.db.team import Team
from bracket.models.db.tournament import Tournament
from bracket.models.db.user import User
@@ -40,32 +41,48 @@ DUMMY_STAGE1 = Stage(
tournament_id=DB_PLACEHOLDER_ID,
created=DUMMY_MOCK_TIME,
is_active=True,
- type=StageType.ROUND_ROBIN,
+ name='Group Stage',
)
DUMMY_STAGE2 = Stage(
tournament_id=DB_PLACEHOLDER_ID,
created=DUMMY_MOCK_TIME,
is_active=False,
- type=StageType.SWISS,
+ name='Knockout Stage',
+)
+
+DUMMY_STAGE_ITEM1 = StageItemToInsert(
+ stage_id=DB_PLACEHOLDER_ID,
+ created=DUMMY_MOCK_TIME,
+ type=StageType.ROUND_ROBIN,
+ team_count=4,
+ name='Group A',
+)
+
+DUMMY_STAGE_ITEM2 = StageItemToInsert(
+ stage_id=DB_PLACEHOLDER_ID,
+ created=DUMMY_MOCK_TIME,
+ type=StageType.SINGLE_ELIMINATION,
+ team_count=2,
+ name='Bracket A',
)
DUMMY_ROUND1 = Round(
- stage_id=DB_PLACEHOLDER_ID,
+ stage_item_id=DB_PLACEHOLDER_ID,
created=DUMMY_MOCK_TIME,
is_draft=False,
name='Round 1',
)
DUMMY_ROUND2 = Round(
- stage_id=DB_PLACEHOLDER_ID,
+ stage_item_id=DB_PLACEHOLDER_ID,
created=DUMMY_MOCK_TIME,
is_draft=True,
name='Round 2',
)
DUMMY_ROUND3 = Round(
- stage_id=2,
+ stage_item_id=DB_PLACEHOLDER_ID,
created=DUMMY_MOCK_TIME,
is_draft=False,
name='Round 3',
diff --git a/backend/precommit.sh b/backend/precommit.sh
index 531b2b42..ec9a393b 100755
--- a/backend/precommit.sh
+++ b/backend/precommit.sh
@@ -3,7 +3,7 @@ set -evo pipefail
black .
ruff --fix .
-! vulture |grep "unused function\|unused class\|unused method"
+! vulture | grep "unused function\|unused class\|unused method"
dmypy run -- --follow-imports=normal --junit-xml= .
ENVIRONMENT=CI pytest --cov --cov-report=xml . -vvv
pylint alembic bracket tests
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index fbfc0f9e..e39545a9 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -63,19 +63,20 @@ ignore_missing_imports = true
[tool.pylint.'MESSAGES CONTROL']
disable = [
- 'missing-docstring',
- 'too-few-public-methods',
- 'no-name-in-module', # Gives false positives.
- 'unused-argument', # Gives false positives.
- 'invalid-name',
'dangerous-default-value',
'duplicate-code',
+ 'import-outside-toplevel',
+ 'invalid-name',
+ 'logging-fstring-interpolation',
+ 'missing-docstring',
+ 'no-name-in-module', # Gives false positives.
+ 'protected-access',
+ 'too-few-public-methods',
+ 'too-many-arguments',
'too-many-locals',
'too-many-nested-blocks',
- 'protected-access',
- 'logging-fstring-interpolation',
- 'too-many-arguments',
'unspecified-encoding',
+ 'unused-argument', # Gives false positives.
'wrong-import-position',
]
diff --git a/backend/tests/integration_tests/api/auth_test.py b/backend/tests/integration_tests/api/auth_test.py
index e3bbd88b..d4d138bf 100644
--- a/backend/tests/integration_tests/api/auth_test.py
+++ b/backend/tests/integration_tests/api/auth_test.py
@@ -82,7 +82,7 @@ async def test_not_authenticated_for_tournament(
) as tournament_inserted:
response = JsonDict(
await send_auth_request(
- HTTPMethod.GET, f'tournaments/{tournament_inserted.id}/teams', auth_context
+ HTTPMethod.GET, f'tournaments/{tournament_inserted.id}/players', auth_context
)
)
assert response == {'detail': 'Could not validate credentials'}
diff --git a/backend/tests/integration_tests/api/courts_test.py b/backend/tests/integration_tests/api/courts_test.py
index 74775437..9c44b034 100644
--- a/backend/tests/integration_tests/api/courts_test.py
+++ b/backend/tests/integration_tests/api/courts_test.py
@@ -68,12 +68,12 @@ async def test_update_court(
DUMMY_COURT1.copy(update={'tournament_id': auth_context.tournament.id})
) as court_inserted:
response = await send_tournament_request(
- HTTPMethod.PATCH, f'courts/{court_inserted.id}', auth_context, json=body
+ HTTPMethod.PUT, f'courts/{court_inserted.id}', auth_context, json=body
)
- patched_court = await fetch_one_parsed_certain(
+ updated_court = await fetch_one_parsed_certain(
database, Court, query=courts.select().where(courts.c.id == court_inserted.id)
)
- assert patched_court.name == body['name']
+ assert updated_court.name == body['name']
assert response['data']['name'] == body['name']
await assert_row_count_and_clear(courts, 1)
diff --git a/backend/tests/integration_tests/api/inputs_test.py b/backend/tests/integration_tests/api/inputs_test.py
new file mode 100644
index 00000000..bea43a33
--- /dev/null
+++ b/backend/tests/integration_tests/api/inputs_test.py
@@ -0,0 +1,43 @@
+from bracket.utils.dummy_records import (
+ DUMMY_STAGE1,
+ DUMMY_STAGE_ITEM1,
+ DUMMY_TEAM1,
+)
+from bracket.utils.http import HTTPMethod
+from tests.integration_tests.api.shared import (
+ send_tournament_request,
+)
+from tests.integration_tests.models import AuthContext
+from tests.integration_tests.sql import (
+ inserted_stage,
+ inserted_stage_item,
+ inserted_team,
+)
+
+
+async def test_available_inputs(
+ startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
+) -> None:
+ async with (
+ inserted_team(
+ DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
+ ) as team_inserted,
+ inserted_stage(
+ DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
+ ) as stage_inserted_1,
+ # inserted_stage(
+ # DUMMY_STAGE2.copy(update={'tournament_id': auth_context.tournament.id})
+ # ) as stage_inserted_2,
+ inserted_stage_item(DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted_1.id})),
+ ):
+ response = await send_tournament_request(
+ HTTPMethod.GET, f'stages/{stage_inserted_1.id}/available_inputs', auth_context
+ )
+
+ assert response == {
+ 'data': [
+ {'team_id': team_inserted.id},
+ # {'team_stage_item_id': 1, 'team_position_in_group': 1},
+ # {'team_stage_item_id': 1, 'team_position_in_group': 2},
+ ]
+ }
diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py
index 7b7731fe..67f9c513 100644
--- a/backend/tests/integration_tests/api/matches_test.py
+++ b/backend/tests/integration_tests/api/matches_test.py
@@ -1,6 +1,6 @@
from bracket.database import database
from bracket.models.db.match import Match
-from bracket.models.db.stage import StageType
+from bracket.models.db.stage_item import StageType
from bracket.schema import matches
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.dummy_records import (
@@ -12,6 +12,7 @@ from bracket.utils.dummy_records import (
DUMMY_PLAYER4,
DUMMY_ROUND1,
DUMMY_STAGE1,
+ DUMMY_STAGE_ITEM1,
DUMMY_TEAM1,
DUMMY_TEAM2,
)
@@ -26,6 +27,7 @@ from tests.integration_tests.sql import (
inserted_player_in_team,
inserted_round,
inserted_stage,
+ inserted_stage_item,
inserted_team,
)
@@ -37,7 +39,12 @@ async def test_create_match(
inserted_stage(
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
) as stage_inserted,
- inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
+ inserted_stage_item(
+ DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
+ ) as stage_item_inserted,
+ inserted_round(
+ DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
+ ) as round_inserted,
inserted_team(
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
) as team1_inserted,
@@ -69,7 +76,12 @@ async def test_delete_match(
inserted_stage(
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
) as stage_inserted,
- inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
+ inserted_stage_item(
+ DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
+ ) as stage_item_inserted,
+ inserted_round(
+ DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
+ ) as round_inserted,
inserted_team(
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
) as team1_inserted,
@@ -106,7 +118,12 @@ async def test_update_match(
inserted_stage(
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
) as stage_inserted,
- inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
+ inserted_stage_item(
+ DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
+ ) as stage_item_inserted,
+ inserted_round(
+ DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
+ ) as round_inserted,
inserted_team(
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
) as team1_inserted,
@@ -135,7 +152,7 @@ async def test_update_match(
}
assert (
await send_tournament_request(
- HTTPMethod.PATCH,
+ HTTPMethod.PUT,
f'matches/{match_inserted.id}',
auth_context,
None,
@@ -143,14 +160,14 @@ async def test_update_match(
)
== SUCCESS_RESPONSE
)
- patched_match = await fetch_one_parsed_certain(
+ updated_match = await fetch_one_parsed_certain(
database,
Match,
query=matches.select().where(matches.c.id == match_inserted.id),
)
- assert patched_match.team1_score == body['team1_score']
- assert patched_match.team2_score == body['team2_score']
- assert patched_match.court_id == body['court_id']
+ assert updated_match.team1_score == body['team1_score']
+ assert updated_match.team2_score == body['team2_score']
+ assert updated_match.court_id == body['court_id']
await assert_row_count_and_clear(matches, 1)
@@ -164,15 +181,17 @@ async def test_upcoming_matches_endpoint(
update={
'is_active': True,
'tournament_id': auth_context.tournament.id,
- 'type': StageType.SWISS,
}
)
) as stage_inserted,
+ inserted_stage_item(
+ DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id, 'type': StageType.SWISS})
+ ) as stage_item_inserted,
inserted_round(
DUMMY_ROUND1.copy(
update={
'is_draft': True,
- 'stage_id': stage_inserted.id,
+ 'stage_item_id': stage_item_inserted.id,
}
)
) as round_inserted,
@@ -243,6 +262,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'team2': {
'id': None,
@@ -274,6 +296,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'elo_diff': 0.0,
'swiss_diff': 0.0,
@@ -311,6 +336,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'team2': {
'id': None,
@@ -342,6 +370,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'elo_diff': 0.0,
'swiss_diff': 0.0,
@@ -379,6 +410,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'team2': {
'id': None,
@@ -410,6 +444,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'elo_diff': 0.0,
'swiss_diff': 0.0,
@@ -447,6 +484,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'team2': {
'id': None,
@@ -478,6 +518,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'elo_diff': 0.0,
'swiss_diff': 0.0,
@@ -515,6 +558,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'team2': {
'id': None,
@@ -546,6 +592,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'elo_diff': 0.0,
'swiss_diff': 0.0,
@@ -583,6 +632,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'team2': {
'id': None,
@@ -614,6 +666,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'elo_diff': 0.0,
'swiss_diff': 0.0,
@@ -651,6 +706,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'team2': {
'id': None,
@@ -682,6 +740,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'elo_diff': 0.0,
'swiss_diff': 0.0,
@@ -719,6 +780,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'team2': {
'id': None,
@@ -750,6 +814,9 @@ async def test_upcoming_matches_endpoint(
],
'swiss_score': 0.0,
'elo_score': 1250.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
},
'elo_diff': 0.0,
'swiss_diff': 0.0,
diff --git a/backend/tests/integration_tests/api/players_test.py b/backend/tests/integration_tests/api/players_test.py
index 00744169..a170f45b 100644
--- a/backend/tests/integration_tests/api/players_test.py
+++ b/backend/tests/integration_tests/api/players_test.py
@@ -74,12 +74,12 @@ async def test_update_player(
DUMMY_PLAYER1.copy(update={'tournament_id': auth_context.tournament.id})
) as player_inserted:
response = await send_tournament_request(
- HTTPMethod.PATCH, f'players/{player_inserted.id}', auth_context, json=body
+ HTTPMethod.PUT, f'players/{player_inserted.id}', auth_context, json=body
)
- patched_player = await fetch_one_parsed_certain(
+ updated_player = await fetch_one_parsed_certain(
database, Player, query=players.select().where(players.c.id == player_inserted.id)
)
- assert patched_player.name == body['name']
+ assert updated_player.name == body['name']
assert response['data']['name'] == body['name']
await assert_row_count_and_clear(players, 1)
diff --git a/backend/tests/integration_tests/api/rounds_test.py b/backend/tests/integration_tests/api/rounds_test.py
index c694873f..dfb5bc78 100644
--- a/backend/tests/integration_tests/api/rounds_test.py
+++ b/backend/tests/integration_tests/api/rounds_test.py
@@ -1,8 +1,9 @@
from bracket.database import database
from bracket.models.db.round import Round
+from bracket.models.db.stage_item import StageType
from bracket.schema import rounds
from bracket.utils.db import fetch_one_parsed_certain
-from bracket.utils.dummy_records import DUMMY_ROUND1, DUMMY_STAGE1, DUMMY_TEAM1
+from bracket.utils.dummy_records import DUMMY_ROUND1, DUMMY_STAGE1, DUMMY_STAGE_ITEM1, DUMMY_TEAM1
from bracket.utils.http import HTTPMethod
from tests.integration_tests.api.shared import SUCCESS_RESPONSE, send_tournament_request
from tests.integration_tests.models import AuthContext
@@ -10,6 +11,7 @@ from tests.integration_tests.sql import (
assert_row_count_and_clear,
inserted_round,
inserted_stage,
+ inserted_stage_item,
inserted_team,
)
@@ -22,10 +24,16 @@ async def test_create_round(
inserted_stage(
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
) as stage_inserted,
+ inserted_stage_item(
+ DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id, 'type': StageType.SWISS})
+ ) as stage_item_inserted,
):
assert (
await send_tournament_request(
- HTTPMethod.POST, 'rounds', auth_context, json={'stage_id': stage_inserted.id}
+ HTTPMethod.POST,
+ 'rounds',
+ auth_context,
+ json={'stage_item_id': stage_item_inserted.id},
)
== SUCCESS_RESPONSE
)
@@ -40,7 +48,12 @@ async def test_delete_round(
inserted_stage(
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
) as stage_inserted,
- inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
+ inserted_stage_item(
+ DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
+ ) as stage_item_inserted,
+ inserted_round(
+ DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
+ ) as round_inserted,
):
assert (
await send_tournament_request(
@@ -60,19 +73,24 @@ async def test_update_round(
inserted_stage(
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
) as stage_inserted,
- inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
+ inserted_stage_item(
+ DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
+ ) as stage_item_inserted,
+ inserted_round(
+ DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
+ ) as round_inserted,
):
assert (
await send_tournament_request(
- HTTPMethod.PATCH, f'rounds/{round_inserted.id}', auth_context, None, body
+ HTTPMethod.PUT, f'rounds/{round_inserted.id}', auth_context, None, body
)
== SUCCESS_RESPONSE
)
- patched_round = await fetch_one_parsed_certain(
+ updated_round = await fetch_one_parsed_certain(
database, Round, query=rounds.select().where(rounds.c.id == round_inserted.id)
)
- assert patched_round.name == body['name']
- assert patched_round.is_draft == body['is_draft']
- assert patched_round.is_active == body['is_active']
+ assert updated_round.name == body['name']
+ assert updated_round.is_draft == body['is_draft']
+ assert updated_round.is_active == body['is_active']
await assert_row_count_and_clear(rounds, 1)
diff --git a/backend/tests/integration_tests/api/stages_test.py b/backend/tests/integration_tests/api/stages_test.py
index 46669948..c34baf97 100644
--- a/backend/tests/integration_tests/api/stages_test.py
+++ b/backend/tests/integration_tests/api/stages_test.py
@@ -1,13 +1,14 @@
import pytest
-from bracket.models.db.stage import StageType
-from bracket.schema import stages
-from bracket.sql.stages import get_stages_with_rounds_and_matches
+from bracket.models.db.stage_item import StageType
+from bracket.schema import rounds, stage_items, stages
+from bracket.sql.stages import get_full_tournament_details
from bracket.utils.dummy_records import (
DUMMY_MOCK_TIME,
DUMMY_ROUND1,
DUMMY_STAGE1,
DUMMY_STAGE2,
+ DUMMY_STAGE_ITEM1,
DUMMY_TEAM1,
)
from bracket.utils.http import HTTPMethod
@@ -22,6 +23,7 @@ from tests.integration_tests.sql import (
assert_row_count_and_clear,
inserted_round,
inserted_stage,
+ inserted_stage_item,
inserted_team,
)
@@ -35,7 +37,12 @@ async def test_stages_endpoint(
inserted_stage(
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
) as stage_inserted,
- inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
+ inserted_stage_item(
+ DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
+ ) as stage_item_inserted,
+ inserted_round(
+ DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
+ ) as round_inserted,
):
if with_auth:
response = await send_tournament_request(HTTPMethod.GET, 'stages', auth_context, {})
@@ -51,18 +58,29 @@ async def test_stages_endpoint(
'id': stage_inserted.id,
'tournament_id': 1,
'created': DUMMY_MOCK_TIME.isoformat(),
- 'type': 'ROUND_ROBIN',
- 'type_name': 'Round robin',
'is_active': True,
- 'rounds': [
+ 'name': 'Group Stage',
+ 'stage_items': [
{
- 'id': round_inserted.id,
+ 'id': stage_item_inserted.id,
+ 'inputs': [],
'stage_id': stage_inserted.id,
- 'created': '2022-01-11T04:32:11+00:00',
- 'is_draft': False,
- 'is_active': False,
- 'name': 'Round 1',
- 'matches': [],
+ 'name': 'Group A',
+ 'created': DUMMY_MOCK_TIME.isoformat(),
+ 'type': 'ROUND_ROBIN',
+ 'team_count': 4,
+ 'rounds': [
+ {
+ 'id': round_inserted.id,
+ 'stage_item_id': stage_item_inserted.id,
+ 'created': DUMMY_MOCK_TIME.isoformat(),
+ 'is_draft': False,
+ 'is_active': False,
+ 'name': 'Round 1',
+ 'matches': [],
+ }
+ ],
+ 'type_name': 'Round robin',
}
],
}
@@ -81,10 +99,12 @@ async def test_create_stage(
HTTPMethod.POST,
'stages',
auth_context,
- json={'type': StageType.SINGLE_ELIMINATION.value},
+ json={'type': StageType.SINGLE_ELIMINATION.value, 'team_count': 2},
)
== SUCCESS_RESPONSE
)
+ await assert_row_count_and_clear(rounds, 1)
+ await assert_row_count_and_clear(stage_items, 1)
await assert_row_count_and_clear(stages, 1)
@@ -109,25 +129,25 @@ async def test_delete_stage(
async def test_update_stage(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
- body = {'type': StageType.ROUND_ROBIN.value, 'is_active': False}
+ body = {'name': 'Optimus'}
async with (
inserted_team(DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})),
inserted_stage(
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
) as stage_inserted,
+ inserted_stage_item(DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})),
):
assert (
await send_tournament_request(
- HTTPMethod.PATCH, f'stages/{stage_inserted.id}', auth_context, None, body
+ HTTPMethod.PUT, f'stages/{stage_inserted.id}', auth_context, None, body
)
== SUCCESS_RESPONSE
)
- [patched_stage] = await get_stages_with_rounds_and_matches(
- assert_some(auth_context.tournament.id)
- )
- assert patched_stage.type.value == body['type']
- assert patched_stage.is_active == body['is_active']
+ [updated_stage] = await get_full_tournament_details(assert_some(auth_context.tournament.id))
+ assert len(updated_stage.stage_items) == 1
+ assert updated_stage.name == body['name']
+ await assert_row_count_and_clear(stage_items, 1)
await assert_row_count_and_clear(stages, 1)
@@ -146,10 +166,11 @@ async def test_activate_stage(
)
== SUCCESS_RESPONSE
)
- [prev_stage, next_stage] = await get_stages_with_rounds_and_matches(
+ [prev_stage, next_stage] = await get_full_tournament_details(
assert_some(auth_context.tournament.id)
)
assert prev_stage.is_active is False
assert next_stage.is_active is True
+ await assert_row_count_and_clear(stage_items, 1)
await assert_row_count_and_clear(stages, 1)
diff --git a/backend/tests/integration_tests/api/teams_test.py b/backend/tests/integration_tests/api/teams_test.py
index 408e7faa..6e60dcb1 100644
--- a/backend/tests/integration_tests/api/teams_test.py
+++ b/backend/tests/integration_tests/api/teams_test.py
@@ -24,8 +24,11 @@ async def test_teams_endpoint(
'name': 'Team 1',
'players': [],
'tournament_id': team_inserted.tournament_id,
- 'elo_score': 0.0,
+ 'elo_score': 1200.0,
'swiss_score': 0.0,
+ 'wins': 0,
+ 'draws': 0,
+ 'losses': 0,
}
],
}
@@ -63,12 +66,12 @@ async def test_update_team(
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
) as team_inserted:
response = await send_tournament_request(
- HTTPMethod.PATCH, f'teams/{team_inserted.id}', auth_context, None, body
+ HTTPMethod.PUT, f'teams/{team_inserted.id}', auth_context, None, body
)
- patched_team = await fetch_one_parsed_certain(
+ updated_team = await fetch_one_parsed_certain(
database, Team, query=teams.select().where(teams.c.id == team_inserted.id)
)
- assert patched_team.name == body['name']
+ assert updated_team.name == body['name']
assert response['data']['name'] == body['name']
await assert_row_count_and_clear(teams, 1)
diff --git a/backend/tests/integration_tests/api/tournaments_test.py b/backend/tests/integration_tests/api/tournaments_test.py
index 844853eb..a904d774 100644
--- a/backend/tests/integration_tests/api/tournaments_test.py
+++ b/backend/tests/integration_tests/api/tournaments_test.py
@@ -78,16 +78,16 @@ async def test_update_tournament(
'auto_assign_courts': True,
}
assert (
- await send_tournament_request(HTTPMethod.PATCH, '', auth_context, json=body)
+ await send_tournament_request(HTTPMethod.PUT, '', auth_context, json=body)
== SUCCESS_RESPONSE
)
- patched_tournament = await fetch_one_parsed_certain(
+ updated_tournament = await fetch_one_parsed_certain(
database,
Tournament,
query=tournaments.select().where(tournaments.c.id == auth_context.tournament.id),
)
- assert patched_tournament.name == body['name']
- assert patched_tournament.dashboard_public == body['dashboard_public']
+ assert updated_tournament.name == body['name']
+ assert updated_tournament.dashboard_public == body['dashboard_public']
await assert_row_count_and_clear(tournaments, 1)
diff --git a/backend/tests/integration_tests/api/users_test.py b/backend/tests/integration_tests/api/users_test.py
index c602e806..8ea0007c 100644
--- a/backend/tests/integration_tests/api/users_test.py
+++ b/backend/tests/integration_tests/api/users_test.py
@@ -40,12 +40,12 @@ async def test_update_user(
) -> None:
body = {'name': 'Some new name', 'email': 'some_email@email.com'}
response = await send_auth_request(
- HTTPMethod.PATCH, f'users/{auth_context.user.id}', auth_context, None, body
+ HTTPMethod.PUT, f'users/{auth_context.user.id}', auth_context, None, body
)
- patched_user = await fetch_one_parsed_certain(
+ updated_user = await fetch_one_parsed_certain(
database, User, query=users.select().where(users.c.id == auth_context.user.id)
)
- assert patched_user.name == body['name']
+ assert updated_user.name == body['name']
assert response['data']['name'] == body['name']
await assert_row_count_and_clear(users, 1)
diff --git a/backend/tests/integration_tests/sql.py b/backend/tests/integration_tests/sql.py
index 730b2092..6a72933f 100644
--- a/backend/tests/integration_tests/sql.py
+++ b/backend/tests/integration_tests/sql.py
@@ -12,6 +12,7 @@ from bracket.models.db.player import Player
from bracket.models.db.player_x_team import PlayerXTeam
from bracket.models.db.round import Round
from bracket.models.db.stage import Stage
+from bracket.models.db.stage_item import StageItem, StageItemToInsert
from bracket.models.db.team import Team
from bracket.models.db.tournament import Tournament
from bracket.models.db.user import User, UserInDB
@@ -23,6 +24,7 @@ from bracket.schema import (
players,
players_x_teams,
rounds,
+ stage_items,
stages,
teams,
tournaments,
@@ -106,6 +108,12 @@ async def inserted_stage(stage: Stage) -> AsyncIterator[Stage]:
yield row_inserted
+@asynccontextmanager
+async def inserted_stage_item(stage_item: StageItemToInsert) -> AsyncIterator[StageItem]:
+ async with inserted_generic(stage_item, stage_items, StageItem) as row_inserted:
+ yield StageItem(**row_inserted.dict())
+
+
@asynccontextmanager
async def inserted_round(round_: Round) -> AsyncIterator[Round]:
async with inserted_generic(round_, rounds, Round) as row_inserted:
diff --git a/backend/tests/unit_tests/elo_test.py b/backend/tests/unit_tests/elo_test.py
index ea45b650..b8bd3d10 100644
--- a/backend/tests/unit_tests/elo_test.py
+++ b/backend/tests/unit_tests/elo_test.py
@@ -1,15 +1,16 @@
from decimal import Decimal
-from bracket.logic.elo import PlayerStatistics, calculate_elo_per_player
+from bracket.logic.elo import calculate_elo_per_player
from bracket.models.db.match import MatchWithDetails
-from bracket.models.db.round import RoundWithMatches
+from bracket.models.db.players import PlayerStatistics
from bracket.models.db.team import FullTeamWithPlayers
+from bracket.models.db.util import RoundWithMatches
from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_PLAYER1, DUMMY_PLAYER2
def test_elo_calculation() -> None:
round_ = RoundWithMatches(
- stage_id=1,
+ stage_item_id=1,
created=DUMMY_MOCK_TIME,
is_draft=False,
is_active=False,
@@ -32,6 +33,9 @@ def test_elo_calculation() -> None:
players=[DUMMY_PLAYER1.copy(update={'id': 1})],
elo_score=DUMMY_PLAYER1.elo_score,
swiss_score=DUMMY_PLAYER1.swiss_score,
+ wins=DUMMY_PLAYER1.wins,
+ draws=DUMMY_PLAYER1.draws,
+ losses=DUMMY_PLAYER1.losses,
),
team2=FullTeamWithPlayers(
name='Dummy team 2',
@@ -41,6 +45,9 @@ def test_elo_calculation() -> None:
players=[DUMMY_PLAYER2.copy(update={'id': 2})],
elo_score=DUMMY_PLAYER2.elo_score,
swiss_score=DUMMY_PLAYER2.swiss_score,
+ wins=DUMMY_PLAYER2.wins,
+ draws=DUMMY_PLAYER2.draws,
+ losses=DUMMY_PLAYER2.losses,
),
)
],
diff --git a/backend/tests/unit_tests/schedule_test.py b/backend/tests/unit_tests/schedule_test.py
new file mode 100644
index 00000000..85cb5b69
--- /dev/null
+++ b/backend/tests/unit_tests/schedule_test.py
@@ -0,0 +1,16 @@
+from bracket.logic.scheduling.elimination import get_number_of_rounds_to_create_single_elimination
+from bracket.logic.scheduling.round_robin import get_number_of_rounds_to_create_round_robin
+
+
+def test_number_of_rounds_round_robin() -> None:
+ assert get_number_of_rounds_to_create_round_robin(0) == 0
+ assert get_number_of_rounds_to_create_round_robin(2) == 1
+ assert get_number_of_rounds_to_create_round_robin(4) == 3
+ assert get_number_of_rounds_to_create_round_robin(6) == 5
+
+
+def test_number_of_rounds_single_elimination() -> None:
+ assert get_number_of_rounds_to_create_single_elimination(0) == 0
+ assert get_number_of_rounds_to_create_single_elimination(2) == 1
+ assert get_number_of_rounds_to_create_single_elimination(4) == 2
+ assert get_number_of_rounds_to_create_single_elimination(8) == 3
diff --git a/frontend/package.json b/frontend/package.json
index 8fb8a5eb..0db05b8f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -35,10 +35,11 @@
"next": "^13.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-ellipsis-text": "^1.2.1",
"react-icons": "^4.8.0",
+ "react-qr-code": "^2.0.12",
"react-redux": "^8.0.5",
- "swr": "^2.1.5",
- "react-qr-code": "^2.0.12"
+ "swr": "^2.1.5"
},
"devDependencies": {
"@babel/core": "^7.22.17",
diff --git a/frontend/src/components/brackets/brackets.tsx b/frontend/src/components/brackets/brackets.tsx
index 2c740641..eae664a3 100644
--- a/frontend/src/components/brackets/brackets.tsx
+++ b/frontend/src/components/brackets/brackets.tsx
@@ -1,23 +1,26 @@
-import { Alert, Container, Grid, Skeleton } from '@mantine/core';
+import { Alert, Button, Container, Grid, Group, Skeleton } from '@mantine/core';
+import { GoPlus } from '@react-icons/all-files/go/GoPlus';
import { IconAlertCircle } from '@tabler/icons-react';
import React from 'react';
import { SWRResponse } from 'swr';
import { RoundInterface } from '../../interfaces/round';
+import { StageWithStageItems } from '../../interfaces/stage';
+import { StageItemWithRounds, stageItemIsHandledAutomatically } from '../../interfaces/stage_item';
import { TournamentMinimal } from '../../interfaces/tournament';
+import { createRound } from '../../services/round';
import { responseIsValid } from '../utils/util';
import Round from './round';
function getRoundsGridCols(
- stages_map: { [p: string]: any },
- selectedStageId: number,
+ stageItem: StageItemWithRounds,
tournamentData: TournamentMinimal,
swrStagesResponse: SWRResponse,
swrCourtsResponse: SWRResponse,
swrUpcomingMatchesResponse: SWRResponse | null,
readOnly: boolean
) {
- return stages_map[selectedStageId].rounds
+ let rounds: React.JSX.Element[] | React.JSX.Element = stageItem.rounds
.sort((r1: any, r2: any) => (r1.name > r2.name ? 1 : 0))
.map((round: RoundInterface) => (
{stageItem.name}
+