diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 9c7d9ee4..cdf340c7 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -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 diff --git a/backend/Pipfile b/backend/Pipfile index 4763cf91..a41ba8e2 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -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" diff --git a/backend/alembic/versions/6458e0bc3e9d_add_stages_table.py b/backend/alembic/versions/6458e0bc3e9d_add_stages_table.py new file mode 100644 index 00000000..03e9c6c1 --- /dev/null +++ b/backend/alembic/versions/6458e0bc3e9d_add_stages_table.py @@ -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 ### diff --git a/backend/bracket/app.py b/backend/bracket/app.py index 3f236904..20cc3b19 100644 --- a/backend/bracket/app.py +++ b/backend/bracket/app.py @@ -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']) diff --git a/backend/bracket/config.py b/backend/bracket/config.py index 6157a0ae..78aba709 100644 --- a/backend/bracket/config.py +++ b/backend/bracket/config.py @@ -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: diff --git a/backend/bracket/logic/elo.py b/backend/bracket/logic/elo.py index 04913dfb..62a2a410 100644 --- a/backend/bracket/logic/elo.py +++ b/backend/bracket/logic/elo.py @@ -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( diff --git a/backend/bracket/logic/scheduling/ladder_players_iter.py b/backend/bracket/logic/scheduling/ladder_players_iter.py index a761e007..379fbd33 100644 --- a/backend/bracket/logic/scheduling/ladder_players_iter.py +++ b/backend/bracket/logic/scheduling/ladder_players_iter.py @@ -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 diff --git a/backend/bracket/logic/scheduling/ladder_teams.py b/backend/bracket/logic/scheduling/ladder_teams.py index c5c93472..072a107a 100644 --- a/backend/bracket/logic/scheduling/ladder_teams.py +++ b/backend/bracket/logic/scheduling/ladder_teams.py @@ -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.') diff --git a/backend/bracket/models/db/round.py b/backend/bracket/models/db/round.py index 83f59ede..72692778 100644 --- a/backend/bracket/models/db/round.py +++ b/backend/bracket/models/db/round.py @@ -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 diff --git a/backend/bracket/models/db/stage.py b/backend/bracket/models/db/stage.py new file mode 100644 index 00000000..c0e7a95e --- /dev/null +++ b/backend/bracket/models/db/stage.py @@ -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 diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index 9b3813f4..5e953ab4 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -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) diff --git a/backend/bracket/routes/models.py b/backend/bracket/routes/models.py index fa067ed4..20ef54d1 100644 --- a/backend/bracket/routes/models.py +++ b/backend/bracket/routes/models.py @@ -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 diff --git a/backend/bracket/routes/rounds.py b/backend/bracket/routes/rounds.py index 3f6a33af..71630fb5 100644 --- a/backend/bracket/routes/rounds.py +++ b/backend/bracket/routes/rounds.py @@ -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}) diff --git a/backend/bracket/routes/stages.py b/backend/bracket/routes/stages.py new file mode 100644 index 00000000..85d4701e --- /dev/null +++ b/backend/bracket/routes/stages.py @@ -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() diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index f1c9f051..85464984 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -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( diff --git a/backend/bracket/routes/util.py b/backend/bracket/routes/util.py index 591e3dd3..97e3d2c1 100644 --- a/backend/bracket/routes/util.py +++ b/backend/bracket/routes/util.py @@ -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: diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index 8d9a5ddc..864f6865 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -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( diff --git a/backend/bracket/sql/rounds.py b/backend/bracket/sql/rounds.py index 282a8c2d..06779ee7 100644 --- a/backend/bracket/sql/rounds.py +++ b/backend/bracket/sql/rounds.py @@ -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}' diff --git a/backend/bracket/sql/stages.py b/backend/bracket/sql/stages.py new file mode 100644 index 00000000..4547a571 --- /dev/null +++ b/backend/bracket/sql/stages.py @@ -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) diff --git a/backend/bracket/utils/conversion.py b/backend/bracket/utils/conversion.py new file mode 100644 index 00000000..293d08d2 --- /dev/null +++ b/backend/bracket/utils/conversion.py @@ -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()} diff --git a/backend/bracket/utils/dummy_records.py b/backend/bracket/utils/dummy_records.py index 154cc7b3..85b16ecd 100644 --- a/backend/bracket/utils/dummy_records.py +++ b/backend/bracket/utils/dummy_records.py @@ -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] diff --git a/backend/cli.py b/backend/cli.py index 01b23dcf..c91046ef 100755 --- a/backend/cli.py +++ b/backend/cli.py @@ -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) diff --git a/backend/precommit.sh b/backend/precommit.sh index 00c4cc5b..f8f1f23a 100755 --- a/backend/precommit.sh +++ b/backend/precommit.sh @@ -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 . diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9960d17d..1449ec6b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -73,6 +73,8 @@ disable = [ 'too-many-locals', 'too-many-nested-blocks', 'protected-access', + 'logging-fstring-interpolation', + 'too-many-arguments', ] [tool.bandit] diff --git a/backend/tests/integration_tests/api/conftest.py b/backend/tests/integration_tests/api/conftest.py index 8f21c042..f7c6b363 100644 --- a/backend/tests/integration_tests/api/conftest.py +++ b/backend/tests/integration_tests/api/conftest.py @@ -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]: diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py index 06580bb4..5e4248f0 100644 --- a/backend/tests/integration_tests/api/matches_test.py +++ b/backend/tests/integration_tests/api/matches_test.py @@ -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': [ { diff --git a/backend/tests/integration_tests/api/rounds_test.py b/backend/tests/integration_tests/api/rounds_test.py index 203bcb96..29937bd9 100644 --- a/backend/tests/integration_tests/api/rounds_test.py +++ b/backend/tests/integration_tests/api/rounds_test.py @@ -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 diff --git a/backend/tests/integration_tests/api/stages_test.py b/backend/tests/integration_tests/api/stages_test.py new file mode 100644 index 00000000..b1401608 --- /dev/null +++ b/backend/tests/integration_tests/api/stages_test.py @@ -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) diff --git a/backend/tests/integration_tests/sql.py b/backend/tests/integration_tests/sql.py index 4db75b58..3719871b 100644 --- a/backend/tests/integration_tests/sql.py +++ b/backend/tests/integration_tests/sql.py @@ -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, + ) diff --git a/backend/tests/unit_tests/elo_test.py b/backend/tests/unit_tests/elo_test.py index 1d8b3f32..0497d661 100644 --- a/backend/tests/unit_tests/elo_test.py +++ b/backend/tests/unit_tests/elo_test.py @@ -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, diff --git a/frontend/src/components/brackets/brackets.tsx b/frontend/src/components/brackets/brackets.tsx index c31fed04..6daf9c88 100644 --- a/frontend/src/components/brackets/brackets.tsx +++ b/frontend/src/components/brackets/brackets.tsx @@ -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
No rounds found
; + if ( + activeStageId == null || + (!swrStagesResponse.isLoading && !responseIsValid(swrStagesResponse)) + ) { + return ( +