Add courts functionality (#256)

This commit is contained in:
Erik Vroon
2023-09-12 13:33:20 +02:00
committed by GitHub
parent 3d2942d6f1
commit aaca527647
39 changed files with 630 additions and 67 deletions

View File

@@ -0,0 +1,52 @@
"""create courts table
Revision ID: 3469289a7e06
Revises: 6458e0bc3e9d
Create Date: 2023-09-11 13:36:28.464161
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str | None = '3469289a7e06'
down_revision: str | None = '6458e0bc3e9d'
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
'courts',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
sa.Column('tournament_id', sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(
['tournament_id'],
['tournaments.id'],
),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_courts_id'), 'courts', ['id'], unique=False)
op.add_column('matches', sa.Column('court_id', sa.BigInteger(), nullable=True))
op.create_foreign_key('matches_courts_fkey', 'matches', 'courts', ['court_id'], ['id'])
op.create_index(op.f('ix_courts_tournament_id'), 'courts', ['tournament_id'], unique=False)
op.add_column(
'tournaments',
sa.Column('auto_assign_courts', sa.Boolean(), server_default='f', nullable=False),
)
op.drop_column('matches', 'label')
def downgrade() -> None:
op.add_column('matches', sa.Column('label', sa.Text(), nullable=True, server_default=''))
op.alter_column('matches', 'label', server_default=None)
op.drop_column('tournaments', 'auto_assign_courts')
op.drop_index(op.f('ix_courts_tournament_id'), table_name='courts')
op.drop_constraint('matches_courts_fkey', 'matches', type_='foreignkey')
op.drop_column('matches', 'court_id')
op.drop_index(op.f('ix_courts_id'), table_name='courts')
op.drop_table('courts')

View File

@@ -7,7 +7,18 @@ from starlette.staticfiles import StaticFiles
from bracket.config import Environment, config, environment, init_sentry
from bracket.database import database, init_db_when_empty
from bracket.routes import auth, clubs, matches, players, rounds, stages, teams, tournaments, users
from bracket.routes import (
auth,
clubs,
courts,
matches,
players,
rounds,
stages,
teams,
tournaments,
users,
)
init_sentry()
@@ -68,4 +79,5 @@ app.include_router(rounds.router, tags=['rounds'])
app.include_router(matches.router, tags=['matches'])
app.include_router(stages.router, tags=['stages'])
app.include_router(teams.router, tags=['teams'])
app.include_router(courts.router, tags=['courts'])
app.include_router(users.router, tags=['users'])

View File

@@ -66,7 +66,7 @@ class DemoConfig(Config):
env_file = 'demo.env'
environment = Environment(os.getenv('ENVIRONMENT', 'CI'))
environment = Environment(os.getenv('ENVIRONMENT', 'CI').upper())
config: Config
match environment:

View File

@@ -0,0 +1,23 @@
from heliclockter import datetime_utc
from bracket.models.db.shared import BaseModelORM
class Court(BaseModelORM):
id: int | None = None
name: str
created: datetime_utc
tournament_id: int
class CourtInDB(Court):
id: int
class CourtBody(BaseModelORM):
name: str
class CourtToInsert(CourtBody):
created: datetime_utc
tournament_id: int

View File

@@ -3,6 +3,7 @@ from decimal import Decimal
from heliclockter import datetime_utc
from pydantic import BaseModel
from bracket.models.db.court import Court
from bracket.models.db.shared import BaseModelORM
from bracket.models.db.team import FullTeamWithPlayers, TeamWithPlayers
from bracket.utils.types import assert_some
@@ -16,7 +17,7 @@ class Match(BaseModelORM):
team2_id: int
team1_score: int
team2_score: int
label: str
court_id: int | None
class UpcomingMatch(BaseModel):
@@ -24,9 +25,10 @@ class UpcomingMatch(BaseModel):
team2_id: int
class MatchWithTeamDetails(Match):
class MatchWithDetails(Match):
team1: FullTeamWithPlayers
team2: FullTeamWithPlayers
court: Court | None
@property
def teams(self) -> list[FullTeamWithPlayers]:
@@ -45,14 +47,14 @@ class MatchBody(BaseModelORM):
round_id: int
team1_score: int = 0
team2_score: int = 0
label: str
court_id: int | None
class MatchCreateBody(BaseModelORM):
round_id: int
team1_id: int
team2_id: int
label: str
court_id: int | None
class MatchToInsert(MatchCreateBody):

View File

@@ -4,7 +4,7 @@ from typing import Any
from heliclockter import datetime_utc
from pydantic import root_validator, validator
from bracket.models.db.match import Match, MatchWithTeamDetails
from bracket.models.db.match import Match, MatchWithDetails
from bracket.models.db.shared import BaseModelORM
from bracket.models.db.stage import Stage, StageType
from bracket.utils.types import assert_some
@@ -20,7 +20,7 @@ class Round(BaseModelORM):
class RoundWithMatches(Round):
matches: list[MatchWithTeamDetails]
matches: list[MatchWithDetails]
@validator('matches', pre=True)
def handle_matches(values: list[Match]) -> list[Match]: # type: ignore[misc]

View File

@@ -11,12 +11,14 @@ class Tournament(BaseModelORM):
dashboard_public: bool
logo_path: str | None
players_can_be_in_multiple_teams: bool
auto_assign_courts: bool
class TournamentUpdateBody(BaseModelORM):
name: str
dashboard_public: bool
players_can_be_in_multiple_teams: bool
auto_assign_courts: bool
class TournamentBody(TournamentUpdateBody):

View File

@@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends
from heliclockter import datetime_utc
from bracket.database import database
from bracket.models.db.court import Court, CourtBody, CourtToInsert
from bracket.models.db.user import UserPublic
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.utils.db import fetch_one_parsed
from bracket.utils.types import assert_some
router = APIRouter()
@router.get("/tournaments/{tournament_id}/courts", response_model=CourtsResponse)
async def get_courts(
tournament_id: int,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> CourtsResponse:
return CourtsResponse(data=await get_all_courts_in_tournament(tournament_id))
@router.patch("/tournaments/{tournament_id}/courts/{court_id}", response_model=SingleCourtResponse)
async def update_court_by_id(
tournament_id: int,
court_id: int,
court_body: CourtBody,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SingleCourtResponse:
await update_court(
tournament_id=tournament_id,
court_id=court_id,
court_body=court_body,
)
return SingleCourtResponse(
data=assert_some(
await fetch_one_parsed(
database,
Court,
courts.select().where(
(courts.c.id == court_id) & (courts.c.tournament_id == tournament_id)
),
)
)
)
@router.delete("/tournaments/{tournament_id}/courts/{court_id}", response_model=SuccessResponse)
async def delete_court(
tournament_id: int, court_id: int, _: UserPublic = Depends(user_authenticated_for_tournament)
) -> SuccessResponse:
await database.execute(
query=courts.delete().where(
courts.c.id == court_id and courts.c.tournament_id == tournament_id
),
)
return SuccessResponse()
@router.post("/tournaments/{tournament_id}/courts", response_model=SingleCourtResponse)
async def create_court(
court_body: CourtBody,
tournament_id: int,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SingleCourtResponse:
last_record_id = await database.execute(
query=courts.insert(),
values=CourtToInsert(
**court_body.dict(),
created=datetime_utc.now(),
tournament_id=tournament_id,
).dict(),
)
return SingleCourtResponse(
data=assert_some(
await fetch_one_parsed(
database,
Court,
courts.select().where(
courts.c.id == last_record_id and courts.c.tournament_id == tournament_id
),
)
)
)

View File

@@ -1,6 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException
from bracket.database import database
from bracket.logic.elo import recalculate_elo_for_tournament_id
from bracket.logic.scheduling.ladder_players_iter import get_possible_upcoming_matches_for_players
from bracket.logic.scheduling.round_robin import get_possible_upcoming_matches_round_robin
@@ -11,8 +10,7 @@ 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.schema import matches
from bracket.sql.matches import sql_create_match, sql_delete_match
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.utils.types import assert_some
@@ -87,9 +85,7 @@ async def update_match_by_id(
_: UserPublic = Depends(user_authenticated_for_tournament),
match: Match = Depends(match_dependency),
) -> SuccessResponse:
await database.execute(
query=matches.update().where(matches.c.id == match.id),
values=match_body.dict(),
)
assert match.id
await sql_update_match(match.id, match_body)
await recalculate_elo_for_tournament_id(tournament_id)
return SuccessResponse()

View File

@@ -4,6 +4,7 @@ from pydantic import BaseModel
from pydantic.generics import GenericModel
from bracket.models.db.club import Club
from bracket.models.db.court import Court
from bracket.models.db.match import Match, SuggestedMatch
from bracket.models.db.player import Player
from bracket.models.db.round import Round, StageWithRounds
@@ -81,3 +82,11 @@ class UserPublicResponse(DataResponse[UserPublic]):
class TokenResponse(DataResponse[Token]):
pass
class CourtsResponse(DataResponse[list[Court]]):
pass
class SingleCourtResponse(DataResponse[Court]):
pass

View File

@@ -24,6 +24,7 @@ tournaments = Table(
Column('dashboard_public', Boolean, nullable=False),
Column('logo_path', String, nullable=True),
Column('players_can_be_in_multiple_teams', Boolean, nullable=False, server_default='f'),
Column('auto_assign_courts', Boolean, nullable=False, server_default='f'),
)
stages = Table(
@@ -66,9 +67,9 @@ matches = Table(
Column('round_id', BigInteger, ForeignKey('rounds.id'), nullable=False),
Column('team1_id', BigInteger, ForeignKey('teams.id'), nullable=False),
Column('team2_id', BigInteger, ForeignKey('teams.id'), nullable=False),
Column('court_id', BigInteger, ForeignKey('courts.id'), nullable=True),
Column('team1_score', Integer, nullable=False),
Column('team2_score', Integer, nullable=False),
Column('label', String, nullable=False),
)
teams = Table(
@@ -96,7 +97,6 @@ players = Table(
Column('active', Boolean, nullable=False, index=True, server_default='t'),
)
users = Table(
'users',
metadata,
@@ -122,3 +122,12 @@ players_x_teams = Table(
Column('player_id', BigInteger, ForeignKey('players.id'), nullable=False),
Column('team_id', BigInteger, ForeignKey('teams.id'), nullable=False),
)
courts = Table(
'courts',
metadata,
Column('id', BigInteger, primary_key=True, index=True),
Column('name', Text, nullable=False),
Column('created', DateTimeTZ, nullable=False),
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), nullable=False, index=True),
)

View File

@@ -0,0 +1,26 @@
from bracket.database import database
from bracket.models.db.court import Court, CourtBody
async def get_all_courts_in_tournament(tournament_id: int) -> list[Court]:
query = '''
SELECT *
FROM courts
WHERE courts.tournament_id = :tournament_id
'''
result = await database.fetch_all(query=query, values={'tournament_id': tournament_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
SET name = :name
WHERE courts.tournament_id = :tournament_id
AND courts.id = :court_id
'''
result = await database.fetch_all(
query=query,
values={'tournament_id': tournament_id, 'court_id': court_id, 'name': court_body.name},
)
return [Court.parse_obj(x._mapping) for x in result]

View File

@@ -1,5 +1,5 @@
from bracket.database import database
from bracket.models.db.match import Match, MatchCreateBody
from bracket.models.db.match import Match, MatchBody, MatchCreateBody
async def sql_delete_match(match_id: int) -> None:
@@ -11,15 +11,27 @@ async def sql_delete_match(match_id: int) -> None:
async def sql_create_match(match: MatchCreateBody) -> Match:
async with database.transaction():
query = '''
INSERT INTO matches (round_id, team1_id, team2_id, team1_score, team2_score, label, created)
VALUES (:round_id, :team1_id, :team2_id, 0, 0, :label, NOW())
RETURNING *
'''
result = await database.fetch_one(query=query, values=match.dict())
query = '''
INSERT INTO matches (round_id, team1_id, team2_id, team1_score, team2_score, court_id, created)
VALUES (:round_id, :team1_id, :team2_id, 0, 0, :court_id, NOW())
RETURNING *
'''
result = await database.fetch_one(query=query, values=match.dict())
if result is None:
raise ValueError('Could not create stage')
return Match.parse_obj(result._mapping)
async def sql_update_match(match_id: int, match: MatchBody) -> None:
query = '''
UPDATE matches
SET round_id = :round_id,
team1_score = :team1_score,
team2_score = :team2_score,
court_id = :court_id
WHERE matches.id = :match_id
RETURNING *
'''
await database.execute(query=query, values={'match_id': match_id, **match.dict()})

View File

@@ -28,12 +28,14 @@ async def get_stages_with_rounds_and_matches(
SELECT DISTINCT ON (matches.id)
matches.*,
to_json(t1) as team1,
to_json(t2) as team2
to_json(t2) as team2,
to_json(c) as court
FROM matches
LEFT JOIN teams_with_players t1 on t1.id = matches.team1_id
LEFT JOIN teams_with_players t2 on t2.id = matches.team2_id
LEFT JOIN rounds r on matches.round_id = r.id
LEFT JOIN stages s2 on r.stage_id = s2.id
LEFT JOIN courts c on matches.court_id = c.id
WHERE s2.tournament_id = :tournament_id
), rounds_with_matches AS (
SELECT DISTINCT ON (rounds.id)

View File

@@ -3,6 +3,7 @@ from zoneinfo import ZoneInfo
from heliclockter import datetime_utc
from bracket.models.db.club import Club
from bracket.models.db.court import Court
from bracket.models.db.match import Match
from bracket.models.db.player import Player
from bracket.models.db.round import Round
@@ -30,6 +31,7 @@ DUMMY_TOURNAMENT = Tournament(
dashboard_public=True,
logo_path=None,
players_can_be_in_multiple_teams=True,
auto_assign_courts=True,
)
DUMMY_STAGE1 = Stage(
@@ -75,7 +77,7 @@ DUMMY_MATCH1 = Match(
team2_id=2,
team1_score=11,
team2_score=22,
label='Court 1 | 11:00 - 11:20',
court_id=DB_PLACEHOLDER_ID,
)
DUMMY_MATCH2 = Match(
@@ -85,7 +87,7 @@ DUMMY_MATCH2 = Match(
team2_id=4,
team1_score=9,
team2_score=6,
label='Court 2 | 11:00 - 11:20',
court_id=DB_PLACEHOLDER_ID,
)
DUMMY_MATCH3 = Match(
@@ -95,7 +97,7 @@ DUMMY_MATCH3 = Match(
team2_id=4,
team1_score=23,
team2_score=26,
label='Court 1 | 11:30 - 11:50',
court_id=None,
)
DUMMY_MATCH4 = Match(
@@ -105,7 +107,7 @@ DUMMY_MATCH4 = Match(
team2_id=3,
team1_score=43,
team2_score=45,
label='Court 2 | 11:30 - 11:50',
court_id=None,
)
DUMMY_USER = User(
@@ -211,3 +213,15 @@ DUMMY_USER_X_CLUB = UserXClub(
user_id=DB_PLACEHOLDER_ID,
club_id=DB_PLACEHOLDER_ID,
)
DUMMY_COURT1 = Court(
name='Endor',
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,
)
DUMMY_COURT2 = Court(
name='Naboo',
created=DUMMY_MOCK_TIME,
tournament_id=DB_PLACEHOLDER_ID,
)

View File

@@ -11,6 +11,7 @@ from bracket.database import database, engine, init_db_when_empty
from bracket.logger import get_logger
from bracket.logic.elo import recalculate_elo_for_tournament_id
from bracket.models.db.club import Club
from bracket.models.db.court import Court
from bracket.models.db.match import Match
from bracket.models.db.player import Player
from bracket.models.db.round import Round
@@ -21,6 +22,7 @@ from bracket.models.db.user import User
from bracket.models.db.user_x_club import UserXClub
from bracket.schema import (
clubs,
courts,
matches,
metadata,
players,
@@ -34,6 +36,8 @@ from bracket.schema import (
from bracket.utils.db import insert_generic
from bracket.utils.dummy_records import (
DUMMY_CLUB,
DUMMY_COURT1,
DUMMY_COURT2,
DUMMY_MATCH1,
DUMMY_MATCH2,
DUMMY_MATCH3,
@@ -112,6 +116,7 @@ async def create_dev_db() -> None:
Round: rounds,
Match: matches,
Tournament: tournaments,
Court: courts,
}
async def insert_dummy(obj_to_insert: BaseModelT) -> int:
@@ -149,14 +154,27 @@ async def create_dev_db() -> None:
round_id_2 = await insert_dummy(DUMMY_ROUND2.copy(update={'stage_id': stage_id_1}))
round_id_3 = await insert_dummy(DUMMY_ROUND3.copy(update={'stage_id': stage_id_2}))
court_id_1 = await insert_dummy(DUMMY_COURT1.copy(update={'tournament_id': tournament_id_1}))
court_id_2 = await insert_dummy(DUMMY_COURT2.copy(update={'tournament_id': tournament_id_1}))
await insert_dummy(
DUMMY_MATCH1.copy(
update={'round_id': round_id_1, 'team1_id': team_id_1, 'team2_id': team_id_2}
update={
'round_id': round_id_1,
'team1_id': team_id_1,
'team2_id': team_id_2,
'court_id': court_id_1,
}
),
)
await insert_dummy(
DUMMY_MATCH2.copy(
update={'round_id': round_id_1, 'team1_id': team_id_3, 'team2_id': team_id_4}
update={
'round_id': round_id_1,
'team1_id': team_id_3,
'team2_id': team_id_4,
'court_id': court_id_2,
}
),
)
await insert_dummy(

View File

@@ -0,0 +1,79 @@
from bracket.database import database
from bracket.models.db.court import Court
from bracket.schema import courts
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.dummy_records import DUMMY_COURT1, DUMMY_MOCK_TIME, DUMMY_TEAM1
from bracket.utils.http import HTTPMethod
from tests.integration_tests.api.shared import SUCCESS_RESPONSE, send_tournament_request
from tests.integration_tests.models import AuthContext
from tests.integration_tests.sql import assert_row_count_and_clear, inserted_court, inserted_team
async def test_courts_endpoint(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with inserted_team(
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
):
async with inserted_court(
DUMMY_COURT1.copy(update={'tournament_id': auth_context.tournament.id})
) as court_inserted:
assert await send_tournament_request(HTTPMethod.GET, 'courts', auth_context, {}) == {
'data': [
{
'created': DUMMY_MOCK_TIME.isoformat(),
'id': court_inserted.id,
'name': 'Endor',
'tournament_id': auth_context.tournament.id,
}
],
}
async def test_create_court(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
body = {'name': 'Some new name', 'active': True}
response = await send_tournament_request(HTTPMethod.POST, 'courts', auth_context, json=body)
assert response['data']['name'] == body['name']
await assert_row_count_and_clear(courts, 1)
async def test_delete_court(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with inserted_team(
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
):
async with inserted_court(
DUMMY_COURT1.copy(update={'tournament_id': auth_context.tournament.id})
) as court_inserted:
assert (
await send_tournament_request(
HTTPMethod.DELETE, f'courts/{court_inserted.id}', auth_context
)
== SUCCESS_RESPONSE
)
await assert_row_count_and_clear(courts, 0)
async def test_update_court(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
body = {'name': 'Some new name'}
async with inserted_team(
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
):
async with inserted_court(
DUMMY_COURT1.copy(update={'tournament_id': auth_context.tournament.id})
) as court_inserted:
response = await send_tournament_request(
HTTPMethod.PATCH, f'courts/{court_inserted.id}', auth_context, json=body
)
patched_court = await fetch_one_parsed_certain(
database, Court, query=courts.select().where(courts.c.id == court_inserted.id)
)
assert patched_court.name == body['name']
assert response['data']['name'] == body['name']
await assert_row_count_and_clear(courts, 1)

View File

@@ -4,6 +4,7 @@ from bracket.models.db.stage import StageType
from bracket.schema import matches
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.dummy_records import (
DUMMY_COURT1,
DUMMY_MATCH1,
DUMMY_PLAYER1,
DUMMY_PLAYER2,
@@ -20,6 +21,7 @@ from tests.integration_tests.api.shared import SUCCESS_RESPONSE, send_tournament
from tests.integration_tests.models import AuthContext
from tests.integration_tests.sql import (
assert_row_count_and_clear,
inserted_court,
inserted_match,
inserted_player_in_team,
inserted_round,
@@ -39,6 +41,9 @@ async def test_create_match(
inserted_team(
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
) as team1_inserted,
inserted_court(
DUMMY_COURT1.copy(update={'tournament_id': auth_context.tournament.id})
) as court1_inserted,
inserted_team(
DUMMY_TEAM2.copy(update={'tournament_id': auth_context.tournament.id})
) as team2_inserted,
@@ -47,14 +52,13 @@ async def test_create_match(
'team1_id': team1_inserted.id,
'team2_id': team2_inserted.id,
'round_id': round_inserted.id,
'label': 'Some label',
'court_id': court1_inserted.id,
}
response = await send_tournament_request(
HTTPMethod.POST, 'matches', auth_context, json=body
)
assert response['data']['id']
# await sql_delete_match(response['data']['id'])
await assert_row_count_and_clear(matches, 1)
@@ -72,12 +76,16 @@ async def test_delete_match(
inserted_team(
DUMMY_TEAM2.copy(update={'tournament_id': auth_context.tournament.id})
) as team2_inserted,
inserted_court(
DUMMY_COURT1.copy(update={'tournament_id': auth_context.tournament.id})
) as court1_inserted,
inserted_match(
DUMMY_MATCH1.copy(
update={
'round_id': round_inserted.id,
'team1_id': team1_inserted.id,
'team2_id': team2_inserted.id,
'court_id': court1_inserted.id,
}
)
) as match_inserted,
@@ -105,12 +113,16 @@ async def test_update_match(
inserted_team(
DUMMY_TEAM2.copy(update={'tournament_id': auth_context.tournament.id})
) as team2_inserted,
inserted_court(
DUMMY_COURT1.copy(update={'tournament_id': auth_context.tournament.id})
) as court1_inserted,
inserted_match(
DUMMY_MATCH1.copy(
update={
'round_id': round_inserted.id,
'team1_id': team1_inserted.id,
'team2_id': team2_inserted.id,
'court_id': court1_inserted.id,
}
)
) as match_inserted,
@@ -119,7 +131,7 @@ async def test_update_match(
'team1_score': 42,
'team2_score': 24,
'round_id': round_inserted.id,
'label': 'Some label',
'court_id': None,
}
assert (
await send_tournament_request(
@@ -138,7 +150,7 @@ async def test_update_match(
)
assert patched_match.team1_score == body['team1_score']
assert patched_match.team2_score == body['team2_score']
assert patched_match.label == body['label']
assert patched_match.court_id == body['court_id']
await assert_row_count_and_clear(matches, 1)

View File

@@ -25,6 +25,7 @@ async def test_tournaments_endpoint(
'name': 'Some Cool Tournament',
'dashboard_public': True,
'players_can_be_in_multiple_teams': True,
'auto_assign_courts': True,
}
],
}
@@ -43,6 +44,7 @@ async def test_tournament_endpoint(
'name': 'Some Cool Tournament',
'dashboard_public': True,
'players_can_be_in_multiple_teams': True,
'auto_assign_courts': True,
},
}
@@ -55,6 +57,7 @@ async def test_create_tournament(
'club_id': auth_context.club.id,
'dashboard_public': False,
'players_can_be_in_multiple_teams': True,
'auto_assign_courts': True,
}
assert (
await send_auth_request(HTTPMethod.POST, 'tournaments', auth_context, json=body)
@@ -70,6 +73,7 @@ async def test_update_tournament(
'name': 'Some new name',
'dashboard_public': False,
'players_can_be_in_multiple_teams': True,
'auto_assign_courts': True,
}
assert (
await send_tournament_request(HTTPMethod.PATCH, '', auth_context, json=body)

View File

@@ -5,6 +5,7 @@ from sqlalchemy import Table
from bracket.database import database
from bracket.models.db.club import Club
from bracket.models.db.court import Court
from bracket.models.db.match import Match
from bracket.models.db.player import Player
from bracket.models.db.player_x_team import PlayerXTeam
@@ -16,6 +17,7 @@ from bracket.models.db.user import User, UserInDB
from bracket.models.db.user_x_club import UserXClub
from bracket.schema import (
clubs,
courts,
matches,
players,
players_x_teams,
@@ -74,6 +76,12 @@ async def inserted_team(team: Team) -> AsyncIterator[Team]:
yield row_inserted
@asynccontextmanager
async def inserted_court(court: Court) -> AsyncIterator[Court]:
async with inserted_generic(court, courts, Court) as row_inserted:
yield row_inserted
@asynccontextmanager
async def inserted_player(player: Player) -> AsyncIterator[Player]:
async with inserted_generic(player, players, Player) as row_inserted:

View File

@@ -1,7 +1,7 @@
from decimal import Decimal
from bracket.logic.elo import PlayerStatistics, calculate_elo_per_player
from bracket.models.db.match import MatchWithTeamDetails
from bracket.models.db.match import MatchWithDetails
from bracket.models.db.round import RoundWithMatches
from bracket.models.db.team import FullTeamWithPlayers
from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_PLAYER1, DUMMY_PLAYER2
@@ -15,14 +15,15 @@ def test_elo_calculation() -> None:
is_active=False,
name='Some round',
matches=[
MatchWithTeamDetails(
MatchWithDetails(
created=DUMMY_MOCK_TIME,
team1_id=1,
team2_id=1,
team1_score=3,
team2_score=4,
round_id=1,
label='Some label',
court_id=None,
court=None,
team1=FullTeamWithPlayers(
name='Dummy team 1',
tournament_id=1,

View File

@@ -14,7 +14,7 @@
"jest:watch": "jest --watch",
"prettier:check": "prettier --check \"**/*.{ts,tsx}\"",
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
"test": "tsc --noEmit && npm run prettier:check && npm run lint && npm run jest -- --passWithNoTests"
"test": "tsc --noEmit && npm run prettier:write && npm run lint && npm run jest -- --passWithNoTests"
},
"dependencies": {
"@emotion/react": "^11.10.6",

View File

@@ -13,6 +13,7 @@ function getRoundsGridCols(
activeStageId: number,
tournamentData: TournamentMinimal,
swrStagesResponse: SWRResponse,
swrCourtsResponse: SWRResponse,
swrUpcomingMatchesResponse: SWRResponse | null,
readOnly: boolean
) {
@@ -24,6 +25,7 @@ function getRoundsGridCols(
tournamentData={tournamentData}
round={round}
swrRoundsResponse={swrStagesResponse}
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
readOnly={readOnly}
/>
@@ -35,12 +37,14 @@ function getRoundsGridCols(
export default function Brackets({
tournamentData,
swrStagesResponse,
swrCourtsResponse,
swrUpcomingMatchesResponse,
readOnly,
activeStageId,
}: {
tournamentData: TournamentMinimal;
swrStagesResponse: SWRResponse;
swrCourtsResponse: SWRResponse;
swrUpcomingMatchesResponse: SWRResponse | null;
readOnly: boolean;
activeStageId: number | null;
@@ -89,6 +93,7 @@ export default function Brackets({
activeStageId,
tournamentData,
swrStagesResponse,
swrCourtsResponse,
swrUpcomingMatchesResponse,
readOnly
)

View File

@@ -34,7 +34,7 @@ const useStyles = createStyles((theme) => ({
}));
function MatchBadge({ match, theme }: { match: MatchInterface; theme: any }) {
const visibility: Visibility = match.label === '' ? 'hidden' : 'visible';
const visibility: Visibility = match.court ? 'visible' : 'hidden';
const badgeColor = theme.colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2];
return (
<Center style={{ transform: 'translateY(0%)', visibility }}>
@@ -47,7 +47,7 @@ function MatchBadge({ match, theme }: { match: MatchInterface; theme: any }) {
}}
>
<Center>
<b>{match.label}</b>
<b>{match.court?.name}</b>
</Center>
</div>
</Center>
@@ -56,12 +56,14 @@ function MatchBadge({ match, theme }: { match: MatchInterface; theme: any }) {
export default function Match({
swrRoundsResponse,
swrCourtsResponse,
swrUpcomingMatchesResponse,
tournamentData,
match,
readOnly,
}: {
swrRoundsResponse: SWRResponse;
swrCourtsResponse: SWRResponse;
swrUpcomingMatchesResponse: SWRResponse | null;
tournamentData: TournamentMinimal;
match: MatchInterface;
@@ -113,6 +115,7 @@ export default function Match({
</UnstyledButton>
<MatchModal
swrRoundsResponse={swrRoundsResponse}
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
tournamentData={tournamentData}
match={match}

View File

@@ -11,22 +11,25 @@ export default function Round({
tournamentData,
round,
swrRoundsResponse,
swrCourtsResponse,
swrUpcomingMatchesResponse,
readOnly,
}: {
tournamentData: TournamentMinimal;
round: RoundInterface;
swrRoundsResponse: SWRResponse;
swrCourtsResponse: SWRResponse;
swrUpcomingMatchesResponse: SWRResponse | null;
readOnly: boolean;
}) {
const matches = round.matches
.sort((m1, m2) => (m1.label > m2.label ? 1 : 0))
.sort((m1, m2) => ((m1.court ? m1.court.name : 'y') > (m2.court ? m2.court.name : 'z') ? 1 : 0))
.map((match) => (
<Match
key={match.id}
tournamentData={tournamentData}
swrRoundsResponse={swrRoundsResponse}
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
match={match}
readOnly={readOnly}

View File

@@ -1,17 +1,39 @@
import { Button, Modal, NumberInput, TextInput } from '@mantine/core';
import { Button, Modal, NumberInput, Select } from '@mantine/core';
import { useForm } from '@mantine/form';
import React from 'react';
import { SWRResponse } from 'swr';
import { Court } from '../../interfaces/court';
import { MatchBodyInterface, MatchInterface } from '../../interfaces/match';
import { TournamentMinimal } from '../../interfaces/tournament';
import { deleteMatch, updateMatch } from '../../services/match';
import DeleteButton from '../buttons/delete';
import { responseIsValid } from '../utils/util';
function CourtsSelect({ form, swrCourtsResponse }: { form: any; swrCourtsResponse: SWRResponse }) {
const noCourtOption = { value: null, label: 'No Court' };
const data = responseIsValid(swrCourtsResponse)
? swrCourtsResponse.data.data.map((court: Court) => ({ value: court.id, label: court.name }))
: [];
return (
<Select
label="Court"
placeholder="Pick a court"
data={data.concat(noCourtOption)}
searchable
maxDropdownHeight={400}
style={{ marginTop: 20 }}
nothingFound="No courts found"
{...form.getInputProps('court_id')}
/>
);
}
export default function MatchModal({
tournamentData,
match,
swrRoundsResponse,
swrCourtsResponse,
swrUpcomingMatchesResponse,
opened,
setOpened,
@@ -19,6 +41,7 @@ export default function MatchModal({
tournamentData: TournamentMinimal;
match: MatchInterface;
swrRoundsResponse: SWRResponse;
swrCourtsResponse: SWRResponse;
swrUpcomingMatchesResponse: SWRResponse | null;
opened: boolean;
setOpened: any;
@@ -27,7 +50,7 @@ export default function MatchModal({
initialValues: {
team1_score: match != null ? match.team1_score : 0,
team2_score: match != null ? match.team2_score : 0,
label: match != null ? match.label : '',
court_id: match != null ? match.court_id : null,
},
validate: {
@@ -41,14 +64,14 @@ export default function MatchModal({
<Modal opened={opened} onClose={() => setOpened(false)} title="Edit Match">
<form
onSubmit={form.onSubmit(async (values) => {
const newMatch: MatchBodyInterface = {
const updatedMatch: MatchBodyInterface = {
id: match.id,
round_id: match.round_id,
team1_score: values.team1_score,
team2_score: values.team2_score,
label: values.label,
court_id: values.court_id,
};
await updateMatch(tournamentData.id, match.id, newMatch);
await updateMatch(tournamentData.id, match.id, updatedMatch);
await swrRoundsResponse.mutate(null);
if (swrUpcomingMatchesResponse != null) await swrUpcomingMatchesResponse.mutate(null);
setOpened(false);
@@ -68,13 +91,7 @@ export default function MatchModal({
{...form.getInputProps('team2_score')}
/>
<TextInput
withAsterisk
style={{ marginTop: 20 }}
label="Label for this match"
placeholder="Court 1 | 11:30 - 12:00"
{...form.getInputProps('label')}
/>
<CourtsSelect form={form} swrCourtsResponse={swrCourtsResponse} />
<Button fullWidth style={{ marginTop: 20 }} color="green" type="submit">
Save
</Button>

View File

@@ -48,6 +48,7 @@ function GeneralTournamentForm({
dashboard_public: tournament == null ? true : tournament.dashboard_public,
players_can_be_in_multiple_teams:
tournament == null ? true : tournament.players_can_be_in_multiple_teams,
auto_assign_courts: tournament == null ? true : tournament.auto_assign_courts,
},
validate: {
@@ -65,7 +66,8 @@ function GeneralTournamentForm({
parseInt(values.club_id, 10),
values.name,
values.dashboard_public,
values.players_can_be_in_multiple_teams
values.players_can_be_in_multiple_teams,
values.auto_assign_courts
);
} else {
assert(tournament != null);
@@ -73,7 +75,8 @@ function GeneralTournamentForm({
tournament.id,
values.name,
values.dashboard_public,
values.players_can_be_in_multiple_teams
values.players_can_be_in_multiple_teams,
values.auto_assign_courts
);
}
await swrTournamentsResponse.mutate(null);
@@ -106,6 +109,11 @@ function GeneralTournamentForm({
label="Allow players to be in multiple teams"
{...form.getInputProps('players_can_be_in_multiple_teams', { type: 'checkbox' })}
/>
<Checkbox
mt="md"
label="Automatically assign courts to matches"
{...form.getInputProps('auto_assign_courts', { type: 'checkbox' })}
/>
{tournament != null ? <DropzoneButton tournament={tournament} /> : null}

View File

@@ -1,5 +1,5 @@
import { Tooltip, UnstyledButton } from '@mantine/core';
import { Icon, IconTournament, IconUser, IconUsers } from '@tabler/icons-react';
import { Icon, IconSoccerField, IconTournament, IconUser, IconUsers } from '@tabler/icons-react';
import { NextRouter, useRouter } from 'next/router';
import React from 'react';
@@ -60,6 +60,12 @@ export function MainLinks({ tournament_id }: any) {
endpoint: `${tm_prefix}/teams`,
router,
},
{
icon: IconSoccerField,
label: 'Courts',
endpoint: `${tm_prefix}/courts`,
router,
},
];
const links = data.map((link) => <MainLink key={link.label} item={link} pathName={pathName} />);

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { SWRResponse } from 'swr';
import { Court } from '../../interfaces/court';
import { Tournament } from '../../interfaces/tournament';
import { deleteCourt } from '../../services/court';
import DeleteButton from '../buttons/delete';
import { EmptyTableInfo } from '../utils/empty_table_info';
import RequestErrorAlert from '../utils/error_alert';
import TableLayout, { ThNotSortable, getTableState, sortTableEntries } from './table';
export default function CourtsTable({
tournament,
swrCourtsResponse,
}: {
tournament: Tournament;
swrCourtsResponse: SWRResponse;
}) {
const courts: Court[] = swrCourtsResponse.data != null ? swrCourtsResponse.data.data : [];
const tableState = getTableState('id');
if (swrCourtsResponse.error) return <RequestErrorAlert error={swrCourtsResponse.error} />;
const rows = courts
.sort((s1: Court, s2: Court) => sortTableEntries(s1, s2, tableState))
.map((court) => (
<tr key={court.name}>
<td>{court.name}</td>
<td>
<DeleteButton
onClick={async () => {
await deleteCourt(tournament.id, court.id);
await swrCourtsResponse.mutate(null);
}}
title="Delete Court"
/>
</td>
</tr>
));
if (rows.length < 1) return <EmptyTableInfo entity_name="courts" />;
return (
<TableLayout>
<thead>
<tr>
<ThNotSortable>Title</ThNotSortable>
<ThNotSortable>Status</ThNotSortable>
<ThNotSortable>{null}</ThNotSortable>
</tr>
</thead>
<tbody>{rows}</tbody>
</TableLayout>
);
}

View File

@@ -0,0 +1,6 @@
export interface Court {
id: number;
tournament_id: number;
created: string;
name: string;
}

View File

@@ -1,3 +1,4 @@
import { Court } from './court';
import { TeamInterface } from './team';
export interface MatchInterface {
@@ -8,7 +9,8 @@ export interface MatchInterface {
team2_score: number;
team1: TeamInterface;
team2: TeamInterface;
label: string;
court_id: number | null;
court: Court | null;
}
export interface MatchBodyInterface {
@@ -16,7 +18,7 @@ export interface MatchBodyInterface {
round_id: number;
team1_score: number;
team2_score: number;
label: string;
court_id: number | null;
}
export interface UpcomingMatchInterface {

View File

@@ -5,6 +5,7 @@ export interface Tournament {
club_id: number;
dashboard_public: boolean;
players_can_be_in_multiple_teams: boolean;
auto_assign_courts: boolean;
logo_path: string;
}
export interface TournamentMinimal {

View File

@@ -33,7 +33,6 @@ export default function App(props: AppProps & { colorScheme: ColorScheme }) {
<Analytics />
</MantineProvider>
</ColorSchemeProvider>
</>
);
}

View File

@@ -18,6 +18,7 @@ import { StageWithRounds } from '../../interfaces/stage';
import { Tournament } from '../../interfaces/tournament';
import {
checkForAuthError,
getCourts,
getStages,
getTournaments,
getUpcomingMatches,
@@ -31,6 +32,7 @@ export default function TournamentPage() {
const swrTournamentsResponse = getTournaments();
checkForAuthError(swrTournamentsResponse);
const swrStagesResponse: SWRResponse = getStages(id);
const swrCourtsResponse: SWRResponse = getCourts(id);
const [onlyBehindSchedule, setOnlyBehindSchedule] = useState('true');
const [eloThreshold, setEloThreshold] = useState(100);
const [iterations, setIterations] = useState(200);
@@ -157,6 +159,7 @@ export default function TournamentPage() {
<Brackets
tournamentData={tournamentDataFull}
swrStagesResponse={swrStagesResponse}
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
readOnly={false}
activeStageId={activeStageId}

View File

@@ -0,0 +1,61 @@
import { Button, Container, Divider, TextInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { SWRResponse } from 'swr';
import CourtsTable from '../../../components/tables/courts';
import { getTournamentIdFromRouter } from '../../../components/utils/util';
import { Tournament } from '../../../interfaces/tournament';
import { getCourts, getTournaments } from '../../../services/adapter';
import { createCourt } from '../../../services/court';
import TournamentLayout from '../_tournament_layout';
function CreateCourtForm(tournament: Tournament, swrCourtsResponse: SWRResponse) {
const form = useForm({
initialValues: { name: '' },
validate: {
name: (value) => (value.length > 0 ? null : 'Name too short'),
},
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
await createCourt(tournament.id, values.name);
await swrCourtsResponse.mutate(null);
})}
>
<Divider mt={12} />
<h3>Add Court</h3>
<TextInput
withAsterisk
label="Name"
placeholder="Best Court Ever"
{...form.getInputProps('name')}
/>
<Button fullWidth style={{ marginTop: 16 }} color="green" type="submit">
Create Court
</Button>
</form>
);
}
export default function CourtsPage() {
const { tournamentData } = getTournamentIdFromRouter();
const swrCourtsResponse = getCourts(tournamentData.id);
const swrTournamentsResponse = getTournaments();
const tournaments: Tournament[] =
swrTournamentsResponse.data != null ? swrTournamentsResponse.data.data : [];
const tournamentDataFull = tournaments.filter(
(tournament) => tournament.id === tournamentData.id
)[0];
return (
<TournamentLayout tournament_id={tournamentData.id}>
<Container>
<CourtsTable tournament={tournamentDataFull} swrCourtsResponse={swrCourtsResponse} />
{CreateCourtForm(tournamentDataFull, swrCourtsResponse)}
</Container>
</TournamentLayout>
);
}

View File

@@ -9,7 +9,7 @@ import StagesTab from '../../../components/utils/stages_tab';
import { getTournamentIdFromRouter, responseIsValid } from '../../../components/utils/util';
import { StageWithRounds } from '../../../interfaces/stage';
import { Tournament } from '../../../interfaces/tournament';
import { getBaseApiUrl, getStages, getTournament } from '../../../services/adapter';
import { getBaseApiUrl, getCourts, getStages, getTournament } from '../../../services/adapter';
function TournamentLogo({ tournamentDataFull }: { tournamentDataFull: Tournament }) {
if (tournamentDataFull == null) {
@@ -44,6 +44,8 @@ function TournamentTitle({ tournamentDataFull }: { tournamentDataFull: Tournamen
export default function Dashboard() {
const { tournamentData } = getTournamentIdFromRouter();
const swrStagesResponse: SWRResponse = getStages(tournamentData.id, true);
const swrCourtsResponse: SWRResponse = getCourts(tournamentData.id);
const swrTournamentsResponse = getTournament(tournamentData.id);
const [activeStageId, setActiveStageId] = useState(null);
@@ -85,6 +87,7 @@ export default function Dashboard() {
<Brackets
tournamentData={tournamentData}
swrStagesResponse={swrStagesResponse}
swrCourtsResponse={swrCourtsResponse}
swrUpcomingMatchesResponse={null}
readOnly
activeStageId={activeStageId}

View File

@@ -9,17 +9,20 @@ const axios = require('axios').default;
export function handleRequestError(response: any) {
if (response.response != null && response.response.data.detail != null) {
// If the detail contains an array, there is likely a pydantic validation error occurring.
const message = Array.isArray(response.response.data.detail)
? 'Unknown error'
: response.response.data.detail.toString();
showNotification({
color: 'red',
title: 'An error occurred',
message: response.response.data.detail.toString(),
message,
});
}
}
export function checkForAuthError(response: any) {
// if (localStorage)
// console.error('asdasd', localStorage.getItem('login'), response.error);
if (
response.error != null &&
response.error.response != null &&
@@ -79,6 +82,10 @@ export function getStages(tournament_id: number, no_draft_rounds: boolean = fals
});
}
export function getCourts(tournament_id: number): SWRResponse {
return useSWR(`tournaments/${tournament_id}/courts`, fetcher);
}
export function getUser(user_id: number): SWRResponse {
return useSWR(`users/${user_id}`, fetcher);
}

View File

@@ -0,0 +1,13 @@
import { createAxios, handleRequestError } from './adapter';
export async function createCourt(tournament_id: number, name: string) {
return createAxios()
.post(`tournaments/${tournament_id}/courts`, { name })
.catch((response: any) => handleRequestError(response));
}
export async function deleteCourt(tournament_id: number, court_id: number) {
return createAxios()
.delete(`tournaments/${tournament_id}/courts/${court_id}`)
.catch((response: any) => handleRequestError(response));
}

View File

@@ -4,13 +4,15 @@ export async function createTournament(
club_id: number,
name: string,
dashboard_public: boolean,
players_can_be_in_multiple_teams: boolean
players_can_be_in_multiple_teams: boolean,
auto_assign_courts: boolean
) {
return createAxios().post('tournaments', {
name,
club_id,
dashboard_public,
players_can_be_in_multiple_teams,
auto_assign_courts,
});
}
@@ -22,11 +24,13 @@ export async function updateTournament(
tournament_id: number,
name: string,
dashboard_public: boolean,
players_can_be_in_multiple_teams: boolean
players_can_be_in_multiple_teams: boolean,
auto_assign_courts: boolean
) {
return createAxios().patch(`tournaments/${tournament_id}`, {
name,
dashboard_public,
players_can_be_in_multiple_teams,
auto_assign_courts,
});
}