Add behavior to go to next stage (#265)

This commit is contained in:
Erik Vroon
2023-09-14 11:51:05 +02:00
committed by GitHub
parent bb5a659670
commit d1484a0bb3
19 changed files with 317 additions and 70 deletions

View File

@@ -1,4 +1,5 @@
from enum import auto
from typing import Literal
from heliclockter import datetime_utc
@@ -26,6 +27,10 @@ class StageUpdateBody(BaseModelORM):
is_active: bool
class StageActivateBody(BaseModelORM):
direction: Literal['next', 'previous'] = 'next'
class StageCreateBody(BaseModelORM):
type: StageType

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from heliclockter import datetime_utc
from starlette import status
from bracket.database import database
from bracket.models.db.court import Court, CourtBody, CourtToInsert
@@ -8,6 +9,7 @@ from bracket.routes.auth import user_authenticated_for_tournament
from bracket.routes.models import CourtsResponse, SingleCourtResponse, SuccessResponse
from bracket.schema import courts
from bracket.sql.courts import get_all_courts_in_tournament, update_court
from bracket.sql.stages import get_stages_with_rounds_and_matches
from bracket.utils.db import fetch_one_parsed
from bracket.utils.types import assert_some
@@ -51,6 +53,20 @@ async def update_court_by_id(
async def delete_court(
tournament_id: int, court_id: int, _: UserPublic = Depends(user_authenticated_for_tournament)
) -> SuccessResponse:
stages = await get_stages_with_rounds_and_matches(tournament_id, no_draft_rounds=False)
used_in_matches_count = 0
for stage in stages:
for round_ in stage.rounds:
for match in round_.matches:
if match.court_id == court_id:
used_in_matches_count += 1
if used_in_matches_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Could not delete court since it's used by {used_in_matches_count} matches",
)
await database.execute(
query=courts.delete().where(
courts.c.id == court_id and courts.c.tournament_id == tournament_id

View File

@@ -10,8 +10,10 @@ from bracket.models.db.user import UserPublic
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
from bracket.sql.courts import get_all_free_courts_in_round
from bracket.sql.matches import sql_create_match, sql_delete_match, sql_update_match
from bracket.sql.stages import get_stages_with_rounds_and_matches
from bracket.sql.tournaments import sql_get_tournament
from bracket.utils.types import assert_some
router = APIRouter()
@@ -75,7 +77,17 @@ async def create_match(
match_body: MatchCreateBody,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SingleMatchResponse:
return SingleMatchResponse(data=await sql_create_match(match_body))
tournament = await sql_get_tournament(tournament_id)
next_free_court_id = None
if tournament.auto_assign_courts:
free_courts = await get_all_free_courts_in_round(tournament_id, match_body.round_id)
if len(free_courts) > 0:
next_free_court_id = free_courts[0].id
match_body = match_body.copy(update={'court_id': next_free_court_id})
match = await sql_create_match(match_body)
return SingleMatchResponse(data=match)
@router.patch("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse)

View File

@@ -4,7 +4,7 @@ from starlette import status
from bracket.database import database
from bracket.logic.elo import recalculate_elo_for_tournament_id
from bracket.models.db.round import StageWithRounds
from bracket.models.db.stage import Stage, StageCreateBody, StageUpdateBody
from bracket.models.db.stage import Stage, StageActivateBody, StageCreateBody, StageUpdateBody
from bracket.models.db.user import UserPublic
from bracket.routes.auth import (
user_authenticated_for_tournament,
@@ -13,7 +13,9 @@ from bracket.routes.auth import (
from bracket.routes.models import RoundsWithMatchesResponse, SuccessResponse
from bracket.routes.util import stage_dependency
from bracket.sql.stages import (
get_next_stage_in_tournament,
get_stages_with_rounds_and_matches,
sql_activate_next_stage,
sql_create_stage,
sql_delete_stage,
)
@@ -94,3 +96,20 @@ async def update_stage(
values={**values, 'is_active': stage_body.is_active},
)
return SuccessResponse()
@router.post("/tournaments/{tournament_id}/stages/activate", response_model=SuccessResponse)
async def activate_next_stage(
tournament_id: int,
stage_body: StageActivateBody,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
new_active_stage_id = await get_next_stage_in_tournament(tournament_id, stage_body.direction)
if new_active_stage_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="There is no next stage",
)
await sql_activate_next_stage(new_active_stage_id, tournament_id)
return SuccessResponse()

View File

@@ -12,6 +12,25 @@ async def get_all_courts_in_tournament(tournament_id: int) -> list[Court]:
return [Court.parse_obj(x._mapping) for x in result]
async def get_all_free_courts_in_round(tournament_id: int, round_id: int) -> list[Court]:
query = '''
SELECT *
FROM courts
WHERE NOT EXISTS (
SELECT 1
FROM matches
WHERE matches.court_id = courts.id
AND matches.round_id = :round_id
)
AND courts.tournament_id = :tournament_id
ORDER BY courts.name
'''
result = await database.fetch_all(
query=query, values={'tournament_id': tournament_id, 'round_id': round_id}
)
return [Court.parse_obj(x._mapping) for x in result]
async def update_court(tournament_id: int, court_id: int, court_body: CourtBody) -> list[Court]:
query = '''
UPDATE courts

View File

@@ -1,3 +1,5 @@
from typing import Literal, cast
from bracket.database import database
from bracket.models.db.round import StageWithRounds
from bracket.models.db.stage import Stage, StageCreateBody
@@ -88,3 +90,58 @@ async def sql_create_stage(stage: StageCreateBody, tournament_id: int) -> Stage:
raise ValueError('Could not create stage')
return Stage.parse_obj(result._mapping)
async def get_next_stage_in_tournament(
tournament_id: int, direction: Literal['next', 'previous']
) -> int | None:
select_query = '''
SELECT id
FROM stages
WHERE
CASE WHEN :direction='next'
THEN (
id > COALESCE(
(
SELECT id FROM stages AS t
WHERE is_active IS TRUE
AND stages.tournament_id = :tournament_id
ORDER BY id ASC
),
-1
)
)
ELSE (
id < COALESCE(
(
SELECT id FROM stages AS t
WHERE is_active IS TRUE
AND stages.tournament_id = :tournament_id
ORDER BY id DESC
),
-1
)
)
END
AND stages.tournament_id = :tournament_id
'''
return cast(
int,
await database.execute(
query=select_query,
values={'tournament_id': tournament_id, 'direction': direction},
),
)
async def sql_activate_next_stage(new_active_stage_id: int, tournament_id: int) -> None:
update_query = '''
UPDATE stages
SET is_active = (stages.id = :new_active_stage_id)
WHERE stages.tournament_id = :tournament_id
'''
await database.execute(
query=update_query,
values={'tournament_id': tournament_id, 'new_active_stage_id': new_active_stage_id},
)

View File

@@ -0,0 +1,10 @@
from bracket.database import database
from bracket.models.db.tournament import Tournament
from bracket.schema import tournaments
from bracket.utils.db import fetch_one_parsed_certain
async def sql_get_tournament(tournament_id: int) -> Tournament:
return await fetch_one_parsed_certain(
database, Tournament, tournaments.select().where(tournaments.c.id == tournament_id)
)

View File

@@ -206,7 +206,12 @@ async def sql_create_dev_db() -> None:
)
await insert_dummy(
DUMMY_MATCH3.copy(
update={'round_id': round_id_2, 'team1_id': team_id_2, 'team2_id': team_id_4}
update={
'round_id': round_id_2,
'team1_id': team_id_2,
'team2_id': team_id_4,
'court_id': court_id_1,
}
),
)
await insert_dummy(

View File

@@ -38,14 +38,14 @@ DUMMY_TOURNAMENT = Tournament(
DUMMY_STAGE1 = Stage(
tournament_id=DB_PLACEHOLDER_ID,
created=DUMMY_MOCK_TIME,
is_active=False,
is_active=True,
type=StageType.ROUND_ROBIN,
)
DUMMY_STAGE2 = Stage(
tournament_id=DB_PLACEHOLDER_ID,
created=DUMMY_MOCK_TIME,
is_active=True,
is_active=False,
type=StageType.SWISS,
)
@@ -59,15 +59,14 @@ DUMMY_ROUND1 = Round(
DUMMY_ROUND2 = Round(
stage_id=DB_PLACEHOLDER_ID,
created=DUMMY_MOCK_TIME,
is_active=True,
is_draft=False,
is_draft=True,
name='Round 2',
)
DUMMY_ROUND3 = Round(
stage_id=2,
created=DUMMY_MOCK_TIME,
is_draft=True,
is_draft=False,
name='Round 3',
)
@@ -98,7 +97,7 @@ DUMMY_MATCH3 = Match(
team2_id=4,
team1_score=23,
team2_score=26,
court_id=None,
court_id=DB_PLACEHOLDER_ID,
)
DUMMY_MATCH4 = Match(

View File

@@ -3,7 +3,13 @@ import pytest
from bracket.models.db.stage import StageType
from bracket.schema import stages
from bracket.sql.stages import get_stages_with_rounds_and_matches
from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_ROUND1, DUMMY_STAGE1, DUMMY_TEAM1
from bracket.utils.dummy_records import (
DUMMY_MOCK_TIME,
DUMMY_ROUND1,
DUMMY_STAGE1,
DUMMY_STAGE2,
DUMMY_TEAM1,
)
from bracket.utils.http import HTTPMethod
from bracket.utils.types import assert_some
from tests.integration_tests.api.shared import (
@@ -47,7 +53,7 @@ async def test_stages_endpoint(
'created': DUMMY_MOCK_TIME.isoformat(),
'type': 'ROUND_ROBIN',
'type_name': 'Round robin',
'is_active': False,
'is_active': True,
'rounds': [
{
'id': round_inserted.id,
@@ -88,7 +94,7 @@ async def test_delete_stage(
async with (
inserted_team(DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})),
inserted_stage(
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
DUMMY_STAGE2.copy(update={'tournament_id': auth_context.tournament.id})
) as stage_inserted,
):
assert (
@@ -123,3 +129,27 @@ async def test_update_stage(
assert patched_stage.is_active == body['is_active']
await assert_row_count_and_clear(stages, 1)
async def test_activate_stage(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
body = {'type': StageType.ROUND_ROBIN.value, 'is_active': False}
async with (
inserted_team(DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})),
inserted_stage(DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})),
inserted_stage(DUMMY_STAGE2.copy(update={'tournament_id': auth_context.tournament.id})),
):
assert (
await send_tournament_request(
HTTPMethod.POST, 'stages/activate?direction=next', auth_context, None, body
)
== SUCCESS_RESPONSE
)
[prev_stage, next_stage] = await get_stages_with_rounds_and_matches(
assert_some(auth_context.tournament.id)
)
assert prev_stage.is_active is False
assert next_stage.is_active is True
await assert_row_count_and_clear(stages, 1)

View File

@@ -10,14 +10,14 @@ import Round from './round';
function getRoundsGridCols(
stages_map: { [p: string]: any },
activeStageId: number,
selectedStageId: number,
tournamentData: TournamentMinimal,
swrStagesResponse: SWRResponse,
swrCourtsResponse: SWRResponse,
swrUpcomingMatchesResponse: SWRResponse | null,
readOnly: boolean
) {
const rounds = stages_map[activeStageId].rounds
return stages_map[selectedStageId].rounds
.sort((r1: any, r2: any) => (r1.name > r2.name ? 1 : 0))
.map((round: RoundInterface) => (
<Grid.Col sm={6} lg={4} xl={3} key={round.id}>
@@ -31,7 +31,34 @@ function getRoundsGridCols(
/>
</Grid.Col>
));
return rounds;
}
function NoRoundsAlert({ readOnly }: { readOnly: boolean }) {
if (readOnly) {
return (
<Alert icon={<IconAlertCircle size={16} />} title="No rounds found" color="blue" radius="lg">
Please wait for the organiser to add them.
</Alert>
);
}
return (
<Alert icon={<IconAlertCircle size={16} />} title="No rounds found" color="blue" radius="lg">
Add a round using the top right button.
</Alert>
);
}
function LoadingSkeleton() {
return (
<Grid>
<Grid.Col sm={6} lg={4} xl={3}>
<Skeleton height={500} mb="xl" radius="xl" />
</Grid.Col>
<Grid.Col sm={6} lg={4} xl={3}>
<Skeleton height={500} mb="xl" radius="xl" />
</Grid.Col>
</Grid>
);
}
export default function Brackets({
@@ -40,57 +67,34 @@ export default function Brackets({
swrCourtsResponse,
swrUpcomingMatchesResponse,
readOnly,
activeStageId,
selectedStageId,
}: {
tournamentData: TournamentMinimal;
swrStagesResponse: SWRResponse;
swrCourtsResponse: SWRResponse;
swrUpcomingMatchesResponse: SWRResponse | null;
readOnly: boolean;
activeStageId: number | null;
selectedStageId: number | null;
}) {
if (
activeStageId == null ||
selectedStageId == null ||
(!swrStagesResponse.isLoading && !responseIsValid(swrStagesResponse))
) {
if (readOnly) {
return (
<Alert
icon={<IconAlertCircle size={16} />}
title="No rounds found"
color="blue"
radius="lg"
>
Please wait for the organiser to add them.
</Alert>
);
}
return (
<Alert icon={<IconAlertCircle size={16} />} title="No rounds found" color="blue" radius="lg">
Add a round using the top right button.
</Alert>
);
return <NoRoundsAlert readOnly={readOnly} />;
}
if (swrStagesResponse.isLoading) {
return (
<Grid>
<Grid.Col sm={6} lg={4} xl={3}>
<Skeleton height={500} mb="xl" radius="xl" />
</Grid.Col>
<Grid.Col sm={6} lg={4} xl={3}>
<Skeleton height={500} mb="xl" radius="xl" />
</Grid.Col>
</Grid>
);
return <LoadingSkeleton />;
}
const stages_map = Object.fromEntries(
swrStagesResponse.data.data.map((x: RoundInterface) => [x.id, x])
);
const rounds =
stages_map[activeStageId].rounds.length > 0 ? (
stages_map[selectedStageId].rounds.length > 0 ? (
getRoundsGridCols(
stages_map,
activeStageId,
selectedStageId,
tournamentData,
swrStagesResponse,
swrCourtsResponse,

View File

@@ -0,0 +1,39 @@
import { Button } from '@mantine/core';
import { IconSquareArrowLeft, IconSquareArrowRight } from '@tabler/icons-react';
import React from 'react';
import { activateNextStage } from '../../services/stage';
export function NextStageButton({ tournamentData, swrStagesResponse }: any) {
return (
<Button
size="md"
style={{ marginBottom: 10 }}
color="indigo"
leftIcon={<IconSquareArrowRight size={24} />}
onClick={async () => {
await activateNextStage(tournamentData.id, 'next');
swrStagesResponse.mutate();
}}
>
Go to next stage
</Button>
);
}
export function PreviousStageButton({ tournamentData, swrStagesResponse }: any) {
return (
<Button
size="md"
style={{ marginBottom: 10 }}
color="indigo"
leftIcon={<IconSquareArrowLeft size={24} />}
onClick={async () => {
await activateNextStage(tournamentData.id, 'previous');
swrStagesResponse.mutate();
}}
>
Go to previous stage
</Button>
);
}

View File

@@ -45,7 +45,6 @@ export default function CourtsTable({
<thead>
<tr>
<ThNotSortable>Title</ThNotSortable>
<ThNotSortable>Status</ThNotSortable>
<ThNotSortable>{null}</ThNotSortable>
</tr>
</thead>

View File

@@ -1,3 +1,4 @@
import { Badge } from '@mantine/core';
import React from 'react';
import { SWRResponse } from 'swr';
@@ -27,7 +28,14 @@ export default function StagesTable({
.map((stage) => (
<tr key={stage.type_name}>
<td>{stage.type_name}</td>
<td>{stage.status}</td>
<td>
{' '}
{stage.is_active ? (
<Badge color="green">Active</Badge>
) : (
<Badge color="dark">Inactive</Badge>
)}
</td>
<td>
<DeleteButton
onClick={async () => {

View File

@@ -66,7 +66,7 @@ function StyledTabs(props: TabsProps & { setSelectedStageId: any }) {
);
}
export default function StagesTab({ swrStagesResponse, activeStageId, setActiveStageId }: any) {
export default function StagesTab({ swrStagesResponse, selectedStageId, setSelectedStageId }: any) {
if (!responseIsValid(swrStagesResponse)) {
return <></>;
}
@@ -80,8 +80,10 @@ export default function StagesTab({ swrStagesResponse, activeStageId, setActiveS
</Tabs.Tab>
));
return (
<StyledTabs value={activeStageId} setSelectedStageId={setActiveStageId}>
<Tabs.List>{items}</Tabs.List>
</StyledTabs>
<>
<StyledTabs value={selectedStageId} setSelectedStageId={setSelectedStageId}>
<Tabs.List>{items}</Tabs.List>
</StyledTabs>
</>
);
}

View File

@@ -6,6 +6,7 @@ import { SWRResponse } from 'swr';
import NotFoundTitle from '../404';
import Brackets from '../../components/brackets/brackets';
import { NextStageButton } from '../../components/buttons/next_stage_button';
import SaveButton from '../../components/buttons/save';
import Scheduler from '../../components/scheduling/scheduling';
import StagesTab from '../../components/utils/stages_tab';
@@ -35,7 +36,7 @@ export default function TournamentPage() {
const [eloThreshold, setEloThreshold] = useState(100);
const [iterations, setIterations] = useState(200);
const [limit, setLimit] = useState(50);
const [activeStageId, setActiveStageId] = useState(null);
const [selectedStageId, setSelectedStageId] = useState(null);
const schedulerSettings: SchedulerSettings = {
eloThreshold,
@@ -65,11 +66,11 @@ export default function TournamentPage() {
[[draftRound]] = draftRounds;
}
const activeTab = swrStagesResponse.data.data.filter(
const selectedTab = swrStagesResponse.data.data.filter(
(stage: RoundInterface) => stage.is_active
);
if (activeTab.length > 0 && activeStageId == null && activeTab[0].id != null) {
setActiveStageId(activeTab[0].id.toString());
if (selectedTab.length > 0 && selectedStageId == null && selectedTab[0].id != null) {
setSelectedStageId(selectedTab[0].id.toString());
}
}
@@ -117,10 +118,14 @@ export default function TournamentPage() {
>
View dashboard
</Button>
{activeStageId == null ? null : (
<NextStageButton
tournamentData={tournamentData}
swrStagesResponse={swrStagesResponse}
/>
{selectedStageId == null ? null : (
<SaveButton
onClick={async () => {
await createRound(tournamentData.id, activeStageId);
await createRound(tournamentData.id, selectedStageId);
await swrStagesResponse.mutate();
}}
leftIcon={<GoPlus size={24} />}
@@ -134,8 +139,8 @@ export default function TournamentPage() {
<Center>
<StagesTab
swrStagesResponse={swrStagesResponse}
activeStageId={activeStageId}
setActiveStageId={setActiveStageId}
selectedStageId={selectedStageId}
setSelectedStageId={setSelectedStageId}
/>
</Center>
<Brackets
@@ -144,7 +149,7 @@ export default function TournamentPage() {
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
readOnly={false}
activeStageId={activeStageId}
selectedStageId={selectedStageId}
/>
{scheduler}
</div>

View File

@@ -47,7 +47,7 @@ export default function Dashboard() {
const swrCourtsResponse: SWRResponse = getCourts(tournamentData.id);
const swrTournamentsResponse = getTournament(tournamentData.id);
const [activeStageId, setActiveStageId] = useState(null);
const [selectedStageId, setSelectedStageId] = useState(null);
const tournamentDataFull: Tournament =
swrTournamentsResponse.data != null ? swrTournamentsResponse.data.data : null;
@@ -61,8 +61,8 @@ export default function Dashboard() {
(stage: StageWithRounds) => stage.is_active
);
if (activeTab.length > 0 && activeStageId == null && activeTab[0].id != null) {
setActiveStageId(activeTab[0].id.toString());
if (activeTab.length > 0 && selectedStageId == null && activeTab[0].id != null) {
setSelectedStageId(activeTab[0].id.toString());
}
}
@@ -79,9 +79,9 @@ export default function Dashboard() {
<Grid.Col span={10}>
<Center>
<StagesTab
activeStageId={activeStageId}
selectedStageId={selectedStageId}
swrStagesResponse={swrStagesResponse}
setActiveStageId={setActiveStageId}
setSelectedStageId={setSelectedStageId}
/>
</Center>
<Brackets
@@ -90,7 +90,7 @@ export default function Dashboard() {
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={null}
readOnly
activeStageId={activeStageId}
selectedStageId={selectedStageId}
/>
</Grid.Col>
</Grid>

View File

@@ -1,7 +1,12 @@
import { Button, Container, Divider, Select } from '@mantine/core';
import { Button, Container, Divider, Group, Select } from '@mantine/core';
import { useForm } from '@mantine/form';
import React from 'react';
import { SWRResponse } from 'swr';
import {
NextStageButton,
PreviousStageButton,
} from '../../../components/buttons/next_stage_button';
import StagesTable from '../../../components/tables/stages';
import { getTournamentIdFromRouter } from '../../../components/utils/util';
import { Tournament } from '../../../interfaces/tournament';
@@ -56,6 +61,13 @@ export default function StagesPage() {
<Container>
<StagesTable tournament={tournamentDataFull} swrStagesResponse={swrStagesResponse} />
{CreateStageForm(tournamentDataFull, swrStagesResponse)}
<Group grow mt="1rem">
<PreviousStageButton
tournamentData={tournamentData}
swrStagesResponse={swrStagesResponse}
/>
<NextStageButton tournamentData={tournamentData} swrStagesResponse={swrStagesResponse} />
</Group>
</Container>
</TournamentLayout>
);

View File

@@ -6,6 +6,12 @@ export async function createStage(tournament_id: number, type: string) {
.catch((response: any) => handleRequestError(response));
}
export async function activateNextStage(tournament_id: number, direction: string) {
return createAxios()
.post(`tournaments/${tournament_id}/stages/activate`, { direction })
.catch((response: any) => handleRequestError(response));
}
export async function deleteStage(tournament_id: number, stage_id: number) {
return createAxios()
.delete(`tournaments/${tournament_id}/stages/${stage_id}`)