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