diff --git a/backend/alembic/versions/c1ab44651e79_add_tournaments_status.py b/backend/alembic/versions/c1ab44651e79_add_tournaments_status.py new file mode 100644 index 00000000..3b2224e2 --- /dev/null +++ b/backend/alembic/versions/c1ab44651e79_add_tournaments_status.py @@ -0,0 +1,32 @@ +"""add tournaments.status + +Revision ID: c1ab44651e79 +Revises: e6e2718365dc +Create Date: 2025-02-09 11:06:32.622324 + +""" + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ENUM + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str | None = "c1ab44651e79" +down_revision: str | None = "e6e2718365dc" +branch_labels: str | None = None +depends_on: str | None = None + +enum = ENUM("OPEN", "ARCHIVED", name="tournament_status", create_type=True) + + +def upgrade() -> None: + enum.create(op.get_bind(), checkfirst=True) + op.add_column( + "tournaments", sa.Column("status", enum, server_default="OPEN", nullable=False, index=True) + ) + + +def downgrade() -> None: + op.drop_column("tournaments", "status") + enum.drop(op.get_bind()) diff --git a/backend/bracket/models/db/tournament.py b/backend/bracket/models/db/tournament.py index cd162554..9bafe6ef 100644 --- a/backend/bracket/models/db/tournament.py +++ b/backend/bracket/models/db/tournament.py @@ -1,9 +1,17 @@ +from enum import auto + from heliclockter import datetime_utc from pydantic import Field from bracket.models.db.shared import BaseModelORM from bracket.utils.id_types import ClubId, TournamentId from bracket.utils.pydantic import EmptyStrToNone +from bracket.utils.types import EnumAutoStr + + +class TournamentStatus(EnumAutoStr): + OPEN = auto() + ARCHIVED = auto() class TournamentInsertable(BaseModelORM): @@ -18,6 +26,7 @@ class TournamentInsertable(BaseModelORM): logo_path: str | None = None players_can_be_in_multiple_teams: bool auto_assign_courts: bool + status: TournamentStatus = TournamentStatus.OPEN class Tournament(TournamentInsertable): @@ -35,5 +44,9 @@ class TournamentUpdateBody(BaseModelORM): margin_minutes: int = Field(..., ge=0) +class TournamentChangeStatusBody(BaseModelORM): + status: TournamentStatus + + class TournamentBody(TournamentUpdateBody): club_id: ClubId diff --git a/backend/bracket/routes/courts.py b/backend/bracket/routes/courts.py index 57c61451..a471248d 100644 --- a/backend/bracket/routes/courts.py +++ b/backend/bracket/routes/courts.py @@ -5,12 +5,14 @@ from starlette import status from bracket.database import database from bracket.logic.subscriptions import check_requirement from bracket.models.db.court import Court, CourtBody, CourtToInsert +from bracket.models.db.tournament import Tournament 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 CourtsResponse, SingleCourtResponse, SuccessResponse +from bracket.routes.util import disallow_archived_tournament from bracket.schema import courts from bracket.sql.courts import get_all_courts_in_tournament, sql_delete_court, update_court from bracket.sql.stages import get_full_tournament_details @@ -35,6 +37,7 @@ async def update_court_by_id( court_id: CourtId, court_body: CourtBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SingleCourtResponse: await update_court( tournament_id=tournament_id, @@ -59,6 +62,7 @@ async def delete_court( tournament_id: TournamentId, court_id: CourtId, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: stages = await get_full_tournament_details(tournament_id, no_draft_rounds=False) used_in_matches_count = 0 @@ -84,6 +88,7 @@ async def create_court( court_body: CourtBody, tournament_id: TournamentId, user: UserPublic = Depends(user_authenticated_for_tournament), + _: Tournament = Depends(disallow_archived_tournament), ) -> SingleCourtResponse: existing_courts = await get_all_courts_in_tournament(tournament_id) check_requirement(existing_courts, user, "max_courts") diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index aeb17957..d4cf44e7 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -25,10 +25,11 @@ from bracket.models.db.match import ( MatchRescheduleBody, ) from bracket.models.db.stage_item import StageType +from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic from bracket.routes.auth import user_authenticated_for_tournament from bracket.routes.models import SingleMatchResponse, SuccessResponse, UpcomingMatchesResponse -from bracket.routes.util import match_dependency +from bracket.routes.util import disallow_archived_tournament, match_dependency from bracket.sql.courts import get_all_courts_in_tournament from bracket.sql.matches import sql_create_match, sql_delete_match, sql_update_match from bracket.sql.rounds import get_round_by_id @@ -76,6 +77,7 @@ async def get_matches_to_schedule( async def delete_match( tournament_id: TournamentId, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), match: Match = Depends(match_dependency), ) -> SuccessResponse: round_ = await get_round_by_id(tournament_id, match.round_id) @@ -100,6 +102,7 @@ async def create_match( tournament_id: TournamentId, match_body: MatchCreateBodyFrontend, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SingleMatchResponse: await check_foreign_keys_belong_to_tournament(match_body, tournament_id) @@ -126,6 +129,7 @@ async def create_match( async def schedule_matches( tournament_id: TournamentId, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: stages = await get_full_tournament_details(tournament_id) await schedule_all_unscheduled_matches(tournament_id, stages) @@ -140,6 +144,7 @@ async def reschedule_match( match_id: MatchId, body: MatchRescheduleBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: await check_foreign_keys_belong_to_tournament(body, tournament_id) await handle_match_reschedule(tournament_id, body, match_id) @@ -153,6 +158,7 @@ async def update_match_by_id( match_id: MatchId, match_body: MatchBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), match: Match = Depends(match_dependency), ) -> SuccessResponse: await check_foreign_keys_belong_to_tournament(match_body, tournament_id) diff --git a/backend/bracket/routes/players.py b/backend/bracket/routes/players.py index d69e6fc5..f0aa0bbd 100644 --- a/backend/bracket/routes/players.py +++ b/backend/bracket/routes/players.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends from bracket.database import database from bracket.logic.subscriptions import check_requirement from bracket.models.db.player import Player, PlayerBody, PlayerMultiBody +from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic from bracket.routes.auth import user_authenticated_for_tournament from bracket.routes.models import ( @@ -11,6 +12,7 @@ from bracket.routes.models import ( SinglePlayerResponse, SuccessResponse, ) +from bracket.routes.util import disallow_archived_tournament from bracket.schema import players from bracket.sql.players import ( get_all_players_in_tournament, @@ -49,6 +51,7 @@ async def update_player_by_id( player_id: PlayerId, player_body: PlayerBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SinglePlayerResponse: await database.execute( query=players.update().where( @@ -74,6 +77,7 @@ async def delete_player( tournament_id: TournamentId, player_id: PlayerId, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: await sql_delete_player(tournament_id, player_id) return SuccessResponse() @@ -84,6 +88,7 @@ async def create_single_player( player_body: PlayerBody, tournament_id: TournamentId, user: UserPublic = Depends(user_authenticated_for_tournament), + _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: existing_players = await get_all_players_in_tournament(tournament_id) check_requirement(existing_players, user, "max_players") @@ -96,6 +101,7 @@ async def create_multiple_players( player_body: PlayerMultiBody, tournament_id: TournamentId, user: UserPublic = Depends(user_authenticated_for_tournament), + _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: player_names = [player.strip() for player in player_body.names.split("\n") if len(player) > 0] existing_players = await get_all_players_in_tournament(tournament_id) diff --git a/backend/bracket/routes/rankings.py b/backend/bracket/routes/rankings.py index b33c98dc..19ac5cad 100644 --- a/backend/bracket/routes/rankings.py +++ b/backend/bracket/routes/rankings.py @@ -7,6 +7,7 @@ from bracket.logic.ranking.elimination import ( from bracket.logic.subscriptions import check_requirement from bracket.models.db.ranking import RankingBody, RankingCreateBody from bracket.models.db.stage_item import StageType +from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic from bracket.routes.auth import ( user_authenticated_for_tournament, @@ -16,6 +17,7 @@ from bracket.routes.models import ( RankingsResponse, SuccessResponse, ) +from bracket.routes.util import disallow_archived_tournament from bracket.sql.rankings import ( get_all_rankings_in_tournament, sql_create_ranking, @@ -43,6 +45,7 @@ async def update_ranking_by_id( ranking_id: RankingId, ranking_body: RankingBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: await sql_update_ranking( tournament_id=tournament_id, @@ -64,6 +67,7 @@ async def delete_ranking( tournament_id: TournamentId, ranking_id: RankingId, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: await sql_delete_ranking(tournament_id, ranking_id) return SuccessResponse() @@ -74,6 +78,7 @@ async def create_ranking( ranking_body: RankingCreateBody, tournament_id: TournamentId, user: UserPublic = Depends(user_authenticated_for_tournament), + _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: existing_rankings = await get_all_rankings_in_tournament(tournament_id) check_requirement(existing_rankings, user, "max_rankings") diff --git a/backend/bracket/routes/rounds.py b/backend/bracket/routes/rounds.py index 9227b7f4..42b2c502 100644 --- a/backend/bracket/routes/rounds.py +++ b/backend/bracket/routes/rounds.py @@ -12,11 +12,13 @@ from bracket.models.db.round import ( RoundInsertable, RoundUpdateBody, ) +from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic from bracket.models.db.util import RoundWithMatches from bracket.routes.auth import user_authenticated_for_tournament from bracket.routes.models import SuccessResponse from bracket.routes.util import ( + disallow_archived_tournament, round_dependency, round_with_matches_dependency, ) @@ -41,6 +43,7 @@ async def delete_round( tournament_id: TournamentId, round_id: RoundId, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), round_with_matches: RoundWithMatches = Depends(round_with_matches_dependency), ) -> SuccessResponse: for match in round_with_matches.matches: @@ -58,6 +61,7 @@ async def create_round( tournament_id: TournamentId, round_body: RoundCreateBody, user: UserPublic = Depends(user_authenticated_for_tournament), + _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: await check_foreign_keys_belong_to_tournament(round_body, tournament_id) @@ -98,6 +102,7 @@ async def update_round_by_id( round_body: RoundUpdateBody, _: UserPublic = Depends(user_authenticated_for_tournament), __: Round = Depends(round_dependency), + ___: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: query = """ UPDATE rounds diff --git a/backend/bracket/routes/stage_item_inputs.py b/backend/bracket/routes/stage_item_inputs.py index 20090373..75f6df33 100644 --- a/backend/bracket/routes/stage_item_inputs.py +++ b/backend/bracket/routes/stage_item_inputs.py @@ -8,13 +8,14 @@ from bracket.models.db.stage_item_inputs import ( StageItemInputUpdateBodyFinal, StageItemInputUpdateBodyTentative, ) +from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic from bracket.models.db.util import StageItemWithRounds from bracket.routes.auth import ( user_authenticated_for_tournament, ) from bracket.routes.models import SuccessResponse -from bracket.routes.util import stage_item_dependency +from bracket.routes.util import disallow_archived_tournament, stage_item_dependency from bracket.sql.stage_item_inputs import get_stage_item_input_by_id from bracket.sql.stages import get_full_tournament_details from bracket.sql.teams import get_team_by_id @@ -72,6 +73,7 @@ async def update_stage_item_input( stage_item_body: StageItemInputUpdateBody, _: UserPublic = Depends(user_authenticated_for_tournament), __: StageItemWithRounds = Depends(stage_item_dependency), + ___: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: stage_item_input = await get_stage_item_input_by_id(tournament_id, stage_item_input_id) await validate_stage_item_update(stage_item_input, stage_item_body, tournament_id) diff --git a/backend/bracket/routes/stage_items.py b/backend/bracket/routes/stage_items.py index 862fa3b0..7f82b5f1 100644 --- a/backend/bracket/routes/stage_items.py +++ b/backend/bracket/routes/stage_items.py @@ -27,13 +27,14 @@ from bracket.models.db.stage_item import ( StageItemUpdateBody, StageType, ) +from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic from bracket.models.db.util import StageItemWithRounds from bracket.routes.auth import ( user_authenticated_for_tournament, ) from bracket.routes.models import SuccessResponse -from bracket.routes.util import stage_item_dependency +from bracket.routes.util import disallow_archived_tournament, stage_item_dependency from bracket.sql.courts import get_all_courts_in_tournament from bracket.sql.matches import sql_create_match from bracket.sql.rounds import ( @@ -101,6 +102,7 @@ async def update_stage_item( stage_item_id: StageItemId, stage_item_body: StageItemUpdateBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), stage_item: StageItemWithRounds = Depends(stage_item_dependency), ) -> SuccessResponse: if stage_item is None: @@ -137,6 +139,7 @@ async def start_next_round( elo_diff_threshold: int = 200, iterations: int = 2_000, only_recommended: bool = False, + _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: draft_round = get_draft_round(stage_item) if draft_round is not None: diff --git a/backend/bracket/routes/stages.py b/backend/bracket/routes/stages.py index ccdddd86..09fcd05f 100644 --- a/backend/bracket/routes/stages.py +++ b/backend/bracket/routes/stages.py @@ -10,6 +10,7 @@ from bracket.logic.scheduling.handle_stage_activation import ( ) from bracket.logic.subscriptions import check_requirement from bracket.models.db.stage import Stage, StageActivateBody, StageUpdateBody +from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic from bracket.models.db.util import StageWithStageItems from bracket.routes.auth import ( @@ -22,7 +23,7 @@ from bracket.routes.models import ( StagesWithStageItemsResponse, SuccessResponse, ) -from bracket.routes.util import stage_dependency +from bracket.routes.util import disallow_archived_tournament, stage_dependency from bracket.sql.stages import ( get_full_tournament_details, get_next_stage_in_tournament, @@ -57,6 +58,7 @@ async def delete_stage( tournament_id: TournamentId, stage_id: StageId, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), stage: StageWithStageItems = Depends(stage_dependency), ) -> SuccessResponse: if len(stage.stage_items) > 0: @@ -80,6 +82,7 @@ async def delete_stage( async def create_stage( tournament_id: TournamentId, user: UserPublic = Depends(user_authenticated_for_tournament), + _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: existing_stages = await get_full_tournament_details(tournament_id) check_requirement(existing_stages, user, "max_stages") @@ -94,6 +97,7 @@ async def update_stage( stage_id: StageId, stage_body: StageUpdateBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), stage: Stage = Depends(stage_dependency), # pylint: disable=redefined-builtin ) -> SuccessResponse: values = {"tournament_id": tournament_id, "stage_id": stage_id} @@ -115,6 +119,7 @@ async def activate_next_stage( tournament_id: TournamentId, stage_body: StageActivateBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: new_active_stage_id = await get_next_stage_in_tournament(tournament_id, stage_body.direction) if new_active_stage_id is None: diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index 3eefc167..b3b6f5a3 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -16,6 +16,7 @@ from bracket.models.db.team import ( TeamInsertable, TeamMultiBody, ) +from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic from bracket.routes.auth import ( user_authenticated_for_tournament, @@ -27,7 +28,11 @@ from bracket.routes.models import ( SuccessResponse, TeamsWithPlayersResponse, ) -from bracket.routes.util import team_dependency, team_with_players_dependency +from bracket.routes.util import ( + disallow_archived_tournament, + team_dependency, + team_with_players_dependency, +) from bracket.schema import players_x_teams, teams from bracket.sql.teams import ( get_team_by_id, @@ -87,6 +92,7 @@ async def update_team_by_id( tournament_id: TournamentId, team_body: TeamBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), team: Team = Depends(team_dependency), ) -> SingleTeamResponse: await check_foreign_keys_belong_to_tournament(team_body, tournament_id) @@ -117,6 +123,7 @@ async def update_team_logo( tournament_id: TournamentId, file: UploadFile | None = None, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), team: Team = Depends(team_dependency), ) -> SingleTeamResponse: old_logo_path = await get_team_logo_path(tournament_id, team.id) @@ -153,6 +160,7 @@ async def update_team_logo( async def delete_team( tournament_id: TournamentId, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), team: FullTeamWithPlayers = Depends(team_with_players_dependency), ) -> SuccessResponse: with check_foreign_key_violation( @@ -172,6 +180,7 @@ async def create_team( team_to_insert: TeamBody, tournament_id: TournamentId, user: UserPublic = Depends(user_authenticated_for_tournament), + _: Tournament = Depends(disallow_archived_tournament), ) -> SingleTeamResponse: await check_foreign_keys_belong_to_tournament(team_to_insert, tournament_id) @@ -198,6 +207,7 @@ async def create_multiple_teams( team_body: TeamMultiBody, tournament_id: TournamentId, user: UserPublic = Depends(user_authenticated_for_tournament), + _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: team_names = [team.strip() for team in team_body.names.split("\n") if len(team) > 0] existing_teams = await get_teams_with_members(tournament_id) diff --git a/backend/bracket/routes/tournaments.py b/backend/bracket/routes/tournaments.py index ad8f9eaa..5ba4e92b 100644 --- a/backend/bracket/routes/tournaments.py +++ b/backend/bracket/routes/tournaments.py @@ -1,4 +1,5 @@ import os +from typing import Literal from uuid import uuid4 import aiofiles.os @@ -11,7 +12,9 @@ from bracket.logic.subscriptions import check_requirement from bracket.logic.tournaments import get_tournament_logo_path from bracket.models.db.ranking import RankingCreateBody from bracket.models.db.tournament import ( + Tournament, TournamentBody, + TournamentChangeStatusBody, TournamentUpdateBody, ) from bracket.models.db.user import UserPublic @@ -22,6 +25,7 @@ from bracket.routes.auth import ( user_authenticated_or_public_dashboard_by_endpoint_name, ) from bracket.routes.models import SuccessResponse, TournamentResponse, TournamentsResponse +from bracket.routes.util import disallow_archived_tournament from bracket.schema import tournaments from bracket.sql.rankings import ( get_all_rankings_in_tournament, @@ -35,6 +39,7 @@ from bracket.sql.tournaments import ( sql_get_tournament_by_endpoint_name, sql_get_tournaments, sql_update_tournament, + sql_update_tournament_status, ) from bracket.sql.users import get_user_access_to_club, get_which_clubs_has_user_access_to from bracket.utils.errors import ( @@ -69,6 +74,7 @@ async def get_tournament( @router.get("/tournaments", response_model=TournamentsResponse) async def get_tournaments( user: UserPublic | None = Depends(user_authenticated_or_public_dashboard_by_endpoint_name), + filter_: Literal["ALL", "OPEN", "ARCHIVED"] = "OPEN", endpoint_name: str | None = None, ) -> TournamentsResponse: match user, endpoint_name: @@ -88,7 +94,7 @@ async def get_tournaments( case _, _ if isinstance(user, UserPublic): user_club_ids = await get_which_clubs_has_user_access_to(user.id) return TournamentsResponse( - data=await sql_get_tournaments(tuple(user_club_ids), endpoint_name) + data=await sql_get_tournaments(tuple(user_club_ids), endpoint_name, filter_) ) raise RuntimeError() @@ -99,6 +105,7 @@ async def update_tournament_by_id( tournament_id: TournamentId, tournament_body: TournamentUpdateBody, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: with check_unique_constraint_violation({UniqueIndex.ix_tournaments_dashboard_endpoint}): await sql_update_tournament(tournament_id, tournament_body) @@ -127,6 +134,28 @@ async def delete_tournament( return SuccessResponse() +@router.post("/tournaments/{tournament_id}/change-status", response_model=SuccessResponse) +async def change_status( + tournament_id: TournamentId, + body: TournamentChangeStatusBody, + _: UserPublic = Depends(user_authenticated_for_tournament), +) -> SuccessResponse: + """ + Make a tournament archived or non-archived. + """ + + tournament = await sql_get_tournament(tournament_id) + if tournament.status == body.status: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tournament already has the requested status", + headers={"WWW-Authenticate": "Bearer"}, + ) + + await sql_update_tournament_status(tournament_id, body) + return SuccessResponse() + + @router.post("/tournaments", response_model=SuccessResponse) async def create_tournament( tournament_to_insert: TournamentBody, user: UserPublic = Depends(user_authenticated) @@ -157,6 +186,7 @@ async def upload_logo( tournament_id: TournamentId, file: UploadFile | None = None, _: UserPublic = Depends(user_authenticated_for_tournament), + __: Tournament = Depends(disallow_archived_tournament), ) -> TournamentResponse: old_logo_path = await get_tournament_logo_path(tournament_id) filename: str | None = None diff --git a/backend/bracket/routes/util.py b/backend/bracket/routes/util.py index d2989902..978764d0 100644 --- a/backend/bracket/routes/util.py +++ b/backend/bracket/routes/util.py @@ -5,12 +5,14 @@ from bracket.database import database from bracket.models.db.match import Match from bracket.models.db.round import Round from bracket.models.db.team import FullTeamWithPlayers, Team +from bracket.models.db.tournament import Tournament, TournamentStatus from bracket.models.db.util import RoundWithMatches, StageItemWithRounds, StageWithStageItems from bracket.schema import matches, rounds, teams from bracket.sql.rounds import get_round_by_id from bracket.sql.stage_items import get_stage_item from bracket.sql.stages import get_full_tournament_details from bracket.sql.teams import get_teams_with_members +from bracket.sql.tournaments import sql_get_tournament from bracket.utils.db import fetch_one_parsed from bracket.utils.id_types import MatchId, RoundId, StageId, StageItemId, TeamId, TournamentId @@ -103,3 +105,15 @@ async def team_with_players_dependency( ) return teams_with_members[0] + + +async def disallow_archived_tournament(tournament_id: TournamentId) -> Tournament: + tournament = await sql_get_tournament(tournament_id) + if tournament.status is TournamentStatus.ARCHIVED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Can't update archived tournament", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return tournament diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index f2b1d294..926357de 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -29,6 +29,17 @@ tournaments = Table( Column("auto_assign_courts", Boolean, nullable=False, server_default="f"), Column("duration_minutes", Integer, nullable=False, server_default="15"), Column("margin_minutes", Integer, nullable=False, server_default="5"), + Column( + "status", + Enum( + "OPEN", + "ARCHIVED", + name="tournament_status", + ), + nullable=False, + server_default="OPEN", + index=True, + ), ) stages = Table( diff --git a/backend/bracket/sql/tournaments.py b/backend/bracket/sql/tournaments.py index 901f8aea..87c55d88 100644 --- a/backend/bracket/sql/tournaments.py +++ b/backend/bracket/sql/tournaments.py @@ -1,7 +1,12 @@ -from typing import Any +from typing import Any, Literal from bracket.database import database -from bracket.models.db.tournament import Tournament, TournamentBody, TournamentUpdateBody +from bracket.models.db.tournament import ( + Tournament, + TournamentBody, + TournamentChangeStatusBody, + TournamentUpdateBody, +) from bracket.utils.id_types import TournamentId @@ -28,7 +33,9 @@ async def sql_get_tournament_by_endpoint_name(endpoint_name: str) -> Tournament async def sql_get_tournaments( - club_ids: tuple[int, ...], endpoint_name: str | None = None + club_ids: tuple[int, ...], + endpoint_name: str | None = None, + filter_: Literal["ALL", "OPEN", "ARCHIVED"] = "ALL", ) -> list[Tournament]: query = """ SELECT * @@ -42,6 +49,11 @@ async def sql_get_tournaments( query += "AND dashboard_endpoint = :endpoint_name" params = {**params, "endpoint_name": endpoint_name} + if filter_ == "OPEN": + query += "AND status = 'OPEN'" + elif filter_ == "ARCHIVED": + query += "AND status = 'ARCHIVED'" + result = await database.fetch_all(query=query, values=params) return [Tournament.model_validate(x) for x in result] @@ -76,6 +88,23 @@ async def sql_update_tournament( ) +async def sql_update_tournament_status( + tournament_id: TournamentId, body: TournamentChangeStatusBody +) -> None: + query = """ + UPDATE tournaments + SET + status = :state, + dashboard_public = :dashboard_public + WHERE tournaments.id = :tournament_id + """ + + # Make dashboard non-public when archiving. + # When tournament is archived, setting dashboard_public to False shouldn't have an effect. + params = {"tournament_id": tournament_id, "state": body.status.value, "dashboard_public": False} + await database.execute(query=query, values=params) + + async def sql_create_tournament(tournament: TournamentBody) -> TournamentId: query = """ INSERT INTO tournaments ( diff --git a/backend/tests/integration_tests/api/tournaments_test.py b/backend/tests/integration_tests/api/tournaments_test.py index baa96f38..457ebb7c 100644 --- a/backend/tests/integration_tests/api/tournaments_test.py +++ b/backend/tests/integration_tests/api/tournaments_test.py @@ -5,7 +5,7 @@ import pytest from bracket.database import database from bracket.logic.tournaments import sql_delete_tournament_completely -from bracket.models.db.tournament import Tournament +from bracket.models.db.tournament import Tournament, TournamentStatus from bracket.schema import tournaments from bracket.sql.tournaments import sql_delete_tournament, sql_get_tournament_by_endpoint_name from bracket.utils.db import fetch_one_parsed_certain @@ -40,6 +40,7 @@ async def test_tournaments_endpoint( "auto_assign_courts": True, "duration_minutes": 10, "margin_minutes": 5, + "status": "OPEN", } ], } @@ -65,6 +66,7 @@ async def test_tournament_endpoint( "auto_assign_courts": True, "duration_minutes": 10, "margin_minutes": 5, + "status": "OPEN", }, } @@ -141,6 +143,36 @@ async def test_update_tournament( assert updated_tournament.dashboard_public == body["dashboard_public"] +@pytest.mark.asyncio(loop_scope="session") +async def test_archive_and_unarchive_tournament( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + query = tournaments.select().where(tournaments.c.id == auth_context.tournament.id) + body = {"status": "ARCHIVED"} + assert ( + await send_tournament_request(HTTPMethod.POST, "change-status", auth_context, json=body) + == SUCCESS_RESPONSE + ) + updated_tournament = await fetch_one_parsed_certain(database, Tournament, query) + assert updated_tournament.status is TournamentStatus.ARCHIVED + assert updated_tournament.dashboard_public is False + + # Archiving twice is not allowed + assert await send_tournament_request( + HTTPMethod.POST, "change-status", auth_context, json=body + ) == {"detail": "Tournament already has the requested status"} + + # Unarchive the tournament + body = {"status": "OPEN"} + assert ( + await send_tournament_request(HTTPMethod.POST, "change-status", auth_context, json=body) + == SUCCESS_RESPONSE + ) + updated_tournament = await fetch_one_parsed_certain(database, Tournament, query) + assert updated_tournament.status is TournamentStatus.OPEN + assert updated_tournament.dashboard_public is False + + @pytest.mark.asyncio(loop_scope="session") async def test_delete_tournament( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext @@ -182,7 +214,7 @@ async def test_tournament_upload_and_remove_logo( body=data, ) - assert response["data"]["logo_path"], f"Response: {response}" + assert response.get("data", {}).get("logo_path"), f"Response: {response}" assert await aiofiles.os.path.exists(f"static/tournament-logos/{response['data']['logo_path']}") response = await send_tournament_request( diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 4fe3d877..caa6ca60 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -28,15 +28,16 @@ "add_team_button": "Add Team", "adjust_start_times_checkbox_label": "Adjust start time of matches in this round to the current time", "all_matches_radio_label": "All matches", + "all_matches_scheduled_description": "Matches have been scheduled on all courts in this round. Add a new round or add a new court for more matches.", "api_docs_title": "API docs", - "mark_round_as_non_draft": "Mark this round as ready", - "mark_round_as_draft": "Mark this round as draft", + "archive_tournament_button": "Archive Tournament", + "archived_label": "Archived", + "archived_header_label": "This tournament is archived. It is now read-only.", "at_least_one_player_validation": "Enter at least one player", "at_least_one_team_validation": "Enter at least one team", "at_least_two_team_validation": "Need at least two teams", "auto_assign_courts_label": "Automatically assign courts to matches", "auto_create_matches_button": "Plan new round automatically", - "courts_filled_badge": "courts filled", "back_home_nav": "Take me back to home page", "back_to_login_nav": "Back to login page", "checkbox_status_checked": "Checked", @@ -53,6 +54,7 @@ "copy_url_button": "Copy URL", "could_not_find_any_alert": "Could not find any", "court_name_input_placeholder": "Best Court Ever", + "courts_filled_badge": "courts filled", "courts_title": "courts", "create_account_alert_description": "Account creation is disabled on this domain for now since bracket is still in beta phase", "create_account_alert_title": "Unavailable", @@ -118,6 +120,7 @@ "filter_stage_item_placeholder": "No filter", "forgot_password_button": "Forgot password?", "github_title": "Github", + "go_to_courts_page": "Go to courts page", "handle_swiss_system": "Handle Swiss System", "home_spotlight_description": "Get to home page", "home_title": "Home", @@ -133,6 +136,8 @@ "loss_points_input_label": "Points for a loss", "lowercase_required": "Includes lowercase letter", "margin_minutes_choose_title": "Please choose a margin between matches", + "mark_round_as_draft": "Mark this round as draft", + "mark_round_as_non_draft": "Mark this round as ready", "match_duration_label": "Match duration (minutes)", "match_filter_option_all": "All matches", "match_filter_option_current": "Current matches", @@ -161,9 +166,9 @@ "no_courts_description": "No courts have been created yet. First, create the tournament structure by adding stages and stage items. Then, create courts here and schedule matches on these courts.", "no_courts_description_swiss": "No courts have been created yet. First add courts before managing a Swiss stage item.", "no_courts_title": "No courts yet", - "go_to_courts_page": "Go to courts page", "no_matches_description": "First, add matches by creating stages and stage items. Then, schedule them using the button in the topright corner.", "no_matches_title": "No matches scheduled yet", + "no_more_matches_title": "No more matches to schedule", "no_players_title": "No players yet", "no_round_description": "There are no rounds in this stage item yet", "no_round_found_description": "Please wait for the organiser to add them.", @@ -248,9 +253,8 @@ "tournament_setting_title": "Tournament Settings", "tournament_title": "tournament", "tournaments_title": "tournaments", + "unarchive_tournament_button": "Unarchive Tournament", "upcoming_matches_empty_table_info": "upcoming matches", - "no_more_matches_title": "No more matches to schedule", - "all_matches_scheduled_description": "Matches have been scheduled on all courts in this round. Add a new round or add a new court for more matches.", "upload_placeholder_team": "Drop a file here to upload as team logo.", "upload_placeholder_tournament": "Drop a file here to upload as tournament logo.", "uppercase_required": "Includes uppercase letter", diff --git a/frontend/src/components/card_tables/tournaments.tsx b/frontend/src/components/card_tables/tournaments.tsx index d6f73133..c3248284 100644 --- a/frontend/src/components/card_tables/tournaments.tsx +++ b/frontend/src/components/card_tables/tournaments.tsx @@ -1,4 +1,4 @@ -import { Button, Card, Group, Image, Text, UnstyledButton } from '@mantine/core'; +import { Badge, Button, Card, Group, Image, Text, UnstyledButton } from '@mantine/core'; import { useTranslation } from 'next-i18next'; import Link from 'next/link'; import React from 'react'; @@ -65,8 +65,9 @@ export default function TournamentsCardTable({ - {tournament.name} - {/*Archived*/} + + {tournament.name} + @@ -74,15 +75,26 @@ export default function TournamentsCardTable({ - + + + {t('archived_label')} + + + diff --git a/frontend/src/components/navbar/_brand.tsx b/frontend/src/components/navbar/_brand.tsx index 865ddcb0..a3800eff 100644 --- a/frontend/src/components/navbar/_brand.tsx +++ b/frontend/src/components/navbar/_brand.tsx @@ -4,7 +4,7 @@ import React from 'react'; export function Brand() { return ( -
+
('OPEN'); + + const swrTournamentsResponse = getTournaments(filter); + checkForAuthError(swrTournamentsResponse); return ( - + {capitalize(t('tournaments_title'))} + +