diff --git a/backend/bracket/models/db/stage.py b/backend/bracket/models/db/stage.py index c0e7a95e..8f44bf80 100644 --- a/backend/bracket/models/db/stage.py +++ b/backend/bracket/models/db/stage.py @@ -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 diff --git a/backend/bracket/routes/courts.py b/backend/bracket/routes/courts.py index 662ed773..7bd4bce0 100644 --- a/backend/bracket/routes/courts.py +++ b/backend/bracket/routes/courts.py @@ -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 diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index f02b34ee..7a0c4c93 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -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) diff --git a/backend/bracket/routes/stages.py b/backend/bracket/routes/stages.py index 826c44b0..f9035520 100644 --- a/backend/bracket/routes/stages.py +++ b/backend/bracket/routes/stages.py @@ -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() diff --git a/backend/bracket/sql/courts.py b/backend/bracket/sql/courts.py index 69d290da..2a80304e 100644 --- a/backend/bracket/sql/courts.py +++ b/backend/bracket/sql/courts.py @@ -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 diff --git a/backend/bracket/sql/stages.py b/backend/bracket/sql/stages.py index 1d2aff36..cd8d8350 100644 --- a/backend/bracket/sql/stages.py +++ b/backend/bracket/sql/stages.py @@ -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}, + ) diff --git a/backend/bracket/sql/tournaments.py b/backend/bracket/sql/tournaments.py new file mode 100644 index 00000000..838fc0b4 --- /dev/null +++ b/backend/bracket/sql/tournaments.py @@ -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) + ) diff --git a/backend/bracket/utils/db_init.py b/backend/bracket/utils/db_init.py index a58e2b72..70b7fc3c 100644 --- a/backend/bracket/utils/db_init.py +++ b/backend/bracket/utils/db_init.py @@ -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( diff --git a/backend/bracket/utils/dummy_records.py b/backend/bracket/utils/dummy_records.py index 6417c3a6..7e61ce9a 100644 --- a/backend/bracket/utils/dummy_records.py +++ b/backend/bracket/utils/dummy_records.py @@ -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( diff --git a/backend/tests/integration_tests/api/stages_test.py b/backend/tests/integration_tests/api/stages_test.py index 8179206a..46669948 100644 --- a/backend/tests/integration_tests/api/stages_test.py +++ b/backend/tests/integration_tests/api/stages_test.py @@ -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) diff --git a/frontend/src/components/brackets/brackets.tsx b/frontend/src/components/brackets/brackets.tsx index e04cd7fe..a605c436 100644 --- a/frontend/src/components/brackets/brackets.tsx +++ b/frontend/src/components/brackets/brackets.tsx @@ -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) => ( @@ -31,7 +31,34 @@ function getRoundsGridCols( /> )); - return rounds; +} + +function NoRoundsAlert({ readOnly }: { readOnly: boolean }) { + if (readOnly) { + return ( + } title="No rounds found" color="blue" radius="lg"> + Please wait for the organiser to add them. + + ); + } + return ( + } title="No rounds found" color="blue" radius="lg"> + Add a round using the top right button. + + ); +} + +function LoadingSkeleton() { + return ( + + + + + + + + + ); } 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 ( - } - title="No rounds found" - color="blue" - radius="lg" - > - Please wait for the organiser to add them. - - ); - } - return ( - } title="No rounds found" color="blue" radius="lg"> - Add a round using the top right button. - - ); + return ; } + if (swrStagesResponse.isLoading) { - return ( - - - - - - - - - ); + return ; } + 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, diff --git a/frontend/src/components/buttons/next_stage_button.tsx b/frontend/src/components/buttons/next_stage_button.tsx new file mode 100644 index 00000000..914fd545 --- /dev/null +++ b/frontend/src/components/buttons/next_stage_button.tsx @@ -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 ( + + ); +} + +export function PreviousStageButton({ tournamentData, swrStagesResponse }: any) { + return ( + + ); +} diff --git a/frontend/src/components/tables/courts.tsx b/frontend/src/components/tables/courts.tsx index 421ca25d..fff40992 100644 --- a/frontend/src/components/tables/courts.tsx +++ b/frontend/src/components/tables/courts.tsx @@ -45,7 +45,6 @@ export default function CourtsTable({ Title - Status {null} diff --git a/frontend/src/components/tables/stages.tsx b/frontend/src/components/tables/stages.tsx index c5161336..3734a784 100644 --- a/frontend/src/components/tables/stages.tsx +++ b/frontend/src/components/tables/stages.tsx @@ -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) => ( {stage.type_name} - {stage.status} + + {' '} + {stage.is_active ? ( + Active + ) : ( + Inactive + )} + { diff --git a/frontend/src/components/utils/stages_tab.tsx b/frontend/src/components/utils/stages_tab.tsx index c3dfa4da..1e0b1848 100644 --- a/frontend/src/components/utils/stages_tab.tsx +++ b/frontend/src/components/utils/stages_tab.tsx @@ -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 )); return ( - - {items} - + <> + + {items} + + ); } diff --git a/frontend/src/pages/tournaments/[id].tsx b/frontend/src/pages/tournaments/[id].tsx index 588ca997..eb1a885e 100644 --- a/frontend/src/pages/tournaments/[id].tsx +++ b/frontend/src/pages/tournaments/[id].tsx @@ -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 - {activeStageId == null ? null : ( + + {selectedStageId == null ? null : ( { - await createRound(tournamentData.id, activeStageId); + await createRound(tournamentData.id, selectedStageId); await swrStagesResponse.mutate(); }} leftIcon={} @@ -134,8 +139,8 @@ export default function TournamentPage() {
{scheduler} diff --git a/frontend/src/pages/tournaments/[id]/dashboard.tsx b/frontend/src/pages/tournaments/[id]/dashboard.tsx index 3e0a778a..ba246358 100644 --- a/frontend/src/pages/tournaments/[id]/dashboard.tsx +++ b/frontend/src/pages/tournaments/[id]/dashboard.tsx @@ -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() {
diff --git a/frontend/src/pages/tournaments/[id]/stages.tsx b/frontend/src/pages/tournaments/[id]/stages.tsx index 4f274152..af488021 100644 --- a/frontend/src/pages/tournaments/[id]/stages.tsx +++ b/frontend/src/pages/tournaments/[id]/stages.tsx @@ -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() { {CreateStageForm(tournamentDataFull, swrStagesResponse)} + + + + ); diff --git a/frontend/src/services/stage.tsx b/frontend/src/services/stage.tsx index 15992182..d58e4aa2 100644 --- a/frontend/src/services/stage.tsx +++ b/frontend/src/services/stage.tsx @@ -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}`)