From 538b4e145c5d07fb826806dc866252e9fbc2cd4f Mon Sep 17 00:00:00 2001 From: Erik Vroon Date: Sun, 27 Oct 2024 20:29:36 +0100 Subject: [PATCH] Show conflicts (#967) --- ...b08be04_add_conflict_columns_to_matches.py | 35 ++++++ backend/bracket/logic/planning/conflicts.py | 117 ++++++++++++++++++ backend/bracket/logic/planning/matches.py | 5 +- backend/bracket/models/db/match.py | 2 + backend/bracket/routes/matches.py | 7 +- backend/bracket/schema.py | 2 + backend/bracket/sql/matches.py | 14 ++- backend/bracket/utils/dummy_records.py | 2 + .../unit_tests/ranking_calculation_test.py | 12 ++ frontend/src/interfaces/match.tsx | 13 +- .../src/pages/tournaments/[id]/schedule.tsx | 11 +- 11 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/55b14b08be04_add_conflict_columns_to_matches.py create mode 100644 backend/bracket/logic/planning/conflicts.py diff --git a/backend/alembic/versions/55b14b08be04_add_conflict_columns_to_matches.py b/backend/alembic/versions/55b14b08be04_add_conflict_columns_to_matches.py new file mode 100644 index 00000000..19ead4e4 --- /dev/null +++ b/backend/alembic/versions/55b14b08be04_add_conflict_columns_to_matches.py @@ -0,0 +1,35 @@ +"""add conflict columns to matches + +Revision ID: 55b14b08be04 +Revises: c97976608633 +Create Date: 2024-10-27 17:24:00.240033 + +""" + +import sqlalchemy as sa +from sqlalchemy import text + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str | None = "55b14b08be04" +down_revision: str | None = "c97976608633" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column("matches", sa.Column("stage_item_input1_conflict", sa.Boolean(), nullable=True)) + op.add_column("matches", sa.Column("stage_item_input2_conflict", sa.Boolean(), nullable=True)) + op.execute( + text( + "UPDATE matches SET stage_item_input1_conflict=false, stage_item_input2_conflict=false" + ) + ) + op.alter_column("matches", "stage_item_input2_conflict", nullable=False) + op.alter_column("matches", "stage_item_input2_conflict", nullable=False) + + +def downgrade() -> None: + op.drop_column("matches", "stage_item_input2_conflict") + op.drop_column("matches", "stage_item_input1_conflict") diff --git a/backend/bracket/logic/planning/conflicts.py b/backend/bracket/logic/planning/conflicts.py new file mode 100644 index 00000000..ac7ac0a7 --- /dev/null +++ b/backend/bracket/logic/planning/conflicts.py @@ -0,0 +1,117 @@ +from bracket.database import database +from bracket.models.db.match import Match, MatchWithDetailsDefinitive +from bracket.models.db.util import StageWithStageItems +from bracket.utils.id_types import MatchId + + +def matchesOverlap(match1: Match, match2: Match) -> bool: + if ( + match1.start_time is None + or match1.end_time is None + or match2.start_time is None + or match2.end_time is None + ): + return False + + return not ( + (match1.end_time < match2.end_time and match1.start_time < match2.start_time) + or (match1.start_time > match2.start_time or match1.end_time > match2.end_time) + ) + + +def get_conflicting_matches( + stages: list[StageWithStageItems], +) -> tuple[ + list[tuple[bool, bool, MatchId]], + set[MatchId], +]: + matches = [ + match + for stage in stages + for stage_item in stage.stage_items + for round_ in stage_item.rounds + for match in round_.matches + if isinstance(match, MatchWithDetailsDefinitive) + ] + + conflicts_to_set = [] + matches_with_conflicts = set() + conflicts_to_clear = set() + + for i, match1 in enumerate(matches): + for match2 in matches[i + 1 :]: + if match1.id == match2.id: + continue + + conflicting_input_ids = [] + + if match2.stage_item_input1_id in match1.stage_item_input_ids: + conflicting_input_ids.append(match2.stage_item_input1_id) + if match2.stage_item_input2_id in match1.stage_item_input_ids: + conflicting_input_ids.append(match2.stage_item_input2_id) + + if len(conflicting_input_ids) < 1: + continue + + if matchesOverlap(match1, match2): + conflicts_to_set.append( + ( + match1.stage_item_input1_id in conflicting_input_ids, + match1.stage_item_input2_id in conflicting_input_ids, + match1.id, + ) + ) + conflicts_to_set.append( + ( + match2.stage_item_input1_id in conflicting_input_ids, + match2.stage_item_input2_id in conflicting_input_ids, + match2.id, + ) + ) + matches_with_conflicts.add(match1.id) + matches_with_conflicts.add(match2.id) + + for match in matches: + if match.id not in matches_with_conflicts: + conflicts_to_clear.add(match.id) + + assert set(con[2] for con in conflicts_to_set).intersection(conflicts_to_clear) == set() + return conflicts_to_set, conflicts_to_clear + + +async def set_conflicts( + conflicts_to_set: list[tuple[bool, bool, MatchId]], + conflicts_to_clear: set[MatchId], +) -> None: + for conflict in conflicts_to_set: + await database.execute( + """ + UPDATE matches + SET + stage_item_input1_conflict = :conflict1_id, + stage_item_input2_conflict = :conflict2_id + WHERE id = :match_id + """, + values={ + "conflict1_id": conflict[0], + "conflict2_id": conflict[1], + "match_id": conflict[2], + }, + ) + + for match_id in conflicts_to_clear: + await database.execute( + """ + UPDATE matches + SET + stage_item_input1_conflict = false, + stage_item_input2_conflict = false + WHERE id = :match_id + """, + values={"match_id": match_id}, + ) + + +async def handle_conflicts(stages: list[StageWithStageItems]) -> None: + conflicts_to_set, conflicts_to_clear = get_conflicting_matches(stages) + await set_conflicts(conflicts_to_set, conflicts_to_clear) diff --git a/backend/bracket/logic/planning/matches.py b/backend/bracket/logic/planning/matches.py index b1ecf2b0..b25cccc4 100644 --- a/backend/bracket/logic/planning/matches.py +++ b/backend/bracket/logic/planning/matches.py @@ -20,9 +20,10 @@ from bracket.utils.id_types import CourtId, MatchId, TournamentId from bracket.utils.types import assert_some -async def schedule_all_unscheduled_matches(tournament_id: TournamentId) -> None: +async def schedule_all_unscheduled_matches( + tournament_id: TournamentId, stages: list[StageWithStageItems] +) -> None: tournament = await sql_get_tournament(tournament_id) - stages = await get_full_tournament_details(tournament_id) courts = await get_all_courts_in_tournament(tournament_id) if len(stages) < 1 or len(courts) < 1: diff --git a/backend/bracket/models/db/match.py b/backend/bracket/models/db/match.py index c3546a5a..29d942c6 100644 --- a/backend/bracket/models/db/match.py +++ b/backend/bracket/models/db/match.py @@ -22,6 +22,8 @@ class MatchBaseInsertable(BaseModelORM): stage_item_input1_score: int stage_item_input2_score: int court_id: CourtId | None = None + stage_item_input1_conflict: bool + stage_item_input2_conflict: bool @property def end_time(self) -> datetime_utc: diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index 86dd735a..8d08384d 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends +from bracket.logic.planning.conflicts import handle_conflicts from bracket.logic.planning.matches import ( get_scheduled_matches, handle_match_reschedule, @@ -87,6 +88,7 @@ async def create_match( match_body: MatchCreateBodyFrontend, _: UserPublic = Depends(user_authenticated_for_tournament), ) -> SingleMatchResponse: + # TODO: check this is a swiss stage item await check_foreign_keys_belong_to_tournament(match_body, tournament_id) tournament = await sql_get_tournament(tournament_id) @@ -104,7 +106,9 @@ async def schedule_matches( tournament_id: TournamentId, _: UserPublic = Depends(user_authenticated_for_tournament), ) -> SuccessResponse: - await schedule_all_unscheduled_matches(tournament_id) + stages = await get_full_tournament_details(tournament_id) + await schedule_all_unscheduled_matches(tournament_id, stages) + # await handle_conflicts(stages) return SuccessResponse() @@ -119,6 +123,7 @@ async def reschedule_match( ) -> SuccessResponse: await check_foreign_keys_belong_to_tournament(body, tournament_id) await handle_match_reschedule(tournament_id, body, match_id) + await handle_conflicts(await get_full_tournament_details(tournament_id)) return SuccessResponse() diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index 53ef7630..18b913bb 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -111,6 +111,8 @@ matches = Table( Column("round_id", BigInteger, ForeignKey("rounds.id"), nullable=False), Column("stage_item_input1_id", BigInteger, ForeignKey("stage_item_inputs.id"), nullable=True), Column("stage_item_input2_id", BigInteger, ForeignKey("stage_item_inputs.id"), nullable=True), + Column("stage_item_input1_conflict", Boolean, nullable=False), + Column("stage_item_input2_conflict", Boolean, nullable=False), Column( "stage_item_input1_winner_from_match_id", BigInteger, diff --git a/backend/bracket/sql/matches.py b/backend/bracket/sql/matches.py index b58d9ce2..cee872ce 100644 --- a/backend/bracket/sql/matches.py +++ b/backend/bracket/sql/matches.py @@ -44,6 +44,8 @@ async def sql_create_match(match: MatchCreateBody) -> Match: custom_margin_minutes, stage_item_input1_score, stage_item_input2_score, + stage_item_input1_conflict, + stage_item_input2_conflict, created ) VALUES ( @@ -59,6 +61,8 @@ async def sql_create_match(match: MatchCreateBody) -> Match: :custom_margin_minutes, 0, 0, + false, + false, NOW() ) RETURNING * @@ -116,6 +120,8 @@ async def sql_reschedule_match( margin_minutes: int, custom_duration_minutes: int | None, custom_margin_minutes: int | None, + stage_item_input1_conflict: bool, + stage_item_input2_conflict: bool, ) -> None: query = """ UPDATE matches @@ -125,7 +131,9 @@ async def sql_reschedule_match( duration_minutes = :duration_minutes, margin_minutes = :margin_minutes, custom_duration_minutes = :custom_duration_minutes, - custom_margin_minutes = :custom_margin_minutes + custom_margin_minutes = :custom_margin_minutes, + stage_item_input1_conflict = :stage_item_input1_conflict, + stage_item_input2_conflict = :stage_item_input2_conflict WHERE matches.id = :match_id """ await database.execute( @@ -139,6 +147,8 @@ async def sql_reschedule_match( "margin_minutes": margin_minutes, "custom_duration_minutes": custom_duration_minutes, "custom_margin_minutes": custom_margin_minutes, + "stage_item_input1_conflict": stage_item_input1_conflict, + "stage_item_input2_conflict": stage_item_input2_conflict, }, ) @@ -170,6 +180,8 @@ async def sql_reschedule_match_and_determine_duration_and_margin( margin_minutes, match.custom_duration_minutes, match.custom_margin_minutes, + match.stage_item_input1_conflict, + match.stage_item_input2_conflict, ) diff --git a/backend/bracket/utils/dummy_records.py b/backend/bracket/utils/dummy_records.py index 230a9bc6..25b878ae 100644 --- a/backend/bracket/utils/dummy_records.py +++ b/backend/bracket/utils/dummy_records.py @@ -132,6 +132,8 @@ DUMMY_MATCH1 = MatchInsertable( custom_duration_minutes=None, custom_margin_minutes=None, position_in_schedule=1, + stage_item_input1_conflict=False, + stage_item_input2_conflict=False, ) DUMMY_USER = UserInsertable( diff --git a/backend/tests/unit_tests/ranking_calculation_test.py b/backend/tests/unit_tests/ranking_calculation_test.py index 4efe276d..b107cf9c 100644 --- a/backend/tests/unit_tests/ranking_calculation_test.py +++ b/backend/tests/unit_tests/ranking_calculation_test.py @@ -57,6 +57,8 @@ def test_determine_ranking_for_stage_item_elimination() -> None: round_id=RoundId(-1), stage_item_input1_score=2, stage_item_input2_score=0, + stage_item_input1_conflict=False, + stage_item_input2_conflict=False, ), MatchWithDetailsDefinitive( id=MatchId(-2), @@ -68,6 +70,8 @@ def test_determine_ranking_for_stage_item_elimination() -> None: round_id=RoundId(-1), stage_item_input1_score=2, stage_item_input2_score=2, + stage_item_input1_conflict=False, + stage_item_input2_conflict=False, ), MatchWithDetails( # This gets ignored in ranking calculation id=MatchId(-3), @@ -77,6 +81,8 @@ def test_determine_ranking_for_stage_item_elimination() -> None: round_id=RoundId(-1), stage_item_input1_score=3, stage_item_input2_score=2, + stage_item_input1_conflict=False, + stage_item_input2_conflict=False, ), ], stage_item_id=StageItemId(-1), @@ -147,6 +153,8 @@ def test_determine_ranking_for_stage_item_swiss() -> None: round_id=RoundId(-1), stage_item_input1_score=2, stage_item_input2_score=0, + stage_item_input1_conflict=False, + stage_item_input2_conflict=False, ), MatchWithDetailsDefinitive( id=MatchId(-2), @@ -158,6 +166,8 @@ def test_determine_ranking_for_stage_item_swiss() -> None: round_id=RoundId(-1), stage_item_input1_score=2, stage_item_input2_score=2, + stage_item_input1_conflict=False, + stage_item_input2_conflict=False, ), MatchWithDetails( # This gets ignored in ranking calculation id=MatchId(-3), @@ -167,6 +177,8 @@ def test_determine_ranking_for_stage_item_swiss() -> None: round_id=RoundId(-1), stage_item_input1_score=3, stage_item_input2_score=2, + stage_item_input1_conflict=False, + stage_item_input2_conflict=False, ), ], stage_item_id=StageItemId(-1), diff --git a/frontend/src/interfaces/match.tsx b/frontend/src/interfaces/match.tsx index cf1aad40..9e868120 100644 --- a/frontend/src/interfaces/match.tsx +++ b/frontend/src/interfaces/match.tsx @@ -1,4 +1,5 @@ import assert from 'assert'; +import { useTranslation } from 'next-i18next'; import { Court } from './court'; import { StageItemInput, getPositionName } from './stage_item_input'; @@ -21,6 +22,8 @@ export interface MatchInterface { margin_minutes: number; custom_duration_minutes: number | null; custom_margin_minutes: number | null; + stage_item_input1_conflict: boolean; + stage_item_input2_conflict: boolean; } export interface MatchBodyInterface { @@ -93,6 +96,7 @@ export function formatMatchInput1( matchesLookup: any, match: MatchInterface ): string { + const { t } = useTranslation(); if (match.stage_item_input1?.team != null) return match.stage_item_input1.team.name; if (match.stage_item_input1?.winner_from_stage_item_id != null) { assert(match.stage_item_input1.winner_position != null); @@ -100,7 +104,9 @@ export function formatMatchInput1( stageItemsLookup[match.stage_item_input1?.winner_from_stage_item_id].name }`; } - assert(match.stage_item_input1_winner_from_match_id != null); + if (match.stage_item_input1_winner_from_match_id == null) { + return t('empty_slot'); + } const winner = matchesLookup[match.stage_item_input1_winner_from_match_id].match; const match_1 = formatMatchInput1(stageItemsLookup, matchesLookup, winner); // eslint-disable-next-line @typescript-eslint/no-use-before-define @@ -113,6 +119,7 @@ export function formatMatchInput2( matchesLookup: any, match: MatchInterface ): string { + const { t } = useTranslation(); if (match.stage_item_input2?.team != null) return match.stage_item_input2.team.name; if (match.stage_item_input2?.winner_from_stage_item_id != null) { assert(match.stage_item_input2.winner_position != null); @@ -120,7 +127,9 @@ export function formatMatchInput2( stageItemsLookup[match.stage_item_input2?.winner_from_stage_item_id].name }`; } - assert(match.stage_item_input2_winner_from_match_id != null); + if (match.stage_item_input2_winner_from_match_id == null) { + return t('empty_slot'); + } const winner = matchesLookup[match.stage_item_input2_winner_from_match_id].match; const match_1 = formatMatchInput1(stageItemsLookup, matchesLookup, winner); const match_2 = formatMatchInput2(stageItemsLookup, matchesLookup, winner); diff --git a/frontend/src/pages/tournaments/[id]/schedule.tsx b/frontend/src/pages/tournaments/[id]/schedule.tsx index 062401ab..2a7ad232 100644 --- a/frontend/src/pages/tournaments/[id]/schedule.tsx +++ b/frontend/src/pages/tournaments/[id]/schedule.tsx @@ -12,6 +12,7 @@ import { Text, Title, } from '@mantine/core'; +import { AiFillWarning } from '@react-icons/all-files/ai/AiFillWarning'; import { IconAlertCircle, IconCalendarPlus, IconDots, IconTrash } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; @@ -69,8 +70,14 @@ function ScheduleRow({ > - {formatMatchInput1(stageItemsLookup, matchesLookup, match)} - {formatMatchInput2(stageItemsLookup, matchesLookup, match)} + + {match.stage_item_input1_conflict && } + {formatMatchInput1(stageItemsLookup, matchesLookup, match)} + + + {match.stage_item_input2_conflict && } + {formatMatchInput2(stageItemsLookup, matchesLookup, match)} +