Skip to content

Commit

Permalink
Add behavior to go to next stage (#265)
Browse files Browse the repository at this point in the history
  • Loading branch information
evroon committed Sep 14, 2023
1 parent bb5a659 commit d1484a0
Show file tree
Hide file tree
Showing 19 changed files with 317 additions and 70 deletions.
5 changes: 5 additions & 0 deletions backend/bracket/models/db/stage.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from enum import auto
from typing import Literal

from heliclockter import datetime_utc

Expand Down Expand Up @@ -26,6 +27,10 @@ class StageUpdateBody(BaseModelORM):
is_active: bool


class StageActivateBody(BaseModelORM):
direction: Literal['next', 'previous'] = 'next'


class StageCreateBody(BaseModelORM):
type: StageType

Expand Down
18 changes: 17 additions & 1 deletion backend/bracket/routes/courts.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,6 +9,7 @@
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

Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion backend/bracket/routes/matches.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
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()
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 20 additions & 1 deletion backend/bracket/routes/stages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
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,
Expand All @@ -13,7 +13,9 @@
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,
)
Expand Down Expand Up @@ -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()
19 changes: 19 additions & 0 deletions backend/bracket/sql/courts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions backend/bracket/sql/stages.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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},
)
10 changes: 10 additions & 0 deletions backend/bracket/sql/tournaments.py
Original file line number Diff line number Diff line change
@@ -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)
)
7 changes: 6 additions & 1 deletion backend/bracket/utils/db_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,12 @@ async def insert_dummy(obj_to_insert: BaseModelT) -> int:
)
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(
Expand Down
11 changes: 5 additions & 6 deletions backend/bracket/utils/dummy_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@
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,
)

Expand All @@ -59,15 +59,14 @@
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',
)

Expand Down Expand Up @@ -98,7 +97,7 @@
team2_id=4,
team1_score=23,
team2_score=26,
court_id=None,
court_id=DB_PLACEHOLDER_ID,
)

DUMMY_MATCH4 = Match(
Expand Down
36 changes: 33 additions & 3 deletions backend/tests/integration_tests/api/stages_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
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 (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Loading

1 comment on commit d1484a0

@vercel
Copy link

@vercel vercel bot commented on d1484a0 Sep 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.