Show conflicts (#967)

This commit is contained in:
Erik Vroon
2024-10-27 20:29:36 +01:00
committed by GitHub
parent e98704f4e1
commit 538b4e145c
11 changed files with 212 additions and 8 deletions

View File

@@ -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")

View File

@@ -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)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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),

View File

@@ -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);

View File

@@ -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({
>
<Grid>
<Grid.Col span="auto">
<Text fw={500}>{formatMatchInput1(stageItemsLookup, matchesLookup, match)}</Text>
<Text fw={500}>{formatMatchInput2(stageItemsLookup, matchesLookup, match)}</Text>
<Group gap="xs">
{match.stage_item_input1_conflict && <AiFillWarning color="red" />}
<Text fw={500}>{formatMatchInput1(stageItemsLookup, matchesLookup, match)}</Text>
</Group>
<Group gap="xs">
{match.stage_item_input2_conflict && <AiFillWarning color="red" />}
<Text fw={500}>{formatMatchInput2(stageItemsLookup, matchesLookup, match)}</Text>
</Group>
</Grid.Col>
<Grid.Col span="content">
<Stack gap="xs" align="end">