From 145d5787e1122491e35d3e71c5bbe821c6e0465d Mon Sep 17 00:00:00 2001 From: Erik Vroon Date: Sun, 5 Nov 2023 15:22:24 +0100 Subject: [PATCH] Implement scheduling for elimination stage items (#314) --- ..._add_columns_for_elimination_scheduling.py | 70 +++++++ backend/bracket/logic/elo.py | 100 ---------- backend/bracket/logic/ranking/__init__.py | 0 backend/bracket/logic/ranking/elo.py | 129 +++++++++++++ backend/bracket/logic/ranking/ranking.py | 0 backend/bracket/logic/scheduling/builder.py | 20 +- .../bracket/logic/scheduling/elimination.py | 120 ++++++------ .../scheduling/handle_stage_activation.py | 64 +++++++ .../logic/scheduling/ladder_players_iter.py | 22 ++- .../bracket/logic/scheduling/ladder_teams.py | 10 +- .../bracket/logic/scheduling/round_robin.py | 55 +++--- backend/bracket/models/db/match.py | 48 +++-- backend/bracket/models/db/player.py | 3 +- .../bracket/models/db/stage_item_inputs.py | 46 +++-- backend/bracket/models/db/team.py | 4 +- backend/bracket/models/db/util.py | 11 +- backend/bracket/routes/matches.py | 12 +- backend/bracket/routes/rounds.py | 4 +- backend/bracket/routes/stage_items.py | 4 +- backend/bracket/routes/stages.py | 7 +- backend/bracket/routes/teams.py | 8 +- backend/bracket/schema.py | 20 +- backend/bracket/sql/matches.py | 75 +++++--- backend/bracket/sql/rounds.py | 5 +- backend/bracket/sql/stage_item_inputs.py | 14 +- backend/bracket/sql/stages.py | 16 +- backend/bracket/utils/db_init.py | 174 +++++++++++++----- backend/bracket/utils/dummy_records.py | 61 ++++-- backend/pyproject.toml | 3 +- .../integration_tests/api/courts_test.py | 2 +- .../integration_tests/api/inputs_test.py | 4 +- .../integration_tests/api/matches_test.py | 64 +++---- .../integration_tests/api/players_test.py | 2 +- backend/tests/unit_tests/elo_test.py | 38 +++- frontend/src/components/brackets/brackets.tsx | 2 +- frontend/src/components/brackets/courts.tsx | 13 +- .../src/components/brackets/courts_large.tsx | 13 +- frontend/src/components/brackets/match.tsx | 30 ++- .../src/components/brackets/match_large.tsx | 16 +- frontend/src/components/brackets/round.tsx | 12 +- frontend/src/components/builder/builder.tsx | 10 +- .../components/modals/create_stage_item.tsx | 21 +-- .../src/components/modals/match_modal.tsx | 30 ++- frontend/src/interfaces/match.tsx | 52 +++++- frontend/src/interfaces/stage_item_input.tsx | 17 +- frontend/src/services/lookups.tsx | 15 ++ 46 files changed, 960 insertions(+), 486 deletions(-) create mode 100644 backend/alembic/versions/9ab8db749982_add_columns_for_elimination_scheduling.py delete mode 100644 backend/bracket/logic/elo.py create mode 100644 backend/bracket/logic/ranking/__init__.py create mode 100644 backend/bracket/logic/ranking/elo.py create mode 100644 backend/bracket/logic/ranking/ranking.py create mode 100644 backend/bracket/logic/scheduling/handle_stage_activation.py diff --git a/backend/alembic/versions/9ab8db749982_add_columns_for_elimination_scheduling.py b/backend/alembic/versions/9ab8db749982_add_columns_for_elimination_scheduling.py new file mode 100644 index 00000000..0d19403a --- /dev/null +++ b/backend/alembic/versions/9ab8db749982_add_columns_for_elimination_scheduling.py @@ -0,0 +1,70 @@ +"""add columns for elimination scheduling + +Revision ID: 9ab8db749982 +Revises: 85d260b43ad4 +Create Date: 2023-11-05 15:07:33.965445 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str | None = '9ab8db749982' +down_revision: str | None = '85d260b43ad4' +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column('matches', sa.Column('start_time', sa.DateTime(timezone=True), nullable=True)) + op.add_column('matches', sa.Column('duration_minutes', sa.Integer(), nullable=True)) + op.add_column( + 'matches', sa.Column('team1_winner_from_match_id', sa.BigInteger(), nullable=True) + ) + op.add_column( + 'matches', sa.Column('team2_winner_from_match_id', sa.BigInteger(), nullable=True) + ) + + op.alter_column( + 'matches', + 'team1_position_in_group', + nullable=True, + new_column_name='team1_winner_position', + ) + op.alter_column( + 'matches', + 'team2_position_in_group', + nullable=True, + new_column_name='team2_winner_position', + ) + op.alter_column( + 'matches', + 'team1_stage_item_id', + nullable=True, + new_column_name='team1_winner_from_stage_item_id', + ) + op.alter_column( + 'matches', + 'team2_stage_item_id', + nullable=True, + new_column_name='team2_winner_from_stage_item_id', + ) + + op.alter_column( + 'stage_item_inputs', + 'team_stage_item_id', + nullable=True, + new_column_name='winner_from_stage_item_id', + ) + op.alter_column( + 'stage_item_inputs', + 'team_position_in_group', + nullable=True, + new_column_name='winner_position', + ) + + +def downgrade() -> None: + """Impossible""" diff --git a/backend/bracket/logic/elo.py b/backend/bracket/logic/elo.py deleted file mode 100644 index e072befd..00000000 --- a/backend/bracket/logic/elo.py +++ /dev/null @@ -1,100 +0,0 @@ -import math -from collections import defaultdict -from decimal import Decimal - -from bracket.database import database -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, update_player_stats -from bracket.sql.stages import get_full_tournament_details -from bracket.utils.types import assert_some - -K = 32 -D = 400 - - -def calculate_elo_per_player(rounds: list[RoundWithMatches]) -> defaultdict[int, PlayerStatistics]: - player_x_elo: defaultdict[int, PlayerStatistics] = defaultdict(PlayerStatistics) - - for round_ in rounds: - if not round_.is_draft: - for match in round_.matches: - if match.team1_score != 0 or match.team2_score != 0: - rating_team1_before = ( - sum( - player_x_elo[player_id].elo_score - for player_id in match.team1.player_ids - ) - / len(match.team1.player_ids) - if len(match.team1.player_ids) > 0 - else START_ELO - ) - rating_team2_before = ( - sum( - player_x_elo[player_id].elo_score - for player_id in match.team2.player_ids - ) - / len(match.team2.player_ids) - if len(match.team2.player_ids) > 0 - else START_ELO - ) - - for team_index, team in enumerate(match.teams): - is_team1 = team_index == 0 - - for player in team.players: - team_score = match.team1_score if team_index == 0 else match.team2_score - was_draw = match.team1_score == match.team2_score - has_won = not was_draw and team_score == max( - match.team1_score, match.team2_score - ) - - if has_won: - player_x_elo[assert_some(player.id)].wins += 1 - swiss_score_diff = Decimal('1.00') - elif was_draw: - player_x_elo[assert_some(player.id)].draws += 1 - swiss_score_diff = Decimal('0.50') - else: - player_x_elo[assert_some(player.id)].losses += 1 - swiss_score_diff = Decimal('0.00') - - player_x_elo[assert_some(player.id)].swiss_score += swiss_score_diff - rating_diff = (rating_team2_before - rating_team1_before) * ( - 1 if is_team1 else -1 - ) - expected_score = Decimal(1.0 / (1.0 + math.pow(10.0, rating_diff / D))) - player_x_elo[assert_some(player.id)].elo_score += int( - K * (swiss_score_diff - expected_score) - ) - - return player_x_elo - - -async def recalculate_elo_for_tournament_id(tournament_id: int) -> None: - 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, rounds: list[RoundWithMatches]) -> None: - elo_per_player = calculate_elo_per_player(rounds) - - for player_id, statistics in elo_per_player.items(): - await update_player_stats(tournament_id, player_id, statistics) - - all_players = await get_all_players_in_tournament(tournament_id) - for player in all_players: - if player.id not in elo_per_player: - await database.execute( - query=players.update().where( - (players.c.id == player.id) & (players.c.tournament_id == tournament_id) - ), - values=PlayerStatistics().dict(), - ) diff --git a/backend/bracket/logic/ranking/__init__.py b/backend/bracket/logic/ranking/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/bracket/logic/ranking/elo.py b/backend/bracket/logic/ranking/elo.py new file mode 100644 index 00000000..084f7675 --- /dev/null +++ b/backend/bracket/logic/ranking/elo.py @@ -0,0 +1,129 @@ +import math +from collections import defaultdict +from decimal import Decimal + +from bracket.database import database +from bracket.models.db.match import MatchWithDetailsDefinitive +from bracket.models.db.players import START_ELO, PlayerStatistics +from bracket.models.db.util import StageItemWithRounds +from bracket.schema import players +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 + +K = 32 +D = 400 + + +def set_statistics_for_player_or_team( + team_index: int, + stats: defaultdict[int, PlayerStatistics], + match: MatchWithDetailsDefinitive, + team_or_player_id: int, + rating_team1_before: float | int, + rating_team2_before: float | int, +) -> None: + is_team1 = team_index == 0 + team_score = match.team1_score if is_team1 else match.team2_score + was_draw = match.team1_score == match.team2_score + has_won = not was_draw and team_score == max(match.team1_score, match.team2_score) + + if has_won: + stats[team_or_player_id].wins += 1 + swiss_score_diff = Decimal('1.00') + elif was_draw: + stats[team_or_player_id].draws += 1 + swiss_score_diff = Decimal('0.50') + else: + stats[team_or_player_id].losses += 1 + swiss_score_diff = Decimal('0.00') + + stats[team_or_player_id].swiss_score += swiss_score_diff + + rating_diff = (rating_team2_before - rating_team1_before) * (1 if is_team1 else -1) + expected_score = Decimal(1.0 / (1.0 + math.pow(10.0, rating_diff / D))) + stats[team_or_player_id].elo_score += int(K * (swiss_score_diff - expected_score)) + + +def determine_ranking_for_stage_items( + stage_items: list[StageItemWithRounds], +) -> tuple[defaultdict[int, PlayerStatistics], defaultdict[int, PlayerStatistics]]: + player_x_stats: defaultdict[int, PlayerStatistics] = defaultdict(PlayerStatistics) + team_x_stats: defaultdict[int, PlayerStatistics] = defaultdict(PlayerStatistics) + matches = [ + match + for stage_item in stage_items + for round_ in stage_item.rounds + if not round_.is_draft + for match in round_.matches + if isinstance(match, MatchWithDetailsDefinitive) + if match.team1_score != 0 or match.team2_score != 0 + ] + for match in matches: + rating_team1_before = ( + sum(player_x_stats[player_id].elo_score for player_id in match.team1.player_ids) + / len(match.team1.player_ids) + if len(match.team1.player_ids) > 0 + else START_ELO + ) + rating_team2_before = ( + sum(player_x_stats[player_id].elo_score for player_id in match.team2.player_ids) + / len(match.team2.player_ids) + if len(match.team2.player_ids) > 0 + else START_ELO + ) + + for team_index, team in enumerate(match.teams): + if team.id is not None: + set_statistics_for_player_or_team( + team_index, + team_x_stats, + match, + team.id, + rating_team1_before, + rating_team2_before, + ) + + for player in team.players: + set_statistics_for_player_or_team( + team_index, + player_x_stats, + match, + assert_some(player.id), + rating_team1_before, + rating_team2_before, + ) + + return player_x_stats, team_x_stats + + +def determine_team_ranking_for_stage_item( + stage_item: StageItemWithRounds, +) -> list[tuple[int, PlayerStatistics]]: + _, team_ranking = determine_ranking_for_stage_items([stage_item]) + return sorted(team_ranking.items(), key=lambda x: x[1].elo_score, reverse=True) + + +async def recalculate_ranking_for_tournament_id(tournament_id: int) -> None: + stages = await get_full_tournament_details(tournament_id) + stage_items = [stage_item for stage in stages for stage_item in stage.stage_items] + await recalculate_ranking_for_stage_items(tournament_id, stage_items) + + +async def recalculate_ranking_for_stage_items( + tournament_id: int, stage_items: list[StageItemWithRounds] +) -> None: + elo_per_player, _ = determine_ranking_for_stage_items(stage_items) + + for player_id, statistics in elo_per_player.items(): + await update_player_stats(tournament_id, player_id, statistics) + + all_players = await get_all_players_in_tournament(tournament_id) + for player in all_players: + if player.id not in elo_per_player: + await database.execute( + query=players.update().where( + (players.c.id == player.id) & (players.c.tournament_id == tournament_id) + ), + values=PlayerStatistics().dict(), + ) diff --git a/backend/bracket/logic/ranking/ranking.py b/backend/bracket/logic/ranking/ranking.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/bracket/logic/scheduling/builder.py b/backend/bracket/logic/scheduling/builder.py index d12ebe0c..c91b2ab3 100644 --- a/backend/bracket/logic/scheduling/builder.py +++ b/backend/bracket/logic/scheduling/builder.py @@ -10,7 +10,6 @@ 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 ( @@ -47,9 +46,7 @@ async def create_rounds_for_new_stage_item(tournament_id: int, stage_item: Stage ) -async def build_matches_for_stage_item( - stage_item: StageItem, tournament_id: int -) -> list[SuggestedMatch | SuggestedVirtualMatch]: +async def build_matches_for_stage_item(stage_item: StageItem, tournament_id: int) -> None: 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)) @@ -60,22 +57,15 @@ async def build_matches_for_stage_item( match stage_item.type: case StageType.ROUND_ROBIN: - upcoming_matches = await build_round_robin_stage_item( - tournament_id, stage_item_with_rounds - ) + 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, - ) + 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, @@ -100,10 +90,10 @@ def determine_available_inputs( results_tentative.extend( [ StageItemInputOptionTentative( - team_stage_item_id=stage_item.id, team_position_in_group=1 + winner_from_stage_item_id=stage_item.id, winner_position=1 ), StageItemInputOptionTentative( - team_stage_item_id=stage_item.id, team_position_in_group=2 + winner_from_stage_item_id=stage_item.id, winner_position=2 ), ] ) diff --git a/backend/bracket/logic/scheduling/elimination.py b/backend/bracket/logic/scheduling/elimination.py index e485d53f..958c7bd5 100644 --- a/backend/bracket/logic/scheduling/elimination.py +++ b/backend/bracket/logic/scheduling/elimination.py @@ -1,84 +1,80 @@ -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.logic.matches import create_match_and_assign_free_court +from bracket.models.db.match import Match, MatchCreateBody +from bracket.models.db.util import RoundWithMatches, 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] = [] + round_: RoundWithMatches, stage_item: StageItemWithRounds +) -> list[MatchCreateBody]: + suggestions: list[MatchCreateBody] = [] - # for i in range(0, stage.team_count, 2): - # match = SuggestedVirtualMatch( - # team1_group_id= - # ) - # suggestions.append(get_suggested_match(team1, team2)) + for i in range(0, len(stage_item.inputs), 2): + first_input = stage_item.inputs[i + 0] + second_input = stage_item.inputs[i + 1] + suggestions.append( + MatchCreateBody( + round_id=assert_some(round_.id), + court_id=None, + team1_id=first_input.team_id, + team1_winner_from_stage_item_id=first_input.winner_from_stage_item_id, + team1_winner_position=first_input.winner_position, + team1_winner_from_match_id=first_input.winner_from_match_id, + team2_id=second_input.team_id, + team2_winner_from_stage_item_id=second_input.winner_from_stage_item_id, + team2_winner_position=second_input.winner_position, + team2_winner_from_match_id=second_input.winner_from_match_id, + ) + ) 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] +def determine_matches_subsequent_round( + prev_matches: list[Match], + round_: RoundWithMatches, +) -> list[MatchCreateBody]: + suggestions: list[MatchCreateBody] = [] - # 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)) + for i in range(0, len(prev_matches), 2): + first_match = prev_matches[i + 0] + second_match = prev_matches[i + 1] + + suggestions.append( + MatchCreateBody( + round_id=assert_some(round_.id), + court_id=None, + team1_id=None, + team1_winner_from_stage_item_id=None, + team1_winner_position=None, + team2_id=None, + team2_winner_from_stage_item_id=None, + team2_winner_position=None, + team1_winner_from_match_id=assert_some(first_match.id), + team2_winner_from_match_id=assert_some(second_match.id), + ) + ) 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) +) -> None: + rounds = await get_rounds_for_stage_item(tournament_id, stage_item.id) assert len(rounds) > 0 + first_round = 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 + prev_matches = [ + await create_match_and_assign_free_court(tournament_id, match) + for match in determine_matches_first_round(first_round, stage_item) + ] - 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 + for round_ in rounds[1:]: + prev_matches = [ + await create_match_and_assign_free_court(tournament_id, match) + for match in determine_matches_subsequent_round(prev_matches, round_) + ] def get_number_of_rounds_to_create_single_elimination(team_count: int) -> int: diff --git a/backend/bracket/logic/scheduling/handle_stage_activation.py b/backend/bracket/logic/scheduling/handle_stage_activation.py new file mode 100644 index 00000000..17a8bf06 --- /dev/null +++ b/backend/bracket/logic/scheduling/handle_stage_activation.py @@ -0,0 +1,64 @@ +from bracket.logic.ranking.elo import ( + determine_team_ranking_for_stage_item, +) +from bracket.models.db.match import MatchWithDetails +from bracket.sql.matches import sql_get_match, sql_update_team_ids_for_match +from bracket.sql.stage_items import get_stage_item +from bracket.sql.stages import get_full_tournament_details +from bracket.utils.types import assert_some + + +async def determine_team_id( + tournament_id: int, + winner_from_stage_item_id: int | None, + winner_position: int | None, + winner_from_match_id: int | None, +) -> int | None: + if winner_from_stage_item_id is not None and winner_position is not None: + stage_item = await get_stage_item(tournament_id, winner_from_stage_item_id) + assert stage_item is not None + + team_ranking = determine_team_ranking_for_stage_item(stage_item) + if len(team_ranking) >= winner_position: + return team_ranking[winner_position - 1][0] + + return None + + if winner_from_match_id is not None: + match = await sql_get_match(winner_from_match_id) + winner_index = match.get_winner_index() + if winner_index is not None: + team_id = match.team1_id if match.get_winner_index() == 1 else match.team2_id + assert team_id is not None + return team_id + + return None + + raise ValueError('Unexpected match type') + + +async def set_team_ids_for_match(tournament_id: int, match: MatchWithDetails) -> None: + team1_id = await determine_team_id( + tournament_id, + match.team1_winner_from_stage_item_id, + match.team1_winner_position, + match.team1_winner_from_match_id, + ) + team2_id = await determine_team_id( + tournament_id, + match.team2_winner_from_stage_item_id, + match.team2_winner_position, + match.team2_winner_from_match_id, + ) + + await sql_update_team_ids_for_match(assert_some(match.id), team1_id, team2_id) + + +async def update_matches_in_activated_stage(tournament_id: int, stage_id: int) -> None: + [stage] = await get_full_tournament_details(tournament_id, stage_id=stage_id) + + for stage_item in stage.stage_items: + for round_ in stage_item.rounds: + for match in round_.matches: + if isinstance(match, MatchWithDetails): + await set_team_ids_for_match(tournament_id, match) diff --git a/backend/bracket/logic/scheduling/ladder_players_iter.py b/backend/bracket/logic/scheduling/ladder_players_iter.py index 12701a69..b780e322 100644 --- a/backend/bracket/logic/scheduling/ladder_players_iter.py +++ b/backend/bracket/logic/scheduling/ladder_players_iter.py @@ -6,7 +6,12 @@ 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, SuggestedVirtualMatch +from bracket.models.db.match import ( + MatchFilter, + MatchWithDetailsDefinitive, + SuggestedMatch, + SuggestedVirtualMatch, +) from bracket.models.db.player import Player from bracket.models.db.team import TeamWithPlayers from bracket.models.db.util import RoundWithMatches @@ -14,9 +19,16 @@ from bracket.sql.players import get_active_players_in_tournament from bracket.sql.stage_items import get_stage_item from bracket.utils.types import assert_some +# TODO: needs refactor +# pylint: disable=too-many-branches + def player_already_scheduled(player: Player, draft_round: RoundWithMatches) -> bool: - return any(player.id in match.player_ids for match in draft_round.matches) + return any( + player.id in match.player_ids + for match in draft_round.matches + if isinstance(match, MatchWithDetailsDefinitive) + ) async def get_possible_upcoming_matches_for_players( @@ -45,6 +57,7 @@ async def get_possible_upcoming_matches_for_players( player1 in match.team1.players and player2 in match.team2.players for round_ in other_rounds for match in round_.matches + if isinstance(match, MatchWithDetailsDefinitive) ) team_already_scheduled_before.cache_clear() @@ -56,8 +69,9 @@ async def get_possible_upcoming_matches_for_players( players_match_count: dict[int, int] = defaultdict(int) for round_ in other_rounds: for match_ in round_.matches: - for player_id in match_.player_ids: - players_match_count[player_id] += 1 + if isinstance(match_, MatchWithDetailsDefinitive): + for player_id in match_.player_ids: + players_match_count[player_id] += 1 for player in players: if player.id not in players_match_count: diff --git a/backend/bracket/logic/scheduling/ladder_teams.py b/backend/bracket/logic/scheduling/ladder_teams.py index 3c4c821d..8f630ad4 100644 --- a/backend/bracket/logic/scheduling/ladder_teams.py +++ b/backend/bracket/logic/scheduling/ladder_teams.py @@ -1,7 +1,12 @@ from fastapi import HTTPException from bracket.logic.scheduling.shared import check_team_combination_adheres_to_filter -from bracket.models.db.match import MatchFilter, SuggestedMatch, SuggestedVirtualMatch +from bracket.models.db.match import ( + MatchFilter, + MatchWithDetailsDefinitive, + SuggestedMatch, + SuggestedVirtualMatch, +) from bracket.sql.rounds import get_rounds_for_stage_item from bracket.sql.teams import get_teams_with_members @@ -10,7 +15,7 @@ async def todo_get_possible_upcoming_matches_for_teams( 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) + rounds = await get_rounds_for_stage_item(tournament_id, stage_id) # TODO: fix stage item 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.') @@ -22,6 +27,7 @@ async def todo_get_possible_upcoming_matches_for_teams( team_already_scheduled = any( team1.id in match.team_ids or team2.id in match.team_ids for match in draft_round.matches + if isinstance(match, MatchWithDetailsDefinitive) ) if team_already_scheduled: continue diff --git a/backend/bracket/logic/scheduling/round_robin.py b/backend/bracket/logic/scheduling/round_robin.py index 522a97f9..20572279 100644 --- a/backend/bracket/logic/scheduling/round_robin.py +++ b/backend/bracket/logic/scheduling/round_robin.py @@ -1,51 +1,58 @@ 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 ( MatchCreateBody, - SuggestedMatch, - SuggestedVirtualMatch, ) +from bracket.models.db.stage_item_inputs import StageItemInputGeneric 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 build_round_robin_stage_item( - tournament_id: int, stage_item: StageItemWithRounds -) -> list[SuggestedMatch | SuggestedVirtualMatch]: - suggestions: list[SuggestedMatch] = [] - teams = await get_teams_with_members(tournament_id, only_active_teams=True) +async def build_round_robin_stage_item(tournament_id: int, stage_item: StageItemWithRounds) -> None: + suggestions: list[set[StageItemInputGeneric]] = [] for round_ in stage_item.rounds: - round_suggestions: list[SuggestedMatch] = [] + round_suggestions: list[set[StageItemInputGeneric]] = [] - 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 + for i, team1 in enumerate(stage_item.inputs): + for _, team2 in enumerate(stage_item.inputs[i + 1 :]): + team1_def = StageItemInputGeneric( + team_id=team1.id, + winner_from_stage_item_id=team1.winner_from_stage_item_id, + winner_position=team1.winner_position, + winner_from_match_id=team1.winner_from_match_id, ) + team2_def = StageItemInputGeneric( + team_id=team2.id, + winner_from_stage_item_id=team2.winner_from_stage_item_id, + winner_position=team2.winner_position, + winner_from_match_id=team2.winner_from_match_id, + ) + team_defs = {team1_def, team2_def} + + match_already_scheduled = any( + team1_def in match and team2_def in match for match in suggestions + ) or any(team1_def in match or team2_def in match for match in round_suggestions) if match_already_scheduled: continue - 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), + team1_winner_from_stage_item_id=team1.winner_from_stage_item_id, + team1_winner_position=team1.winner_position, + team1_winner_from_match_id=team1.winner_from_match_id, + team2_winner_from_stage_item_id=team2.winner_from_stage_item_id, + team2_winner_position=team2.winner_position, + team2_winner_from_match_id=team2.winner_from_match_id, court_id=None, ) - await create_match_and_assign_free_court(tournament_id, match) - return cast(list[SuggestedMatch | SuggestedVirtualMatch], suggestions) + suggestions.append(team_defs) + round_suggestions.append(team_defs) + await create_match_and_assign_free_court(tournament_id, match) def get_number_of_rounds_to_create_round_robin(team_count: int) -> int: diff --git a/backend/bracket/models/db/match.py b/backend/bracket/models/db/match.py index 8c88747e..1026a215 100644 --- a/backend/bracket/models/db/match.py +++ b/backend/bracket/models/db/match.py @@ -19,11 +19,27 @@ class MatchBase(BaseModelORM): class Match(MatchBase): - team1_id: int - team2_id: int + team1_id: int | None + team2_id: int | None + team1_winner_position: int | None + team1_winner_from_stage_item_id: int | None + team2_winner_from_stage_item_id: int | None + team2_winner_position: int | None + team1_winner_from_match_id: int | None + team2_winner_from_match_id: int | None + + def get_winner_index(self) -> int | None: + if self.team1_score == self.team2_score: + return None + + return 1 if self.team1_score > self.team2_score else 0 class MatchWithDetails(Match): + court: Court | None + + +class MatchWithDetailsDefinitive(Match): team1: FullTeamWithPlayers team2: FullTeamWithPlayers court: Court | None @@ -40,11 +56,6 @@ 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 @@ -55,18 +66,15 @@ class MatchBody(BaseModelORM): class MatchCreateBody(BaseModelORM): round_id: int - team1_id: int - team2_id: int 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 + team1_id: int | None + team2_id: int | None + team1_winner_from_stage_item_id: int | None + team1_winner_position: int | None + team1_winner_from_match_id: int | None + team2_winner_from_stage_item_id: int | None + team2_winner_position: int | None + team2_winner_from_match_id: int | None class MatchFilter(BaseModel): @@ -77,9 +85,9 @@ class MatchFilter(BaseModel): class SuggestedVirtualMatch(BaseModel): - team1_group_id: int + team1_winner_from_stage_item_id: int team1_position_in_group: int - team2_group_id: int + team2_winner_from_stage_item_id: int team2_position_in_group: int diff --git a/backend/bracket/models/db/player.py b/backend/bracket/models/db/player.py index 28da5436..ac009a36 100644 --- a/backend/bracket/models/db/player.py +++ b/backend/bracket/models/db/player.py @@ -1,6 +1,7 @@ from decimal import Decimal from heliclockter import datetime_utc +from pydantic import Field from bracket.models.db.shared import BaseModelORM @@ -22,7 +23,7 @@ class Player(BaseModelORM): class PlayerBody(BaseModelORM): - name: str + name: str = Field(..., max_length=30) active: bool diff --git a/backend/bracket/models/db/stage_item_inputs.py b/backend/bracket/models/db/stage_item_inputs.py index 836f3085..a7b20193 100644 --- a/backend/bracket/models/db/stage_item_inputs.py +++ b/backend/bracket/models/db/stage_item_inputs.py @@ -10,25 +10,49 @@ class StageItemInputBase(BaseModelORM): stage_item_id: int | None -class StageItemInputTentative(StageItemInputBase): +class StageItemInputGeneric(BaseModel): + team_id: int | None + winner_from_stage_item_id: int | None + winner_position: int | None + winner_from_match_id: int | None + + def __hash__(self) -> int: + return ( + self.team_id, + self.winner_from_stage_item_id, + self.winner_position, + self.winner_from_match_id, + ).__hash__() + + +class StageItemInputTentative(StageItemInputBase, StageItemInputGeneric): team_id: None = None - team_stage_item_id: int - team_position_in_group: int = Field(ge=1) + winner_from_match_id: None = None + winner_from_stage_item_id: int + winner_position: int = Field(ge=1) -class StageItemInputFinal(StageItemInputBase): +class StageItemInputFinal(StageItemInputBase, StageItemInputGeneric): team_id: int - team_stage_item_id: None = None - team_position_in_group: None = None + winner_from_match_id: None = None + winner_from_stage_item_id: None = None + winner_position: None = None -StageItemInput = StageItemInputTentative | StageItemInputFinal +class StageItemInputMatch(StageItemInputBase, StageItemInputGeneric): + team_id: None = None + winner_from_match_id: int + winner_from_stage_item_id: None = None + winner_position: None = None + + +StageItemInput = StageItemInputTentative | StageItemInputFinal | StageItemInputMatch class StageItemInputCreateBodyTentative(BaseModel): slot: int - team_stage_item_id: int - team_position_in_group: int = Field(ge=1) + winner_from_stage_item_id: int + winner_position: int = Field(ge=1) class StageItemInputCreateBodyFinal(BaseModel): @@ -44,5 +68,5 @@ class StageItemInputOptionFinal(BaseModel): class StageItemInputOptionTentative(BaseModel): - team_stage_item_id: int - team_position_in_group: int + winner_from_stage_item_id: int + winner_position: int diff --git a/backend/bracket/models/db/team.py b/backend/bracket/models/db/team.py index 055f17d4..810fe91f 100644 --- a/backend/bracket/models/db/team.py +++ b/backend/bracket/models/db/team.py @@ -5,7 +5,7 @@ import json from decimal import Decimal from heliclockter import datetime_utc -from pydantic import BaseModel, validator +from pydantic import BaseModel, Field, validator from bracket.models.db.player import Player from bracket.models.db.shared import BaseModelORM @@ -80,7 +80,7 @@ class FullTeamWithPlayers(TeamWithPlayers, Team): class TeamBody(BaseModelORM): - name: str + name: str = Field(..., max_length=30) active: bool player_ids: list[int] diff --git a/backend/bracket/models/db/util.py b/backend/bracket/models/db/util.py index 63b9c1f0..57e998cb 100644 --- a/backend/bracket/models/db/util.py +++ b/backend/bracket/models/db/util.py @@ -6,7 +6,7 @@ from typing import Any from pydantic import root_validator, validator -from bracket.models.db.match import Match, MatchWithDetails +from bracket.models.db.match import Match, MatchWithDetails, MatchWithDetailsDefinitive from bracket.models.db.round import Round from bracket.models.db.stage import Stage from bracket.models.db.stage_item import StageItem, StageType @@ -15,7 +15,7 @@ from bracket.utils.types import assert_some class RoundWithMatches(Round): - matches: list[MatchWithDetails] + matches: list[MatchWithDetailsDefinitive | MatchWithDetails] @validator('matches', pre=True) def handle_matches(values: list[Match]) -> list[Match]: # type: ignore[misc] @@ -24,7 +24,12 @@ class RoundWithMatches(Round): return values def get_team_ids(self) -> set[int]: - return {assert_some(team.id) for match in self.matches for team in match.teams} + return { + assert_some(team.id) + for match in self.matches + if isinstance(match, MatchWithDetailsDefinitive) + for team in match.teams + } class StageItemWithRounds(StageItem): diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index b55c2a4b..c784f56e 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException -from bracket.logic.elo import recalculate_elo_for_tournament_id from bracket.logic.matches import create_match_and_assign_free_court +from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id from bracket.logic.scheduling.upcoming_matches import ( get_upcoming_matches_for_swiss_round, ) @@ -52,7 +52,7 @@ async def delete_match( match: Match = Depends(match_dependency), ) -> SuccessResponse: await sql_delete_match(assert_some(match.id)) - await recalculate_elo_for_tournament_id(tournament_id) + await recalculate_ranking_for_tournament_id(tournament_id) return SuccessResponse() @@ -108,6 +108,12 @@ async def create_matches_automatically( team1_id=match.team1.id, team2_id=match.team2.id, court_id=None, + team1_winner_from_stage_item_id=None, + team1_winner_position=None, + team1_winner_from_match_id=None, + team2_winner_from_stage_item_id=None, + team2_winner_position=None, + team2_winner_from_match_id=None, ), ) @@ -123,5 +129,5 @@ async def update_match_by_id( ) -> SuccessResponse: assert match.id await sql_update_match(match.id, match_body) - await recalculate_elo_for_tournament_id(tournament_id) + await recalculate_ranking_for_tournament_id(tournament_id) return SuccessResponse() diff --git a/backend/bracket/routes/rounds.py b/backend/bracket/routes/rounds.py index 49ed9907..2d6efba0 100644 --- a/backend/bracket/routes/rounds.py +++ b/backend/bracket/routes/rounds.py @@ -3,7 +3,7 @@ from heliclockter import datetime_utc from starlette import status from bracket.database import database -from bracket.logic.elo import recalculate_elo_for_tournament_id +from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id from bracket.models.db.round import ( Round, RoundCreateBody, @@ -40,7 +40,7 @@ async def delete_round( rounds.c.id == round_id and rounds.c.tournament_id == tournament_id ), ) - await recalculate_elo_for_tournament_id(tournament_id) + await recalculate_ranking_for_tournament_id(tournament_id) return SuccessResponse() diff --git a/backend/bracket/routes/stage_items.py b/backend/bracket/routes/stage_items.py index ddc366a4..952f9563 100644 --- a/backend/bracket/routes/stage_items.py +++ b/backend/bracket/routes/stage_items.py @@ -2,7 +2,7 @@ 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.ranking.elo import recalculate_ranking_for_tournament_id from bracket.logic.scheduling.builder import ( build_matches_for_stage_item, ) @@ -37,7 +37,7 @@ async def delete_stage_item( 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) + await recalculate_ranking_for_tournament_id(tournament_id) return SuccessResponse() diff --git a/backend/bracket/routes/stages.py b/backend/bracket/routes/stages.py index 9702ad27..b70367b0 100644 --- a/backend/bracket/routes/stages.py +++ b/backend/bracket/routes/stages.py @@ -2,8 +2,9 @@ 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.ranking.elo import recalculate_ranking_for_tournament_id from bracket.logic.scheduling.builder import determine_available_inputs +from bracket.logic.scheduling.handle_stage_activation import update_matches_in_activated_stage from bracket.models.db.stage import Stage, StageActivateBody, StageUpdateBody from bracket.models.db.user import UserPublic from bracket.models.db.util import StageWithStageItems @@ -66,7 +67,7 @@ async def delete_stage( await sql_delete_stage(tournament_id, stage_id) - await recalculate_elo_for_tournament_id(tournament_id) + await recalculate_ranking_for_tournament_id(tournament_id) return SuccessResponse() @@ -115,6 +116,8 @@ async def activate_next_stage( ) await sql_activate_next_stage(new_active_stage_id, tournament_id) + if stage_body.direction == 'next': + await update_matches_in_activated_stage(tournament_id, new_active_stage_id) return SuccessResponse() diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index aa328d29..dd77898f 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -3,7 +3,7 @@ from heliclockter import datetime_utc from starlette import status from bracket.database import database -from bracket.logic.elo import recalculate_elo_for_tournament_id +from bracket.logic.ranking.elo import recalculate_ranking_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 ( @@ -39,7 +39,7 @@ async def update_team_members(team_id: int, tournament_id: int, player_ids: list & (players_x_teams.c.team_id == team_id) ), ) - await recalculate_elo_for_tournament_id(tournament_id) + await recalculate_ranking_for_tournament_id(tournament_id) @router.get("/tournaments/{tournament_id}/teams", response_model=TeamsWithPlayersResponse) @@ -63,7 +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) + await recalculate_ranking_for_tournament_id(tournament_id) return SingleTeamResponse( data=assert_some( @@ -105,7 +105,7 @@ async def delete_team( teams.c.id == team.id and teams.c.tournament_id == tournament_id ), ) - await recalculate_elo_for_tournament_id(tournament_id) + await recalculate_ranking_for_tournament_id(tournament_id) return SuccessResponse() diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index a26bff59..431b5107 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -72,8 +72,8 @@ stage_item_inputs = Table( 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), + Column('winner_from_stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=True), + Column('winner_position', Integer, nullable=True), ) rounds = Table( @@ -93,13 +93,21 @@ matches = Table( metadata, Column('id', BigInteger, primary_key=True, index=True), Column('created', DateTimeTZ, nullable=False), + Column('start_time', DateTimeTZ, nullable=True), + Column('duration_minutes', Integer, nullable=True), Column('round_id', BigInteger, ForeignKey('rounds.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( + 'team1_winner_from_stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=True + ), + Column( + 'team2_winner_from_stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=True + ), + Column('team1_winner_position', Integer, nullable=True), + Column('team2_winner_position', Integer, nullable=True), + Column('team1_winner_from_match_id', BigInteger, ForeignKey('matches.id'), nullable=True), + Column('team2_winner_from_match_id', BigInteger, ForeignKey('matches.id'), nullable=True), Column('court_id', BigInteger, ForeignKey('courts.id'), nullable=True), Column('team1_score', Integer, nullable=False), Column('team2_score', Integer, nullable=False), diff --git a/backend/bracket/sql/matches.py b/backend/bracket/sql/matches.py index d5980644..6f89e285 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, MatchVirtualCreateBody +from bracket.models.db.match import Match, MatchBody, MatchCreateBody async def sql_delete_match(match_id: int) -> None: @@ -27,33 +27,15 @@ async def sql_create_match(match: MatchCreateBody) -> Match: query = ''' INSERT INTO matches ( round_id, + court_id, team1_id, team2_id, - team1_score, - team2_score, - court_id, - created - ) - 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_winner_from_stage_item_id, + team2_winner_from_stage_item_id, + team1_winner_position, + team2_winner_position, + team1_winner_from_match_id, + team2_winner_from_match_id, team1_score, team2_score, created @@ -61,10 +43,14 @@ async def todo_sql_create_virtual_match(match: MatchVirtualCreateBody) -> Match: VALUES ( :round_id, :court_id, - :team1_stage_item_id, - :team2_stage_item_id, - :team1_position_in_group, - :team2_position_in_group, + :team1_id, + :team2_id, + :team1_winner_from_stage_item_id, + :team2_winner_from_stage_item_id, + :team1_winner_position, + :team2_winner_position, + :team1_winner_from_match_id, + :team2_winner_from_match_id, 0, 0, NOW() @@ -90,3 +76,32 @@ async def sql_update_match(match_id: int, match: MatchBody) -> None: RETURNING * ''' await database.execute(query=query, values={'match_id': match_id, **match.dict()}) + + +async def sql_update_team_ids_for_match( + match_id: int, team1_id: int | None, team2_id: int | None +) -> None: + query = ''' + UPDATE matches + SET team1_id = :team1_id, + team2_id = :team2_id + WHERE matches.id = :match_id + RETURNING * + ''' + await database.execute( + query=query, values={'match_id': match_id, 'team1_id': team1_id, 'team2_id': team2_id} + ) + + +async def sql_get_match(match_id: int) -> Match: + query = ''' + SELECT * + FROM matches + WHERE matches.id = :match_id + ''' + result = await database.fetch_one(query=query, values={'match_id': match_id}) + + if result is None: + raise ValueError('Could not create stage') + + return Match.parse_obj(result._mapping) diff --git a/backend/bracket/sql/rounds.py b/backend/bracket/sql/rounds.py index 7729d638..c0dfc191 100644 --- a/backend/bracket/sql/rounds.py +++ b/backend/bracket/sql/rounds.py @@ -19,8 +19,9 @@ async def get_rounds_for_stage_item( 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_item_id - WHERE s.tournament_id = :tournament_id + JOIN stage_items on stage_items.id = rounds.stage_item_id + JOIN stages on stage_items.stage_id = stages.id + WHERE stages.tournament_id = :tournament_id AND rounds.stage_item_id = :stage_item_id ''' round_count = int( diff --git a/backend/bracket/sql/stage_item_inputs.py b/backend/bracket/sql/stage_item_inputs.py index 3ffe845f..2c1ed721 100644 --- a/backend/bracket/sql/stage_item_inputs.py +++ b/backend/bracket/sql/stage_item_inputs.py @@ -10,7 +10,7 @@ from bracket.models.db.stage_item_inputs import ( 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 + WHERE stage_item_id = :stage_item_id OR winner_from_stage_item_id = :stage_item_id ''' await database.execute(query=query, values={'stage_item_id': stage_item_id}) @@ -26,8 +26,8 @@ async def sql_create_stage_item_input( tournament_id, stage_item_id, team_id, - team_stage_item_id, - team_position_in_group + winner_from_stage_item_id, + winner_position ) VALUES ( @@ -35,8 +35,8 @@ async def sql_create_stage_item_input( :tournament_id, :stage_item_id, :team_id, - :team_stage_item_id, - :team_position_in_group + :winner_from_stage_item_id, + :winner_position ) RETURNING * ''' @@ -49,10 +49,10 @@ async def sql_create_stage_item_input( '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 + 'winner_from_stage_item_id': stage_item_input.winner_from_stage_item_id if isinstance(stage_item_input, StageItemInputCreateBodyTentative) else None, - 'team_position_in_group': stage_item_input.team_position_in_group + 'winner_position': stage_item_input.winner_position if isinstance(stage_item_input, StageItemInputCreateBodyTentative) else None, }, diff --git a/backend/bracket/sql/stages.py b/backend/bracket/sql/stages.py index 6c89ced3..dfffd9eb 100644 --- a/backend/bracket/sql/stages.py +++ b/backend/bracket/sql/stages.py @@ -64,17 +64,18 @@ async def get_full_tournament_details( 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 st on r.stage_item_id = st.id - LEFT JOIN stage_items si on st.id = si.stage_id + LEFT JOIN stage_items si on r.stage_item_id = si.id + LEFT JOIN stages s2 on s2.id = si.stage_id LEFT JOIN courts c on matches.court_id = c.id - WHERE st.tournament_id = :tournament_id + WHERE s2.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_item_id = s2.id + LEFT JOIN stage_items si on rounds.stage_item_id = si.id + LEFT JOIN stages s2 on s2.id = si.stage_id WHERE s2.tournament_id = :tournament_id {draft_filter} {round_filter} @@ -103,8 +104,7 @@ async def get_full_tournament_details( 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 + LEFT JOIN stage_items_with_inputs ON stage_items_with_inputs.id = stage_items.id ) SELECT stages.*, to_json(array_agg(r.*)) AS stage_items FROM stages @@ -180,7 +180,7 @@ async def get_next_stage_in_tournament( ORDER BY id ASC LIMIT 1 ), - 10000000000000 + -1 ) ) ELSE ( @@ -192,7 +192,7 @@ async def get_next_stage_in_tournament( ORDER BY id DESC LIMIT 1 ), - -1 + 10000000000 ) ) END diff --git a/backend/bracket/utils/db_init.py b/backend/bracket/utils/db_init.py index e3cd3c32..b46296a8 100644 --- a/backend/bracket/utils/db_init.py +++ b/backend/bracket/utils/db_init.py @@ -1,10 +1,10 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any 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.ranking.elo import recalculate_ranking_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 @@ -52,12 +52,12 @@ from bracket.utils.dummy_records import ( DUMMY_PLAYER6, DUMMY_PLAYER7, DUMMY_PLAYER8, - DUMMY_PLAYER9, DUMMY_PLAYER_X_TEAM, DUMMY_STAGE1, DUMMY_STAGE2, DUMMY_STAGE_ITEM1, DUMMY_STAGE_ITEM2, + DUMMY_STAGE_ITEM3, DUMMY_TEAM1, DUMMY_TEAM2, DUMMY_TEAM3, @@ -106,6 +106,8 @@ async def init_db_when_empty() -> int | None: async def sql_create_dev_db() -> None: + # TODO: refactor into smaller functions + # pylint: disable=too-many-statements assert environment is Environment.DEVELOPMENT logger.warning('Initializing database with dummy records') @@ -129,9 +131,12 @@ async def sql_create_dev_db() -> None: StageItem: stage_items, } - async def insert_dummy(obj_to_insert: BaseModelT) -> int: + async def insert_dummy(obj_to_insert: BaseModelT, update_data: dict[str, Any] = {}) -> int: record_id, _ = await insert_generic( - database, obj_to_insert, table_lookup[type(obj_to_insert)], type(obj_to_insert) + database, + obj_to_insert.copy(update=update_data), + table_lookup[type(obj_to_insert)], + type(obj_to_insert), ) return record_id @@ -143,52 +148,81 @@ async def sql_create_dev_db() -> None: if real_user_id is not None: await insert_dummy(UserXClub(user_id=real_user_id, club_id=club_id_1)) - 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})) + tournament_id_1 = await insert_dummy(DUMMY_TOURNAMENT, {'club_id': club_id_1}) + stage_id_1 = await insert_dummy(DUMMY_STAGE1, {'tournament_id': tournament_id_1}) + stage_id_2 = await insert_dummy(DUMMY_STAGE2, {'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})) - team_id_4 = await insert_dummy(DUMMY_TEAM4.copy(update={'tournament_id': tournament_id_1})) - - player_id_1 = await insert_dummy(DUMMY_PLAYER1.copy(update={'tournament_id': tournament_id_1})) - player_id_2 = await insert_dummy(DUMMY_PLAYER2.copy(update={'tournament_id': tournament_id_1})) - player_id_3 = await insert_dummy(DUMMY_PLAYER3.copy(update={'tournament_id': tournament_id_1})) - player_id_4 = await insert_dummy(DUMMY_PLAYER4.copy(update={'tournament_id': tournament_id_1})) - player_id_5 = await insert_dummy(DUMMY_PLAYER5.copy(update={'tournament_id': tournament_id_1})) - player_id_6 = await insert_dummy(DUMMY_PLAYER6.copy(update={'tournament_id': tournament_id_1})) - player_id_7 = await insert_dummy(DUMMY_PLAYER7.copy(update={'tournament_id': tournament_id_1})) - player_id_8 = await insert_dummy(DUMMY_PLAYER8.copy(update={'tournament_id': tournament_id_1})) - await insert_dummy(DUMMY_PLAYER9.copy(update={'tournament_id': tournament_id_1})) - - await insert_dummy( - DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_1, 'team_id': team_id_1}) + team_id_1 = await insert_dummy(DUMMY_TEAM1, {'tournament_id': tournament_id_1}) + team_id_2 = await insert_dummy(DUMMY_TEAM2, {'tournament_id': tournament_id_1}) + team_id_3 = await insert_dummy(DUMMY_TEAM3, {'tournament_id': tournament_id_1}) + team_id_4 = await insert_dummy(DUMMY_TEAM4, {'tournament_id': tournament_id_1}) + team_id_5 = await insert_dummy( + DUMMY_TEAM4, {'name': 'Team 5', 'tournament_id': tournament_id_1} ) - await insert_dummy( - DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_2, 'team_id': team_id_1}) + team_id_6 = await insert_dummy( + DUMMY_TEAM4, {'name': 'Team 6', 'tournament_id': tournament_id_1} ) - await insert_dummy( - DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_3, 'team_id': team_id_2}) + team_id_7 = await insert_dummy( + DUMMY_TEAM4, {'name': 'Team 7', 'tournament_id': tournament_id_1} ) - await insert_dummy( - DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_4, 'team_id': team_id_2}) - ) - await insert_dummy( - DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_5, 'team_id': team_id_3}) - ) - await insert_dummy( - DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_6, 'team_id': team_id_3}) - ) - await insert_dummy( - DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_7, 'team_id': team_id_4}) - ) - await insert_dummy( - DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_8, 'team_id': team_id_4}) + team_id_8 = await insert_dummy( + DUMMY_TEAM4, {'name': 'Team 8', 'tournament_id': tournament_id_1} ) - await insert_dummy(DUMMY_COURT1.copy(update={'tournament_id': tournament_id_1})) - await insert_dummy(DUMMY_COURT2.copy(update={'tournament_id': tournament_id_1})) + player_id_1 = await insert_dummy(DUMMY_PLAYER1, {'tournament_id': tournament_id_1}) + player_id_2 = await insert_dummy(DUMMY_PLAYER2, {'tournament_id': tournament_id_1}) + player_id_3 = await insert_dummy(DUMMY_PLAYER3, {'tournament_id': tournament_id_1}) + player_id_4 = await insert_dummy(DUMMY_PLAYER4, {'tournament_id': tournament_id_1}) + player_id_5 = await insert_dummy(DUMMY_PLAYER5, {'tournament_id': tournament_id_1}) + player_id_6 = await insert_dummy(DUMMY_PLAYER6, {'tournament_id': tournament_id_1}) + player_id_7 = await insert_dummy(DUMMY_PLAYER7, {'tournament_id': tournament_id_1}) + player_id_8 = await insert_dummy(DUMMY_PLAYER8, {'tournament_id': tournament_id_1}) + + player_id_9 = await insert_dummy( + DUMMY_PLAYER8, {'name': 'Player 9', 'tournament_id': tournament_id_1} + ) + player_id_10 = await insert_dummy( + DUMMY_PLAYER8, {'name': 'Player 10', 'tournament_id': tournament_id_1} + ) + player_id_11 = await insert_dummy( + DUMMY_PLAYER8, {'name': 'Player 11', 'tournament_id': tournament_id_1} + ) + player_id_12 = await insert_dummy( + DUMMY_PLAYER8, {'name': 'Player 12', 'tournament_id': tournament_id_1} + ) + player_id_13 = await insert_dummy( + DUMMY_PLAYER8, {'name': 'Player 13', 'tournament_id': tournament_id_1} + ) + player_id_14 = await insert_dummy( + DUMMY_PLAYER8, {'name': 'Player 14', 'tournament_id': tournament_id_1} + ) + player_id_15 = await insert_dummy( + DUMMY_PLAYER8, {'name': 'Player 15', 'tournament_id': tournament_id_1} + ) + player_id_16 = await insert_dummy( + DUMMY_PLAYER8, {'name': 'Player 16', 'tournament_id': tournament_id_1} + ) + + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_1, 'team_id': team_id_1}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_2, 'team_id': team_id_1}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_3, 'team_id': team_id_2}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_4, 'team_id': team_id_2}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_5, 'team_id': team_id_3}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_6, 'team_id': team_id_3}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_7, 'team_id': team_id_4}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_8, 'team_id': team_id_4}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_8, 'team_id': team_id_4}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_9, 'team_id': team_id_5}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_10, 'team_id': team_id_5}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_11, 'team_id': team_id_6}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_12, 'team_id': team_id_6}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_13, 'team_id': team_id_7}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_14, 'team_id': team_id_7}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_15, 'team_id': team_id_8}) + await insert_dummy(DUMMY_PLAYER_X_TEAM, {'player_id': player_id_16, 'team_id': team_id_8}) + + await insert_dummy(DUMMY_COURT1, {'tournament_id': tournament_id_1}) + await insert_dummy(DUMMY_COURT2, {'tournament_id': tournament_id_1}) stage_item_1 = await sql_create_stage_item( tournament_id_1, @@ -220,20 +254,57 @@ async def sql_create_dev_db() -> None: stage_item_2 = await sql_create_stage_item( tournament_id_1, StageItemCreateBody( - stage_id=stage_id_2, + stage_id=stage_id_1, name=DUMMY_STAGE_ITEM2.name, team_count=DUMMY_STAGE_ITEM2.team_count, type=DUMMY_STAGE_ITEM2.type, + inputs=[ + StageItemInputCreateBodyFinal( + slot=1, + team_id=team_id_5, + ), + StageItemInputCreateBodyFinal( + slot=2, + team_id=team_id_6, + ), + StageItemInputCreateBodyFinal( + slot=3, + team_id=team_id_7, + ), + StageItemInputCreateBodyFinal( + slot=4, + team_id=team_id_8, + ), + ], + ), + ) + stage_item_3 = await sql_create_stage_item( + tournament_id_1, + StageItemCreateBody( + stage_id=stage_id_2, + name=DUMMY_STAGE_ITEM3.name, + team_count=DUMMY_STAGE_ITEM3.team_count, + type=DUMMY_STAGE_ITEM3.type, inputs=[ StageItemInputCreateBodyTentative( slot=1, - team_stage_item_id=stage_item_1.id, - team_position_in_group=1, + winner_from_stage_item_id=stage_item_1.id, + winner_position=1, ), StageItemInputCreateBodyTentative( slot=2, - team_stage_item_id=stage_item_1.id, - team_position_in_group=2, + winner_from_stage_item_id=stage_item_1.id, + winner_position=2, + ), + StageItemInputCreateBodyTentative( + slot=3, + winner_from_stage_item_id=stage_item_2.id, + winner_position=1, + ), + StageItemInputCreateBodyTentative( + slot=4, + winner_from_stage_item_id=stage_item_2.id, + winner_position=2, ), ], ), @@ -241,6 +312,7 @@ async def sql_create_dev_db() -> None: await build_matches_for_stage_item(stage_item_1, tournament_id_1) await build_matches_for_stage_item(stage_item_2, tournament_id_1) + await build_matches_for_stage_item(stage_item_3, tournament_id_1) for tournament in await database.fetch_all(tournaments.select()): - await recalculate_elo_for_tournament_id(tournament.id) # type: ignore[attr-defined] + await recalculate_ranking_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 eba0a3d4..dc3e17b6 100644 --- a/backend/bracket/utils/dummy_records.py +++ b/backend/bracket/utils/dummy_records.py @@ -60,10 +60,18 @@ DUMMY_STAGE_ITEM1 = StageItemToInsert( ) DUMMY_STAGE_ITEM2 = StageItemToInsert( + stage_id=DB_PLACEHOLDER_ID, + created=DUMMY_MOCK_TIME, + type=StageType.ROUND_ROBIN, + team_count=4, + name='Group B', +) + +DUMMY_STAGE_ITEM3 = StageItemToInsert( stage_id=DB_PLACEHOLDER_ID, created=DUMMY_MOCK_TIME, type=StageType.SINGLE_ELIMINATION, - team_count=2, + team_count=4, name='Bracket A', ) @@ -96,6 +104,12 @@ DUMMY_MATCH1 = Match( team1_score=11, team2_score=22, court_id=DB_PLACEHOLDER_ID, + team1_winner_from_stage_item_id=None, + team1_winner_position=None, + team1_winner_from_match_id=None, + team2_winner_from_stage_item_id=None, + team2_winner_position=None, + team2_winner_from_match_id=None, ) DUMMY_MATCH2 = Match( @@ -106,6 +120,12 @@ DUMMY_MATCH2 = Match( team1_score=9, team2_score=6, court_id=DB_PLACEHOLDER_ID, + team1_winner_from_stage_item_id=None, + team1_winner_position=None, + team1_winner_from_match_id=None, + team2_winner_from_stage_item_id=None, + team2_winner_position=None, + team2_winner_from_match_id=None, ) DUMMY_MATCH3 = Match( @@ -116,6 +136,12 @@ DUMMY_MATCH3 = Match( team1_score=23, team2_score=26, court_id=DB_PLACEHOLDER_ID, + team1_winner_from_stage_item_id=None, + team1_winner_position=None, + team1_winner_from_match_id=None, + team2_winner_from_stage_item_id=None, + team2_winner_position=None, + team2_winner_from_match_id=None, ) DUMMY_MATCH4 = Match( @@ -126,6 +152,12 @@ DUMMY_MATCH4 = Match( team1_score=43, team2_score=45, court_id=None, + team1_winner_from_stage_item_id=None, + team1_winner_position=None, + team1_winner_from_match_id=None, + team2_winner_from_stage_item_id=None, + team2_winner_position=None, + team2_winner_from_match_id=None, ) DUMMY_USER = User( @@ -165,63 +197,56 @@ DUMMY_TEAM4 = Team( DUMMY_PLAYER1 = Player( - name='Luke', + name='Player 1', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER2 = Player( - name='Anakin', + name='Player 2', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER3 = Player( - name='Leia', + name='Player 3', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER4 = Player( - name='Yoda', + name='Player 4', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER5 = Player( - name='Boba', + name='Player 5', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER6 = Player( - name='General', + name='Player 6', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER7 = Player( - name='Han', + name='Player 7', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER8 = Player( - name='Emperor', - active=True, - created=DUMMY_MOCK_TIME, - tournament_id=DB_PLACEHOLDER_ID, -) - -DUMMY_PLAYER9 = Player( - name='R2D2', + name='Player 8', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, @@ -238,13 +263,13 @@ DUMMY_USER_X_CLUB = UserXClub( ) DUMMY_COURT1 = Court( - name='Endor', + name='Court 1', created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_COURT2 = Court( - name='Naboo', + name='Court 2', created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e39545a9..b7d1af4b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -78,6 +78,7 @@ disable = [ 'unspecified-encoding', 'unused-argument', # Gives false positives. 'wrong-import-position', + 'fixme', ] [tool.bandit] @@ -110,7 +111,7 @@ select = [ "UP", "W", ] -ignore = [] +ignore = ["FIX002", "TD002", "TD003"] line-length = 100 respect-gitignore = false show-fixes = true diff --git a/backend/tests/integration_tests/api/courts_test.py b/backend/tests/integration_tests/api/courts_test.py index 9c44b034..580ad6e5 100644 --- a/backend/tests/integration_tests/api/courts_test.py +++ b/backend/tests/integration_tests/api/courts_test.py @@ -23,7 +23,7 @@ async def test_courts_endpoint( { 'created': DUMMY_MOCK_TIME.isoformat(), 'id': court_inserted.id, - 'name': 'Endor', + 'name': 'Court 1', 'tournament_id': auth_context.tournament.id, } ], diff --git a/backend/tests/integration_tests/api/inputs_test.py b/backend/tests/integration_tests/api/inputs_test.py index bea43a33..5b96c839 100644 --- a/backend/tests/integration_tests/api/inputs_test.py +++ b/backend/tests/integration_tests/api/inputs_test.py @@ -37,7 +37,7 @@ async def test_available_inputs( 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}, + # {'winner_from_stage_item_id': 1, 'winner_position': 1}, + # {'winner_from_stage_item_id': 1, 'winner_position': 2}, ] } diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py index 67f9c513..187f2abc 100644 --- a/backend/tests/integration_tests/api/matches_test.py +++ b/backend/tests/integration_tests/api/matches_test.py @@ -238,7 +238,7 @@ async def test_upcoming_matches_endpoint( { 'id': 4, 'active': True, - 'name': 'Yoda', + 'name': 'Player 4', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1400.0, @@ -250,7 +250,7 @@ async def test_upcoming_matches_endpoint( { 'id': 1, 'active': True, - 'name': 'Luke', + 'name': 'Player 1', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1100.0, @@ -272,7 +272,7 @@ async def test_upcoming_matches_endpoint( { 'id': 2, 'active': True, - 'name': 'Anakin', + 'name': 'Player 2', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1300.0, @@ -284,7 +284,7 @@ async def test_upcoming_matches_endpoint( { 'id': 3, 'active': True, - 'name': 'Leia', + 'name': 'Player 3', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1200.0, @@ -312,7 +312,7 @@ async def test_upcoming_matches_endpoint( { 'id': 4, 'active': True, - 'name': 'Yoda', + 'name': 'Player 4', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1400.0, @@ -324,7 +324,7 @@ async def test_upcoming_matches_endpoint( { 'id': 1, 'active': True, - 'name': 'Luke', + 'name': 'Player 1', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1100.0, @@ -346,7 +346,7 @@ async def test_upcoming_matches_endpoint( { 'id': 3, 'active': True, - 'name': 'Leia', + 'name': 'Player 3', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1200.0, @@ -358,7 +358,7 @@ async def test_upcoming_matches_endpoint( { 'id': 2, 'active': True, - 'name': 'Anakin', + 'name': 'Player 2', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1300.0, @@ -386,7 +386,7 @@ async def test_upcoming_matches_endpoint( { 'id': 2, 'active': True, - 'name': 'Anakin', + 'name': 'Player 2', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1300.0, @@ -398,7 +398,7 @@ async def test_upcoming_matches_endpoint( { 'id': 3, 'active': True, - 'name': 'Leia', + 'name': 'Player 3', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1200.0, @@ -420,7 +420,7 @@ async def test_upcoming_matches_endpoint( { 'id': 1, 'active': True, - 'name': 'Luke', + 'name': 'Player 1', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1100.0, @@ -432,7 +432,7 @@ async def test_upcoming_matches_endpoint( { 'id': 4, 'active': True, - 'name': 'Yoda', + 'name': 'Player 4', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1400.0, @@ -460,7 +460,7 @@ async def test_upcoming_matches_endpoint( { 'id': 1, 'active': True, - 'name': 'Luke', + 'name': 'Player 1', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1100.0, @@ -472,7 +472,7 @@ async def test_upcoming_matches_endpoint( { 'id': 4, 'active': True, - 'name': 'Yoda', + 'name': 'Player 4', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1400.0, @@ -494,7 +494,7 @@ async def test_upcoming_matches_endpoint( { 'id': 2, 'active': True, - 'name': 'Anakin', + 'name': 'Player 2', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1300.0, @@ -506,7 +506,7 @@ async def test_upcoming_matches_endpoint( { 'id': 3, 'active': True, - 'name': 'Leia', + 'name': 'Player 3', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1200.0, @@ -534,7 +534,7 @@ async def test_upcoming_matches_endpoint( { 'id': 3, 'active': True, - 'name': 'Leia', + 'name': 'Player 3', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1200.0, @@ -546,7 +546,7 @@ async def test_upcoming_matches_endpoint( { 'id': 2, 'active': True, - 'name': 'Anakin', + 'name': 'Player 2', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1300.0, @@ -568,7 +568,7 @@ async def test_upcoming_matches_endpoint( { 'id': 4, 'active': True, - 'name': 'Yoda', + 'name': 'Player 4', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1400.0, @@ -580,7 +580,7 @@ async def test_upcoming_matches_endpoint( { 'id': 1, 'active': True, - 'name': 'Luke', + 'name': 'Player 1', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1100.0, @@ -608,7 +608,7 @@ async def test_upcoming_matches_endpoint( { 'id': 3, 'active': True, - 'name': 'Leia', + 'name': 'Player 3', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1200.0, @@ -620,7 +620,7 @@ async def test_upcoming_matches_endpoint( { 'id': 2, 'active': True, - 'name': 'Anakin', + 'name': 'Player 2', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1300.0, @@ -642,7 +642,7 @@ async def test_upcoming_matches_endpoint( { 'id': 1, 'active': True, - 'name': 'Luke', + 'name': 'Player 1', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1100.0, @@ -654,7 +654,7 @@ async def test_upcoming_matches_endpoint( { 'id': 4, 'active': True, - 'name': 'Yoda', + 'name': 'Player 4', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1400.0, @@ -682,7 +682,7 @@ async def test_upcoming_matches_endpoint( { 'id': 1, 'active': True, - 'name': 'Luke', + 'name': 'Player 1', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1100.0, @@ -694,7 +694,7 @@ async def test_upcoming_matches_endpoint( { 'id': 4, 'active': True, - 'name': 'Yoda', + 'name': 'Player 4', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1400.0, @@ -716,7 +716,7 @@ async def test_upcoming_matches_endpoint( { 'id': 3, 'active': True, - 'name': 'Leia', + 'name': 'Player 3', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1200.0, @@ -728,7 +728,7 @@ async def test_upcoming_matches_endpoint( { 'id': 2, 'active': True, - 'name': 'Anakin', + 'name': 'Player 2', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1300.0, @@ -756,7 +756,7 @@ async def test_upcoming_matches_endpoint( { 'id': 2, 'active': True, - 'name': 'Anakin', + 'name': 'Player 2', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1300.0, @@ -768,7 +768,7 @@ async def test_upcoming_matches_endpoint( { 'id': 3, 'active': True, - 'name': 'Leia', + 'name': 'Player 3', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1200.0, @@ -790,7 +790,7 @@ async def test_upcoming_matches_endpoint( { 'id': 4, 'active': True, - 'name': 'Yoda', + 'name': 'Player 4', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1400.0, @@ -802,7 +802,7 @@ async def test_upcoming_matches_endpoint( { 'id': 1, 'active': True, - 'name': 'Luke', + 'name': 'Player 1', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': 1, 'elo_score': 1100.0, diff --git a/backend/tests/integration_tests/api/players_test.py b/backend/tests/integration_tests/api/players_test.py index a170f45b..a6cd2c64 100644 --- a/backend/tests/integration_tests/api/players_test.py +++ b/backend/tests/integration_tests/api/players_test.py @@ -29,7 +29,7 @@ async def test_players_endpoint( 'wins': 0, 'draws': 0, 'losses': 0, - 'name': 'Luke', + 'name': 'Player 1', 'tournament_id': auth_context.tournament.id, } ], diff --git a/backend/tests/unit_tests/elo_test.py b/backend/tests/unit_tests/elo_test.py index b8bd3d10..82510c95 100644 --- a/backend/tests/unit_tests/elo_test.py +++ b/backend/tests/unit_tests/elo_test.py @@ -1,11 +1,18 @@ from decimal import Decimal -from bracket.logic.elo import calculate_elo_per_player -from bracket.models.db.match import MatchWithDetails +from bracket.logic.ranking.elo import ( + determine_ranking_for_stage_items, +) +from bracket.models.db.match import MatchWithDetailsDefinitive 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 +from bracket.models.db.util import RoundWithMatches, StageItemWithRounds +from bracket.utils.dummy_records import ( + DUMMY_MOCK_TIME, + DUMMY_PLAYER1, + DUMMY_PLAYER2, + DUMMY_STAGE_ITEM1, +) def test_elo_calculation() -> None: @@ -16,16 +23,23 @@ def test_elo_calculation() -> None: is_active=False, name='Some round', matches=[ - MatchWithDetails( + MatchWithDetailsDefinitive( created=DUMMY_MOCK_TIME, team1_id=1, team2_id=1, + team1_winner_from_stage_item_id=None, + team1_winner_position=None, + team1_winner_from_match_id=None, + team2_winner_from_stage_item_id=None, + team2_winner_position=None, + team2_winner_from_match_id=None, team1_score=3, team2_score=4, round_id=1, court_id=None, court=None, team1=FullTeamWithPlayers( + id=3, name='Dummy team 1', tournament_id=1, active=True, @@ -38,6 +52,7 @@ def test_elo_calculation() -> None: losses=DUMMY_PLAYER1.losses, ), team2=FullTeamWithPlayers( + id=4, name='Dummy team 2', tournament_id=1, active=True, @@ -52,8 +67,17 @@ def test_elo_calculation() -> None: ) ], ) - calculation = calculate_elo_per_player([round_]) - assert calculation == { + stage_item = StageItemWithRounds( + **DUMMY_STAGE_ITEM1.copy(update={'rounds': [round_]}).dict(), + id=-1, + inputs=[], + ) + player_stats, team_stats = determine_ranking_for_stage_items([stage_item]) + assert player_stats == { 1: PlayerStatistics(losses=1, elo_score=1184, swiss_score=Decimal('0.00')), 2: PlayerStatistics(wins=1, elo_score=1216, swiss_score=Decimal('1.00')), } + assert team_stats == { + 3: PlayerStatistics(losses=1, elo_score=1184, swiss_score=Decimal('0.00')), + 4: PlayerStatistics(wins=1, elo_score=1216, swiss_score=Decimal('1.00')), + } diff --git a/frontend/src/components/brackets/brackets.tsx b/frontend/src/components/brackets/brackets.tsx index eae664a3..af03a466 100644 --- a/frontend/src/components/brackets/brackets.tsx +++ b/frontend/src/components/brackets/brackets.tsx @@ -27,7 +27,7 @@ function getRoundsGridCols( ((m1.court ? m1.court.name : 'y') > (m2.court ? m2.court.name : 'z') ? 1 : 0)) .map((match) => ( @@ -13,7 +19,7 @@ function getRoundsGridCols(activeRound: RoundInterface, tournamentData: Tourname {activeRound.name} - {getRoundsGridCols(activeRound, tournamentData)} + {getRoundsGridCols(swrStagesResponse, activeRound, tournamentData)} ); } diff --git a/frontend/src/components/brackets/courts_large.tsx b/frontend/src/components/brackets/courts_large.tsx index 7272ce1e..de49f15a 100644 --- a/frontend/src/components/brackets/courts_large.tsx +++ b/frontend/src/components/brackets/courts_large.tsx @@ -1,11 +1,17 @@ import { Grid } from '@mantine/core'; import React from 'react'; +import { SWRResponse } from 'swr'; import { RoundInterface } from '../../interfaces/round'; import { TournamentMinimal } from '../../interfaces/tournament'; +import { getStages } from '../../services/adapter'; import MatchLarge from './match_large'; -function getRoundsGridCols(activeRound: RoundInterface, tournamentData: TournamentMinimal) { +function getRoundsGridCols( + swrStagesResponse: SWRResponse, + activeRound: RoundInterface, + tournamentData: TournamentMinimal +) { return activeRound.matches .sort((m1, m2) => ((m1.court ? m1.court.name : 'y') > (m2.court ? m2.court.name : 'z') ? 1 : 0)) .map((match) => ( @@ -13,7 +19,7 @@ function getRoundsGridCols(activeRound: RoundInterface, tournamentData: Tourname - {getRoundsGridCols(activeRound, tournamentData)} + {getRoundsGridCols(swrStagesResponse, activeRound, tournamentData)} ); } diff --git a/frontend/src/components/brackets/match.tsx b/frontend/src/components/brackets/match.tsx index 09437817..a655e756 100644 --- a/frontend/src/components/brackets/match.tsx +++ b/frontend/src/components/brackets/match.tsx @@ -4,8 +4,9 @@ import { Property } from 'csstype'; import React, { useState } from 'react'; import { SWRResponse } from 'swr'; -import { MatchInterface } from '../../interfaces/match'; +import { MatchInterface, formatMatchTeam1, formatMatchTeam2 } from '../../interfaces/match'; import { TournamentMinimal } from '../../interfaces/tournament'; +import { getMatchLookup, getStageItemLookup } from '../../services/lookups'; import MatchModal from '../modals/match_modal'; import Visibility = Property.Visibility; @@ -56,7 +57,7 @@ export function MatchBadge({ match, theme }: { match: MatchInterface; theme: any } export default function Match({ - swrRoundsResponse, + swrStagesResponse, swrCourtsResponse, swrUpcomingMatchesResponse, tournamentData, @@ -64,7 +65,7 @@ export default function Match({ readOnly, dynamicSchedule, }: { - swrRoundsResponse: SWRResponse | null; + swrStagesResponse: SWRResponse; swrCourtsResponse: SWRResponse | null; swrUpcomingMatchesResponse: SWRResponse | null; tournamentData: TournamentMinimal; @@ -79,17 +80,28 @@ export default function Match({ }; const showTeamMemberNames = false; + const stageItemsLookup = getStageItemLookup(swrStagesResponse); + const matchesLookup = getMatchLookup(swrStagesResponse); + const team1_style = match.team1_score > match.team2_score ? winner_style : {}; const team2_style = match.team1_score < match.team2_score ? winner_style : {}; - const team1_players = match.team1.players.map((player) => player.name).join(', '); - const team2_players = match.team2.players.map((player) => player.name).join(', '); + const team1_players = match.team1 + ? match.team1.players.map((player) => player.name).join(', ') + : ''; + const team2_players = match.team2 + ? match.team2.players.map((player) => player.name).join(', ') + : ''; 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 team1_label = showTeamMemberNames + ? team1_players_label + : formatMatchTeam1(stageItemsLookup, matchesLookup, match); + const team2_label = showTeamMemberNames + ? team2_players_label + : formatMatchTeam2(stageItemsLookup, matchesLookup, match); const [opened, setOpened] = useState(false); @@ -115,7 +127,7 @@ export default function Match({ if (readOnly) { return
{bracket}
; } - assert(swrRoundsResponse != null); + assert(swrStagesResponse != null); assert(swrCourtsResponse != null); return ( @@ -124,7 +136,7 @@ export default function Match({ {bracket} ({ })); export default function MatchLarge({ - swrRoundsResponse, + swrStagesResponse, swrCourtsResponse, swrUpcomingMatchesResponse, tournamentData, match, readOnly, }: { - swrRoundsResponse: SWRResponse | null; + swrStagesResponse: SWRResponse; swrCourtsResponse: SWRResponse | null; swrUpcomingMatchesResponse: SWRResponse | null; tournamentData: TournamentMinimal; @@ -56,8 +56,12 @@ export default function MatchLarge({ const team1_style = match.team1_score > match.team2_score ? winner_style : {}; const team2_style = match.team1_score < match.team2_score ? winner_style : {}; - const team1_players = match.team1.players.map((player) => player.name).join(', '); - const team2_players = match.team2.players.map((player) => player.name).join(', '); + const team1_players = match.team1 + ? match.team1.players.map((player) => player.name).join(', ') + : ''; + const team2_players = match.team2 + ? match.team2.players.map((player) => player.name).join(', ') + : ''; const team1_players_label = team1_players === '' ? 'No players' : team1_players; const team2_players_label = team2_players === '' ? 'No players' : team2_players; @@ -86,7 +90,7 @@ export default function MatchLarge({ if (readOnly) { return
{bracket}
; } - assert(swrRoundsResponse != null); + assert(swrStagesResponse != null); assert(swrCourtsResponse != null); return ( @@ -95,7 +99,7 @@ export default function MatchLarge({ {bracket} ); return ( -
+
(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] + const teamStageItem = input.winner_from_stage_item_id + ? stageItemsLookup[input.winner_from_stage_item_id] : null; return ( diff --git a/frontend/src/components/modals/create_stage_item.tsx b/frontend/src/components/modals/create_stage_item.tsx index 6fef0c5c..4b8d0ed8 100644 --- a/frontend/src/components/modals/create_stage_item.tsx +++ b/frontend/src/components/modals/create_stage_item.tsx @@ -6,7 +6,7 @@ import React, { useState } from 'react'; import { SWRResponse } from 'swr'; import { StageWithStageItems } from '../../interfaces/stage'; -import { StageItemInputOption, getPositionName } from '../../interfaces/stage_item_input'; +import { StageItemInputOption, formatStageItemInput } from '../../interfaces/stage_item_input'; import { Tournament } from '../../interfaces/tournament'; import { getAvailableStageItemInputs } from '../../services/adapter'; import { getStageItemLookup, getTeamsLookup } from '../../services/lookups'; @@ -96,11 +96,6 @@ function StageItemInputs({ )); } -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, @@ -133,7 +128,7 @@ export function CreateStageItemModal({ ); const availableInputs = responseIsValid(swrAvailableInputsResponse) ? swrAvailableInputsResponse.data.data.map((option: StageItemInputOption) => { - if (option.team_stage_item_id == null) { + if (option.winner_from_stage_item_id == null) { if (option.team_id == null) return null; const team = teamsMap[option.team_id]; if (team == null) return null; @@ -142,12 +137,12 @@ export function CreateStageItemModal({ label: team.name, }; } - assert(option.team_position_in_group != null); - const stageItem = stageItemMap[option.team_stage_item_id]; + assert(option.winner_position != null); + const stageItem = stageItemMap[option.winner_from_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)}`, + value: `${option.winner_from_stage_item_id}_${option.winner_position}`, + label: `${formatStageItemInput(option.winner_position, stageItem.name)}`, }; }) : {}; @@ -163,9 +158,9 @@ export function CreateStageItemModal({ return { slot: i + 1, team_id: Number(teamId), - team_stage_item_id: + winner_from_stage_item_id: typeof teamId === 'string' ? Number(teamId.split('_')[0]) : null, - team_position_in_group: + winner_position: typeof teamId === 'string' ? Number(teamId.split('_')[1]) : null, }; }); diff --git a/frontend/src/components/modals/match_modal.tsx b/frontend/src/components/modals/match_modal.tsx index e82d7cba..c3897d85 100644 --- a/frontend/src/components/modals/match_modal.tsx +++ b/frontend/src/components/modals/match_modal.tsx @@ -4,8 +4,14 @@ import React from 'react'; import { SWRResponse } from 'swr'; import { Court } from '../../interfaces/court'; -import { MatchBodyInterface, MatchInterface } from '../../interfaces/match'; +import { + MatchBodyInterface, + MatchInterface, + formatMatchTeam1, + formatMatchTeam2, +} from '../../interfaces/match'; import { TournamentMinimal } from '../../interfaces/tournament'; +import { getMatchLookup, getStageItemLookup } from '../../services/lookups'; import { deleteMatch, updateMatch } from '../../services/match'; import DeleteButton from '../buttons/delete'; import { responseIsValid } from '../utils/util'; @@ -61,7 +67,7 @@ function MatchDeleteButton({ export default function MatchModal({ tournamentData, match, - swrRoundsResponse, + swrStagesResponse, swrCourtsResponse, swrUpcomingMatchesResponse, opened, @@ -70,7 +76,7 @@ export default function MatchModal({ }: { tournamentData: TournamentMinimal; match: MatchInterface; - swrRoundsResponse: SWRResponse; + swrStagesResponse: SWRResponse; swrCourtsResponse: SWRResponse; swrUpcomingMatchesResponse: SWRResponse | null; opened: boolean; @@ -90,6 +96,12 @@ export default function MatchModal({ }, }); + const stageItemsLookup = getStageItemLookup(swrStagesResponse); + const matchesLookup = getMatchLookup(swrStagesResponse); + + const team1Name = formatMatchTeam1(stageItemsLookup, matchesLookup, match); + const team2Name = formatMatchTeam2(stageItemsLookup, matchesLookup, match); + return ( <> setOpened(false)} title="Edit Match"> @@ -103,22 +115,22 @@ export default function MatchModal({ court_id: values.court_id, }; await updateMatch(tournamentData.id, match.id, updatedMatch); - await swrRoundsResponse.mutate(null); + await swrStagesResponse.mutate(null); if (swrUpcomingMatchesResponse != null) await swrUpcomingMatchesResponse.mutate(null); setOpened(false); })} > @@ -128,7 +140,7 @@ export default function MatchModal({ + stage.stage_items.forEach((stage_item) => { + stage_item.rounds.forEach((round) => { + round.matches.forEach((match) => { + result = result.concat([[match.id, match]]); + }); + }); + }) + ); + return Object.fromEntries(result); +} + export function getActiveRounds(swrStagesResponse: SWRResponse) { let result: RoundInterface[] = [];