From 4b3dfb9b20007385cdfdb3236280a634b6ef4b99 Mon Sep 17 00:00:00 2001 From: Erik Vroon Date: Tue, 21 Nov 2023 21:11:25 +0100 Subject: [PATCH] Implement custom time per match (#337) --- ...db7_create_custom_match_duration_fields.py | 33 ++++++ backend/bracket/logic/planning/matches.py | 44 +++++-- backend/bracket/logic/planning/rounds.py | 78 ++++++++++-- .../bracket/logic/scheduling/elimination.py | 19 ++- .../bracket/logic/scheduling/ladder_teams.py | 4 +- .../bracket/logic/scheduling/round_robin.py | 6 + backend/bracket/models/db/match.py | 21 +++- backend/bracket/models/db/stage_item.py | 4 + backend/bracket/models/db/tournament.py | 5 + backend/bracket/routes/matches.py | 25 +++- backend/bracket/routes/stage_items.py | 19 ++- backend/bracket/schema.py | 4 + backend/bracket/sql/matches.py | 87 +++++++++++++- backend/bracket/utils/db_init.py | 2 +- backend/bracket/utils/dummy_records.py | 21 ++-- .../api/activate_next_stage_test.py | 4 +- .../integration_tests/api/matches_test.py | 8 +- .../integration_tests/api/players_test.py | 2 +- .../integration_tests/api/tournaments_test.py | 8 ++ backend/tests/unit_tests/elo_test.py | 3 + frontend/src/components/brackets/brackets.tsx | 49 ++++---- frontend/src/components/brackets/courts.tsx | 1 - frontend/src/components/brackets/match.tsx | 3 - frontend/src/components/brackets/round.tsx | 3 - frontend/src/components/dashboard/layout.tsx | 3 + .../forms/player_create_csv_input.tsx | 2 +- .../modals/activate_next_round_modal.tsx | 69 +++++++++++ .../src/components/modals/match_modal.tsx | 80 ++++++++++++- frontend/src/components/utils/skeletons.tsx | 33 ++++++ frontend/src/interfaces/match.tsx | 28 ++++- frontend/src/interfaces/tournament.tsx | 2 + frontend/src/pages/tournaments/[id].tsx | 13 +- .../tournaments/[id]/dashboard/courts.tsx | 22 ++-- .../tournaments/[id]/dashboard/index.tsx | 9 +- .../tournaments/[id]/dashboard/standings.tsx | 5 + .../src/pages/tournaments/[id]/settings.tsx | 111 ++++++++++-------- frontend/src/services/adapter.tsx | 33 +++++- frontend/src/services/round.tsx | 10 +- frontend/src/services/tournament.tsx | 6 +- 39 files changed, 690 insertions(+), 189 deletions(-) create mode 100644 backend/alembic/versions/8bae62f80db7_create_custom_match_duration_fields.py create mode 100644 frontend/src/components/modals/activate_next_round_modal.tsx create mode 100644 frontend/src/components/utils/skeletons.tsx diff --git a/backend/alembic/versions/8bae62f80db7_create_custom_match_duration_fields.py b/backend/alembic/versions/8bae62f80db7_create_custom_match_duration_fields.py new file mode 100644 index 00000000..034e0b85 --- /dev/null +++ b/backend/alembic/versions/8bae62f80db7_create_custom_match_duration_fields.py @@ -0,0 +1,33 @@ +"""create custom match duration fields + +Revision ID: 8bae62f80db7 +Revises: d104afae31e9 +Create Date: 2023-11-19 15:05:51.284093 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str | None = '8bae62f80db7' +down_revision: str | None = 'd104afae31e9' +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column('matches', sa.Column('margin_minutes', sa.Integer(), nullable=True)) + op.add_column('matches', sa.Column('custom_duration_minutes', sa.Integer(), nullable=True)) + op.add_column('matches', sa.Column('custom_margin_minutes', sa.Integer(), nullable=True)) + op.add_column( + 'tournaments', sa.Column('margin_minutes', sa.Integer(), server_default='5', nullable=False) + ) + + +def downgrade() -> None: + op.drop_column('tournaments', 'margin_minutes') + op.drop_column('matches', 'custom_margin_minutes') + op.drop_column('matches', 'custom_duration_minutes') + op.drop_column('matches', 'margin_minutes') diff --git a/backend/bracket/logic/planning/matches.py b/backend/bracket/logic/planning/matches.py index ef823f2a..a3d9dd1e 100644 --- a/backend/bracket/logic/planning/matches.py +++ b/backend/bracket/logic/planning/matches.py @@ -15,7 +15,10 @@ from bracket.models.db.stage_item_inputs import StageItemInputGeneric from bracket.models.db.tournament import Tournament from bracket.models.db.util import StageWithStageItems from bracket.sql.courts import get_all_courts_in_tournament -from bracket.sql.matches import sql_create_match, sql_reschedule_match +from bracket.sql.matches import ( + sql_create_match, + sql_reschedule_match_and_determine_duration_and_margin, +) from bracket.sql.stages import get_full_tournament_details from bracket.sql.tournaments import sql_get_tournament from bracket.utils.types import assert_some @@ -45,11 +48,16 @@ async def schedule_all_unscheduled_matches(tournament_id: int) -> None: for round_ in stage_item.rounds: for match in round_.matches: if match.start_time is None and match.position_in_schedule is None: - await sql_reschedule_match( - assert_some(match.id), court.id, start_time, position_in_schedule + await sql_reschedule_match_and_determine_duration_and_margin( + assert_some(match.id), + court.id, + start_time, + position_in_schedule, + match, + tournament, ) - start_time += timedelta(minutes=15) + start_time += timedelta(minutes=match.duration_minutes) position_in_schedule += 1 for stage in stages[1:]: @@ -58,12 +66,17 @@ async def schedule_all_unscheduled_matches(tournament_id: int) -> None: for stage_item in stage.stage_items: for round_ in stage_item.rounds: for match in round_.matches: - start_time += timedelta(minutes=15) + start_time += timedelta(minutes=match.duration_minutes) position_in_schedule += 1 if match.start_time is None and match.position_in_schedule is None: - await sql_reschedule_match( - assert_some(match.id), courts[-1].id, start_time, position_in_schedule + await sql_reschedule_match_and_determine_duration_and_margin( + assert_some(match.id), + courts[-1].id, + start_time, + position_in_schedule, + match, + tournament, ) @@ -131,13 +144,14 @@ async def iterative_scheduling( winner_from_match_id=match.team2_winner_from_match_id, ) team_defs = {match.team1_id, match.team2_id} - court_id = sorted(match_count_per_court.items(), key=lambda x: x[1])[0][0] try: position_in_schedule = len(matches_per_court[court_id]) last_match = matches_per_court[court_id][-1] - start_time = assert_some(last_match.start_time) + timedelta(minutes=15) + start_time = assert_some(last_match.start_time) + timedelta( + minutes=match.duration_minutes + ) except IndexError: start_time = tournament.start_time position_in_schedule = 0 @@ -162,8 +176,8 @@ async def iterative_scheduling( attempts_since_last_write = 0 random.shuffle(matches_to_schedule) - await sql_reschedule_match( - assert_some(match.id), court_id, start_time, position_in_schedule + await sql_reschedule_match_and_determine_duration_and_margin( + assert_some(match.id), court_id, start_time, position_in_schedule, match, tournament ) @@ -184,13 +198,17 @@ async def reorder_matches_for_court( last_start_time = tournament.start_time for i, match_pos in enumerate(matches_this_court): - await sql_reschedule_match( + await sql_reschedule_match_and_determine_duration_and_margin( assert_some(match_pos.match.id), court_id, last_start_time, position_in_schedule=i, + match=match_pos.match, + tournament=tournament, + ) + last_start_time = last_start_time + timedelta( + minutes=match_pos.match.duration_minutes + match_pos.match.margin_minutes ) - last_start_time = last_start_time + timedelta(minutes=15) async def handle_match_reschedule( diff --git a/backend/bracket/logic/planning/rounds.py b/backend/bracket/logic/planning/rounds.py index cd2facb8..e6b278d6 100644 --- a/backend/bracket/logic/planning/rounds.py +++ b/backend/bracket/logic/planning/rounds.py @@ -1,13 +1,20 @@ -from heliclockter import timedelta +from heliclockter import datetime_utc, timedelta from bracket.logic.planning.matches import get_scheduled_matches_per_court from bracket.models.db.util import RoundWithMatches, StageItemWithRounds from bracket.sql.courts import get_all_courts_in_tournament -from bracket.sql.matches import sql_reschedule_match +from bracket.sql.matches import ( + sql_reschedule_match_and_determine_duration_and_margin, +) from bracket.sql.stages import get_full_tournament_details +from bracket.sql.tournaments import sql_get_tournament from bracket.utils.types import assert_some +class MatchTimingAdjustmentInfeasible(Exception): + pass + + def get_active_and_next_rounds( stage_item: StageItemWithRounds, ) -> tuple[RoundWithMatches | None, RoundWithMatches | None]: @@ -29,11 +36,13 @@ def get_active_and_next_rounds( async def schedule_all_matches_for_swiss_round( - tournament_id: int, active_round: RoundWithMatches + tournament_id: int, active_round: RoundWithMatches, adjust_to_time: datetime_utc | None ) -> None: courts = await get_all_courts_in_tournament(tournament_id) stages = await get_full_tournament_details(tournament_id) + tournament = await sql_get_tournament(tournament_id) matches_per_court = get_scheduled_matches_per_court(stages) + rescheduling_operations = [] if len(courts) < 1: return @@ -42,11 +51,60 @@ async def schedule_all_matches_for_swiss_round( for i, match in enumerate(active_round.matches): court_id = assert_some(courts[i].id) - last_match = matches_per_court[court_id][-1] - - await sql_reschedule_match( - assert_some(match.id), - court_id, - assert_some(last_match.match.start_time) + timedelta(minutes=15), - assert_some(last_match.match.position_in_schedule) + 1, + last_match = ( + next((m for m in matches_per_court[court_id][::-1] if m.match.id != match.id), None) + if court_id in matches_per_court + else None ) + + if last_match is not None: + timing_difference_minutes = 0.0 + if adjust_to_time is not None: + last_match_end = last_match.match.end_time + timing_difference_minutes = (adjust_to_time - last_match_end).total_seconds() // 60 + + if ( + timing_difference_minutes < 0 + and -timing_difference_minutes > last_match.match.margin_minutes + ): + raise MatchTimingAdjustmentInfeasible( + "A match from the previous round is still happening" + ) + + if timing_difference_minutes != 0: + last_match_adjusted = last_match.match.copy( + update={ + 'custom_margin_minutes': last_match.match.margin_minutes + + timing_difference_minutes + } + ) + rescheduling_operations.append( + sql_reschedule_match_and_determine_duration_and_margin( + assert_some(last_match.match.id), + court_id, + assert_some(last_match.match.start_time), + assert_some(last_match.match.position_in_schedule), + last_match_adjusted, + tournament, + ) + ) + + start_time = assert_some(last_match.match.start_time) + timedelta( + minutes=match.duration_minutes + + last_match.match.margin_minutes + + timing_difference_minutes + ) + pos_in_schedule = assert_some(last_match.match.position_in_schedule) + 1 + else: + start_time = tournament.start_time + pos_in_schedule = 1 + + rescheduling_operations.append( + sql_reschedule_match_and_determine_duration_and_margin( + assert_some(match.id), court_id, start_time, pos_in_schedule, match, tournament + ) + ) + + # TODO: if safe: await asyncio.gather(*rescheduling_operations) + for op in rescheduling_operations: + await op diff --git a/backend/bracket/logic/scheduling/elimination.py b/backend/bracket/logic/scheduling/elimination.py index c4c5f878..e43eb229 100644 --- a/backend/bracket/logic/scheduling/elimination.py +++ b/backend/bracket/logic/scheduling/elimination.py @@ -1,12 +1,14 @@ from bracket.logic.planning.matches import create_match_and_assign_free_court from bracket.models.db.match import Match, MatchCreateBody +from bracket.models.db.tournament import Tournament from bracket.models.db.util import RoundWithMatches, StageItemWithRounds from bracket.sql.rounds import get_rounds_for_stage_item +from bracket.sql.tournaments import sql_get_tournament from bracket.utils.types import assert_some def determine_matches_first_round( - round_: RoundWithMatches, stage_item: StageItemWithRounds + round_: RoundWithMatches, stage_item: StageItemWithRounds, tournament: Tournament ) -> list[MatchCreateBody]: suggestions: list[MatchCreateBody] = [] @@ -25,6 +27,10 @@ def determine_matches_first_round( 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, + duration_minutes=tournament.duration_minutes, + margin_minutes=tournament.margin_minutes, + custom_duration_minutes=None, + custom_margin_minutes=None, ) ) @@ -34,6 +40,7 @@ def determine_matches_first_round( def determine_matches_subsequent_round( prev_matches: list[Match], round_: RoundWithMatches, + tournament: Tournament, ) -> list[MatchCreateBody]: suggestions: list[MatchCreateBody] = [] @@ -53,6 +60,10 @@ def determine_matches_subsequent_round( team2_winner_position=None, team1_winner_from_match_id=assert_some(first_match.id), team2_winner_from_match_id=assert_some(second_match.id), + duration_minutes=tournament.duration_minutes, + margin_minutes=tournament.margin_minutes, + custom_duration_minutes=None, + custom_margin_minutes=None, ) ) return suggestions @@ -62,18 +73,20 @@ async def build_single_elimination_stage_item( tournament_id: int, stage_item: StageItemWithRounds ) -> None: rounds = await get_rounds_for_stage_item(tournament_id, stage_item.id) + tournament = await sql_get_tournament(tournament_id) + assert len(rounds) > 0 first_round = rounds[0] prev_matches = [ await create_match_and_assign_free_court(tournament_id, match) - for match in determine_matches_first_round(first_round, stage_item) + for match in determine_matches_first_round(first_round, stage_item, tournament) ] 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_) + for match in determine_matches_subsequent_round(prev_matches, round_, tournament) ] diff --git a/backend/bracket/logic/scheduling/ladder_teams.py b/backend/bracket/logic/scheduling/ladder_teams.py index f4aab866..22516258 100644 --- a/backend/bracket/logic/scheduling/ladder_teams.py +++ b/backend/bracket/logic/scheduling/ladder_teams.py @@ -61,7 +61,9 @@ def get_possible_upcoming_matches_for_swiss( raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.') draft_round_team_ids = get_draft_round_team_ids(draft_round) - teams_to_schedule = [team for team in teams if team.id not in draft_round_team_ids] + teams_to_schedule = [ + team for team in teams if team.id not in draft_round_team_ids and team.active + ] if len(teams_to_schedule) < 1: return suggestions diff --git a/backend/bracket/logic/scheduling/round_robin.py b/backend/bracket/logic/scheduling/round_robin.py index 04770b28..acc77b6f 100644 --- a/backend/bracket/logic/scheduling/round_robin.py +++ b/backend/bracket/logic/scheduling/round_robin.py @@ -3,6 +3,7 @@ from bracket.models.db.match import ( MatchCreateBody, ) from bracket.models.db.util import StageItemWithRounds +from bracket.sql.tournaments import sql_get_tournament from bracket.utils.types import assert_some @@ -36,6 +37,7 @@ def get_round_robin_combinations(team_count: int) -> list[list[tuple[int, int]]] async def build_round_robin_stage_item(tournament_id: int, stage_item: StageItemWithRounds) -> None: matches = get_round_robin_combinations(len(stage_item.inputs)) + tournament = await sql_get_tournament(tournament_id) for i, round_ in enumerate(stage_item.rounds): for team_1_id, team_2_id in matches[i]: @@ -53,6 +55,10 @@ async def build_round_robin_stage_item(tournament_id: int, stage_item: StageItem team2_winner_position=team_2.winner_position, team2_winner_from_match_id=team_2.winner_from_match_id, court_id=None, + duration_minutes=tournament.duration_minutes, + margin_minutes=tournament.margin_minutes, + custom_duration_minutes=None, + custom_margin_minutes=None, ) await create_match_and_assign_free_court(tournament_id, match) diff --git a/backend/bracket/models/db/match.py b/backend/bracket/models/db/match.py index b73a5e1d..b0274d1b 100644 --- a/backend/bracket/models/db/match.py +++ b/backend/bracket/models/db/match.py @@ -13,7 +13,10 @@ class MatchBase(BaseModelORM): id: int | None = None created: datetime_utc start_time: datetime_utc | None - duration_minutes: int | None + duration_minutes: int + margin_minutes: int + custom_duration_minutes: int | None + custom_margin_minutes: int | None position_in_schedule: int | None round_id: int team1_score: int @@ -21,11 +24,10 @@ class MatchBase(BaseModelORM): court_id: int | None @property - def end_time(self, default_minutes: int = 15) -> datetime_utc: + def end_time(self) -> datetime_utc: assert self.start_time return datetime_utc.from_datetime( - self.start_time - + timedelta(minutes=self.duration_minutes if self.duration_minutes else default_minutes) + self.start_time + timedelta(minutes=self.duration_minutes + self.margin_minutes) ) @@ -83,9 +85,11 @@ class MatchBody(BaseModelORM): team1_score: int = 0 team2_score: int = 0 court_id: int | None + custom_duration_minutes: int | None + custom_margin_minutes: int | None -class MatchCreateBody(BaseModelORM): +class MatchCreateBodyFrontend(BaseModelORM): round_id: int court_id: int | None team1_id: int | None @@ -98,6 +102,13 @@ class MatchCreateBody(BaseModelORM): team2_winner_from_match_id: int | None +class MatchCreateBody(MatchCreateBodyFrontend): + duration_minutes: int + margin_minutes: int + custom_duration_minutes: int | None + custom_margin_minutes: int | None + + class MatchRescheduleBody(BaseModelORM): old_court_id: int old_position: int diff --git a/backend/bracket/models/db/stage_item.py b/backend/bracket/models/db/stage_item.py index 2a7ec2a7..028940ef 100644 --- a/backend/bracket/models/db/stage_item.py +++ b/backend/bracket/models/db/stage_item.py @@ -36,6 +36,10 @@ class StageItemUpdateBody(BaseModelORM): name: str +class StageItemActivateNextBody(BaseModelORM): + adjust_to_time: datetime_utc | None + + class StageItemCreateBody(BaseModelORM): stage_id: int name: str | None diff --git a/backend/bracket/models/db/tournament.py b/backend/bracket/models/db/tournament.py index df9ffb3f..f5d5c201 100644 --- a/backend/bracket/models/db/tournament.py +++ b/backend/bracket/models/db/tournament.py @@ -1,4 +1,5 @@ from heliclockter import datetime_utc +from pydantic import Field from bracket.models.db.shared import BaseModelORM @@ -9,6 +10,8 @@ class Tournament(BaseModelORM): name: str created: datetime_utc start_time: datetime_utc + duration_minutes: int = Field(..., ge=1) + margin_minutes: int = Field(..., ge=0) dashboard_public: bool dashboard_endpoint: str | None logo_path: str | None @@ -23,6 +26,8 @@ class TournamentUpdateBody(BaseModelORM): dashboard_endpoint: str | None players_can_be_in_multiple_teams: bool auto_assign_courts: bool + duration_minutes: int = Field(..., ge=1) + margin_minutes: int = Field(..., ge=0) class TournamentBody(TournamentUpdateBody): diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index ce6d30bd..ed598473 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -13,6 +13,7 @@ from bracket.models.db.match import ( Match, MatchBody, MatchCreateBody, + MatchCreateBodyFrontend, MatchFilter, MatchRescheduleBody, SuggestedMatch, @@ -24,7 +25,8 @@ 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, round_with_matches_dependency from bracket.sql.courts import get_all_courts_in_tournament -from bracket.sql.matches import sql_delete_match, sql_update_match +from bracket.sql.matches import sql_create_match, sql_delete_match, sql_update_match +from bracket.sql.tournaments import sql_get_tournament from bracket.utils.types import assert_some router = APIRouter() @@ -72,13 +74,18 @@ async def delete_match( @router.post("/tournaments/{tournament_id}/matches", response_model=SingleMatchResponse) async def create_match( tournament_id: int, - match_body: MatchCreateBody, + match_body: MatchCreateBodyFrontend, _: UserPublic = Depends(user_authenticated_for_tournament), ) -> SingleMatchResponse: - return SingleMatchResponse( - data=await create_match_and_assign_free_court(tournament_id, match_body) + tournament = await sql_get_tournament(tournament_id) + body_with_durations = MatchCreateBody( + **match_body.dict(), + duration_minutes=tournament.duration_minutes, + margin_minutes=tournament.margin_minutes, ) + return SingleMatchResponse(data=await sql_create_match(body_with_durations)) + @router.post("/tournaments/{tournament_id}/schedule_matches", response_model=SuccessResponse) async def schedule_matches( @@ -123,7 +130,9 @@ async def create_matches_automatically( limit=1, iterations=iterations, ) + courts = await get_all_courts_in_tournament(tournament_id) + tournament = await sql_get_tournament(tournament_id) limit = len(courts) - len(round_.matches) for __ in range(limit): @@ -150,6 +159,10 @@ async def create_matches_automatically( team2_winner_from_stage_item_id=None, team2_winner_position=None, team2_winner_from_match_id=None, + duration_minutes=tournament.duration_minutes, + margin_minutes=tournament.margin_minutes, + custom_duration_minutes=None, + custom_margin_minutes=None, ), ) @@ -164,6 +177,8 @@ async def update_match_by_id( match: Match = Depends(match_dependency), ) -> SuccessResponse: assert match.id - await sql_update_match(match.id, match_body) + tournament = await sql_get_tournament(tournament_id) + + await sql_update_match(match.id, match_body, tournament) 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 9998325d..15d4a56f 100644 --- a/backend/bracket/routes/stage_items.py +++ b/backend/bracket/routes/stage_items.py @@ -3,6 +3,7 @@ from starlette import status from bracket.database import database from bracket.logic.planning.rounds import ( + MatchTimingAdjustmentInfeasible, get_active_and_next_rounds, schedule_all_matches_for_swiss_round, ) @@ -10,7 +11,11 @@ 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.stage_item import StageItemCreateBody, StageItemUpdateBody +from bracket.models.db.stage_item import ( + StageItemActivateNextBody, + StageItemCreateBody, + StageItemUpdateBody, +) from bracket.models.db.user import UserPublic from bracket.models.db.util import StageItemWithRounds from bracket.routes.auth import ( @@ -87,6 +92,7 @@ async def update_stage_item( async def start_next_round( tournament_id: int, stage_item_id: int, + active_next_body: StageItemActivateNextBody, stage_item: StageItemWithRounds = Depends(stage_item_dependency), _: UserPublic = Depends(user_authenticated_for_tournament), ) -> SuccessResponse: @@ -100,6 +106,16 @@ async def start_next_round( ), ) + try: + await schedule_all_matches_for_swiss_round( + tournament_id, next_round, active_next_body.adjust_to_time + ) + except MatchTimingAdjustmentInfeasible as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + if active_round is not None and active_round.id is not None: await set_round_active_or_draft( active_round.id, tournament_id, is_active=False, is_draft=False @@ -108,5 +124,4 @@ async def start_next_round( assert next_round.id is not None await set_round_active_or_draft(next_round.id, tournament_id, is_active=True, is_draft=False) - await schedule_all_matches_for_swiss_round(tournament_id, next_round) return SuccessResponse() diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index e911dc97..0424478d 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -28,6 +28,7 @@ tournaments = Table( Column('players_can_be_in_multiple_teams', Boolean, nullable=False, server_default='f'), Column('auto_assign_courts', Boolean, nullable=False, server_default='f'), Column('duration_minutes', Integer, nullable=False, server_default='15'), + Column('margin_minutes', Integer, nullable=False, server_default='5'), ) stages = Table( @@ -97,6 +98,9 @@ matches = Table( Column('created', DateTimeTZ, nullable=False), Column('start_time', DateTimeTZ, nullable=True), Column('duration_minutes', Integer, nullable=True), + Column('margin_minutes', Integer, nullable=True), + Column('custom_duration_minutes', Integer, nullable=True), + Column('custom_margin_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), diff --git a/backend/bracket/sql/matches.py b/backend/bracket/sql/matches.py index ed61b8d9..cf76b88f 100644 --- a/backend/bracket/sql/matches.py +++ b/backend/bracket/sql/matches.py @@ -4,6 +4,7 @@ from heliclockter import datetime_utc from bracket.database import database from bracket.models.db.match import Match, MatchBody, MatchCreateBody +from bracket.models.db.tournament import Tournament async def sql_delete_match(match_id: int) -> None: @@ -40,6 +41,10 @@ async def sql_create_match(match: MatchCreateBody) -> Match: team2_winner_position, team1_winner_from_match_id, team2_winner_from_match_id, + duration_minutes, + custom_duration_minutes, + margin_minutes, + custom_margin_minutes, team1_score, team2_score, created @@ -55,6 +60,10 @@ async def sql_create_match(match: MatchCreateBody) -> Match: :team2_winner_position, :team1_winner_from_match_id, :team2_winner_from_match_id, + :duration_minutes, + :custom_duration_minutes, + :margin_minutes, + :custom_margin_minutes, 0, 0, NOW() @@ -69,17 +78,40 @@ async def sql_create_match(match: MatchCreateBody) -> Match: return Match.parse_obj(result._mapping) -async def sql_update_match(match_id: int, match: MatchBody) -> None: +async def sql_update_match(match_id: int, match: MatchBody, tournament: Tournament) -> None: query = ''' UPDATE matches SET round_id = :round_id, team1_score = :team1_score, team2_score = :team2_score, - court_id = :court_id + court_id = :court_id, + custom_duration_minutes = :custom_duration_minutes, + custom_margin_minutes = :custom_margin_minutes, + duration_minutes = :duration_minutes, + margin_minutes = :margin_minutes WHERE matches.id = :match_id RETURNING * ''' - await database.execute(query=query, values={'match_id': match_id, **match.dict()}) + + duration_minutes = ( + match.custom_duration_minutes + if match.custom_duration_minutes is not None + else tournament.duration_minutes + ) + margin_minutes = ( + match.custom_margin_minutes + if match.custom_margin_minutes is not None + else tournament.margin_minutes + ) + await database.execute( + query=query, + values={ + 'match_id': match_id, + **match.dict(), + 'duration_minutes': duration_minutes, + 'margin_minutes': margin_minutes, + }, + ) async def sql_update_team_ids_for_match( @@ -97,13 +129,24 @@ async def sql_update_team_ids_for_match( async def sql_reschedule_match( - match_id: int, court_id: int | None, start_time: datetime_utc, position_in_schedule: int | None + match_id: int, + court_id: int | None, + start_time: datetime_utc, + position_in_schedule: int | None, + duration_minutes: int, + margin_minutes: int, + custom_duration_minutes: int | None, + custom_margin_minutes: int | None, ) -> None: query = ''' UPDATE matches SET court_id = :court_id, start_time = :start_time, - position_in_schedule = :position_in_schedule + position_in_schedule = :position_in_schedule, + duration_minutes = :duration_minutes, + margin_minutes = :margin_minutes, + custom_duration_minutes = :custom_duration_minutes, + custom_margin_minutes = :custom_margin_minutes WHERE matches.id = :match_id ''' await database.execute( @@ -113,10 +156,44 @@ async def sql_reschedule_match( 'match_id': match_id, 'position_in_schedule': position_in_schedule, 'start_time': datetime.fromisoformat(start_time.isoformat()), + 'duration_minutes': duration_minutes, + 'margin_minutes': margin_minutes, + 'custom_duration_minutes': custom_duration_minutes, + 'custom_margin_minutes': custom_margin_minutes, }, ) +async def sql_reschedule_match_and_determine_duration_and_margin( + match_id: int, + court_id: int | None, + start_time: datetime_utc, + position_in_schedule: int | None, + match: Match, + tournament: Tournament, +) -> None: + duration_minutes = ( + tournament.duration_minutes + if match.custom_duration_minutes is None + else match.custom_duration_minutes + ) + margin_minutes = ( + tournament.margin_minutes + if match.custom_margin_minutes is None + else match.custom_margin_minutes + ) + await sql_reschedule_match( + match_id, + court_id, + start_time, + position_in_schedule, + duration_minutes, + margin_minutes, + match.custom_duration_minutes, + match.custom_margin_minutes, + ) + + async def sql_get_match(match_id: int) -> Match: query = ''' SELECT * diff --git a/backend/bracket/utils/db_init.py b/backend/bracket/utils/db_init.py index 15661516..814ff8b7 100644 --- a/backend/bracket/utils/db_init.py +++ b/backend/bracket/utils/db_init.py @@ -179,7 +179,7 @@ async def sql_create_dev_db() -> None: 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} + DUMMY_PLAYER8, {'name': 'Player 09', 'tournament_id': tournament_id_1} ) player_id_10 = await insert_dummy( DUMMY_PLAYER8, {'name': 'Player 10', 'tournament_id': tournament_id_1} diff --git a/backend/bracket/utils/dummy_records.py b/backend/bracket/utils/dummy_records.py index c5746e0a..1b87cc01 100644 --- a/backend/bracket/utils/dummy_records.py +++ b/backend/bracket/utils/dummy_records.py @@ -36,6 +36,8 @@ DUMMY_TOURNAMENT = Tournament( logo_path=None, players_can_be_in_multiple_teams=True, auto_assign_courts=True, + duration_minutes=10, + margin_minutes=5, ) DUMMY_STAGE1 = Stage( @@ -113,6 +115,9 @@ DUMMY_MATCH1 = Match( team2_winner_position=None, team2_winner_from_match_id=None, duration_minutes=10, + margin_minutes=5, + custom_duration_minutes=None, + custom_margin_minutes=None, position_in_schedule=1, ) @@ -153,56 +158,56 @@ DUMMY_TEAM4 = Team( DUMMY_PLAYER1 = Player( - name='Player 1', + name='Player 01', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER2 = Player( - name='Player 2', + name='Player 02', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER3 = Player( - name='Player 3', + name='Player 03', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER4 = Player( - name='Player 4', + name='Player 04', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER5 = Player( - name='Player 5', + name='Player 05', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER6 = Player( - name='Player 6', + name='Player 06', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER7 = Player( - name='Player 7', + name='Player 07', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, ) DUMMY_PLAYER8 = Player( - name='Player 8', + name='Player 08', active=True, created=DUMMY_MOCK_TIME, tournament_id=DB_PLACEHOLDER_ID, diff --git a/backend/tests/integration_tests/api/activate_next_stage_test.py b/backend/tests/integration_tests/api/activate_next_stage_test.py index 406b97a1..99295ac2 100644 --- a/backend/tests/integration_tests/api/activate_next_stage_test.py +++ b/backend/tests/integration_tests/api/activate_next_stage_test.py @@ -111,7 +111,9 @@ async def test_activate_next_stage( assert isinstance(match1, MatchWithDetailsDefinitive) assert match1.team2.id == team_inserted_2.id await sql_update_match( - assert_some(match1.id), MatchBody(**match1.copy(update={'team2_score': 42}).dict()) + assert_some(match1.id), + MatchBody(**match1.copy(update={'team2_score': 42}).dict()), + auth_context.tournament, ) response = await send_tournament_request( diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py index 10a2c511..1aaae90f 100644 --- a/backend/tests/integration_tests/api/matches_test.py +++ b/backend/tests/integration_tests/api/matches_test.py @@ -239,7 +239,7 @@ async def test_upcoming_matches_endpoint( { 'id': player_inserted_1.id, 'active': True, - 'name': 'Player 1', + 'name': 'Player 01', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': auth_context.tournament.id, 'elo_score': 1100, @@ -251,7 +251,7 @@ async def test_upcoming_matches_endpoint( { 'id': player_inserted_3.id, 'active': True, - 'name': 'Player 3', + 'name': 'Player 03', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': auth_context.tournament.id, 'elo_score': 1200, @@ -274,7 +274,7 @@ async def test_upcoming_matches_endpoint( { 'id': player_inserted_2.id, 'active': True, - 'name': 'Player 2', + 'name': 'Player 02', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': auth_context.tournament.id, 'elo_score': 1300, @@ -286,7 +286,7 @@ async def test_upcoming_matches_endpoint( { 'id': player_inserted_4.id, 'active': True, - 'name': 'Player 4', + 'name': 'Player 04', 'created': '2022-01-11T04:32:11+00:00', 'tournament_id': auth_context.tournament.id, 'elo_score': 1400, diff --git a/backend/tests/integration_tests/api/players_test.py b/backend/tests/integration_tests/api/players_test.py index 7ec61fef..4b341183 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': 'Player 1', + 'name': 'Player 01', 'tournament_id': auth_context.tournament.id, } ], diff --git a/backend/tests/integration_tests/api/tournaments_test.py b/backend/tests/integration_tests/api/tournaments_test.py index de0285dd..74b0ebc8 100644 --- a/backend/tests/integration_tests/api/tournaments_test.py +++ b/backend/tests/integration_tests/api/tournaments_test.py @@ -28,6 +28,8 @@ async def test_tournaments_endpoint( 'dashboard_endpoint': 'cool-tournament', 'players_can_be_in_multiple_teams': True, 'auto_assign_courts': True, + 'duration_minutes': 10, + 'margin_minutes': 5, } ], } @@ -49,6 +51,8 @@ async def test_tournament_endpoint( 'dashboard_endpoint': 'cool-tournament', 'players_can_be_in_multiple_teams': True, 'auto_assign_courts': True, + 'duration_minutes': 10, + 'margin_minutes': 5, }, } @@ -63,6 +67,8 @@ async def test_create_tournament( 'dashboard_public': False, 'players_can_be_in_multiple_teams': True, 'auto_assign_courts': True, + 'duration_minutes': 12, + 'margin_minutes': 3, } assert ( await send_auth_request(HTTPMethod.POST, 'tournaments', auth_context, json=body) @@ -80,6 +86,8 @@ async def test_update_tournament( 'dashboard_public': False, 'players_can_be_in_multiple_teams': True, 'auto_assign_courts': True, + 'duration_minutes': 12, + 'margin_minutes': 3, } assert ( await send_tournament_request(HTTPMethod.PUT, '', auth_context, json=body) diff --git a/backend/tests/unit_tests/elo_test.py b/backend/tests/unit_tests/elo_test.py index 04a34024..f75b4a17 100644 --- a/backend/tests/unit_tests/elo_test.py +++ b/backend/tests/unit_tests/elo_test.py @@ -40,6 +40,9 @@ def test_elo_calculation() -> None: court_id=None, court=None, duration_minutes=10, + margin_minutes=5, + custom_duration_minutes=None, + custom_margin_minutes=None, position_in_schedule=0, team1=FullTeamWithPlayers( id=3, diff --git a/frontend/src/components/brackets/brackets.tsx b/frontend/src/components/brackets/brackets.tsx index e334bc36..8fc13933 100644 --- a/frontend/src/components/brackets/brackets.tsx +++ b/frontend/src/components/brackets/brackets.tsx @@ -1,6 +1,6 @@ import { Alert, Button, Container, Grid, Group, Skeleton } from '@mantine/core'; import { GoPlus } from '@react-icons/all-files/go/GoPlus'; -import { IconAlertCircle, IconSquareArrowRight } from '@tabler/icons-react'; +import { IconAlertCircle } from '@tabler/icons-react'; import React from 'react'; import { SWRResponse } from 'swr'; @@ -9,7 +9,8 @@ import { RoundInterface } from '../../interfaces/round'; import { StageWithStageItems } from '../../interfaces/stage'; import { StageItemWithRounds, stageItemIsHandledAutomatically } from '../../interfaces/stage_item'; import { TournamentMinimal } from '../../interfaces/tournament'; -import { createRound, startNextRound } from '../../services/round'; +import { createRound } from '../../services/round'; +import ActivateNextRoundModal from '../modals/activate_next_round_modal'; import { responseIsValid } from '../utils/util'; import Round from './round'; @@ -17,7 +18,6 @@ function getRoundsGridCols( stageItem: StageItemWithRounds, tournamentData: TournamentMinimal, swrStagesResponse: SWRResponse, - swrCourtsResponse: SWRResponse, swrUpcomingMatchesResponse: SWRResponse | null, readOnly: boolean, displaySettings: BracketDisplaySettings @@ -30,7 +30,6 @@ function getRoundsGridCols( tournamentData={tournamentData} round={round} swrStagesResponse={swrStagesResponse} - swrCourtsResponse={swrCourtsResponse} swrUpcomingMatchesResponse={swrUpcomingMatchesResponse} readOnly={readOnly} dynamicSchedule={!stageItemIsHandledAutomatically(stageItem)} @@ -46,8 +45,8 @@ function getRoundsGridCols( ); } - const showAddRoundButton = - tournamentData != null && (readOnly || stageItemIsHandledAutomatically(stageItem)); + const hideAddRoundButton = + tournamentData == null || readOnly || stageItemIsHandledAutomatically(stageItem); return ( @@ -58,7 +57,7 @@ function getRoundsGridCols( - {showAddRoundButton ? null : ( + {hideAddRoundButton ? null : ( + {hideAddRoundButton ? null : ( + )} @@ -130,21 +123,20 @@ function NotStartedAlert() { function LoadingSkeleton() { return ( - - + +
- - +
+
- - +
+
); } export default function Brackets({ tournamentData, swrStagesResponse, - swrCourtsResponse, swrUpcomingMatchesResponse, readOnly, selectedStageId, @@ -152,12 +144,14 @@ export default function Brackets({ }: { tournamentData: TournamentMinimal; swrStagesResponse: SWRResponse; - swrCourtsResponse: SWRResponse; swrUpcomingMatchesResponse: SWRResponse | null; readOnly: boolean; selectedStageId: string | null; displaySettings: BracketDisplaySettings; }) { + if (swrStagesResponse.isLoading) { + return ; + } if (selectedStageId == null) { return ; } @@ -182,7 +176,6 @@ export default function Brackets({ stageItem, tournamentData, swrStagesResponse, - swrCourtsResponse, swrUpcomingMatchesResponse, readOnly, displaySettings diff --git a/frontend/src/components/brackets/courts.tsx b/frontend/src/components/brackets/courts.tsx index b8c638ca..6c778334 100644 --- a/frontend/src/components/brackets/courts.tsx +++ b/frontend/src/components/brackets/courts.tsx @@ -20,7 +20,6 @@ function getRoundsGridCols( key={match.id} tournamentData={tournamentData} swrStagesResponse={swrStagesResponse} - swrCourtsResponse={null} swrUpcomingMatchesResponse={null} match={match} readOnly diff --git a/frontend/src/components/brackets/match.tsx b/frontend/src/components/brackets/match.tsx index 741d965b..c9453261 100644 --- a/frontend/src/components/brackets/match.tsx +++ b/frontend/src/components/brackets/match.tsx @@ -68,7 +68,6 @@ export function MatchBadge({ match, theme }: { match: MatchInterface; theme: any export default function Match({ swrStagesResponse, - swrCourtsResponse, swrUpcomingMatchesResponse, tournamentData, match, @@ -77,7 +76,6 @@ export default function Match({ displaySettings, }: { swrStagesResponse: SWRResponse; - swrCourtsResponse: SWRResponse | null; swrUpcomingMatchesResponse: SWRResponse | null; tournamentData: TournamentMinimal; match: MatchInterface; @@ -141,7 +139,6 @@ export default function Match({ return
{bracket}
; } assert(swrStagesResponse != null); - assert(swrCourtsResponse != null); return ( <> diff --git a/frontend/src/components/brackets/round.tsx b/frontend/src/components/brackets/round.tsx index 94844f9c..245b640c 100644 --- a/frontend/src/components/brackets/round.tsx +++ b/frontend/src/components/brackets/round.tsx @@ -17,7 +17,6 @@ export default function Round({ tournamentData, round, swrStagesResponse, - swrCourtsResponse, swrUpcomingMatchesResponse, readOnly, dynamicSchedule, @@ -26,7 +25,6 @@ export default function Round({ tournamentData: TournamentMinimal; round: RoundInterface; swrStagesResponse: SWRResponse; - swrCourtsResponse: SWRResponse; swrUpcomingMatchesResponse: SWRResponse | null; readOnly: boolean; dynamicSchedule: boolean; @@ -45,7 +43,6 @@ export default function Round({ key={match.id} tournamentData={tournamentData} swrStagesResponse={swrStagesResponse} - swrCourtsResponse={swrCourtsResponse} swrUpcomingMatchesResponse={swrUpcomingMatchesResponse} match={match} readOnly={readOnly} diff --git a/frontend/src/components/dashboard/layout.tsx b/frontend/src/components/dashboard/layout.tsx index bdd3e480..e43203b0 100644 --- a/frontend/src/components/dashboard/layout.tsx +++ b/frontend/src/components/dashboard/layout.tsx @@ -7,6 +7,9 @@ import { getBaseApiUrl } from '../../services/adapter'; import { getBaseURL } from '../utils/util'; export function TournamentQRCode({ tournamentDataFull }: { tournamentDataFull: Tournament }) { + if (tournamentDataFull == null) { + return null; + } return (
}) { return (