Feature: archived tournaments (#1112)

fixes https://github.com/evroon/bracket/issues/690
This commit is contained in:
Erik Vroon
2025-02-09 18:00:52 +01:00
committed by GitHub
parent 1da8fd3f42
commit 489fc2ba64
27 changed files with 434 additions and 64 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({
</Card.Section>
<Group justify="space-between" mt="md" mb="xs">
<Text fw={500}>{tournament.name}</Text>
{/*<Badge color="pink">Archived</Badge>*/}
<Text fw={500} lineClamp={1}>
{tournament.name}
</Text>
</Group>
<Card.Section className={classes.section}>
@@ -74,15 +75,26 @@ export default function TournamentsCardTable({
</Card.Section>
<Card.Section className={classes.section}>
<Button
component={Link}
color="blue"
fullWidth
radius="md"
href={`/tournaments/${tournament.id}/stages`}
>
OPEN
</Button>
<Group w="100%">
<Badge
fullWidth
color="yellow"
variant="outline"
size="lg"
style={{ visibility: tournament.status === 'ARCHIVED' ? 'visible' : 'hidden' }}
>
{t('archived_label')}
</Badge>
<Button
component={Link}
color="blue"
fullWidth
radius="md"
href={`/tournaments/${tournament.id}/stages`}
>
OPEN
</Button>
</Group>
</Card.Section>
</Card>
</UnstyledButton>

View File

@@ -4,7 +4,7 @@ import React from 'react';
export function Brand() {
return (
<Center mr="1rem">
<Center mr="1rem" miw="12rem">
<UnstyledButton component={Link} href="/">
<Group>
<Image

View File

@@ -1,3 +1,7 @@
export type TournamentStatus = 'OPEN' | 'ARCHIVED';
export type TournamentFilter = 'ALL' | TournamentStatus;
export interface Tournament {
id: number;
name: string;
@@ -11,6 +15,7 @@ export interface Tournament {
logo_path: string;
duration_minutes: number;
margin_minutes: number;
status: TournamentStatus;
}
export interface TournamentMinimal {
id: number;

View File

@@ -1,7 +1,6 @@
.fullWithMobile {
@media (max-width: $mantine-breakpoint-xs) {
width: 100%;
padding-left: 0;
padding-right: 1rem;
padding: var(--mantine-spacing-sm) var(--mantine-spacing-sm);
}
}

View File

@@ -1,26 +1,45 @@
import { Grid, Title } from '@mantine/core';
import { Grid, Select, Title } from '@mantine/core';
import { GetStaticProps } from 'next';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useState } from 'react';
import TournamentsCardTable from '../components/card_tables/tournaments';
import TournamentModal from '../components/modals/tournament_modal';
import { capitalize } from '../components/utils/util';
import { TournamentFilter } from '../interfaces/tournament';
import { checkForAuthError, getTournaments } from '../services/adapter';
import Layout from './_layout';
import classes from './index.module.css';
export default function HomePage() {
const swrTournamentsResponse = getTournaments();
checkForAuthError(swrTournamentsResponse);
const { t } = useTranslation();
const [filter, setFilter] = useState<TournamentFilter>('OPEN');
const swrTournamentsResponse = getTournaments(filter);
checkForAuthError(swrTournamentsResponse);
return (
<Layout>
<Grid justify="space-between">
<Grid>
<Grid.Col span="auto">
<Title>{capitalize(t('tournaments_title'))}</Title>
</Grid.Col>
<Grid.Col span="content" className={classes.fullWithMobile}>
<Select
size="md"
placeholder="Pick value"
data={[
{ label: 'All', value: 'ALL' },
{ label: 'Archived', value: 'ARCHIVED' },
{ label: 'Open', value: 'OPEN' },
]}
allowDeselect={false}
value={filter}
// @ts-ignore
onChange={(f: TournamentFilter) => setFilter(f)}
/>
</Grid.Col>
<Grid.Col span="content" className={classes.fullWithMobile}>
<TournamentModal swrTournamentsResponse={swrTournamentsResponse} />
</Grid.Col>

View File

@@ -4,8 +4,10 @@ import {
Checkbox,
Container,
CopyButton,
Divider,
Fieldset,
Grid,
Group,
Image,
NumberInput,
Select,
@@ -15,12 +17,14 @@ import {
import { DateTimePicker } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { MdDelete } from '@react-icons/all-files/md/MdDelete';
import { IconCalendar, IconCalendarTime, IconCopy } from '@tabler/icons-react';
import { MdUnarchive } from '@react-icons/all-files/md/MdUnarchive';
import { IconCalendar, IconCalendarTime, IconCopy, IconPencil } from '@tabler/icons-react';
import assert from 'assert';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useRouter } from 'next/router';
import React from 'react';
import { MdArchive } from 'react-icons/md';
import { SWRResponse } from 'swr';
import NotFoundTitle from '../../404';
@@ -36,7 +40,12 @@ import {
handleRequestError,
removeTournamentLogo,
} from '../../../services/adapter';
import { deleteTournament, updateTournament } from '../../../services/tournament';
import {
archiveTournament,
deleteTournament,
unarchiveTournament,
updateTournament,
} from '../../../services/tournament';
import TournamentLayout from '../_tournament_layout';
export function TournamentLogo({ tournament }: { tournament: Tournament | null }) {
@@ -50,6 +59,62 @@ export function TournamentLogo({ tournament }: { tournament: Tournament | null }
);
}
function ArchiveTournamentButton({
t,
tournament,
swrTournamentResponse,
}: {
t: any;
tournament: Tournament;
swrTournamentResponse: SWRResponse;
}) {
return (
<Button
variant="outline"
mt="sm"
color="orange"
size="lg"
leftSection={<MdArchive size={36} />}
onClick={async () => {
await archiveTournament(tournament.id).catch((response: any) =>
handleRequestError(response)
);
await swrTournamentResponse.mutate();
}}
>
{t('archive_tournament_button')}
</Button>
);
}
function UnarchiveTournamentButton({
t,
tournament,
swrTournamentResponse,
}: {
t: any;
tournament: Tournament;
swrTournamentResponse: SWRResponse;
}) {
return (
<Button
variant="outline"
mt="sm"
color="orange"
size="lg"
leftSection={<MdUnarchive size={36} />}
onClick={async () => {
await unarchiveTournament(tournament.id).catch((response: any) =>
handleRequestError(response)
);
await swrTournamentResponse.mutate();
}}
>
{t('unarchive_tournament_button')}
</Button>
);
}
function GeneralTournamentForm({
tournament,
swrTournamentResponse,
@@ -233,27 +298,50 @@ function GeneralTournamentForm({
/>
</Fieldset>
<Button fullWidth mt={24} color="green" type="submit">
<Button
fullWidth
mt={24}
size="md"
color="green"
type="submit"
leftSection={<IconPencil size={36} />}
>
{t('save_button')}
</Button>
<Button
fullWidth
variant="outline"
mt="sm"
color="red"
size="sm"
leftSection={<MdDelete size={20} />}
onClick={async () => {
await deleteTournament(tournament.id)
.then(async () => {
await router.push('/');
})
.catch((response: any) => handleRequestError(response));
}}
>
{t('delete_tournament_button')}
</Button>
<Divider mt="2rem" mb="1rem" size="2px" />
<Group grow>
<Button
variant="outline"
mt="sm"
color="red"
size="lg"
leftSection={<MdDelete size={36} />}
onClick={async () => {
await deleteTournament(tournament.id)
.then(async () => {
await router.push('/');
})
.catch((response: any) => handleRequestError(response));
}}
>
{t('delete_tournament_button')}
</Button>
{tournament.status === 'OPEN' ? (
<ArchiveTournamentButton
tournament={tournament}
t={t}
swrTournamentResponse={swrTournamentResponse}
/>
) : (
<UnarchiveTournamentButton
tournament={tournament}
t={t}
swrTournamentResponse={swrTournamentResponse}
/>
)}
</Group>
</form>
);
}

View File

@@ -1,4 +1,7 @@
import { Group, ThemeIcon, Title, Tooltip } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import React from 'react';
import { HiArchiveBoxArrowDown } from 'react-icons/hi2';
import { TournamentLinks } from '../../components/navbar/_main_links';
import { responseIsValid } from '../../components/utils/util';
@@ -6,12 +9,33 @@ import { checkForAuthError, getTournamentById } from '../../services/adapter';
import Layout from '../_layout';
export default function TournamentLayout({ children, tournament_id }: any) {
const { t } = useTranslation();
const tournamentResponse = getTournamentById(tournament_id);
checkForAuthError(tournamentResponse);
const tournamentLinks = <TournamentLinks tournament_id={tournament_id} />;
const breadcrumbs = responseIsValid(tournamentResponse) ? (
<h2>/ {tournamentResponse.data.data.name}</h2>
<Group gap="xs" miw="25rem">
<Title order={2} maw="20rem">
/
</Title>
<Title order={2} maw="20rem" lineClamp={1}>
{tournamentResponse.data.data.name}
</Title>
<Tooltip label={`${t('archived_header_label')}`}>
<ThemeIcon
color="yellow"
variant="light"
style={{
visibility: tournamentResponse.data.data.status === 'ARCHIVED' ? 'visible' : 'hidden',
}}
>
<HiArchiveBoxArrowDown />
</ThemeIcon>
</Tooltip>
</Group>
) : null;
return (

View File

@@ -7,6 +7,7 @@ import useSWR, { SWRResponse } from 'swr';
import { Pagination } from '../components/utils/util';
import { SchedulerSettings } from '../interfaces/match';
import { RoundInterface } from '../interfaces/round';
import { TournamentFilter } from '../interfaces/tournament';
import { getLogin, performLogout, tokenPresent } from './local_storage';
// TODO: This is a workaround for the fact that axios is not properly typed.
@@ -114,8 +115,8 @@ export function getTournamentById(tournament_id: number): SWRResponse {
return useSWR(`tournaments/${tournament_id}`, fetcher);
}
export function getTournaments(): SWRResponse {
return useSWR('tournaments', fetcher);
export function getTournaments(filter: TournamentFilter): SWRResponse {
return useSWR(`tournaments?filter_=${filter}`, fetcher);
}
export function getPlayers(tournament_id: number, not_in_team: boolean = false): SWRResponse {

View File

@@ -6,11 +6,13 @@ export async function createTeam(
active: boolean,
player_ids: string[]
) {
return createAxios().post(`tournaments/${tournament_id}/teams`, {
name,
active,
player_ids,
});
return createAxios()
.post(`tournaments/${tournament_id}/teams`, {
name,
active,
player_ids,
})
.catch((response: any) => handleRequestError(response));
}
export async function createTeams(tournament_id: number, names: string, active: boolean) {

View File

@@ -31,6 +31,14 @@ export async function deleteTournament(tournament_id: number) {
return createAxios().delete(`tournaments/${tournament_id}`);
}
export async function archiveTournament(tournament_id: number) {
return createAxios().post(`tournaments/${tournament_id}/change-status`, { status: 'ARCHIVED' });
}
export async function unarchiveTournament(tournament_id: number) {
return createAxios().post(`tournaments/${tournament_id}/change-status`, { status: 'OPEN' });
}
export async function updateTournament(
tournament_id: number,
name: string,