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) => ( @@ -28,9 +31,53 @@ function getRoundsGridCols( swrCourtsResponse={swrCourtsResponse} swrUpcomingMatchesResponse={swrUpcomingMatchesResponse} readOnly={readOnly} + dynamicSchedule={!stageItemIsHandledAutomatically(stageItem)} /> )); + + if (rounds.length < 1) { + rounds = ( + } title="No rounds" color="blue" radius="lg"> + There are no rounds in this stage item yet + + ); + } + + return ( + +
+ + +

{stageItem.name}

+
+ + + {stageItem == null || stageItemIsHandledAutomatically(stageItem) ? null : ( + + )} + + +
+
+
+ {rounds} +
+
+ ); } function NoRoundsAlert({ readOnly }: { readOnly: boolean }) { @@ -106,24 +153,18 @@ export default function Brackets({ } const stages_map = Object.fromEntries( - swrStagesResponse.data.data.map((x: RoundInterface) => [x.id, x]) + swrStagesResponse.data.data.map((x: StageWithStageItems) => [x.id, x]) + ); + const rounds = stages_map[selectedStageId].stage_items.map((stageItem: StageItemWithRounds) => + getRoundsGridCols( + stageItem, + tournamentData, + swrStagesResponse, + swrCourtsResponse, + swrUpcomingMatchesResponse, + readOnly + ) ); - const rounds = - stages_map[selectedStageId].rounds.length > 0 ? ( - getRoundsGridCols( - stages_map, - selectedStageId, - tournamentData, - swrStagesResponse, - swrCourtsResponse, - swrUpcomingMatchesResponse, - readOnly - ) - ) : ( - } title="No rounds" color="blue" radius="lg"> - There are no rounds in this stage yet - - ); return (
diff --git a/frontend/src/components/brackets/courts.tsx b/frontend/src/components/brackets/courts.tsx index 358e2e6e..f36a1ee3 100644 --- a/frontend/src/components/brackets/courts.tsx +++ b/frontend/src/components/brackets/courts.tsx @@ -18,6 +18,7 @@ function getRoundsGridCols(activeRound: RoundInterface, tournamentData: Tourname swrUpcomingMatchesResponse={null} match={match} readOnly + dynamicSchedule={false} /> )); diff --git a/frontend/src/components/brackets/courts_large.tsx b/frontend/src/components/brackets/courts_large.tsx index 9a999bed..7272ce1e 100644 --- a/frontend/src/components/brackets/courts_large.tsx +++ b/frontend/src/components/brackets/courts_large.tsx @@ -1,4 +1,4 @@ -import { Grid, Title } from '@mantine/core'; +import { Grid } from '@mantine/core'; import React from 'react'; import { RoundInterface } from '../../interfaces/round'; @@ -32,7 +32,6 @@ export default function CourtsLarge({ }) { return (
- {activeRound.name} {getRoundsGridCols(activeRound, tournamentData)}
); diff --git a/frontend/src/components/brackets/match.tsx b/frontend/src/components/brackets/match.tsx index 7616c35a..09437817 100644 --- a/frontend/src/components/brackets/match.tsx +++ b/frontend/src/components/brackets/match.tsx @@ -62,6 +62,7 @@ export default function Match({ tournamentData, match, readOnly, + dynamicSchedule, }: { swrRoundsResponse: SWRResponse | null; swrCourtsResponse: SWRResponse | null; @@ -69,12 +70,15 @@ export default function Match({ tournamentData: TournamentMinimal; match: MatchInterface; readOnly: boolean; + dynamicSchedule: boolean; }) { const { classes } = useStyles(); const theme = useMantineTheme(); const winner_style = { backgroundColor: theme.colorScheme === 'dark' ? theme.colors.green[9] : theme.colors.green[4], }; + const showTeamMemberNames = false; + const team1_style = match.team1_score > match.team2_score ? winner_style : {}; const team2_style = match.team1_score < match.team2_score ? winner_style : {}; @@ -84,6 +88,9 @@ export default function Match({ const team1_players_label = team1_players === '' ? 'No players' : team1_players; const team2_players_label = team2_players === '' ? 'No players' : team2_players; + const team1_label = showTeamMemberNames ? team1_players_label : match.team1.name; + const team2_label = showTeamMemberNames ? team2_players_label : match.team2.name; + const [opened, setOpened] = useState(false); const bracket = ( @@ -91,14 +98,14 @@ export default function Match({
- {team1_players_label} + {team1_label} {match.team1_score}
- {team2_players_label} + {team2_label} {match.team2_score}
@@ -124,6 +131,7 @@ export default function Match({ match={match} opened={opened} setOpened={setOpened} + dynamicSchedule={dynamicSchedule} /> ); diff --git a/frontend/src/components/brackets/match_large.tsx b/frontend/src/components/brackets/match_large.tsx index 6bd1209b..0f04d677 100644 --- a/frontend/src/components/brackets/match_large.tsx +++ b/frontend/src/components/brackets/match_large.tsx @@ -102,6 +102,7 @@ export default function MatchLarge({ match={match} opened={opened} setOpened={setOpened} + dynamicSchedule={false} /> ); diff --git a/frontend/src/components/brackets/round.tsx b/frontend/src/components/brackets/round.tsx index d1d0adab..a8e0339c 100644 --- a/frontend/src/components/brackets/round.tsx +++ b/frontend/src/components/brackets/round.tsx @@ -14,6 +14,7 @@ export default function Round({ swrCourtsResponse, swrUpcomingMatchesResponse, readOnly, + dynamicSchedule, }: { tournamentData: TournamentMinimal; round: RoundInterface; @@ -21,6 +22,7 @@ export default function Round({ swrCourtsResponse: SWRResponse; swrUpcomingMatchesResponse: SWRResponse | null; readOnly: boolean; + dynamicSchedule: boolean; }) { const matches = round.matches .sort((m1, m2) => ((m1.court ? m1.court.name : 'y') > (m2.court ? m2.court.name : 'z') ? 1 : 0)) @@ -33,6 +35,7 @@ export default function Round({ swrUpcomingMatchesResponse={swrUpcomingMatchesResponse} match={match} readOnly={readOnly} + dynamicSchedule={dynamicSchedule} /> )); const active_round_style = round.is_active @@ -58,15 +61,16 @@ export default function Round({ round={round} swrRoundsResponse={swrRoundsResponse} swrUpcomingMatchesResponse={swrUpcomingMatchesResponse} + dynamicSchedule={dynamicSchedule} /> ); return ( -
+
+ {content} + + ); +} + +function StageItemRow({ + teamsMap, + tournament, + stageItem, + swrStagesResponse, +}: { + teamsMap: any; + tournament: Tournament; + stageItem: StageItemWithRounds; + swrStagesResponse: SWRResponse; +}) { + const [opened, setOpened] = useState(false); + const stageItemsLookup = getStageItemLookup(swrStagesResponse); + + const inputs = stageItem.inputs + .sort((i1, i2) => (i1.slot > i2.slot ? 1 : 0)) + .map((input, i) => { + const team = input.team_id ? teamsMap[input.team_id] : null; + const teamStageItem = input.team_stage_item_id + ? stageItemsLookup[input.team_stage_item_id] + : null; + + return ( + + ); + }); + + return ( + + + + {stageItem.name} + + + + + + + + + + } + onClick={() => { + setOpened(true); + }} + > + Edit name + + } + onClick={async () => { + await deleteStageItem(tournament.id, stageItem.id); + await swrStagesResponse.mutate(null); + }} + color="red" + > + Delete + + + + + + {inputs} + + ); +} + +function StageColumn({ + tournament, + stage, + swrStagesResponse, +}: { + tournament: Tournament; + stage: StageWithStageItems; + swrStagesResponse: SWRResponse; +}) { + const [opened, setOpened] = useState(false); + const teamsMap = getTeamsLookup(tournament != null ? tournament.id : -1); + + if (teamsMap == null) { + return null; + } + + const rows = stage.stage_items.map((stageItem: StageItemWithRounds) => ( + + )); + + return ( + + + +

+ {stage.name} + {stage.is_active ? ( + + Active + + ) : null} +

+ + + + + + + + + } + onClick={() => { + setOpened(true); + }} + > + Edit name + + } + onClick={async () => { + await deleteStage(tournament.id, stage.id); + await swrStagesResponse.mutate(null); + }} + color="red" + > + Delete + + + +
+ {rows} + +
+ ); +} + +export default function Builder({ + tournament, + swrStagesResponse, +}: { + tournament: Tournament; + swrStagesResponse: SWRResponse; +}) { + const stages: StageWithStageItems[] = + swrStagesResponse.data != null ? swrStagesResponse.data.data : []; + + if (swrStagesResponse.error) return ; + + const cols = stages + .sort((s1: StageWithStageItems, s2: StageWithStageItems) => (s1.id > s2.id ? 1 : 0)) + .map((stage) => ( + + )); + + const button = ( + +

+ +

+
+ ); + const colsWithButton = cols.concat([button]); + + return {colsWithButton}; +} diff --git a/frontend/src/components/buttons/create_matches_auto.tsx b/frontend/src/components/buttons/create_matches_auto.tsx new file mode 100644 index 00000000..0a1db543 --- /dev/null +++ b/frontend/src/components/buttons/create_matches_auto.tsx @@ -0,0 +1,26 @@ +import { Button } from '@mantine/core'; +import { IconTool } from '@tabler/icons-react'; +import React from 'react'; + +import { createMatchesAuto } from '../../services/round'; + +export function AutoCreateMatchesButton({ tournamentData, swrStagesResponse, roundId }: any) { + if (roundId == null) { + return null; + } + return ( + + ); +} diff --git a/frontend/src/components/buttons/create_stage.tsx b/frontend/src/components/buttons/create_stage.tsx new file mode 100644 index 00000000..dfc74773 --- /dev/null +++ b/frontend/src/components/buttons/create_stage.tsx @@ -0,0 +1,31 @@ +import { Button } from '@mantine/core'; +import { GoPlus } from '@react-icons/all-files/go/GoPlus'; +import React from 'react'; +import { SWRResponse } from 'swr'; + +import { Tournament } from '../../interfaces/tournament'; +import { createStage } from '../../services/stage'; + +export default function CreateStageButton({ + tournament, + swrStagesResponse, +}: { + tournament: Tournament; + swrStagesResponse: SWRResponse; +}) { + return ( + + ); +} diff --git a/frontend/src/components/buttons/save.tsx b/frontend/src/components/buttons/save.tsx index 6be62640..9554a0bd 100644 --- a/frontend/src/components/buttons/save.tsx +++ b/frontend/src/components/buttons/save.tsx @@ -2,7 +2,12 @@ import { Button } from '@mantine/core'; export default function SaveButton(props: any) { return ( - ); diff --git a/frontend/src/components/dashboard/layout.tsx b/frontend/src/components/dashboard/layout.tsx index a86e5a70..0efe4286 100644 --- a/frontend/src/components/dashboard/layout.tsx +++ b/frontend/src/components/dashboard/layout.tsx @@ -8,24 +8,24 @@ import { getBaseURL } from '../utils/util'; export function TournamentQRCode({ tournamentDataFull }: { tournamentDataFull: Tournament }) { return ( -
-
-
- -
-
-
+
+
+ +
+
); } @@ -41,7 +41,6 @@ export function TournamentLogo({ tournamentDataFull }: { tournamentDataFull: Tou src={`${getBaseApiUrl()}/static/${tournamentDataFull.logo_path}`} style={{ maxWidth: '400px' }} /> - ) : null; } diff --git a/frontend/src/components/info/player_score.tsx b/frontend/src/components/info/player_score.tsx index 2a1be7a7..f7b467e4 100644 --- a/frontend/src/components/info/player_score.tsx +++ b/frontend/src/components/info/player_score.tsx @@ -3,14 +3,15 @@ import { DefaultMantineColor } from '@mantine/styles/lib/theme/types/MantineColo interface ScoreProps { score: number; + min_score: number; max_score: number; color: DefaultMantineColor; decimals: number; } -export function PlayerScore({ score, max_score, color, decimals }: ScoreProps) { +export function PlayerScore({ score, min_score, max_score, color, decimals }: ScoreProps) { const theme = useMantineTheme(); - const percentageScale = 100.0 / max_score; + const percentageScale = 100.0 / (max_score - min_score); const base_color = theme.colors[color]; return ( @@ -23,9 +24,9 @@ export function PlayerScore({ score, max_score, color, decimals }: ScoreProps) { diff --git a/frontend/src/components/info/player_statistics.tsx b/frontend/src/components/info/player_statistics.tsx index 0aeeef23..a79337a5 100644 --- a/frontend/src/components/info/player_statistics.tsx +++ b/frontend/src/components/info/player_statistics.tsx @@ -14,8 +14,23 @@ interface PlayerStatisticsProps { losses: number; } -export function PlayerStatistics({ wins, draws, losses }: PlayerStatisticsProps) { - const { classes, theme } = useStyles(); +export function getWinColor() { + const { theme } = useStyles(); + return theme.colorScheme === 'dark' ? theme.colors.teal[9] : theme.colors.teal[6]; +} + +export function getDrawColor() { + const { theme } = useStyles(); + return theme.colorScheme === 'dark' ? theme.colors.orange[9] : theme.colors.orange[6]; +} + +export function getLossColor() { + const { theme } = useStyles(); + return theme.colorScheme === 'dark' ? theme.colors.red[9] : theme.colors.red[6]; +} + +export function WinDistribution({ wins, draws, losses }: PlayerStatisticsProps) { + const { classes } = useStyles(); const percentageScale = 100.0 / (wins + draws + losses); const draws_text = @@ -41,17 +56,17 @@ export function PlayerStatistics({ wins, draws, losses }: PlayerStatisticsProps) sections={[ { value: percentageScale * wins, - color: theme.colorScheme === 'dark' ? theme.colors.teal[9] : theme.colors.teal[6], + color: getWinColor(), tooltip: `Wins (${(percentageScale * wins).toFixed(0)}%)`, }, { value: percentageScale * draws, - color: theme.colorScheme === 'dark' ? theme.colors.orange[9] : theme.colors.orange[6], + color: getDrawColor(), tooltip: `Draws (${(percentageScale * draws).toFixed(0)}%)`, }, { value: percentageScale * losses, - color: theme.colorScheme === 'dark' ? theme.colors.red[9] : theme.colors.red[6], + color: getLossColor(), tooltip: `Losses (${(percentageScale * losses).toFixed(0)}%)`, }, ]} diff --git a/frontend/src/components/modals/create_stage_item.tsx b/frontend/src/components/modals/create_stage_item.tsx new file mode 100644 index 00000000..6fef0c5c --- /dev/null +++ b/frontend/src/components/modals/create_stage_item.tsx @@ -0,0 +1,208 @@ +import { Button, Divider, Modal, NumberInput, Select } from '@mantine/core'; +import { UseFormReturnType, useForm } from '@mantine/form'; +import { GoPlus } from '@react-icons/all-files/go/GoPlus'; +import assert from 'assert'; +import React, { useState } from 'react'; +import { SWRResponse } from 'swr'; + +import { StageWithStageItems } from '../../interfaces/stage'; +import { StageItemInputOption, getPositionName } from '../../interfaces/stage_item_input'; +import { Tournament } from '../../interfaces/tournament'; +import { getAvailableStageItemInputs } from '../../services/adapter'; +import { getStageItemLookup, getTeamsLookup } from '../../services/lookups'; +import { createStageItem } from '../../services/stage_item'; +import { responseIsValid } from '../utils/util'; + +function TeamCountSelectElimination({ form }: { form: UseFormReturnType }) { + const data = [ + { value: '2', label: '2' }, + { value: '4', label: '4' }, + { value: '8', label: '8' }, + ]; + return ( + + ); +} + +function getTeamCount(values: any) { + return Number( + values.type === 'SINGLE_ELIMINATION' + ? values.team_count_elimination + : values.team_count_round_robin + ); +} + +function StageItemInputs({ + form, + possibleOptions, +}: { + form: UseFormReturnType; + possibleOptions: any[]; +}) { + return Array.from(Array(Math.max(getTeamCount(form.values), 2)).keys()).map((x) => ( + + )); +} + +export function formatStageItemInput(team_position_in_group: number, teamName: string) { + // @ts-ignore + return `${getPositionName(team_position_in_group)} of ${teamName}`; +} + +export function CreateStageItemModal({ + tournament, + stage, + swrStagesResponse, +}: { + tournament: Tournament; + stage: StageWithStageItems; + swrStagesResponse: SWRResponse; +}) { + const [opened, setOpened] = useState(false); + + const form = useForm({ + initialValues: { type: 'ROUND_ROBIN', team_count_round_robin: 2, team_count_elimination: 2 }, + validate: { + team_count_round_robin: (value) => (value >= 2 ? null : 'Need at least two teams'), + team_count_elimination: (value) => (value >= 2 ? null : 'Need at least two teams'), + }, + }); + + const teamsMap = getTeamsLookup(tournament != null ? tournament.id : -1); + const stageItemMap = getStageItemLookup(swrStagesResponse); + + if (teamsMap == null || stageItemMap == null) { + return null; + } + + const swrAvailableInputsResponse: SWRResponse = getAvailableStageItemInputs( + tournament.id, + stage.id + ); + const availableInputs = responseIsValid(swrAvailableInputsResponse) + ? swrAvailableInputsResponse.data.data.map((option: StageItemInputOption) => { + if (option.team_stage_item_id == null) { + if (option.team_id == null) return null; + const team = teamsMap[option.team_id]; + if (team == null) return null; + return { + value: option.team_id, + label: team.name, + }; + } + assert(option.team_position_in_group != null); + const stageItem = stageItemMap[option.team_stage_item_id]; + if (stageItem == null) return null; + return { + value: `${option.team_stage_item_id}_${option.team_position_in_group}`, + label: `${formatStageItemInput(option.team_position_in_group, stageItem.name)}`, + }; + }) + : {}; + + return ( + <> + setOpened(false)} title="Add stage item"> +
{ + const teamCount = getTeamCount(values); + const inputs = Array.from(Array(teamCount).keys()).map((i) => { + const teamId = values[`team_${i + 1}` as keyof typeof values]; + return { + slot: i + 1, + team_id: Number(teamId), + team_stage_item_id: + typeof teamId === 'string' ? Number(teamId.split('_')[0]) : null, + team_position_in_group: + typeof teamId === 'string' ? Number(teamId.split('_')[1]) : null, + }; + }); + await createStageItem(tournament.id, stage.id, values.type, teamCount, inputs); + await swrStagesResponse.mutate(null); + })} + > + - -
- ); -} - export default function StagesPage() { const { tournamentData } = getTournamentIdFromRouter(); const swrStagesResponse = getStages(tournamentData.id); @@ -59,9 +24,8 @@ export default function StagesPage() { return ( - - - {CreateStageForm(tournamentDataFull, swrStagesResponse)} + + handleRequestError(response)); diff --git a/frontend/src/services/lookups.tsx b/frontend/src/services/lookups.tsx new file mode 100644 index 00000000..a74608b7 --- /dev/null +++ b/frontend/src/services/lookups.tsx @@ -0,0 +1,41 @@ +import { SWRResponse } from 'swr'; + +import { responseIsValid } from '../components/utils/util'; +import { RoundInterface } from '../interfaces/round'; +import { StageWithStageItems } from '../interfaces/stage'; +import { TeamInterface } from '../interfaces/team'; +import { getTeams } from './adapter'; + +export function getTeamsLookup(tournamentId: number) { + const swrTeamsResponse: SWRResponse = getTeams(tournamentId); + const isResponseValid = responseIsValid(swrTeamsResponse); + + if (!isResponseValid) { + return null; + } + return Object.fromEntries(swrTeamsResponse.data.data.map((x: TeamInterface) => [x.id, x])); +} + +export function getStageItemLookup(swrStagesResponse: SWRResponse) { + let result: any[] = []; + + swrStagesResponse.data.data.map((stage: StageWithStageItems) => + stage.stage_items.forEach((stage_item) => { + result = result.concat([[stage_item.id, stage_item]]); + }) + ); + return Object.fromEntries(result); +} + +export function getActiveRounds(swrStagesResponse: SWRResponse) { + let result: RoundInterface[] = []; + + swrStagesResponse.data.data.map((stage: StageWithStageItems) => + stage.stage_items.forEach((stage_item) => { + stage_item.rounds.forEach((round) => { + if (round.is_active) result = result.concat([round]); + }); + }) + ); + return result; +} diff --git a/frontend/src/services/match.tsx b/frontend/src/services/match.tsx index e35d69c8..7381b3f8 100644 --- a/frontend/src/services/match.tsx +++ b/frontend/src/services/match.tsx @@ -19,6 +19,6 @@ export async function updateMatch( match: MatchBodyInterface ) { return createAxios() - .patch(`tournaments/${tournament_id}/matches/${match_id}`, match) + .put(`tournaments/${tournament_id}/matches/${match_id}`, match) .catch((response: any) => handleRequestError(response)); } diff --git a/frontend/src/services/player.tsx b/frontend/src/services/player.tsx index e14e19ac..8bfe2a51 100644 --- a/frontend/src/services/player.tsx +++ b/frontend/src/services/player.tsx @@ -29,7 +29,7 @@ export async function updatePlayer( team_id: string | null ) { return createAxios() - .patch(`tournaments/${tournament_id}/players/${player_id}`, { + .put(`tournaments/${tournament_id}/players/${player_id}`, { name, active, team_id, diff --git a/frontend/src/services/round.tsx b/frontend/src/services/round.tsx index 3c9aa29d..d45c3dbe 100644 --- a/frontend/src/services/round.tsx +++ b/frontend/src/services/round.tsx @@ -1,14 +1,20 @@ import { RoundInterface } from '../interfaces/round'; import { createAxios, handleRequestError } from './adapter'; -export async function createRound(tournament_id: number, stage_id: number) { +export async function createRound(tournament_id: number, stage_item_id: number) { return createAxios() .post(`tournaments/${tournament_id}/rounds`, { - stage_id, + stage_item_id, }) .catch((response: any) => handleRequestError(response)); } +export async function createMatchesAuto(tournament_id: number, round_id: number) { + return createAxios() + .post(`tournaments/${tournament_id}/rounds/${round_id}/schedule_auto`) + .catch((response: any) => handleRequestError(response)); +} + export async function deleteRound(tournament_id: number, round_id: number) { return createAxios() .delete(`tournaments/${tournament_id}/rounds/${round_id}`) @@ -17,6 +23,6 @@ export async function deleteRound(tournament_id: number, round_id: number) { export async function updateRound(tournament_id: number, round_id: number, round: RoundInterface) { return createAxios() - .patch(`tournaments/${tournament_id}/rounds/${round_id}`, round) + .put(`tournaments/${tournament_id}/rounds/${round_id}`, round) .catch((response: any) => handleRequestError(response)); } diff --git a/frontend/src/services/stage.tsx b/frontend/src/services/stage.tsx index d58e4aa2..40afd3af 100644 --- a/frontend/src/services/stage.tsx +++ b/frontend/src/services/stage.tsx @@ -1,8 +1,14 @@ import { createAxios, handleRequestError } from './adapter'; -export async function createStage(tournament_id: number, type: string) { +export async function createStage(tournament_id: number) { return createAxios() - .post(`tournaments/${tournament_id}/stages`, { type }) + .post(`tournaments/${tournament_id}/stages`) + .catch((response: any) => handleRequestError(response)); +} + +export async function updateStage(tournament_id: number, stage_id: number, name: string) { + return createAxios() + .put(`tournaments/${tournament_id}/stages/${stage_id}`, { name }) .catch((response: any) => handleRequestError(response)); } diff --git a/frontend/src/services/stage_item.tsx b/frontend/src/services/stage_item.tsx new file mode 100644 index 00000000..d1cf7925 --- /dev/null +++ b/frontend/src/services/stage_item.tsx @@ -0,0 +1,26 @@ +import { StageItemInputCreateBody } from '../interfaces/stage_item_input'; +import { createAxios, handleRequestError } from './adapter'; + +export async function createStageItem( + tournament_id: number, + stage_id: number, + type: string, + team_count: number, + inputs: StageItemInputCreateBody[] +) { + return createAxios() + .post(`tournaments/${tournament_id}/stage_items`, { stage_id, type, team_count, inputs }) + .catch((response: any) => handleRequestError(response)); +} + +export async function updateStageItem(tournament_id: number, stage_item_id: number, name: string) { + return createAxios() + .put(`tournaments/${tournament_id}/stage_items/${stage_item_id}`, { name }) + .catch((response: any) => handleRequestError(response)); +} + +export async function deleteStageItem(tournament_id: number, stage_item_id: number) { + return createAxios() + .delete(`tournaments/${tournament_id}/stage_items/${stage_item_id}`) + .catch((response: any) => handleRequestError(response)); +} diff --git a/frontend/src/services/team.tsx b/frontend/src/services/team.tsx index 52c0510b..c7573d8c 100644 --- a/frontend/src/services/team.tsx +++ b/frontend/src/services/team.tsx @@ -27,7 +27,7 @@ export async function updateTeam( player_ids: string[] ) { await createAxios() - .patch(`tournaments/${tournament_id}/teams/${team_id}`, { + .put(`tournaments/${tournament_id}/teams/${team_id}`, { name, active, player_ids, diff --git a/frontend/src/services/tournament.tsx b/frontend/src/services/tournament.tsx index 7e9e7f67..59db11c9 100644 --- a/frontend/src/services/tournament.tsx +++ b/frontend/src/services/tournament.tsx @@ -31,7 +31,7 @@ export async function updateTournament( players_can_be_in_multiple_teams: boolean, auto_assign_courts: boolean ) { - return createAxios().patch(`tournaments/${tournament_id}`, { + return createAxios().put(`tournaments/${tournament_id}`, { name, dashboard_public, dashboard_endpoint, diff --git a/frontend/src/services/user.tsx b/frontend/src/services/user.tsx index a1b3708f..2c32d1d2 100644 --- a/frontend/src/services/user.tsx +++ b/frontend/src/services/user.tsx @@ -29,13 +29,13 @@ export async function performLogin(username: string, password: string) { export async function updateUser(user_id: number, user: UserBodyInterface) { return createAxios() - .patch(`users/${user_id}`, user) + .put(`users/${user_id}`, user) .catch((response: any) => handleRequestError(response)); } export async function updatePassword(user_id: number, password: string) { return createAxios() - .patch(`users/${user_id}/password`, { password }) + .put(`users/${user_id}/password`, { password }) .catch((response: any) => handleRequestError(response)); } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 1be74638..e8503b7d 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2410,6 +2410,11 @@ cookies-next@^4.0.0: "@types/node" "^16.10.2" cookie "^0.4.0" +core-js@3: + version "3.33.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.33.0.tgz#70366dbf737134761edb017990cf5ce6c6369c40" + integrity sha512-HoZr92+ZjFEKar5HS6MC776gYslNOKHt75mEBKWKnPeFDpZ6nH5OeF3S6HFT1mUAUZKrzkez05VboaX8myjSuw== + core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -5133,6 +5138,16 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +react-dom@^16.8.6: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" + integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.19.1" + react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" @@ -5150,6 +5165,16 @@ react-dropzone@14.2.3: file-selector "^0.6.0" prop-types "^15.8.1" +react-ellipsis-text@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/react-ellipsis-text/-/react-ellipsis-text-1.2.1.tgz#2671b66903fa023151a16d745fdd9762d45babf0" + integrity sha512-r9eybYj6KLolCb/stTookClwCwxu+5Gage1EI/v4EVQggo1dgce/sgSqj1WQEinYKbzN3vD2993teZIqaU/V5w== + dependencies: + core-js "3" + prop-types "^15.7.2" + react "^16.8.6" + react-dom "^16.8.6" + react-icons@^4.8.0: version "4.8.0" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.8.0.tgz#621e900caa23b912f737e41be57f27f6b2bff445" @@ -5242,6 +5267,15 @@ react-transition-group@4.4.2: loose-envify "^1.4.0" prop-types "^15.6.2" +react@^16.8.6: + version "16.14.0" + resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" + integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" @@ -5416,6 +5450,14 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.23.0: version "0.23.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"