mirror of
https://github.com/evroon/bracket.git
synced 2026-03-06 16:18:27 -05:00
Add stages in backend (#181)
This commit is contained in:
2
.github/workflows/backend.yml
vendored
2
.github/workflows/backend.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
working-directory: backend
|
||||
|
||||
- name: Run tests
|
||||
run: SQLALCHEMY_SILENCE_UBER_WARNING=1 pipenv run pytest --cov --cov-report=xml .
|
||||
run: pipenv run pytest --cov --cov-report=xml .
|
||||
working-directory: backend
|
||||
env:
|
||||
ENVIRONMENT: CI
|
||||
|
||||
@@ -35,6 +35,7 @@ pylint = ">=2.15.10"
|
||||
pytest = ">=7.2.0"
|
||||
pytest-cov = ">=4.0.0"
|
||||
pytest-asyncio = ">=0.20.3"
|
||||
pytest-xdist = ">=3.2.1"
|
||||
aiohttp = ">=3.8.3"
|
||||
aioresponses = ">=0.7.4"
|
||||
|
||||
|
||||
98
backend/alembic/versions/6458e0bc3e9d_add_stages_table.py
Normal file
98
backend/alembic/versions/6458e0bc3e9d_add_stages_table.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Add stages table
|
||||
|
||||
Revision ID: 6458e0bc3e9d
|
||||
Revises: 274385f2a757
|
||||
Create Date: 2023-04-19 08:59:32.383715
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import ENUM
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str | None = '6458e0bc3e9d'
|
||||
down_revision: str | None = '274385f2a757'
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
'stages',
|
||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('tournament_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column(
|
||||
'type',
|
||||
ENUM(
|
||||
'SINGLE_ELIMINATION',
|
||||
'DOUBLE_ELIMINATION',
|
||||
'SWISS',
|
||||
'SWISS_DYNAMIC_TEAMS',
|
||||
'ROUND_ROBIN',
|
||||
name='stage_type',
|
||||
create_type=True,
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['tournament_id'],
|
||||
['tournaments.id'],
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(op.f('ix_stages_id'), 'stages', ['id'], unique=False)
|
||||
|
||||
op.execute(
|
||||
'''
|
||||
INSERT INTO stages (type, tournament_id, created)
|
||||
(
|
||||
SELECT 'SWISS_DYNAMIC_TEAMS', id, NOW()
|
||||
FROM tournaments
|
||||
)
|
||||
'''
|
||||
)
|
||||
op.add_column('rounds', sa.Column('stage_id', sa.BigInteger(), nullable=True))
|
||||
op.execute(
|
||||
'''
|
||||
UPDATE rounds
|
||||
SET stage_id = (
|
||||
SELECT id
|
||||
FROM stages
|
||||
WHERE stages.tournament_id = rounds.tournament_id
|
||||
LIMIT 1
|
||||
)
|
||||
'''
|
||||
)
|
||||
op.alter_column(
|
||||
"rounds",
|
||||
"stage_id",
|
||||
existing_type=sa.BigInteger(),
|
||||
type_=sa.BigInteger(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
op.drop_constraint('rounds_tournament_id_fkey', 'rounds', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'rounds', 'stages', ['stage_id'], ['id'])
|
||||
# op.drop_column('rounds', 'tournament_id')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# op.add_column(
|
||||
# 'rounds', sa.Column('tournament_id', sa.BIGINT(), autoincrement=False, nullable=True)
|
||||
# )
|
||||
# op.drop_constraint('rounds_tournament_id_fkey', 'rounds', type_='foreignkey')
|
||||
op.create_foreign_key(
|
||||
'rounds_tournament_id_fkey', 'rounds', 'tournaments', ['tournament_id'], ['id']
|
||||
)
|
||||
op.drop_column('rounds', 'stage_id')
|
||||
op.drop_index(op.f('ix_stages_id'), table_name='stages')
|
||||
op.drop_table('stages')
|
||||
op.execute('DROP TYPE stage_type')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,10 +1,13 @@
|
||||
from fastapi import FastAPI
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
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, teams, tournaments, users
|
||||
from bracket.routes import auth, clubs, matches, players, rounds, stages, teams, tournaments, users
|
||||
|
||||
init_sentry()
|
||||
|
||||
@@ -45,6 +48,16 @@ async def ping() -> str:
|
||||
return 'ping'
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def validation_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
return JSONResponse({'detail': exc.detail}, status_code=exc.status_code)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
return JSONResponse({'detail': 'Internal server error'}, status_code=500)
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
app.include_router(auth.router, tags=['auth'])
|
||||
@@ -53,5 +66,6 @@ app.include_router(tournaments.router, tags=['tournaments'])
|
||||
app.include_router(players.router, tags=['players'])
|
||||
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(users.router, tags=['users'])
|
||||
|
||||
@@ -71,7 +71,7 @@ class DemoConfig(Config):
|
||||
env_file = 'demo.env'
|
||||
|
||||
|
||||
environment = Environment(os.getenv('ENVIRONMENT', 'DEVELOPMENT'))
|
||||
environment = Environment(os.getenv('ENVIRONMENT', 'CI'))
|
||||
config: Config
|
||||
|
||||
match environment:
|
||||
|
||||
@@ -5,10 +5,10 @@ from decimal import Decimal
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.models.db.round import RoundWithMatches
|
||||
from bracket.models.db.round import RoundWithMatches, StageWithRounds
|
||||
from bracket.schema import players
|
||||
from bracket.sql.players import get_all_players_in_tournament
|
||||
from bracket.sql.rounds import get_rounds_with_matches
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
START_ELO: int = 1200
|
||||
@@ -83,8 +83,13 @@ def calculate_elo_per_player(rounds: list[RoundWithMatches]) -> defaultdict[int,
|
||||
|
||||
|
||||
async def recalculate_elo_for_tournament_id(tournament_id: int) -> None:
|
||||
rounds_response = await get_rounds_with_matches(tournament_id)
|
||||
elo_per_player = calculate_elo_per_player(rounds_response)
|
||||
stages = await get_stages_with_rounds_and_matches(tournament_id)
|
||||
for stage in stages:
|
||||
await recalculate_elo_for_stage(tournament_id, stage)
|
||||
|
||||
|
||||
async def recalculate_elo_for_stage(tournament_id: int, stage: StageWithRounds) -> None:
|
||||
elo_per_player = calculate_elo_per_player(stage.rounds)
|
||||
|
||||
for player_id, statistics in elo_per_player.items():
|
||||
await database.execute(
|
||||
|
||||
@@ -10,7 +10,7 @@ from bracket.models.db.player import Player
|
||||
from bracket.models.db.round import RoundWithMatches
|
||||
from bracket.models.db.team import TeamWithPlayers
|
||||
from bracket.sql.players import get_active_players_in_tournament
|
||||
from bracket.sql.rounds import get_rounds_with_matches
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
@@ -23,9 +23,14 @@ async def get_possible_upcoming_matches_for_players(
|
||||
) -> list[SuggestedMatch]:
|
||||
random.seed(10)
|
||||
suggestions: set[SuggestedMatch] = set()
|
||||
all_rounds = await get_rounds_with_matches(tournament_id)
|
||||
draft_round = next((round_ for round_ in all_rounds if round_.is_draft), None)
|
||||
other_rounds = [round_ for round_ in all_rounds if not round_.is_draft]
|
||||
stages = await get_stages_with_rounds_and_matches(tournament_id)
|
||||
active_stage = next((stage for stage in stages if stage.is_active), None)
|
||||
|
||||
if active_stage is None:
|
||||
raise HTTPException(400, 'There is no active stage, so no matches can be scheduled.')
|
||||
|
||||
draft_round = next((round_ for round_ in active_stage.rounds if round_.is_draft), None)
|
||||
other_rounds = [round_ for round_ in active_stage.rounds if not round_.is_draft]
|
||||
max_matches_per_round = (
|
||||
max(len(other_round.matches) for other_round in other_rounds)
|
||||
if len(other_rounds) > 0
|
||||
|
||||
@@ -2,16 +2,16 @@ from fastapi import HTTPException
|
||||
|
||||
from bracket.logic.scheduling.shared import check_team_combination_adheres_to_filter
|
||||
from bracket.models.db.match import MatchFilter, SuggestedMatch
|
||||
from bracket.sql.rounds import get_rounds_with_matches
|
||||
from bracket.sql.rounds import get_rounds_for_stage
|
||||
from bracket.sql.teams import get_teams_with_members
|
||||
|
||||
|
||||
async def get_possible_upcoming_matches_for_teams(
|
||||
tournament_id: int, filter_: MatchFilter
|
||||
tournament_id: int, stage_id: int, filter_: MatchFilter
|
||||
) -> list[SuggestedMatch]:
|
||||
suggestions: list[SuggestedMatch] = []
|
||||
rounds_response = await get_rounds_with_matches(tournament_id)
|
||||
draft_round = next((round for round in rounds_response if round.is_draft), None)
|
||||
rounds = await get_rounds_for_stage(tournament_id, stage_id)
|
||||
draft_round = next((round_ for round_ in rounds if round_.is_draft), None)
|
||||
if draft_round is None:
|
||||
raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@ from pydantic import validator
|
||||
|
||||
from bracket.models.db.match import Match, MatchWithTeamDetails
|
||||
from bracket.models.db.shared import BaseModelORM
|
||||
from bracket.models.db.stage import Stage
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
class Round(BaseModelORM):
|
||||
id: int | None = None
|
||||
tournament_id: int
|
||||
stage_id: int
|
||||
created: datetime_utc
|
||||
is_draft: bool
|
||||
is_active: bool = False
|
||||
@@ -21,7 +22,20 @@ class RoundWithMatches(Round):
|
||||
matches: list[MatchWithTeamDetails]
|
||||
|
||||
@validator('matches', pre=True)
|
||||
def handle_players(values: list[Match]) -> list[Match]: # type: ignore[misc]
|
||||
def handle_matches(values: list[Match]) -> list[Match]: # type: ignore[misc]
|
||||
if values == [None]:
|
||||
return []
|
||||
return values
|
||||
|
||||
def get_team_ids(self) -> set[int]:
|
||||
return {assert_some(team.id) for match in self.matches for team in match.teams}
|
||||
|
||||
|
||||
class StageWithRounds(Stage):
|
||||
rounds: list[RoundWithMatches]
|
||||
|
||||
@validator('rounds', pre=True)
|
||||
def handle_rounds(values: list[Round]) -> list[Round]: # type: ignore[misc]
|
||||
if isinstance(values, str):
|
||||
values_json = json.loads(values)
|
||||
if values_json == [None]:
|
||||
@@ -30,18 +44,20 @@ class RoundWithMatches(Round):
|
||||
|
||||
return values
|
||||
|
||||
def get_team_ids(self) -> set[int]:
|
||||
return {assert_some(team.id) for match in self.matches for team in match.teams}
|
||||
|
||||
|
||||
class RoundBody(BaseModelORM):
|
||||
class RoundUpdateBody(BaseModelORM):
|
||||
name: str
|
||||
is_draft: bool
|
||||
is_active: bool
|
||||
|
||||
|
||||
class RoundToInsert(RoundBody):
|
||||
class RoundCreateBody(BaseModelORM):
|
||||
name: str | None
|
||||
stage_id: int
|
||||
|
||||
|
||||
class RoundToInsert(RoundUpdateBody):
|
||||
created: datetime_utc
|
||||
tournament_id: int
|
||||
stage_id: int
|
||||
is_draft: bool = False
|
||||
is_active: bool = False
|
||||
|
||||
37
backend/bracket/models/db/stage.py
Normal file
37
backend/bracket/models/db/stage.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from enum import auto
|
||||
|
||||
from heliclockter import datetime_utc
|
||||
|
||||
from bracket.models.db.shared import BaseModelORM
|
||||
from bracket.utils.types import EnumAutoStr
|
||||
|
||||
|
||||
class StageType(EnumAutoStr):
|
||||
SINGLE_ELIMINATION = auto()
|
||||
DOUBLE_ELIMINATION = auto()
|
||||
SWISS = auto()
|
||||
SWISS_DYNAMIC_TEAMS = auto()
|
||||
ROUND_ROBIN = auto()
|
||||
|
||||
|
||||
class Stage(BaseModelORM):
|
||||
id: int | None = None
|
||||
tournament_id: int
|
||||
created: datetime_utc
|
||||
type: StageType
|
||||
is_active: bool
|
||||
|
||||
|
||||
class StageUpdateBody(BaseModelORM):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class StageCreateBody(BaseModelORM):
|
||||
type: StageType
|
||||
|
||||
|
||||
class StageToInsert(BaseModelORM):
|
||||
created: datetime_utc
|
||||
tournament_id: int
|
||||
type: StageType
|
||||
is_active: bool = False
|
||||
@@ -14,7 +14,10 @@ from bracket.schema import matches
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/tournaments/{tournament_id}/upcoming_matches", response_model=UpcomingMatchesResponse)
|
||||
@router.get(
|
||||
"/tournaments/{tournament_id}/upcoming_matches",
|
||||
response_model=UpcomingMatchesResponse,
|
||||
)
|
||||
async def get_matches_to_schedule(
|
||||
tournament_id: int,
|
||||
elo_diff_threshold: int = 100,
|
||||
@@ -32,9 +35,6 @@ async def get_matches_to_schedule(
|
||||
return UpcomingMatchesResponse(
|
||||
data=await get_possible_upcoming_matches_for_players(tournament_id, match_filter)
|
||||
)
|
||||
# return UpcomingMatchesResponse(
|
||||
# data=await get_possible_upcoming_matches_for_teams(tournament_id, MatchFilter())
|
||||
# )
|
||||
|
||||
|
||||
@router.delete("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse)
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic.generics import GenericModel
|
||||
from bracket.models.db.club import Club
|
||||
from bracket.models.db.match import SuggestedMatch
|
||||
from bracket.models.db.player import Player
|
||||
from bracket.models.db.round import Round, RoundWithMatches
|
||||
from bracket.models.db.round import Round, StageWithRounds
|
||||
from bracket.models.db.team import FullTeamWithPlayers, Team
|
||||
from bracket.models.db.tournament import Tournament
|
||||
from bracket.models.db.user import UserPublic
|
||||
@@ -51,7 +51,7 @@ class RoundsResponse(DataResponse[list[Round]]):
|
||||
pass
|
||||
|
||||
|
||||
class RoundsWithMatchesResponse(DataResponse[list[RoundWithMatches]]):
|
||||
class RoundsWithMatchesResponse(DataResponse[list[StageWithRounds]]):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -4,35 +4,23 @@ 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 Round, RoundBody, RoundToInsert, RoundWithMatches
|
||||
from bracket.models.db.user import UserPublic
|
||||
from bracket.routes.auth import (
|
||||
user_authenticated_for_tournament,
|
||||
user_authenticated_or_public_dashboard,
|
||||
from bracket.models.db.round import (
|
||||
Round,
|
||||
RoundCreateBody,
|
||||
RoundToInsert,
|
||||
RoundUpdateBody,
|
||||
RoundWithMatches,
|
||||
)
|
||||
from bracket.routes.models import RoundsWithMatchesResponse, SuccessResponse
|
||||
from bracket.models.db.user import UserPublic
|
||||
from bracket.routes.auth import user_authenticated_for_tournament
|
||||
from bracket.routes.models import SuccessResponse
|
||||
from bracket.routes.util import round_dependency, round_with_matches_dependency
|
||||
from bracket.schema import rounds
|
||||
from bracket.sql.rounds import get_next_round_name, get_rounds_with_matches
|
||||
from bracket.sql.rounds import get_next_round_name
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/tournaments/{tournament_id}/rounds", response_model=RoundsWithMatchesResponse)
|
||||
async def get_rounds(
|
||||
tournament_id: int,
|
||||
user: UserPublic = Depends(user_authenticated_or_public_dashboard),
|
||||
no_draft_rounds: bool = False,
|
||||
) -> RoundsWithMatchesResponse:
|
||||
rounds_ = await get_rounds_with_matches(
|
||||
tournament_id, no_draft_rounds=user is None or no_draft_rounds
|
||||
)
|
||||
if user is not None:
|
||||
return RoundsWithMatchesResponse(data=rounds_)
|
||||
|
||||
return RoundsWithMatchesResponse(data=[round_ for round_ in rounds_ if not round_.is_draft])
|
||||
|
||||
|
||||
@router.delete("/tournaments/{tournament_id}/rounds/{round_id}", response_model=SuccessResponse)
|
||||
async def delete_round(
|
||||
tournament_id: int,
|
||||
@@ -57,14 +45,16 @@ async def delete_round(
|
||||
|
||||
@router.post("/tournaments/{tournament_id}/rounds", response_model=SuccessResponse)
|
||||
async def create_round(
|
||||
tournament_id: int, _: UserPublic = Depends(user_authenticated_for_tournament)
|
||||
tournament_id: int,
|
||||
round_body: RoundCreateBody,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
) -> SuccessResponse:
|
||||
await database.execute(
|
||||
query=rounds.insert(),
|
||||
values=RoundToInsert(
|
||||
created=datetime_utc.now(),
|
||||
tournament_id=tournament_id,
|
||||
name=await get_next_round_name(tournament_id),
|
||||
stage_id=round_body.stage_id,
|
||||
name=await get_next_round_name(tournament_id, round_body.stage_id),
|
||||
).dict(),
|
||||
)
|
||||
return SuccessResponse()
|
||||
@@ -74,9 +64,9 @@ async def create_round(
|
||||
async def update_round_by_id(
|
||||
tournament_id: int,
|
||||
round_id: int,
|
||||
round_body: RoundBody,
|
||||
round_body: RoundUpdateBody,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
round: Round = Depends(round_dependency), # pylint: disable=redefined-builtin
|
||||
round_: Round = Depends(round_dependency), # pylint: disable=redefined-builtin
|
||||
) -> SuccessResponse:
|
||||
values = {'tournament_id': tournament_id, 'round_id': round_id}
|
||||
query = '''
|
||||
@@ -90,7 +80,12 @@ async def update_round_by_id(
|
||||
CASE WHEN rounds.id=:round_id THEN :is_active
|
||||
ELSE is_active AND NOT :is_active
|
||||
END
|
||||
WHERE rounds.tournament_id = :tournament_id
|
||||
WHERE rounds.id IN (
|
||||
SELECT rounds.id
|
||||
FROM rounds
|
||||
JOIN stages s on s.id = rounds.stage_id
|
||||
WHERE s.tournament_id = :tournament_id
|
||||
)
|
||||
'''
|
||||
await database.execute(
|
||||
query=query,
|
||||
@@ -99,7 +94,12 @@ async def update_round_by_id(
|
||||
query = '''
|
||||
UPDATE rounds
|
||||
SET name = :name
|
||||
WHERE rounds.tournament_id = :tournament_id
|
||||
WHERE rounds.id IN (
|
||||
SELECT rounds.id
|
||||
FROM rounds
|
||||
JOIN stages s on s.id = rounds.stage_id
|
||||
WHERE s.tournament_id = :tournament_id
|
||||
)
|
||||
AND rounds.id = :round_id
|
||||
'''
|
||||
await database.execute(query=query, values={**values, 'name': round_body.name})
|
||||
|
||||
90
backend/bracket/routes/stages.py
Normal file
90
backend/bracket/routes/stages.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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.user import UserPublic
|
||||
from bracket.routes.auth import (
|
||||
user_authenticated_for_tournament,
|
||||
user_authenticated_or_public_dashboard,
|
||||
)
|
||||
from bracket.routes.models import RoundsWithMatchesResponse, SuccessResponse
|
||||
from bracket.routes.util import stage_dependency
|
||||
from bracket.sql.stages import (
|
||||
get_stages_with_rounds_and_matches,
|
||||
sql_create_stage,
|
||||
sql_delete_stage,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/tournaments/{tournament_id}/stages", response_model=RoundsWithMatchesResponse)
|
||||
async def get_stages(
|
||||
tournament_id: int,
|
||||
user: UserPublic = Depends(user_authenticated_or_public_dashboard),
|
||||
no_draft_rounds: bool = False,
|
||||
) -> RoundsWithMatchesResponse:
|
||||
if no_draft_rounds is False and user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Can't view draft rounds when not authorized",
|
||||
)
|
||||
|
||||
stages_ = await get_stages_with_rounds_and_matches(
|
||||
tournament_id, no_draft_rounds=no_draft_rounds
|
||||
)
|
||||
return RoundsWithMatchesResponse(data=stages_)
|
||||
|
||||
|
||||
@router.delete("/tournaments/{tournament_id}/stages/{stage_id}", response_model=SuccessResponse)
|
||||
async def delete_stage(
|
||||
tournament_id: int,
|
||||
stage_id: int,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
stage: StageWithRounds = Depends(stage_dependency),
|
||||
) -> SuccessResponse:
|
||||
if len(stage.rounds) > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Round contains matches, delete those first",
|
||||
)
|
||||
|
||||
await sql_delete_stage(tournament_id, stage_id)
|
||||
|
||||
await recalculate_elo_for_tournament_id(tournament_id)
|
||||
return SuccessResponse()
|
||||
|
||||
|
||||
@router.post("/tournaments/{tournament_id}/stages", response_model=SuccessResponse)
|
||||
async def create_stage(
|
||||
tournament_id: int,
|
||||
stage_body: StageCreateBody,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
) -> SuccessResponse:
|
||||
await sql_create_stage(stage_body, tournament_id)
|
||||
return SuccessResponse()
|
||||
|
||||
|
||||
@router.patch("/tournaments/{tournament_id}/stages/{stage_id}", response_model=SuccessResponse)
|
||||
async def update_stage(
|
||||
tournament_id: int,
|
||||
stage_id: int,
|
||||
stage_body: StageUpdateBody,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
stage: Stage = Depends(stage_dependency), # pylint: disable=redefined-builtin
|
||||
) -> SuccessResponse:
|
||||
values = {'tournament_id': tournament_id, 'stage_id': stage_id}
|
||||
query = '''
|
||||
UPDATE stages
|
||||
SET is_active = :is_active
|
||||
WHERE stages.id = :stage_id
|
||||
AND stages.tournament_id = :tournament_id
|
||||
'''
|
||||
await database.execute(
|
||||
query=query,
|
||||
values={**values, 'is_active': stage_body.is_active},
|
||||
)
|
||||
return SuccessResponse()
|
||||
@@ -10,7 +10,7 @@ from bracket.routes.auth import user_authenticated_for_tournament
|
||||
from bracket.routes.models import SingleTeamResponse, SuccessResponse, TeamsWithPlayersResponse
|
||||
from bracket.routes.util import team_dependency, team_with_players_dependency
|
||||
from bracket.schema import players_x_teams, teams
|
||||
from bracket.sql.rounds import get_rounds_with_matches
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.sql.teams import get_team_by_id, get_teams_with_members
|
||||
from bracket.utils.db import fetch_one_parsed
|
||||
from bracket.utils.types import assert_some
|
||||
@@ -80,13 +80,14 @@ async def delete_team(
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
team: FullTeamWithPlayers = Depends(team_with_players_dependency),
|
||||
) -> SuccessResponse:
|
||||
rounds = await get_rounds_with_matches(tournament_id, no_draft_rounds=False)
|
||||
for round_ in rounds:
|
||||
if team.id in round_.get_team_ids():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Could not delete team that participates in matches in the tournament",
|
||||
)
|
||||
stages = await get_stages_with_rounds_and_matches(tournament_id, no_draft_rounds=False)
|
||||
for stage in stages:
|
||||
for round_ in stage.rounds:
|
||||
if team.id in round_.get_team_ids():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Could not delete team that participates in matches in the tournament",
|
||||
)
|
||||
|
||||
if len(team.players):
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,10 +3,10 @@ from starlette import status
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.models.db.match import Match
|
||||
from bracket.models.db.round import Round, RoundWithMatches
|
||||
from bracket.models.db.round import Round, RoundWithMatches, StageWithRounds
|
||||
from bracket.models.db.team import FullTeamWithPlayers, Team
|
||||
from bracket.schema import matches, rounds, teams
|
||||
from bracket.sql.rounds import get_rounds_with_matches
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.sql.teams import get_teams_with_members
|
||||
from bracket.utils.db import fetch_one_parsed
|
||||
|
||||
@@ -28,15 +28,31 @@ async def round_dependency(tournament_id: int, round_id: int) -> Round:
|
||||
|
||||
|
||||
async def round_with_matches_dependency(tournament_id: int, round_id: int) -> RoundWithMatches:
|
||||
rounds_ = await get_rounds_with_matches(tournament_id, no_draft_rounds=False, round_id=round_id)
|
||||
stages = await get_stages_with_rounds_and_matches(
|
||||
tournament_id, no_draft_rounds=False, round_id=round_id
|
||||
)
|
||||
|
||||
if len(rounds_) < 1:
|
||||
if len(stages) < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Could not find round with id {round_id}",
|
||||
)
|
||||
|
||||
return rounds_[0]
|
||||
return stages[0].rounds[0]
|
||||
|
||||
|
||||
async def stage_dependency(tournament_id: int, stage_id: int) -> StageWithRounds:
|
||||
stages = await get_stages_with_rounds_and_matches(
|
||||
tournament_id, no_draft_rounds=False, stage_id=stage_id
|
||||
)
|
||||
|
||||
if len(stages) < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Could not find stage with id {stage_id}",
|
||||
)
|
||||
|
||||
return stages[0]
|
||||
|
||||
|
||||
async def match_dependency(tournament_id: int, match_id: int) -> Match:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, Table
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import DeclarativeMeta # type: ignore[attr-defined]
|
||||
from sqlalchemy.sql.sqltypes import BigInteger, Boolean, DateTime, Float, Text
|
||||
from sqlalchemy.orm import declarative_base # type: ignore[attr-defined]
|
||||
from sqlalchemy.sql.sqltypes import BigInteger, Boolean, DateTime, Enum, Float, Text
|
||||
|
||||
Base: DeclarativeMeta = declarative_base()
|
||||
Base = declarative_base()
|
||||
metadata = Base.metadata
|
||||
DateTimeTZ = DateTime(timezone=True)
|
||||
|
||||
@@ -27,6 +26,27 @@ tournaments = Table(
|
||||
Column('players_can_be_in_multiple_teams', Boolean, nullable=False, server_default='f'),
|
||||
)
|
||||
|
||||
stages = Table(
|
||||
'stages',
|
||||
metadata,
|
||||
Column('id', BigInteger, primary_key=True, index=True),
|
||||
Column('created', DateTimeTZ, nullable=False),
|
||||
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), nullable=False),
|
||||
Column('is_active', Boolean, nullable=False, server_default='false'),
|
||||
Column(
|
||||
'type',
|
||||
Enum(
|
||||
'SINGLE_ELIMINATION',
|
||||
'DOUBLE_ELIMINATION',
|
||||
'SWISS',
|
||||
'SWISS_DYNAMIC_TEAMS',
|
||||
'ROUND_ROBIN',
|
||||
name='stage_type',
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
rounds = Table(
|
||||
'rounds',
|
||||
metadata,
|
||||
@@ -35,7 +55,7 @@ rounds = Table(
|
||||
Column('created', DateTimeTZ, nullable=False),
|
||||
Column('is_draft', Boolean, nullable=False),
|
||||
Column('is_active', Boolean, nullable=False, server_default='false'),
|
||||
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), nullable=False),
|
||||
Column('stage_id', BigInteger, ForeignKey('stages.id'), nullable=False),
|
||||
)
|
||||
|
||||
matches = Table(
|
||||
|
||||
@@ -1,54 +1,29 @@
|
||||
from typing import List
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.models.db.round import RoundWithMatches
|
||||
from bracket.utils.types import dict_without_none
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
|
||||
|
||||
async def get_rounds_with_matches(
|
||||
tournament_id: int,
|
||||
no_draft_rounds: bool = False,
|
||||
round_id: int | None = None,
|
||||
) -> list[RoundWithMatches]:
|
||||
draft_filter = 'AND rounds.is_draft IS FALSE' if no_draft_rounds else ''
|
||||
round_filter = 'AND rounds.id = :round_id' if round_id is not None else ''
|
||||
query = f'''
|
||||
WITH teams_with_players AS (
|
||||
SELECT DISTINCT ON (teams.id)
|
||||
teams.*,
|
||||
to_json(array_remove(array_agg(p), NULL)) as players
|
||||
FROM teams
|
||||
LEFT JOIN players_x_teams pt on pt.team_id = teams.id
|
||||
LEFT JOIN players p on pt.player_id = p.id
|
||||
WHERE teams.tournament_id = :tournament_id
|
||||
GROUP BY teams.id
|
||||
), matches_with_teams AS (
|
||||
SELECT DISTINCT ON (matches.id)
|
||||
matches.*,
|
||||
to_json(t1) as team1,
|
||||
to_json(t2) as team2
|
||||
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
|
||||
WHERE r.tournament_id = :tournament_id
|
||||
)
|
||||
SELECT rounds.*, to_json(array_agg(m.*)) AS matches FROM rounds
|
||||
LEFT JOIN matches_with_teams m on rounds.id = m.round_id
|
||||
WHERE rounds.tournament_id = :tournament_id
|
||||
{draft_filter}
|
||||
{round_filter}
|
||||
GROUP BY rounds.id
|
||||
'''
|
||||
values = dict_without_none({'tournament_id': tournament_id, 'round_id': round_id})
|
||||
result = await database.fetch_all(query=query, values=values)
|
||||
return [RoundWithMatches.parse_obj(x._mapping) for x in result]
|
||||
async def get_rounds_for_stage(tournament_id: int, stage_id: int) -> List[RoundWithMatches]:
|
||||
stages = await get_stages_with_rounds_and_matches(tournament_id)
|
||||
result_stage = next((stage for stage in stages if stage.id == stage_id), None)
|
||||
if result_stage is None:
|
||||
raise ValueError(f'Could not find stage with id {stage_id} for tournament {tournament_id}')
|
||||
|
||||
return result_stage.rounds
|
||||
|
||||
|
||||
async def get_next_round_name(tournament_id: int) -> str:
|
||||
async def get_next_round_name(tournament_id: int, stage_id: int) -> str:
|
||||
query = '''
|
||||
SELECT count(*) FROM rounds
|
||||
WHERE rounds.tournament_id = :tournament_id
|
||||
JOIN stages s on s.id = rounds.stage_id
|
||||
WHERE s.tournament_id = :tournament_id
|
||||
AND rounds.stage_id = :stage_id
|
||||
'''
|
||||
round_count = int(
|
||||
await database.fetch_val(query=query, values={'tournament_id': tournament_id})
|
||||
await database.fetch_val(
|
||||
query=query, values={'tournament_id': tournament_id, 'stage_id': stage_id}
|
||||
)
|
||||
)
|
||||
return f'Round {round_count + 1}'
|
||||
|
||||
88
backend/bracket/sql/stages.py
Normal file
88
backend/bracket/sql/stages.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from bracket.database import database
|
||||
from bracket.models.db.round import StageWithRounds
|
||||
from bracket.models.db.stage import Stage, StageCreateBody
|
||||
from bracket.utils.types import dict_without_none
|
||||
|
||||
|
||||
async def get_stages_with_rounds_and_matches(
|
||||
tournament_id: int,
|
||||
round_id: int | None = None,
|
||||
stage_id: int | None = None,
|
||||
*,
|
||||
no_draft_rounds: bool = False,
|
||||
) -> list[StageWithRounds]:
|
||||
draft_filter = 'AND rounds.is_draft IS FALSE' if no_draft_rounds else ''
|
||||
round_filter = 'AND rounds.id = :round_id' if round_id is not None else ''
|
||||
stage_filter = 'AND stages.id = :stage_id' if stage_id is not None else ''
|
||||
query = f'''
|
||||
WITH teams_with_players AS (
|
||||
SELECT DISTINCT ON (teams.id)
|
||||
teams.*,
|
||||
to_json(array_remove(array_agg(p), NULL)) as players
|
||||
FROM teams
|
||||
LEFT JOIN players_x_teams pt on pt.team_id = teams.id
|
||||
LEFT JOIN players p on pt.player_id = p.id
|
||||
WHERE teams.tournament_id = :tournament_id
|
||||
GROUP BY teams.id
|
||||
), matches_with_teams AS (
|
||||
SELECT DISTINCT ON (matches.id)
|
||||
matches.*,
|
||||
to_json(t1) as team1,
|
||||
to_json(t2) as team2
|
||||
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
|
||||
WHERE s2.tournament_id = :tournament_id
|
||||
), rounds_with_matches AS (
|
||||
SELECT DISTINCT ON (rounds.id)
|
||||
rounds.*,
|
||||
to_json(array_agg(m.*)) AS matches
|
||||
FROM rounds
|
||||
LEFT JOIN matches_with_teams m on m.round_id = rounds.id
|
||||
LEFT JOIN stages s2 on rounds.stage_id = s2.id
|
||||
WHERE s2.tournament_id = :tournament_id
|
||||
{draft_filter}
|
||||
{round_filter}
|
||||
GROUP BY rounds.id
|
||||
)
|
||||
SELECT stages.*, to_json(array_agg(r.*)) AS rounds FROM stages
|
||||
LEFT JOIN rounds_with_matches r on stages.id = r.stage_id
|
||||
WHERE stages.tournament_id = :tournament_id
|
||||
{stage_filter}
|
||||
GROUP BY stages.id
|
||||
'''
|
||||
values = dict_without_none(
|
||||
{'tournament_id': tournament_id, 'round_id': round_id, 'stage_id': stage_id}
|
||||
)
|
||||
result = await database.fetch_all(query=query, values=values)
|
||||
return [StageWithRounds.parse_obj(x._mapping) for x in result]
|
||||
|
||||
|
||||
async def sql_delete_stage(tournament_id: int, stage_id: int) -> None:
|
||||
query = '''
|
||||
DELETE FROM stages
|
||||
WHERE stages.id = :stage_id
|
||||
AND stages.tournament_id = :tournament_id
|
||||
'''
|
||||
await database.fetch_one(
|
||||
query=query, values={'stage_id': stage_id, 'tournament_id': tournament_id}
|
||||
)
|
||||
|
||||
|
||||
async def sql_create_stage(stage: StageCreateBody, tournament_id: int) -> Stage:
|
||||
async with database.transaction():
|
||||
query = '''
|
||||
INSERT INTO stages (type, created, is_active, tournament_id)
|
||||
VALUES (:stage_type, NOW(), false, :tournament_id)
|
||||
RETURNING *
|
||||
'''
|
||||
result = await database.fetch_one(
|
||||
query=query, values={'stage_type': stage.type.value, 'tournament_id': tournament_id}
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise ValueError('Could not create stage')
|
||||
|
||||
return Stage.parse_obj(result._mapping)
|
||||
19
backend/bracket/utils/conversion.py
Normal file
19
backend/bracket/utils/conversion.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from typing import Any, Mapping
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bracket.utils.types import EnumAutoStr
|
||||
|
||||
|
||||
def _map_to_str(value: Any) -> Any:
|
||||
match value:
|
||||
case EnumAutoStr():
|
||||
return value.name
|
||||
return value
|
||||
|
||||
|
||||
def to_string_mapping(obj: BaseModel) -> Mapping[str, Any]:
|
||||
"""
|
||||
Turns a pydantic object into a string mapping to be used as database query
|
||||
"""
|
||||
return {key: _map_to_str(value) for key, value in obj.dict().items()}
|
||||
@@ -6,6 +6,7 @@ from bracket.models.db.club import Club
|
||||
from bracket.models.db.match import Match
|
||||
from bracket.models.db.player import Player
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.stage import Stage, StageType
|
||||
from bracket.models.db.team import Team
|
||||
from bracket.models.db.tournament import Tournament
|
||||
from bracket.models.db.user import User
|
||||
@@ -28,15 +29,29 @@ DUMMY_TOURNAMENT = Tournament(
|
||||
players_can_be_in_multiple_teams=True,
|
||||
)
|
||||
|
||||
DUMMY_ROUND1 = Round(
|
||||
DUMMY_STAGE1 = Stage(
|
||||
tournament_id=1,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_active=False,
|
||||
type=StageType.ROUND_ROBIN,
|
||||
)
|
||||
|
||||
DUMMY_STAGE2 = Stage(
|
||||
tournament_id=1,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_active=True,
|
||||
type=StageType.SWISS,
|
||||
)
|
||||
|
||||
DUMMY_ROUND1 = Round(
|
||||
stage_id=1,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_draft=False,
|
||||
name='Round 1',
|
||||
)
|
||||
|
||||
DUMMY_ROUND2 = Round(
|
||||
tournament_id=1,
|
||||
stage_id=1,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_active=True,
|
||||
is_draft=False,
|
||||
@@ -44,7 +59,7 @@ DUMMY_ROUND2 = Round(
|
||||
)
|
||||
|
||||
DUMMY_ROUND3 = Round(
|
||||
tournament_id=1,
|
||||
stage_id=2,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_draft=True,
|
||||
name='Round 3',
|
||||
@@ -197,6 +212,7 @@ DUMMY_USER_X_CLUB = UserXClub(
|
||||
|
||||
DUMMY_CLUBS = [DUMMY_CLUB]
|
||||
DUMMY_TOURNAMENTS = [DUMMY_TOURNAMENT]
|
||||
DUMMY_STAGES = [DUMMY_STAGE1, DUMMY_STAGE2]
|
||||
DUMMY_ROUNDS = [DUMMY_ROUND1, DUMMY_ROUND2, DUMMY_ROUND3]
|
||||
DUMMY_MATCHES = [DUMMY_MATCH1, DUMMY_MATCH2, DUMMY_MATCH3, DUMMY_MATCH4]
|
||||
DUMMY_USERS = [DUMMY_USER]
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
import click
|
||||
from sqlalchemy import Table
|
||||
|
||||
from bracket.config import Environment, environment
|
||||
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
|
||||
@@ -15,16 +16,19 @@ from bracket.schema import (
|
||||
metadata,
|
||||
players,
|
||||
rounds,
|
||||
stages,
|
||||
teams,
|
||||
tournaments,
|
||||
users,
|
||||
users_x_clubs,
|
||||
)
|
||||
from bracket.utils.conversion import to_string_mapping
|
||||
from bracket.utils.dummy_records import (
|
||||
DUMMY_CLUBS,
|
||||
DUMMY_MATCHES,
|
||||
DUMMY_PLAYERS,
|
||||
DUMMY_ROUNDS,
|
||||
DUMMY_STAGES,
|
||||
DUMMY_TEAMS,
|
||||
DUMMY_TOURNAMENTS,
|
||||
DUMMY_USERS,
|
||||
@@ -65,12 +69,14 @@ def cli() -> None:
|
||||
|
||||
async def bulk_insert(table: Table, rows: list[BaseModelT]) -> None:
|
||||
for row in rows:
|
||||
await database.execute(query=table.insert(), values=row.dict())
|
||||
await database.execute(query=table.insert(), values=to_string_mapping(row)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@click.command()
|
||||
@run_async
|
||||
async def create_dev_db() -> None:
|
||||
assert environment is Environment.DEVELOPMENT
|
||||
|
||||
logger.warning('Initializing database with dummy records')
|
||||
await database.connect()
|
||||
metadata.drop_all(engine)
|
||||
@@ -79,6 +85,7 @@ async def create_dev_db() -> None:
|
||||
await bulk_insert(users, DUMMY_USERS)
|
||||
await bulk_insert(clubs, DUMMY_CLUBS)
|
||||
await bulk_insert(tournaments, DUMMY_TOURNAMENTS)
|
||||
await bulk_insert(stages, DUMMY_STAGES)
|
||||
await bulk_insert(teams, DUMMY_TEAMS)
|
||||
await bulk_insert(players, DUMMY_PLAYERS)
|
||||
await bulk_insert(rounds, DUMMY_ROUNDS)
|
||||
|
||||
@@ -3,6 +3,6 @@ set -evo pipefail
|
||||
|
||||
black .
|
||||
dmypy run -- --follow-imports=normal --junit-xml= .
|
||||
SQLALCHEMY_SILENCE_UBER_WARNING=1 ENVIRONMENT=CI pytest --cov --cov-report=xml . -vvv
|
||||
ENVIRONMENT=CI pytest --cov --cov-report=xml . -vvv
|
||||
pylint alembic bracket tests
|
||||
isort .
|
||||
|
||||
@@ -73,6 +73,8 @@ disable = [
|
||||
'too-many-locals',
|
||||
'too-many-nested-blocks',
|
||||
'protected-access',
|
||||
'logging-fstring-interpolation',
|
||||
'too-many-arguments',
|
||||
]
|
||||
|
||||
[tool.bandit]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# pylint: disable=redefined-outer-name
|
||||
import asyncio
|
||||
import os
|
||||
from asyncio import AbstractEventLoop
|
||||
from typing import AsyncIterator
|
||||
|
||||
@@ -13,8 +12,6 @@ from tests.integration_tests.api.shared import UvicornTestServer
|
||||
from tests.integration_tests.models import AuthContext
|
||||
from tests.integration_tests.sql import inserted_auth_context
|
||||
|
||||
os.environ['ENVIRONMENT'] = 'CI'
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def startup_and_shutdown_uvicorn_server() -> AsyncIterator[None]:
|
||||
|
||||
@@ -9,6 +9,7 @@ from bracket.utils.dummy_records import (
|
||||
DUMMY_PLAYER3,
|
||||
DUMMY_PLAYER4,
|
||||
DUMMY_ROUND1,
|
||||
DUMMY_STAGE1,
|
||||
DUMMY_TEAM1,
|
||||
DUMMY_TEAM2,
|
||||
)
|
||||
@@ -21,6 +22,7 @@ from tests.integration_tests.sql import (
|
||||
inserted_match,
|
||||
inserted_player_in_team,
|
||||
inserted_round,
|
||||
inserted_stage,
|
||||
inserted_team,
|
||||
)
|
||||
|
||||
@@ -28,98 +30,109 @@ from tests.integration_tests.sql import (
|
||||
async def test_create_match(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
async with inserted_round(DUMMY_ROUND1) as round_inserted:
|
||||
async with inserted_team(DUMMY_TEAM1) as team1_inserted:
|
||||
async with inserted_team(DUMMY_TEAM2) as team2_inserted:
|
||||
body = {
|
||||
'team1_id': team1_inserted.id,
|
||||
'team2_id': team2_inserted.id,
|
||||
'round_id': round_inserted.id,
|
||||
'label': 'Some label',
|
||||
}
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.POST, 'matches', auth_context, json=body
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
await assert_row_count_and_clear(matches, 1)
|
||||
async with (
|
||||
inserted_stage(DUMMY_STAGE1) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
inserted_team(DUMMY_TEAM1) as team1_inserted,
|
||||
inserted_team(DUMMY_TEAM2) as team2_inserted,
|
||||
):
|
||||
body = {
|
||||
'team1_id': team1_inserted.id,
|
||||
'team2_id': team2_inserted.id,
|
||||
'round_id': round_inserted.id,
|
||||
'label': 'Some label',
|
||||
}
|
||||
assert (
|
||||
await send_tournament_request(HTTPMethod.POST, 'matches', auth_context, json=body)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
await assert_row_count_and_clear(matches, 1)
|
||||
|
||||
|
||||
async def test_delete_match(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
async with inserted_round(DUMMY_ROUND1) as round_inserted:
|
||||
async with inserted_team(DUMMY_TEAM1) as team1_inserted:
|
||||
async with inserted_team(DUMMY_TEAM2) as team2_inserted:
|
||||
async with inserted_match(
|
||||
DUMMY_MATCH1.copy(
|
||||
update={
|
||||
'round_id': round_inserted.id,
|
||||
'team1_id': team1_inserted.id,
|
||||
'team2_id': team2_inserted.id,
|
||||
}
|
||||
)
|
||||
) as match_inserted:
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.DELETE, f'matches/{match_inserted.id}', auth_context, {}
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
await assert_row_count_and_clear(matches, 0)
|
||||
async with (
|
||||
inserted_stage(DUMMY_STAGE1) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
inserted_team(DUMMY_TEAM1) as team1_inserted,
|
||||
inserted_team(DUMMY_TEAM2) as team2_inserted,
|
||||
inserted_match(
|
||||
DUMMY_MATCH1.copy(
|
||||
update={
|
||||
'round_id': round_inserted.id,
|
||||
'team1_id': team1_inserted.id,
|
||||
'team2_id': team2_inserted.id,
|
||||
}
|
||||
)
|
||||
) as match_inserted,
|
||||
):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.DELETE, f'matches/{match_inserted.id}', auth_context, {}
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
await assert_row_count_and_clear(matches, 0)
|
||||
|
||||
|
||||
async def test_update_match(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
async with inserted_team(DUMMY_TEAM1) as team1_inserted:
|
||||
async with inserted_team(DUMMY_TEAM2) as team2_inserted:
|
||||
async with inserted_round(DUMMY_ROUND1) as round_inserted:
|
||||
async with inserted_match(
|
||||
DUMMY_MATCH1.copy(
|
||||
update={
|
||||
'round_id': round_inserted.id,
|
||||
'team1_id': team1_inserted.id,
|
||||
'team2_id': team2_inserted.id,
|
||||
}
|
||||
)
|
||||
) as match_inserted:
|
||||
body = {
|
||||
'team1_score': 42,
|
||||
'team2_score': 24,
|
||||
'round_id': round_inserted.id,
|
||||
'label': 'Some label',
|
||||
}
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.PATCH,
|
||||
f'matches/{match_inserted.id}',
|
||||
auth_context,
|
||||
None,
|
||||
body,
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
patched_match = await fetch_one_parsed_certain(
|
||||
database,
|
||||
Match,
|
||||
query=matches.select().where(matches.c.id == round_inserted.id),
|
||||
)
|
||||
assert patched_match.team1_score == body['team1_score']
|
||||
assert patched_match.team2_score == body['team2_score']
|
||||
assert patched_match.label == body['label']
|
||||
async with (
|
||||
inserted_stage(DUMMY_STAGE1) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
inserted_team(DUMMY_TEAM1) as team1_inserted,
|
||||
inserted_team(DUMMY_TEAM2) as team2_inserted,
|
||||
inserted_match(
|
||||
DUMMY_MATCH1.copy(
|
||||
update={
|
||||
'round_id': round_inserted.id,
|
||||
'team1_id': team1_inserted.id,
|
||||
'team2_id': team2_inserted.id,
|
||||
}
|
||||
)
|
||||
) as match_inserted,
|
||||
):
|
||||
body = {
|
||||
'team1_score': 42,
|
||||
'team2_score': 24,
|
||||
'round_id': round_inserted.id,
|
||||
'label': 'Some label',
|
||||
}
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.PATCH,
|
||||
f'matches/{match_inserted.id}',
|
||||
auth_context,
|
||||
None,
|
||||
body,
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
patched_match = await fetch_one_parsed_certain(
|
||||
database,
|
||||
Match,
|
||||
query=matches.select().where(matches.c.id == round_inserted.id),
|
||||
)
|
||||
assert patched_match.team1_score == body['team1_score']
|
||||
assert patched_match.team2_score == body['team2_score']
|
||||
assert patched_match.label == body['label']
|
||||
|
||||
await assert_row_count_and_clear(matches, 1)
|
||||
await assert_row_count_and_clear(matches, 1)
|
||||
|
||||
|
||||
async def test_upcoming_matches_endpoint(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
async with (
|
||||
inserted_stage(DUMMY_STAGE1.copy(update={'is_active': True})) as stage_inserted,
|
||||
inserted_round(
|
||||
DUMMY_ROUND1.copy(
|
||||
update={'tournament_id': auth_context.tournament.id, 'is_draft': True}
|
||||
update={
|
||||
'is_draft': True,
|
||||
'stage_id': stage_inserted.id,
|
||||
}
|
||||
)
|
||||
),
|
||||
inserted_team(
|
||||
@@ -147,6 +160,7 @@ async def test_upcoming_matches_endpoint(
|
||||
json_response = await send_tournament_request(
|
||||
HTTPMethod.GET, 'upcoming_matches', auth_context, {}
|
||||
)
|
||||
print(json_response)
|
||||
assert json_response == {
|
||||
'data': [
|
||||
{
|
||||
|
||||
@@ -1,53 +1,27 @@
|
||||
import pytest
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.schema import rounds
|
||||
from bracket.utils.db import fetch_one_parsed_certain
|
||||
from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_ROUND1, DUMMY_TEAM1
|
||||
from bracket.utils.dummy_records import DUMMY_ROUND1, DUMMY_STAGE1, DUMMY_TEAM1
|
||||
from bracket.utils.http import HTTPMethod
|
||||
from tests.integration_tests.api.shared import (
|
||||
SUCCESS_RESPONSE,
|
||||
send_request,
|
||||
send_tournament_request,
|
||||
)
|
||||
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_round, inserted_team
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("with_auth",), [(True,), (False,)])
|
||||
async def test_rounds_endpoint(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext, with_auth: bool
|
||||
) -> None:
|
||||
async with (inserted_team(DUMMY_TEAM1), inserted_round(DUMMY_ROUND1) as round_inserted):
|
||||
if with_auth:
|
||||
response = await send_tournament_request(HTTPMethod.GET, 'rounds', auth_context, {})
|
||||
else:
|
||||
response = await send_request(
|
||||
HTTPMethod.GET, f'tournaments/{auth_context.tournament.id}/rounds'
|
||||
)
|
||||
|
||||
assert response == {
|
||||
'data': [
|
||||
{
|
||||
'created': DUMMY_MOCK_TIME.isoformat(),
|
||||
'id': round_inserted.id,
|
||||
'is_active': False,
|
||||
'is_draft': False,
|
||||
'matches': [],
|
||||
'name': 'Round 1',
|
||||
'tournament_id': 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
from tests.integration_tests.sql import (
|
||||
assert_row_count_and_clear,
|
||||
inserted_round,
|
||||
inserted_stage,
|
||||
inserted_team,
|
||||
)
|
||||
|
||||
|
||||
async def test_create_round(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
async with inserted_team(DUMMY_TEAM1):
|
||||
async with (inserted_team(DUMMY_TEAM1), inserted_stage(DUMMY_STAGE1) as stage_inserted):
|
||||
assert (
|
||||
await send_tournament_request(HTTPMethod.POST, 'rounds', auth_context, {})
|
||||
await send_tournament_request(
|
||||
HTTPMethod.POST, 'rounds', auth_context, json={'stage_id': stage_inserted.id}
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
await assert_row_count_and_clear(rounds, 1)
|
||||
@@ -56,7 +30,11 @@ async def test_create_round(
|
||||
async def test_delete_round(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
async with (inserted_team(DUMMY_TEAM1), inserted_round(DUMMY_ROUND1) as round_inserted):
|
||||
async with (
|
||||
inserted_team(DUMMY_TEAM1),
|
||||
inserted_stage(DUMMY_STAGE1) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.DELETE, f'rounds/{round_inserted.id}', auth_context, {}
|
||||
@@ -70,7 +48,11 @@ async def test_update_round(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
body = {'name': 'Some new name', 'is_draft': True, 'is_active': False}
|
||||
async with (inserted_team(DUMMY_TEAM1), inserted_round(DUMMY_ROUND1) as round_inserted):
|
||||
async with (
|
||||
inserted_team(DUMMY_TEAM1),
|
||||
inserted_stage(DUMMY_STAGE1) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.PATCH, f'rounds/{round_inserted.id}', auth_context, None, body
|
||||
|
||||
116
backend/tests/integration_tests/api/stages_test.py
Normal file
116
backend/tests/integration_tests/api/stages_test.py
Normal file
@@ -0,0 +1,116 @@
|
||||
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.http import HTTPMethod
|
||||
from bracket.utils.types import assert_some
|
||||
from tests.integration_tests.api.shared import (
|
||||
SUCCESS_RESPONSE,
|
||||
send_request,
|
||||
send_tournament_request,
|
||||
)
|
||||
from tests.integration_tests.models import AuthContext
|
||||
from tests.integration_tests.sql import (
|
||||
assert_row_count_and_clear,
|
||||
inserted_round,
|
||||
inserted_stage,
|
||||
inserted_team,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("with_auth",), [(True,), (False,)])
|
||||
async def test_stages_endpoint(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext, with_auth: bool
|
||||
) -> None:
|
||||
async with (
|
||||
inserted_team(DUMMY_TEAM1),
|
||||
inserted_stage(DUMMY_STAGE1) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
):
|
||||
if with_auth:
|
||||
response = await send_tournament_request(HTTPMethod.GET, 'stages', auth_context, {})
|
||||
else:
|
||||
response = await send_request(
|
||||
HTTPMethod.GET,
|
||||
f'tournaments/{auth_context.tournament.id}/stages?no_draft_rounds=true',
|
||||
)
|
||||
|
||||
assert response == {
|
||||
'data': [
|
||||
{
|
||||
'id': stage_inserted.id,
|
||||
'tournament_id': 1,
|
||||
'created': DUMMY_MOCK_TIME.isoformat(),
|
||||
'type': 'ROUND_ROBIN',
|
||||
'is_active': False,
|
||||
'rounds': [
|
||||
{
|
||||
'id': round_inserted.id,
|
||||
'stage_id': stage_inserted.id,
|
||||
'created': '2022-01-11T04:32:11+00:00',
|
||||
'is_draft': False,
|
||||
'is_active': False,
|
||||
'name': 'Round 1',
|
||||
'matches': [],
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def test_create_stage(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
async with inserted_team(DUMMY_TEAM1):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.POST,
|
||||
'stages',
|
||||
auth_context,
|
||||
json={'type': StageType.SINGLE_ELIMINATION.value},
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
await assert_row_count_and_clear(stages, 1)
|
||||
|
||||
|
||||
async def test_delete_stage(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
async with (
|
||||
inserted_team(DUMMY_TEAM1),
|
||||
inserted_stage(DUMMY_STAGE1) as stage_inserted,
|
||||
):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.DELETE, f'stages/{stage_inserted.id}', auth_context, {}
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
await assert_row_count_and_clear(stages, 0)
|
||||
|
||||
|
||||
async def test_update_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),
|
||||
inserted_stage(DUMMY_STAGE1) as stage_inserted,
|
||||
):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.PATCH, f'stages/{stage_inserted.id}', auth_context, None, body
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
[patched_stage] = await get_stages_with_rounds_and_matches(
|
||||
assert_some(auth_context.tournament.id)
|
||||
)
|
||||
assert patched_stage.type.value == body['type']
|
||||
assert patched_stage.is_active == body['is_active']
|
||||
|
||||
await assert_row_count_and_clear(stages, 1)
|
||||
@@ -9,6 +9,7 @@ from bracket.models.db.match import Match
|
||||
from bracket.models.db.player import Player
|
||||
from bracket.models.db.player_x_team import PlayerXTeam
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.stage import Stage
|
||||
from bracket.models.db.team import Team
|
||||
from bracket.models.db.tournament import Tournament
|
||||
from bracket.models.db.user import User, UserInDB
|
||||
@@ -19,13 +20,16 @@ from bracket.schema import (
|
||||
players,
|
||||
players_x_teams,
|
||||
rounds,
|
||||
stages,
|
||||
teams,
|
||||
tournaments,
|
||||
users,
|
||||
users_x_clubs,
|
||||
)
|
||||
from bracket.utils.conversion import to_string_mapping
|
||||
from bracket.utils.db import fetch_one_parsed
|
||||
from bracket.utils.dummy_records import DUMMY_CLUB, DUMMY_TOURNAMENT
|
||||
from bracket.utils.logging import logger
|
||||
from bracket.utils.types import BaseModelT, assert_some
|
||||
from tests.integration_tests.mocks import MOCK_USER, get_mock_token
|
||||
from tests.integration_tests.models import AuthContext
|
||||
@@ -40,7 +44,14 @@ async def assert_row_count_and_clear(table: Table, expected_rows: int) -> None:
|
||||
async def inserted_generic(
|
||||
data_model: BaseModelT, table: Table, return_type: Type[BaseModelT]
|
||||
) -> AsyncIterator[BaseModelT]:
|
||||
last_record_id = await database.execute(query=table.insert(), values=data_model.dict())
|
||||
try:
|
||||
last_record_id = await database.execute(
|
||||
query=table.insert(), values=to_string_mapping(data_model) # type: ignore[arg-type]
|
||||
)
|
||||
except:
|
||||
logger.exception(f'Could not insert {type(data_model).__name__}')
|
||||
raise
|
||||
|
||||
row_inserted = await fetch_one_parsed(
|
||||
database, return_type, table.select().where(table.c.id == last_record_id)
|
||||
)
|
||||
@@ -92,6 +103,12 @@ async def inserted_player_in_team(player: Player, team_id: int) -> AsyncIterator
|
||||
yield row_inserted
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def inserted_stage(stage: Stage) -> AsyncIterator[Stage]:
|
||||
async with inserted_generic(stage, stages, Stage) as row_inserted:
|
||||
yield row_inserted
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def inserted_round(round_: Round) -> AsyncIterator[Round]:
|
||||
async with inserted_generic(round_, rounds, Round) as row_inserted:
|
||||
@@ -113,16 +130,18 @@ async def inserted_user_x_club(user_x_club: UserXClub) -> AsyncIterator[UserXClu
|
||||
@asynccontextmanager
|
||||
async def inserted_auth_context() -> AsyncIterator[AuthContext]:
|
||||
headers = {'Authorization': f'Bearer {get_mock_token()}'}
|
||||
async with inserted_user(MOCK_USER) as user_inserted:
|
||||
async with inserted_club(DUMMY_CLUB) as club_inserted:
|
||||
async with inserted_tournament(DUMMY_TOURNAMENT) as tournament_inserted:
|
||||
async with inserted_user_x_club(
|
||||
UserXClub(user_id=user_inserted.id, club_id=assert_some(club_inserted.id))
|
||||
) as user_x_club_inserted:
|
||||
yield AuthContext(
|
||||
headers=headers,
|
||||
user=user_inserted,
|
||||
club=club_inserted,
|
||||
tournament=tournament_inserted,
|
||||
user_x_club=user_x_club_inserted,
|
||||
)
|
||||
async with (
|
||||
inserted_user(MOCK_USER) as user_inserted,
|
||||
inserted_club(DUMMY_CLUB) as club_inserted,
|
||||
inserted_tournament(DUMMY_TOURNAMENT) as tournament_inserted,
|
||||
inserted_user_x_club(
|
||||
UserXClub(user_id=user_inserted.id, club_id=assert_some(club_inserted.id))
|
||||
) as user_x_club_inserted,
|
||||
):
|
||||
yield AuthContext(
|
||||
headers=headers,
|
||||
user=user_inserted,
|
||||
club=club_inserted,
|
||||
tournament=tournament_inserted,
|
||||
user_x_club=user_x_club_inserted,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_PLAYER1, DUMMY_PL
|
||||
|
||||
def test_elo_calculation() -> None:
|
||||
round_ = RoundWithMatches(
|
||||
tournament_id=1,
|
||||
stage_id=1,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_draft=False,
|
||||
is_active=False,
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import { Grid, Skeleton } from '@mantine/core';
|
||||
import { Alert, Grid, Skeleton } from '@mantine/core';
|
||||
import { IconAlertCircle } from '@tabler/icons';
|
||||
import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { RoundInterface } from '../../interfaces/round';
|
||||
import { StageInterface } from '../../interfaces/round';
|
||||
import { TournamentMinimal } from '../../interfaces/tournament';
|
||||
import { responseIsValid } from '../utils/util';
|
||||
import Round from './round';
|
||||
|
||||
export default function Brackets({
|
||||
tournamentData,
|
||||
swrRoundsResponse,
|
||||
swrStagesResponse,
|
||||
swrUpcomingMatchesResponse,
|
||||
readOnly,
|
||||
activeStageId,
|
||||
}: {
|
||||
tournamentData: TournamentMinimal;
|
||||
swrRoundsResponse: SWRResponse;
|
||||
swrStagesResponse: SWRResponse;
|
||||
swrUpcomingMatchesResponse: SWRResponse | null;
|
||||
readOnly: boolean;
|
||||
activeStageId: number | null;
|
||||
}) {
|
||||
if (!swrRoundsResponse.isLoading && swrRoundsResponse.data == null) {
|
||||
return <p>No rounds found</p>;
|
||||
if (
|
||||
activeStageId == null ||
|
||||
(!swrStagesResponse.isLoading && !responseIsValid(swrStagesResponse))
|
||||
) {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="No rounds found" color="blue" radius="lg">
|
||||
Add a round using the top right button.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (swrRoundsResponse.isLoading) {
|
||||
if (swrStagesResponse.isLoading) {
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col sm={6} lg={4} xl={3}>
|
||||
@@ -31,20 +43,27 @@ export default function Brackets({
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
const stages_map = Object.fromEntries(
|
||||
swrStagesResponse.data.data.map((x: StageInterface) => [x.id, x])
|
||||
);
|
||||
|
||||
const rounds = swrRoundsResponse.data.data
|
||||
const rounds = stages_map[activeStageId].rounds
|
||||
.sort((r1: any, r2: any) => (r1.name > r2.name ? 1 : 0))
|
||||
.map((round: RoundInterface) => (
|
||||
.map((round: StageInterface) => (
|
||||
<Grid.Col sm={6} lg={4} xl={3} key={round.id}>
|
||||
<Round
|
||||
tournamentData={tournamentData}
|
||||
round={round}
|
||||
swrRoundsResponse={swrRoundsResponse}
|
||||
swrRoundsResponse={swrStagesResponse}
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</Grid.Col>
|
||||
));
|
||||
|
||||
return <Grid>{rounds}</Grid>;
|
||||
return (
|
||||
<div>
|
||||
<Grid>{rounds}</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Center, Title } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { RoundInterface } from '../../interfaces/round';
|
||||
import { StageInterface } from '../../interfaces/round';
|
||||
import { TournamentMinimal } from '../../interfaces/tournament';
|
||||
import RoundModal from '../modals/round_modal';
|
||||
import Match from './match';
|
||||
@@ -15,7 +15,7 @@ export default function Round({
|
||||
readOnly,
|
||||
}: {
|
||||
tournamentData: TournamentMinimal;
|
||||
round: RoundInterface;
|
||||
round: StageInterface;
|
||||
swrRoundsResponse: SWRResponse;
|
||||
swrUpcomingMatchesResponse: SWRResponse | null;
|
||||
readOnly: boolean;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { IconPencil } from '@tabler/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { RoundInterface } from '../../interfaces/round';
|
||||
import { StageInterface } from '../../interfaces/round';
|
||||
import { TournamentMinimal } from '../../interfaces/tournament';
|
||||
import { deleteRound, updateRound } from '../../services/round';
|
||||
import DeleteButton from '../buttons/delete';
|
||||
@@ -24,7 +24,7 @@ export default function RoundModal({
|
||||
swrUpcomingMatchesResponse,
|
||||
}: {
|
||||
tournamentData: TournamentMinimal;
|
||||
round: RoundInterface;
|
||||
round: StageInterface;
|
||||
swrRoundsResponse: SWRResponse;
|
||||
swrUpcomingMatchesResponse: SWRResponse | null;
|
||||
}) {
|
||||
@@ -47,7 +47,7 @@ export default function RoundModal({
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Edit Round">
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
await updateRound(tournamentData.id, round.id, values as RoundInterface);
|
||||
await updateRound(tournamentData.id, round.id, values as StageInterface);
|
||||
await swrRoundsResponse.mutate(null);
|
||||
setOpened(false);
|
||||
})}
|
||||
|
||||
@@ -1,47 +1,29 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Group,
|
||||
Image,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { IconMoonStars, IconSun } from '@tabler/icons';
|
||||
import { Box, Group, Image, Title, UnstyledButton } from '@mantine/core';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
|
||||
export function Brand() {
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
paddingLeft: theme.spacing.xs,
|
||||
paddingRight: theme.spacing.xs,
|
||||
paddingBottom: theme.spacing.lg,
|
||||
borderBottom: `1px solid ${
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]
|
||||
}`,
|
||||
sx={() => ({
|
||||
paddingTop: '1rem',
|
||||
})}
|
||||
>
|
||||
<Group position="apart">
|
||||
<Group position="apart" ml="1rem">
|
||||
<UnstyledButton>
|
||||
<Group>
|
||||
<Image src="/favicon.svg" width="50px" height="50px" mt="-8px" />
|
||||
<Image src="/favicon.svg" width="40px" height="40px" mt="-0.5rem" />
|
||||
<Title
|
||||
onClick={() => {
|
||||
router.push('/');
|
||||
onClick={async () => {
|
||||
await router.push('/');
|
||||
}}
|
||||
>
|
||||
Bracket
|
||||
</Title>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<ActionIcon variant="default" onClick={() => toggleColorScheme()} size={30}>
|
||||
{colorScheme === 'dark' ? <IconSun size={16} /> : <IconMoonStars size={16} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Tooltip, UnstyledButton } from '@mantine/core';
|
||||
import { IconTournament, IconUser, IconUsers, TablerIcon } from '@tabler/icons';
|
||||
import { NextRouter, useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
@@ -14,15 +15,24 @@ interface MainLinkProps {
|
||||
function MainLink({ item, pathName }: { item: MainLinkProps; pathName: String }) {
|
||||
const { classes, cx } = useNavbarStyles();
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
key={item.endpoint}
|
||||
className={cx(classes.link, { [classes.linkActive]: pathName === item.endpoint })}
|
||||
onClick={() => item.router.push(item.endpoint)}
|
||||
>
|
||||
<item.icon className={classes.linkIcon} stroke={1.5} />
|
||||
<span style={{ marginLeft: '10px' }}>{item.label}</span>
|
||||
</a>
|
||||
<Tooltip label={item.label} position="right" transitionProps={{ duration: 0 }}>
|
||||
<UnstyledButton
|
||||
onClick={() => item.router.push(item.endpoint)}
|
||||
className={cx(classes.link, { [classes.linkActive]: pathName === item.endpoint })}
|
||||
>
|
||||
{/*<Icon size="1.2rem" stroke={1.5} />*/}
|
||||
<item.icon className={classes.linkIcon} stroke={1.5} />
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
// <a
|
||||
// href="#"
|
||||
// key={item.endpoint}
|
||||
// className={cx(classes.link, { [classes.linkActive]: pathName === item.endpoint })}
|
||||
// onClick={() => item.router.push(item.endpoint)}
|
||||
// >
|
||||
// <item.icon className={classes.linkIcon} stroke={1.5} />
|
||||
// <span style={{ marginLeft: '10px' }}>{item.label}</span>
|
||||
// </a>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,14 @@ export const useNavbarStyles = createStyles((theme) => {
|
||||
link: {
|
||||
...theme.fn.focusStyles(),
|
||||
display: 'flex',
|
||||
width: rem(50),
|
||||
height: rem(50),
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textDecoration: 'none',
|
||||
fontSize: theme.fontSizes.sm,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[1] : theme.colors.gray[7],
|
||||
padding: `${theme.spacing.xs} ${theme.spacing.sm}`,
|
||||
padding: `${theme.spacing.xs} ${theme.spacing.xs}`,
|
||||
borderRadius: theme.radius.sm,
|
||||
fontWeight: 500,
|
||||
|
||||
@@ -46,7 +49,6 @@ export const useNavbarStyles = createStyles((theme) => {
|
||||
linkIcon: {
|
||||
ref: icon,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
|
||||
marginRight: theme.spacing.sm,
|
||||
},
|
||||
|
||||
linkActive: {
|
||||
|
||||
86
frontend/src/components/utils/stages_tab.tsx
Normal file
86
frontend/src/components/utils/stages_tab.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Tabs, TabsProps, rem } from '@mantine/core';
|
||||
import { IconSettings } from '@tabler/icons';
|
||||
|
||||
import { StageInterface } from '../../interfaces/stage';
|
||||
import { responseIsValid } from './util';
|
||||
|
||||
function StyledTabs(props: TabsProps & { setActiveStageId: any }) {
|
||||
return (
|
||||
<Tabs
|
||||
unstyled
|
||||
onTabChange={(value) => props.setActiveStageId(value)}
|
||||
mb="1rem"
|
||||
styles={(theme) => ({
|
||||
tab: {
|
||||
...theme.fn.focusStyles(),
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.white,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.gray[9],
|
||||
border: `${rem(1)} solid ${
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[4]
|
||||
}`,
|
||||
padding: `${theme.spacing.xs} ${theme.spacing.md}`,
|
||||
cursor: 'pointer',
|
||||
fontSize: theme.fontSizes.sm,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
'&:disabled': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
|
||||
'&:not(:first-of-type)': {
|
||||
borderLeft: 0,
|
||||
},
|
||||
|
||||
'&:first-of-type': {
|
||||
borderTopLeftRadius: theme.radius.md,
|
||||
borderBottomLeftRadius: theme.radius.md,
|
||||
},
|
||||
|
||||
'&:last-of-type': {
|
||||
borderTopRightRadius: theme.radius.md,
|
||||
borderBottomRightRadius: theme.radius.md,
|
||||
},
|
||||
|
||||
'&[data-active]': {
|
||||
backgroundColor: theme.colors.blue[7],
|
||||
borderColor: theme.colors.blue[7],
|
||||
color: theme.white,
|
||||
},
|
||||
},
|
||||
|
||||
tabIcon: {
|
||||
marginRight: theme.spacing.xs,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
tabsList: {
|
||||
display: 'flex',
|
||||
},
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StagesTab({ swrStagesResponse, setActiveStageId }: any) {
|
||||
if (!responseIsValid(swrStagesResponse)) {
|
||||
return <></>;
|
||||
}
|
||||
const items = swrStagesResponse.data.data.map((item: StageInterface) => (
|
||||
<Tabs.Tab
|
||||
value={item.id.toString()}
|
||||
key={item.id.toString()}
|
||||
icon={<IconSettings size="1rem" />}
|
||||
>
|
||||
{item.type}
|
||||
</Tabs.Tab>
|
||||
));
|
||||
return (
|
||||
<StyledTabs setActiveStageId={setActiveStageId}>
|
||||
<Tabs.List>{items}</Tabs.List>
|
||||
</StyledTabs>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
export function getItemColor(theme: any) {
|
||||
const darkTheme = theme.colorScheme === 'dark';
|
||||
@@ -42,3 +43,7 @@ export function getTournamentIdFromRouter() {
|
||||
const tournamentData = { id };
|
||||
return { id, tournamentData };
|
||||
}
|
||||
|
||||
export function responseIsValid(response: SWRResponse) {
|
||||
return response.data != null && response.data.data != null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { MatchInterface } from './match';
|
||||
|
||||
export interface RoundInterface {
|
||||
export interface StageInterface {
|
||||
id: number;
|
||||
tournament_id: number;
|
||||
created: string;
|
||||
name: string;
|
||||
is_draft: boolean;
|
||||
|
||||
9
frontend/src/interfaces/stage.tsx
Normal file
9
frontend/src/interfaces/stage.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface StageInterface {
|
||||
id: number;
|
||||
tournament_id: number;
|
||||
created: string;
|
||||
type: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
rounds: StageInterface[];
|
||||
}
|
||||
@@ -1,84 +1,189 @@
|
||||
import { AppShell, Burger, Header, MediaQuery, Navbar, useMantineTheme } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Burger,
|
||||
Container,
|
||||
Grid,
|
||||
Header,
|
||||
Menu,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
createStyles,
|
||||
rem,
|
||||
useMantineColorScheme,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { FaGithub } from '@react-icons/all-files/fa/FaGithub';
|
||||
import { IconMoonStars, IconSun } from '@tabler/icons';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
import { Brand } from '../components/navbar/_brand';
|
||||
import { User } from '../components/navbar/_user';
|
||||
import { getBaseApiUrl } from '../services/adapter';
|
||||
|
||||
export default function Layout({ children, links }: any) {
|
||||
const theme = useMantineTheme();
|
||||
const [navBarOpened, setNavBarOpened] = useState(false);
|
||||
const LINKS = [
|
||||
{ link: '/clubs', label: 'Clubs', links: null },
|
||||
{ link: '/', label: 'Tournaments', links: null },
|
||||
{ link: '/user', label: 'User', links: [{ link: '/user', label: 'Logout', icon: null }] },
|
||||
{
|
||||
link: '/docs',
|
||||
label: 'More',
|
||||
links: [
|
||||
{ link: 'https://evroon.github.io/bracket/', label: 'Website', icon: null },
|
||||
{ link: 'https://github.com/evroon/bracket', label: 'GitHub', icon: <FaGithub size={20} /> },
|
||||
{ link: `${getBaseApiUrl()}/docs`, label: 'API docs', icon: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<MediaQuery
|
||||
largerThan="md"
|
||||
styles={{
|
||||
'--mantine-header-height': '0px',
|
||||
const HEADER_HEIGHT = rem(70);
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
inner: {
|
||||
height: HEADER_HEIGHT,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
links: {
|
||||
[theme.fn.smallerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
burger: {
|
||||
[theme.fn.largerThan('sm')]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
link: {
|
||||
display: 'block',
|
||||
lineHeight: 1,
|
||||
padding: `${rem(8)} ${rem(12)}`,
|
||||
borderRadius: theme.radius.sm,
|
||||
textDecoration: 'none',
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.gray[7],
|
||||
fontSize: theme.fontSizes.sm,
|
||||
fontWeight: 500,
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0],
|
||||
},
|
||||
},
|
||||
|
||||
linkLabel: {
|
||||
marginRight: rem(5),
|
||||
},
|
||||
}));
|
||||
|
||||
interface HeaderActionProps {
|
||||
links: {
|
||||
link: string;
|
||||
label: string;
|
||||
icon?: Component | null;
|
||||
links?: { link: string; label: string; icon?: ReactNode }[] | null;
|
||||
}[];
|
||||
}
|
||||
export function HeaderAction({ links }: HeaderActionProps) {
|
||||
const { classes } = useStyles();
|
||||
const router = useRouter();
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
|
||||
const items = links.map((link) => {
|
||||
const menuItems = link.links?.map((item) => (
|
||||
<Menu.Item
|
||||
key={item.link}
|
||||
onClick={async () => {
|
||||
await router.push(item.link);
|
||||
}}
|
||||
>
|
||||
<AppShell
|
||||
fixed
|
||||
styles={{
|
||||
main: {
|
||||
background:
|
||||
theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0],
|
||||
},
|
||||
}}
|
||||
padding="md"
|
||||
navbarOffsetBreakpoint="md"
|
||||
header={
|
||||
<MediaQuery
|
||||
largerThan="md"
|
||||
styles={{
|
||||
display: 'none',
|
||||
'--mantine-header-height': '0px',
|
||||
<Container>
|
||||
<span>{item.icon}</span>
|
||||
<span style={{ marginLeft: '0.25rem' }}>{item.label}</span>
|
||||
</Container>
|
||||
</Menu.Item>
|
||||
));
|
||||
|
||||
if (menuItems) {
|
||||
return (
|
||||
<Menu key={link.label} trigger="hover" transitionProps={{ exitDuration: 0 }} withinPortal>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
className={classes.link}
|
||||
onClick={async () => {
|
||||
await router.push(link.link);
|
||||
}}
|
||||
>
|
||||
<Header height={70} p="md">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Burger
|
||||
opened={navBarOpened}
|
||||
onClick={() => setNavBarOpened((o) => !o)}
|
||||
size="sm"
|
||||
color={theme.colors.gray[6]}
|
||||
mr="xl"
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
</MediaQuery>
|
||||
}
|
||||
navbar={
|
||||
<Navbar
|
||||
p="md"
|
||||
width={{ sm: 300, lg: 300 }}
|
||||
hidden={!navBarOpened}
|
||||
hiddenBreakpoint="md"
|
||||
>
|
||||
<Navbar.Section mt="md">
|
||||
<Brand />
|
||||
</Navbar.Section>
|
||||
{links == null ? (
|
||||
<Navbar.Section grow>
|
||||
<div />
|
||||
</Navbar.Section>
|
||||
) : (
|
||||
links
|
||||
)}
|
||||
<Navbar.Section>
|
||||
<User />
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
</MediaQuery>
|
||||
</>
|
||||
<>
|
||||
{link.icon}
|
||||
<Text span className={classes.linkLabel}>
|
||||
{link.label}
|
||||
</Text>
|
||||
</>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>{menuItems}</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
mr="1rem"
|
||||
key={link.label}
|
||||
className={classes.link}
|
||||
onClick={async () => {
|
||||
await router.push(link.link);
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</UnstyledButton>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Header height={{ base: HEADER_HEIGHT, md: HEADER_HEIGHT }}>
|
||||
<Burger opened={opened} onClick={toggle} className={classes.burger} size="sm" mt="0.5rem" />
|
||||
<Grid>
|
||||
<Grid.Col span={4}>
|
||||
<Brand />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={4} offset={4}>
|
||||
<Container className={classes.inner} fluid style={{ justifyContent: 'end' }}>
|
||||
{items}
|
||||
{/*<Button radius="xl" h={30}>*/}
|
||||
{/* Get early access*/}
|
||||
{/*</Button>*/}
|
||||
<ActionIcon variant="default" onClick={() => toggleColorScheme()} size={30} ml="1rem">
|
||||
{colorScheme === 'dark' ? <IconSun size={16} /> : <IconMoonStars size={16} />}
|
||||
</ActionIcon>
|
||||
</Container>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout({ children, navbar }: any) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
styles={{
|
||||
main: {
|
||||
background: theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0],
|
||||
},
|
||||
}}
|
||||
layout="default"
|
||||
navbarOffsetBreakpoint="sm"
|
||||
asideOffsetBreakpoint="sm"
|
||||
header={<HeaderAction links={LINKS} />}
|
||||
navbar={navbar}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Grid, Group, Title } from '@mantine/core';
|
||||
import { Button, Center, Grid, Group, Title } from '@mantine/core';
|
||||
import { GoPlus } from '@react-icons/all-files/go/GoPlus';
|
||||
import { IconExternalLink } from '@tabler/icons';
|
||||
import React, { useState } from 'react';
|
||||
@@ -9,13 +9,14 @@ import Brackets from '../../components/brackets/brackets';
|
||||
import SaveButton from '../../components/buttons/save';
|
||||
import TournamentModal from '../../components/modals/tournament_modal';
|
||||
import Scheduler from '../../components/scheduling/scheduler';
|
||||
import { getTournamentIdFromRouter } from '../../components/utils/util';
|
||||
import StagesTab from '../../components/utils/stages_tab';
|
||||
import { getTournamentIdFromRouter, responseIsValid } from '../../components/utils/util';
|
||||
import { SchedulerSettings } from '../../interfaces/match';
|
||||
import { RoundInterface } from '../../interfaces/round';
|
||||
import { StageInterface } from '../../interfaces/round';
|
||||
import { Tournament } from '../../interfaces/tournament';
|
||||
import {
|
||||
checkForAuthError,
|
||||
getRounds,
|
||||
getStages,
|
||||
getTournaments,
|
||||
getUpcomingMatches,
|
||||
} from '../../services/adapter';
|
||||
@@ -27,11 +28,12 @@ export default function TournamentPage() {
|
||||
|
||||
const swrTournamentsResponse = getTournaments();
|
||||
checkForAuthError(swrTournamentsResponse);
|
||||
const swrRoundsResponse: SWRResponse = getRounds(id);
|
||||
const swrStagesResponse: SWRResponse = getStages(id);
|
||||
const [onlyBehindSchedule, setOnlyBehindSchedule] = useState('true');
|
||||
const [eloThreshold, setEloThreshold] = useState(100);
|
||||
const [iterations, setIterations] = useState(200);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [activeStageId, setActiveStageId] = useState(null);
|
||||
|
||||
const schedulerSettings: SchedulerSettings = {
|
||||
eloThreshold,
|
||||
@@ -54,10 +56,12 @@ export default function TournamentPage() {
|
||||
return <NotFoundTitle />;
|
||||
}
|
||||
|
||||
const draft_round =
|
||||
swrRoundsResponse.data != null
|
||||
? swrRoundsResponse.data.data.filter((round: RoundInterface) => round.is_draft)
|
||||
: null;
|
||||
let draft_round = null;
|
||||
if (responseIsValid(swrStagesResponse)) {
|
||||
draft_round = swrStagesResponse.data.data
|
||||
.flat()
|
||||
.filter((stage: StageInterface) => stage.is_draft);
|
||||
}
|
||||
|
||||
const scheduler =
|
||||
draft_round != null && draft_round.length > 0 ? (
|
||||
@@ -66,7 +70,7 @@ export default function TournamentPage() {
|
||||
<Scheduler
|
||||
round_id={draft_round[0].id}
|
||||
tournamentData={tournamentDataFull}
|
||||
swrRoundsResponse={swrRoundsResponse}
|
||||
swrRoundsResponse={swrStagesResponse}
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
schedulerSettings={schedulerSettings}
|
||||
/>
|
||||
@@ -106,7 +110,7 @@ export default function TournamentPage() {
|
||||
<SaveButton
|
||||
onClick={async () => {
|
||||
await createRound(tournamentData.id);
|
||||
await swrRoundsResponse.mutate();
|
||||
await swrStagesResponse.mutate();
|
||||
}}
|
||||
leftIcon={<GoPlus size={24} />}
|
||||
title="Add Round"
|
||||
@@ -115,11 +119,15 @@ export default function TournamentPage() {
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<div style={{ marginTop: '15px' }}>
|
||||
<Center>
|
||||
<StagesTab swrStagesResponse={swrStagesResponse} setActiveStageId={setActiveStageId} />
|
||||
</Center>
|
||||
<Brackets
|
||||
tournamentData={tournamentDataFull}
|
||||
swrRoundsResponse={swrRoundsResponse}
|
||||
swrStagesResponse={swrStagesResponse}
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
readOnly={false}
|
||||
activeStageId={activeStageId}
|
||||
/>
|
||||
{scheduler}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Grid, Image, Skeleton, Title } from '@mantine/core';
|
||||
import { Center, Grid, Image, Skeleton, Title } from '@mantine/core';
|
||||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import NotFoundTitle from '../../404';
|
||||
import Brackets from '../../../components/brackets/brackets';
|
||||
import StagesTab from '../../../components/utils/stages_tab';
|
||||
import { getTournamentIdFromRouter } from '../../../components/utils/util';
|
||||
import { Tournament } from '../../../interfaces/tournament';
|
||||
import { getBaseApiUrl, getRounds, getTournament } from '../../../services/adapter';
|
||||
import { getBaseApiUrl, getStages, getTournament } from '../../../services/adapter';
|
||||
|
||||
function TournamentLogo({ tournamentDataFull }: { tournamentDataFull: Tournament }) {
|
||||
if (tournamentDataFull == null) {
|
||||
@@ -41,8 +42,9 @@ function TournamentTitle({ tournamentDataFull }: { tournamentDataFull: Tournamen
|
||||
|
||||
export default function Dashboard() {
|
||||
const { tournamentData } = getTournamentIdFromRouter();
|
||||
const swrRoundsResponse: SWRResponse = getRounds(tournamentData.id, true);
|
||||
const swrStagesResponse: SWRResponse = getStages(tournamentData.id, true);
|
||||
const swrTournamentsResponse = getTournament(tournamentData.id);
|
||||
const [activeStageId, setActiveStageId] = useState(null);
|
||||
|
||||
const tournamentDataFull: Tournament =
|
||||
swrTournamentsResponse.data != null ? swrTournamentsResponse.data.data : null;
|
||||
@@ -62,11 +64,15 @@ export default function Dashboard() {
|
||||
<TournamentLogo tournamentDataFull={tournamentDataFull} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={10}>
|
||||
<Center>
|
||||
<StagesTab swrStagesResponse={swrStagesResponse} setActiveStageId={setActiveStageId} />
|
||||
</Center>
|
||||
<Brackets
|
||||
tournamentData={tournamentData}
|
||||
swrRoundsResponse={swrRoundsResponse}
|
||||
swrStagesResponse={swrStagesResponse}
|
||||
swrUpcomingMatchesResponse={null}
|
||||
readOnly
|
||||
activeStageId={activeStageId}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
import { Navbar } from '@mantine/core';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { MainLinks } from '../../components/navbar/_main_links';
|
||||
import { checkForAuthError, getTournaments } from '../../services/adapter';
|
||||
import Layout from '../_layout';
|
||||
|
||||
function NavBar({ navBarOpened, links }: any) {
|
||||
return (
|
||||
<Navbar p="md" width={{ base: 80 }} hidden={!navBarOpened} hiddenBreakpoint="sm">
|
||||
{links == null ? (
|
||||
<Navbar.Section grow>
|
||||
<div />
|
||||
</Navbar.Section>
|
||||
) : (
|
||||
links
|
||||
)}
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TournamentLayout({ children, tournament_id }: any) {
|
||||
const tournament = getTournaments();
|
||||
const [navBarOpened] = useState(false);
|
||||
checkForAuthError(tournament);
|
||||
const links = (
|
||||
<Navbar.Section grow mt="md">
|
||||
<Navbar.Section grow>
|
||||
<MainLinks tournament_id={tournament_id} />
|
||||
</Navbar.Section>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Layout links={links}>{children}</Layout>
|
||||
<Layout navbar={<NavBar navBarOpened={navBarOpened} links={links} />}>{children}</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ export function getTeams(tournament_id: number): SWRResponse {
|
||||
return useSWR(`tournaments/${tournament_id}/teams`, fetcher);
|
||||
}
|
||||
|
||||
export function getRounds(tournament_id: number, no_draft_rounds: boolean = false): SWRResponse {
|
||||
return useSWR(`tournaments/${tournament_id}/rounds?no_draft_rounds=${no_draft_rounds}`, fetcher, {
|
||||
export function getStages(tournament_id: number, no_draft_rounds: boolean = false): SWRResponse {
|
||||
return useSWR(`tournaments/${tournament_id}/stages?no_draft_rounds=${no_draft_rounds}`, fetcher, {
|
||||
refreshInterval: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RoundInterface } from '../interfaces/round';
|
||||
import { StageInterface } from '../interfaces/round';
|
||||
import { createAxios, handleRequestError } from './adapter';
|
||||
|
||||
export async function createRound(tournament_id: number) {
|
||||
@@ -13,7 +13,7 @@ export async function deleteRound(tournament_id: number, round_id: number) {
|
||||
.catch((response: any) => handleRequestError(response));
|
||||
}
|
||||
|
||||
export async function updateRound(tournament_id: number, round_id: number, round: RoundInterface) {
|
||||
export async function updateRound(tournament_id: number, round_id: number, round: StageInterface) {
|
||||
return createAxios()
|
||||
.patch(`tournaments/${tournament_id}/rounds/${round_id}`, round)
|
||||
.catch((response: any) => handleRequestError(response));
|
||||
|
||||
Reference in New Issue
Block a user