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