Add stages in backend (#181)

This commit is contained in:
Erik Vroon
2023-05-02 19:23:52 +02:00
committed by GitHub
parent d2fddaf0fb
commit 352f46113c
46 changed files with 1190 additions and 405 deletions

View File

@@ -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

View File

@@ -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"

View 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 ###

View File

@@ -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'])

View File

@@ -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:

View File

@@ -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(

View File

@@ -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

View File

@@ -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.')

View File

@@ -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

View 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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})

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

View File

@@ -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(

View File

@@ -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:

View File

@@ -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(

View File

@@ -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}'

View 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)

View 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()}

View File

@@ -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]

View File

@@ -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)

View File

@@ -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 .

View File

@@ -73,6 +73,8 @@ disable = [
'too-many-locals',
'too-many-nested-blocks',
'protected-access',
'logging-fstring-interpolation',
'too-many-arguments',
]
[tool.bandit]

View File

@@ -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]:

View File

@@ -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': [
{

View File

@@ -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

View 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)

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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);
})}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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: {

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -0,0 +1,9 @@
export interface StageInterface {
id: number;
tournament_id: number;
created: string;
type: string;
name: string;
is_active: boolean;
rounds: StageInterface[];
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
</>
);
}

View File

@@ -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,
});
}

View File

@@ -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));