mirror of
https://github.com/evroon/bracket.git
synced 2026-04-18 22:37:02 -04:00
Show conflicts (#967)
This commit is contained in:
@@ -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")
|
||||
117
backend/bracket/logic/planning/conflicts.py
Normal file
117
backend/bracket/logic/planning/conflicts.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user