mirror of
https://github.com/evroon/bracket.git
synced 2026-04-28 19:21:05 -04:00
Add schedule builder (#267)
This commit is contained in:
@@ -30,7 +30,6 @@ def upgrade() -> None:
|
||||
'type',
|
||||
ENUM(
|
||||
'SINGLE_ELIMINATION',
|
||||
'DOUBLE_ELIMINATION',
|
||||
'SWISS',
|
||||
'SWISS_DYNAMIC_TEAMS',
|
||||
'ROUND_ROBIN',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
@@ -14,6 +17,7 @@ from bracket.routes import (
|
||||
matches,
|
||||
players,
|
||||
rounds,
|
||||
stage_items,
|
||||
stages,
|
||||
teams,
|
||||
tournaments,
|
||||
@@ -23,10 +27,23 @@ from bracket.utils.db_init import init_db_when_empty
|
||||
|
||||
init_sentry()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
await database.connect()
|
||||
await init_db_when_empty()
|
||||
|
||||
yield
|
||||
|
||||
if environment != Environment.CI:
|
||||
await database.disconnect()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Bracket API",
|
||||
docs_url="/docs",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
origins = ["http://localhost", "http://localhost:3000", *config.cors_origins.split(',')]
|
||||
@@ -41,20 +58,6 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
await database.connect()
|
||||
await init_db_when_empty()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown() -> None:
|
||||
# On CI, we need first to clean up db data in conftest.py before we can disconnect the
|
||||
# db connections.
|
||||
if environment != Environment.CI:
|
||||
await database.disconnect()
|
||||
|
||||
|
||||
@app.get('/ping', summary="Healthcheck ping")
|
||||
async def ping() -> str:
|
||||
return 'ping'
|
||||
@@ -74,11 +77,12 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
app.include_router(auth.router, tags=['auth'])
|
||||
app.include_router(clubs.router, tags=['clubs'])
|
||||
app.include_router(tournaments.router, tags=['tournaments'])
|
||||
app.include_router(courts.router, tags=['courts'])
|
||||
app.include_router(matches.router, tags=['matches'])
|
||||
app.include_router(players.router, tags=['players'])
|
||||
app.include_router(rounds.router, tags=['rounds'])
|
||||
app.include_router(matches.router, tags=['matches'])
|
||||
app.include_router(stage_items.router, tags=['stage_items'])
|
||||
app.include_router(stages.router, tags=['stages'])
|
||||
app.include_router(teams.router, tags=['teams'])
|
||||
app.include_router(courts.router, tags=['courts'])
|
||||
app.include_router(tournaments.router, tags=['tournaments'])
|
||||
app.include_router(users.router, tags=['users'])
|
||||
|
||||
@@ -2,28 +2,18 @@ import math
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.models.db.round import RoundWithMatches, StageWithRounds
|
||||
from bracket.models.db.players import START_ELO, PlayerStatistics
|
||||
from bracket.models.db.util import RoundWithMatches
|
||||
from bracket.schema import players
|
||||
from bracket.sql.players import get_all_players_in_tournament
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.sql.players import get_all_players_in_tournament, update_player_stats
|
||||
from bracket.sql.stages import get_full_tournament_details
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
START_ELO: int = 1200
|
||||
K = 32
|
||||
D = 400
|
||||
|
||||
|
||||
class PlayerStatistics(BaseModel):
|
||||
wins: int = 0
|
||||
draws: int = 0
|
||||
losses: int = 0
|
||||
elo_score: int = START_ELO
|
||||
swiss_score: Decimal = Decimal('0.00')
|
||||
|
||||
|
||||
def calculate_elo_per_player(rounds: list[RoundWithMatches]) -> defaultdict[int, PlayerStatistics]:
|
||||
player_x_elo: defaultdict[int, PlayerStatistics] = defaultdict(PlayerStatistics)
|
||||
|
||||
@@ -83,21 +73,21 @@ def calculate_elo_per_player(rounds: list[RoundWithMatches]) -> defaultdict[int,
|
||||
|
||||
|
||||
async def recalculate_elo_for_tournament_id(tournament_id: int) -> None:
|
||||
stages = await get_stages_with_rounds_and_matches(tournament_id)
|
||||
for stage in stages:
|
||||
await recalculate_elo_for_stage(tournament_id, stage)
|
||||
stages = await get_full_tournament_details(tournament_id)
|
||||
rounds = [
|
||||
round_
|
||||
for stage in stages
|
||||
for stage_item in stage.stage_items
|
||||
for round_ in stage_item.rounds
|
||||
]
|
||||
await recalculate_elo_for_stage(tournament_id, rounds)
|
||||
|
||||
|
||||
async def recalculate_elo_for_stage(tournament_id: int, stage: StageWithRounds) -> None:
|
||||
elo_per_player = calculate_elo_per_player(stage.rounds)
|
||||
async def recalculate_elo_for_stage(tournament_id: int, rounds: list[RoundWithMatches]) -> None:
|
||||
elo_per_player = calculate_elo_per_player(rounds)
|
||||
|
||||
for player_id, statistics in elo_per_player.items():
|
||||
await database.execute(
|
||||
query=players.update().where(
|
||||
(players.c.id == player_id) & (players.c.tournament_id == tournament_id)
|
||||
),
|
||||
values=statistics.dict(),
|
||||
)
|
||||
await update_player_stats(tournament_id, player_id, statistics)
|
||||
|
||||
all_players = await get_all_players_in_tournament(tournament_id)
|
||||
for player in all_players:
|
||||
|
||||
20
backend/bracket/logic/matches.py
Normal file
20
backend/bracket/logic/matches.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from bracket.models.db.match import Match, MatchCreateBody
|
||||
from bracket.sql.courts import get_all_free_courts_in_round
|
||||
from bracket.sql.matches import sql_create_match
|
||||
from bracket.sql.tournaments import sql_get_tournament
|
||||
|
||||
|
||||
async def create_match_and_assign_free_court(
|
||||
tournament_id: int,
|
||||
match_body: MatchCreateBody,
|
||||
) -> Match:
|
||||
tournament = await sql_get_tournament(tournament_id)
|
||||
next_free_court_id = None
|
||||
|
||||
if tournament.auto_assign_courts and match_body.court_id is None:
|
||||
free_courts = await get_all_free_courts_in_round(tournament_id, match_body.round_id)
|
||||
if len(free_courts) > 0:
|
||||
next_free_court_id = free_courts[0].id
|
||||
|
||||
match_body = match_body.copy(update={'court_id': next_free_court_id})
|
||||
return await sql_create_match(match_body)
|
||||
112
backend/bracket/logic/scheduling/builder.py
Normal file
112
backend/bracket/logic/scheduling/builder.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from fastapi import HTTPException
|
||||
from heliclockter import datetime_utc
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.logic.scheduling.elimination import (
|
||||
build_single_elimination_stage_item,
|
||||
get_number_of_rounds_to_create_single_elimination,
|
||||
)
|
||||
from bracket.logic.scheduling.round_robin import (
|
||||
build_round_robin_stage_item,
|
||||
get_number_of_rounds_to_create_round_robin,
|
||||
)
|
||||
from bracket.models.db.match import SuggestedMatch, SuggestedVirtualMatch
|
||||
from bracket.models.db.round import RoundToInsert
|
||||
from bracket.models.db.stage_item import StageItem, StageType
|
||||
from bracket.models.db.stage_item_inputs import (
|
||||
StageItemInputOptionFinal,
|
||||
StageItemInputOptionTentative,
|
||||
)
|
||||
from bracket.models.db.team import FullTeamWithPlayers
|
||||
from bracket.models.db.util import StageWithStageItems
|
||||
from bracket.schema import rounds
|
||||
from bracket.sql.rounds import get_next_round_name
|
||||
from bracket.sql.stage_items import get_stage_item
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
async def create_rounds_for_new_stage_item(tournament_id: int, stage_item: StageItem) -> None:
|
||||
rounds_count: int
|
||||
match stage_item.type:
|
||||
case StageType.ROUND_ROBIN:
|
||||
rounds_count = get_number_of_rounds_to_create_round_robin(stage_item.team_count)
|
||||
case StageType.SINGLE_ELIMINATION:
|
||||
rounds_count = get_number_of_rounds_to_create_single_elimination(stage_item.team_count)
|
||||
case other:
|
||||
raise NotImplementedError(f'No round creation implementation for {other}')
|
||||
|
||||
now = datetime_utc.now()
|
||||
for _ in range(rounds_count):
|
||||
await database.execute(
|
||||
query=rounds.insert(),
|
||||
values=RoundToInsert(
|
||||
created=now,
|
||||
stage_item_id=assert_some(stage_item.id),
|
||||
name=await get_next_round_name(tournament_id, assert_some(stage_item.id)),
|
||||
).dict(),
|
||||
)
|
||||
|
||||
|
||||
async def build_matches_for_stage_item(
|
||||
stage_item: StageItem, tournament_id: int
|
||||
) -> list[SuggestedMatch | SuggestedVirtualMatch]:
|
||||
await create_rounds_for_new_stage_item(tournament_id, stage_item)
|
||||
stage_item_with_rounds = await get_stage_item(tournament_id, assert_some(stage_item.id))
|
||||
|
||||
if stage_item_with_rounds is None:
|
||||
raise ValueError(
|
||||
f'Could not find stage item with id {stage_item.id} for tournament {tournament_id}'
|
||||
)
|
||||
|
||||
match stage_item.type:
|
||||
case StageType.ROUND_ROBIN:
|
||||
upcoming_matches = await build_round_robin_stage_item(
|
||||
tournament_id, stage_item_with_rounds
|
||||
)
|
||||
case StageType.SINGLE_ELIMINATION:
|
||||
upcoming_matches = await build_single_elimination_stage_item(
|
||||
tournament_id,
|
||||
stage_item_with_rounds,
|
||||
)
|
||||
|
||||
case _:
|
||||
raise HTTPException(
|
||||
400, f'Cannot automatically create matches for stage type {stage_item.type}'
|
||||
)
|
||||
|
||||
return upcoming_matches
|
||||
|
||||
|
||||
def determine_available_inputs(
|
||||
stage_id: int,
|
||||
teams: list[FullTeamWithPlayers],
|
||||
stages: list[StageWithStageItems],
|
||||
) -> list[StageItemInputOptionTentative | StageItemInputOptionFinal]:
|
||||
results_team_ids = [assert_some(team.id) for team in teams]
|
||||
results_tentative = []
|
||||
|
||||
for stage in stages:
|
||||
if stage_id == stage.id:
|
||||
break
|
||||
|
||||
for stage_item in stage.stage_items:
|
||||
item_team_id_inputs = [
|
||||
input.team_id for input in stage_item.inputs if input.team_id is not None
|
||||
]
|
||||
for input_ in item_team_id_inputs:
|
||||
if input_ in results_team_ids:
|
||||
results_team_ids.remove(input_)
|
||||
|
||||
results_tentative.extend(
|
||||
[
|
||||
StageItemInputOptionTentative(
|
||||
team_stage_item_id=stage_item.id, team_position_in_group=1
|
||||
),
|
||||
StageItemInputOptionTentative(
|
||||
team_stage_item_id=stage_item.id, team_position_in_group=2
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
results_final = [StageItemInputOptionFinal(team_id=team_id) for team_id in results_team_ids]
|
||||
return results_final + results_tentative
|
||||
96
backend/bracket/logic/scheduling/elimination.py
Normal file
96
backend/bracket/logic/scheduling/elimination.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from bracket.logic.scheduling.shared import get_suggested_match
|
||||
from bracket.models.db.match import SuggestedMatch, SuggestedVirtualMatch
|
||||
from bracket.models.db.team import FullTeamWithPlayers, TeamWithPlayers
|
||||
from bracket.models.db.util import StageItemWithRounds
|
||||
from bracket.sql.rounds import get_rounds_for_stage_item
|
||||
from bracket.sql.teams import get_teams_with_members
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
def determine_matches_first_round(
|
||||
stage_item: StageItemWithRounds, teams_sorted: list[FullTeamWithPlayers]
|
||||
) -> list[SuggestedMatch | SuggestedVirtualMatch]:
|
||||
suggestions: list[SuggestedMatch | SuggestedVirtualMatch] = []
|
||||
|
||||
# for i in range(0, stage.team_count, 2):
|
||||
# match = SuggestedVirtualMatch(
|
||||
# team1_group_id=
|
||||
# )
|
||||
# suggestions.append(get_suggested_match(team1, team2))
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def todo_determine_matches_other_round(
|
||||
stage_item: StageItemWithRounds, teams_sorted: list[TeamWithPlayers]
|
||||
) -> list[SuggestedMatch | SuggestedVirtualMatch]:
|
||||
suggestions: list[SuggestedMatch | SuggestedVirtualMatch] = []
|
||||
# previous_round = sorted(
|
||||
# [round_ for round_ in rounds if assert_some(round_.id) < round_id],
|
||||
# key=lambda round_: assert_some(round_.id),
|
||||
# reverse=True,*
|
||||
# )[0]
|
||||
|
||||
# winners = []
|
||||
# for match in previous_round.matches:
|
||||
# winner = match.get_winner()
|
||||
# assert winner is not None
|
||||
# winners.append(winner)
|
||||
#
|
||||
# assert len(winners) % 2 == 0
|
||||
# for i in range(0, len(winners), 2):
|
||||
# team1, team2 = teams_sorted[i + 0], teams_sorted[i + 1]
|
||||
# suggestions.append(get_suggested_match(team1, team2))
|
||||
return suggestions
|
||||
|
||||
|
||||
async def build_single_elimination_stage_item(
|
||||
tournament_id: int, stage_item: StageItemWithRounds
|
||||
) -> list[SuggestedMatch | SuggestedVirtualMatch]:
|
||||
stage_id = assert_some(stage_item.stage_id)
|
||||
suggestions: list[SuggestedMatch | SuggestedVirtualMatch] = []
|
||||
rounds = await get_rounds_for_stage_item(tournament_id, stage_id)
|
||||
assert len(rounds) > 0
|
||||
|
||||
for j, round_ in enumerate(stage_item.rounds):
|
||||
first_round_id = min(assert_some(round_.id) for round_ in rounds)
|
||||
first_round = round_.id == first_round_id
|
||||
|
||||
teams = await get_teams_with_members(tournament_id, only_active_teams=True)
|
||||
teams_sorted = sorted(teams, key=lambda team: team.elo_score, reverse=True)
|
||||
|
||||
assert stage_item.team_count % 2 == 0
|
||||
assert stage_item.team_count % 2 == 0
|
||||
|
||||
if first_round:
|
||||
return determine_matches_first_round(stage_item, teams_sorted)
|
||||
|
||||
previous_round = stage_item.rounds[j - 1]
|
||||
|
||||
winners = []
|
||||
for match in previous_round.matches:
|
||||
winner = match.get_winner()
|
||||
assert winner is not None
|
||||
winners.append(winner)
|
||||
|
||||
assert len(winners) % 2 == 0
|
||||
for i in range(0, len(winners), 2):
|
||||
team1, team2 = teams_sorted[i + 0], teams_sorted[i + 1]
|
||||
suggestions.append(get_suggested_match(team1, team2))
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def get_number_of_rounds_to_create_single_elimination(team_count: int) -> int:
|
||||
if team_count < 1:
|
||||
return 0
|
||||
|
||||
assert team_count % 2 == 0
|
||||
|
||||
game_count_lookup = {
|
||||
2: 1,
|
||||
4: 2,
|
||||
8: 3,
|
||||
16: 4,
|
||||
}
|
||||
return game_count_lookup[team_count]
|
||||
@@ -1,16 +1,17 @@
|
||||
import random
|
||||
from collections import defaultdict
|
||||
from functools import lru_cache
|
||||
from typing import cast
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from bracket.logic.scheduling.shared import check_team_combination_adheres_to_filter
|
||||
from bracket.models.db.match import MatchFilter, SuggestedMatch
|
||||
from bracket.models.db.match import MatchFilter, SuggestedMatch, SuggestedVirtualMatch
|
||||
from bracket.models.db.player import Player
|
||||
from bracket.models.db.round import RoundWithMatches
|
||||
from bracket.models.db.team import TeamWithPlayers
|
||||
from bracket.models.db.util import RoundWithMatches
|
||||
from bracket.sql.players import get_active_players_in_tournament
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.sql.stage_items import get_stage_item
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
@@ -19,18 +20,19 @@ def player_already_scheduled(player: Player, draft_round: RoundWithMatches) -> b
|
||||
|
||||
|
||||
async def get_possible_upcoming_matches_for_players(
|
||||
tournament_id: int, filter_: MatchFilter, stage_id: int, round_id: int
|
||||
) -> list[SuggestedMatch]:
|
||||
tournament_id: int, filter_: MatchFilter, stage_item_id: int, round_id: int
|
||||
) -> list[SuggestedMatch | SuggestedVirtualMatch]:
|
||||
random.seed(10)
|
||||
suggestions: set[SuggestedMatch] = set()
|
||||
stages = await get_stages_with_rounds_and_matches(tournament_id, stage_id=stage_id)
|
||||
stage_item = await get_stage_item(tournament_id, stage_item_id)
|
||||
|
||||
if len(stages) < 1:
|
||||
raise HTTPException(400, 'There is no active stage, so no matches can be scheduled.')
|
||||
if stage_item is None:
|
||||
raise ValueError(
|
||||
f'Could not find stage item with id {stage_item_id} for tournament {tournament_id}'
|
||||
)
|
||||
|
||||
[active_stage] = stages
|
||||
draft_round = next((round_ for round_ in active_stage.rounds if round_.id == round_id), None)
|
||||
other_rounds = [round_ for round_ in active_stage.rounds if not round_.is_draft]
|
||||
draft_round = next((round_ for round_ in stage_item.rounds if round_.id == round_id), None)
|
||||
other_rounds = [round_ for round_ in stage_item.rounds if not round_.is_draft]
|
||||
max_matches_per_round = (
|
||||
max(len(other_round.matches) for other_round in other_rounds)
|
||||
if len(other_rounds) > 0
|
||||
@@ -108,4 +110,4 @@ async def get_possible_upcoming_matches_for_players(
|
||||
result[i].is_recommended = True
|
||||
|
||||
team_already_scheduled_before.cache_clear()
|
||||
return result[: filter_.limit]
|
||||
return cast(list[SuggestedMatch | SuggestedVirtualMatch], result[: filter_.limit])
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
from fastapi import HTTPException
|
||||
|
||||
from bracket.logic.scheduling.shared import check_team_combination_adheres_to_filter
|
||||
from bracket.models.db.match import MatchFilter, SuggestedMatch
|
||||
from bracket.sql.rounds import get_rounds_for_stage
|
||||
from bracket.models.db.match import MatchFilter, SuggestedMatch, SuggestedVirtualMatch
|
||||
from bracket.sql.rounds import get_rounds_for_stage_item
|
||||
from bracket.sql.teams import get_teams_with_members
|
||||
|
||||
|
||||
async def todo_get_possible_upcoming_matches_for_teams(
|
||||
tournament_id: int, stage_id: int, filter_: MatchFilter
|
||||
) -> list[SuggestedMatch]:
|
||||
suggestions: list[SuggestedMatch] = []
|
||||
rounds = await get_rounds_for_stage(tournament_id, stage_id)
|
||||
tournament_id: int, filter_: MatchFilter, stage_id: int
|
||||
) -> list[SuggestedMatch | SuggestedVirtualMatch]:
|
||||
suggestions: list[SuggestedMatch | SuggestedVirtualMatch] = []
|
||||
rounds = await get_rounds_for_stage_item(tournament_id, stage_id)
|
||||
draft_round = next((round_ for round_ in rounds if round_.is_draft), None)
|
||||
if draft_round is None:
|
||||
raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
import math
|
||||
from typing import cast
|
||||
|
||||
from bracket.logic.matches import create_match_and_assign_free_court
|
||||
from bracket.logic.scheduling.shared import get_suggested_match
|
||||
from bracket.models.db.match import SuggestedMatch
|
||||
from bracket.sql.rounds import get_rounds_for_stage
|
||||
from bracket.models.db.match import (
|
||||
MatchCreateBody,
|
||||
SuggestedMatch,
|
||||
SuggestedVirtualMatch,
|
||||
)
|
||||
from bracket.models.db.util import StageItemWithRounds
|
||||
from bracket.sql.teams import get_teams_with_members
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
async def get_possible_upcoming_matches_round_robin(
|
||||
tournament_id: int, stage_id: int, round_id: int
|
||||
) -> list[SuggestedMatch]:
|
||||
async def build_round_robin_stage_item(
|
||||
tournament_id: int, stage_item: StageItemWithRounds
|
||||
) -> list[SuggestedMatch | SuggestedVirtualMatch]:
|
||||
suggestions: list[SuggestedMatch] = []
|
||||
rounds = await get_rounds_for_stage(tournament_id, stage_id)
|
||||
draft_round = next(round_ for round_ in rounds if round_.id == round_id)
|
||||
|
||||
teams = await get_teams_with_members(tournament_id, only_active_teams=True)
|
||||
|
||||
for i, team1 in enumerate(teams):
|
||||
for _, team2 in enumerate(teams[i + 1 :]):
|
||||
team_already_scheduled = any(
|
||||
team1.id in match.team_ids or team2.id in match.team_ids
|
||||
for match in draft_round.matches
|
||||
)
|
||||
if team_already_scheduled:
|
||||
continue
|
||||
for round_ in stage_item.rounds:
|
||||
round_suggestions: list[SuggestedMatch] = []
|
||||
|
||||
suggestions.append(get_suggested_match(team1, team2))
|
||||
for i, team1 in enumerate(teams):
|
||||
for _, team2 in enumerate(teams[i + 1 :]):
|
||||
match_already_scheduled = any(
|
||||
team1.id in match.team_ids and team2.id in match.team_ids
|
||||
for match in suggestions
|
||||
) or any(
|
||||
team1.id in match.team_ids or team2.id in match.team_ids
|
||||
for match in round_suggestions
|
||||
)
|
||||
if match_already_scheduled:
|
||||
continue
|
||||
|
||||
return suggestions
|
||||
suggestions.append(get_suggested_match(team1, team2))
|
||||
round_suggestions.append(get_suggested_match(team1, team2))
|
||||
|
||||
match = MatchCreateBody(
|
||||
round_id=assert_some(round_.id),
|
||||
team1_id=assert_some(team1.id),
|
||||
team2_id=assert_some(team2.id),
|
||||
court_id=None,
|
||||
)
|
||||
await create_match_and_assign_free_court(tournament_id, match)
|
||||
|
||||
return cast(list[SuggestedMatch | SuggestedVirtualMatch], suggestions)
|
||||
|
||||
|
||||
def get_number_of_rounds_to_create_round_robin(team_count: int) -> int:
|
||||
if team_count < 1:
|
||||
return 0
|
||||
|
||||
concurrency = team_count // 2
|
||||
number_of_games = (team_count - 1) * team_count / 2
|
||||
return math.ceil(number_of_games / concurrency)
|
||||
|
||||
25
backend/bracket/logic/scheduling/upcoming_matches.py
Normal file
25
backend/bracket/logic/scheduling/upcoming_matches.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from fastapi import HTTPException
|
||||
|
||||
from bracket.logic.scheduling.ladder_players_iter import get_possible_upcoming_matches_for_players
|
||||
from bracket.models.db.match import MatchFilter, SuggestedMatch, SuggestedVirtualMatch
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.stage_item import StageType
|
||||
from bracket.sql.stages import get_full_tournament_details
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
async def get_upcoming_matches_for_swiss_round(
|
||||
match_filter: MatchFilter, round_: Round, tournament_id: int
|
||||
) -> list[SuggestedMatch | SuggestedVirtualMatch]:
|
||||
[stage] = await get_full_tournament_details(tournament_id, stage_item_id=round_.stage_item_id)
|
||||
assert len(stage.stage_items) == 1
|
||||
[stage_item] = stage.stage_items
|
||||
|
||||
if stage_item.type is not StageType.SWISS:
|
||||
raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
|
||||
|
||||
upcoming_matches = await get_possible_upcoming_matches_for_players(
|
||||
tournament_id, match_filter, assert_some(stage_item.id), assert_some(round_.id)
|
||||
)
|
||||
|
||||
return upcoming_matches
|
||||
@@ -9,17 +9,20 @@ from bracket.models.db.team import FullTeamWithPlayers, TeamWithPlayers
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
class Match(BaseModelORM):
|
||||
class MatchBase(BaseModelORM):
|
||||
id: int | None = None
|
||||
created: datetime_utc
|
||||
round_id: int
|
||||
team1_id: int
|
||||
team2_id: int
|
||||
team1_score: int
|
||||
team2_score: int
|
||||
court_id: int | None
|
||||
|
||||
|
||||
class Match(MatchBase):
|
||||
team1_id: int
|
||||
team2_id: int
|
||||
|
||||
|
||||
class MatchWithDetails(Match):
|
||||
team1: FullTeamWithPlayers
|
||||
team2: FullTeamWithPlayers
|
||||
@@ -37,6 +40,11 @@ class MatchWithDetails(Match):
|
||||
def player_ids(self) -> list[int]:
|
||||
return self.team1.player_ids + self.team2.player_ids
|
||||
|
||||
def get_winner(self) -> FullTeamWithPlayers | None:
|
||||
if self.team1.elo_score == self.team2.elo_score:
|
||||
return None
|
||||
return self.team1 if self.team1.elo_score > self.team2.elo_score else self.team2
|
||||
|
||||
|
||||
class MatchBody(BaseModelORM):
|
||||
round_id: int
|
||||
@@ -52,6 +60,15 @@ class MatchCreateBody(BaseModelORM):
|
||||
court_id: int | None
|
||||
|
||||
|
||||
class MatchVirtualCreateBody(BaseModelORM):
|
||||
round_id: int
|
||||
court_id: int | None
|
||||
team1_stage_item_id: int
|
||||
team1_position_in_group: int
|
||||
team2_stage_item_id: int
|
||||
team2_position_in_group: int
|
||||
|
||||
|
||||
class MatchFilter(BaseModel):
|
||||
elo_diff_threshold: int
|
||||
only_behind_schedule: bool
|
||||
@@ -59,6 +76,13 @@ class MatchFilter(BaseModel):
|
||||
iterations: int
|
||||
|
||||
|
||||
class SuggestedVirtualMatch(BaseModel):
|
||||
team1_group_id: int
|
||||
team1_position_in_group: int
|
||||
team2_group_id: int
|
||||
team2_position_in_group: int
|
||||
|
||||
|
||||
class SuggestedMatch(BaseModel):
|
||||
team1: TeamWithPlayers
|
||||
team2: TeamWithPlayers
|
||||
@@ -67,6 +91,10 @@ class SuggestedMatch(BaseModel):
|
||||
is_recommended: bool
|
||||
player_behind_schedule_count: int
|
||||
|
||||
@property
|
||||
def team_ids(self) -> list[int]:
|
||||
return [assert_some(self.team1.id), assert_some(self.team2.id)]
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return sum(
|
||||
pow(100, i) + player.id
|
||||
|
||||
13
backend/bracket/models/db/players.py
Normal file
13
backend/bracket/models/db/players.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
START_ELO: int = 1200
|
||||
|
||||
|
||||
class PlayerStatistics(BaseModel):
|
||||
wins: int = 0
|
||||
draws: int = 0
|
||||
losses: int = 0
|
||||
elo_score: int = START_ELO
|
||||
swiss_score: Decimal = Decimal('0.00')
|
||||
@@ -1,62 +1,17 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from heliclockter import datetime_utc
|
||||
from pydantic import root_validator, validator
|
||||
|
||||
from bracket.models.db.match import Match, MatchWithDetails
|
||||
from bracket.models.db.shared import BaseModelORM
|
||||
from bracket.models.db.stage import Stage, StageType
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
class Round(BaseModelORM):
|
||||
id: int | None = None
|
||||
stage_id: int
|
||||
stage_item_id: int
|
||||
created: datetime_utc
|
||||
is_draft: bool
|
||||
is_active: bool = False
|
||||
name: str
|
||||
|
||||
|
||||
class RoundWithMatches(Round):
|
||||
matches: list[MatchWithDetails]
|
||||
|
||||
@validator('matches', pre=True)
|
||||
def handle_matches(values: list[Match]) -> list[Match]: # type: ignore[misc]
|
||||
if values == [None]:
|
||||
return []
|
||||
return values
|
||||
|
||||
def get_team_ids(self) -> set[int]:
|
||||
return {assert_some(team.id) for match in self.matches for team in match.teams}
|
||||
|
||||
|
||||
class StageWithRounds(Stage):
|
||||
rounds: list[RoundWithMatches]
|
||||
type_name: str
|
||||
|
||||
@root_validator(pre=True)
|
||||
def fill_type_name(cls, values: Any) -> Any:
|
||||
match values['type']:
|
||||
case str() as type_:
|
||||
values['type_name'] = type_.lower().capitalize().replace('_', ' ')
|
||||
case StageType() as type_:
|
||||
values['type_name'] = type_.value.lower().capitalize().replace('_', ' ')
|
||||
|
||||
return values
|
||||
|
||||
@validator('rounds', pre=True)
|
||||
def handle_rounds(values: list[Round]) -> list[Round]: # type: ignore[misc]
|
||||
if isinstance(values, str):
|
||||
values_json = json.loads(values)
|
||||
if values_json == [None]:
|
||||
return []
|
||||
return values_json
|
||||
|
||||
return values
|
||||
|
||||
|
||||
class RoundUpdateBody(BaseModelORM):
|
||||
name: str
|
||||
is_draft: bool
|
||||
@@ -65,11 +20,11 @@ class RoundUpdateBody(BaseModelORM):
|
||||
|
||||
class RoundCreateBody(BaseModelORM):
|
||||
name: str | None
|
||||
stage_id: int
|
||||
stage_item_id: int
|
||||
|
||||
|
||||
class RoundToInsert(RoundUpdateBody):
|
||||
created: datetime_utc
|
||||
stage_id: int
|
||||
stage_item_id: int
|
||||
is_draft: bool = False
|
||||
is_active: bool = False
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
from enum import auto
|
||||
from typing import Literal
|
||||
|
||||
from heliclockter import datetime_utc
|
||||
|
||||
from bracket.models.db.shared import BaseModelORM
|
||||
from bracket.utils.types import EnumAutoStr
|
||||
|
||||
|
||||
class StageType(EnumAutoStr):
|
||||
DOUBLE_ELIMINATION = auto()
|
||||
ROUND_ROBIN = auto()
|
||||
SINGLE_ELIMINATION = auto()
|
||||
SWISS = auto()
|
||||
SWISS_DYNAMIC_TEAMS = auto()
|
||||
|
||||
|
||||
class Stage(BaseModelORM):
|
||||
id: int | None = None
|
||||
tournament_id: int
|
||||
name: str
|
||||
created: datetime_utc
|
||||
type: StageType
|
||||
is_active: bool
|
||||
|
||||
|
||||
class StageUpdateBody(BaseModelORM):
|
||||
is_active: bool
|
||||
name: str
|
||||
|
||||
|
||||
class StageActivateBody(BaseModelORM):
|
||||
direction: Literal['next', 'previous'] = 'next'
|
||||
|
||||
|
||||
class StageCreateBody(BaseModelORM):
|
||||
type: StageType
|
||||
|
||||
52
backend/bracket/models/db/stage_item.py
Normal file
52
backend/bracket/models/db/stage_item.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from enum import auto
|
||||
from typing import Any
|
||||
|
||||
from heliclockter import datetime_utc
|
||||
from pydantic import Field, root_validator
|
||||
|
||||
from bracket.models.db.shared import BaseModelORM
|
||||
from bracket.models.db.stage_item_inputs import StageItemInputCreateBody
|
||||
from bracket.utils.types import EnumAutoStr
|
||||
|
||||
|
||||
class StageType(EnumAutoStr):
|
||||
ROUND_ROBIN = auto()
|
||||
SINGLE_ELIMINATION = auto()
|
||||
SWISS = auto()
|
||||
|
||||
@property
|
||||
def supports_dynamic_number_of_rounds(self) -> bool:
|
||||
return self in [StageType.SWISS]
|
||||
|
||||
|
||||
class StageItemToInsert(BaseModelORM):
|
||||
id: int | None = None
|
||||
stage_id: int
|
||||
name: str
|
||||
created: datetime_utc
|
||||
type: StageType
|
||||
team_count: int = Field(ge=2, le=16)
|
||||
|
||||
|
||||
class StageItem(StageItemToInsert):
|
||||
id: int
|
||||
|
||||
|
||||
class StageItemUpdateBody(BaseModelORM):
|
||||
name: str
|
||||
|
||||
|
||||
class StageItemCreateBody(BaseModelORM):
|
||||
stage_id: int
|
||||
name: str | None
|
||||
type: StageType
|
||||
team_count: int = Field(ge=2, le=16)
|
||||
inputs: list[StageItemInputCreateBody]
|
||||
|
||||
def get_name_or_default_name(self) -> str:
|
||||
return self.name if self.name is not None else self.type.value.replace('_', ' ').title()
|
||||
|
||||
@root_validator(pre=True)
|
||||
def handle_inputs_length(cls, values: Any) -> Any:
|
||||
assert len(values['inputs']) == values['team_count']
|
||||
return values
|
||||
48
backend/bracket/models/db/stage_item_inputs.py
Normal file
48
backend/bracket/models/db/stage_item_inputs.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from bracket.models.db.shared import BaseModelORM
|
||||
|
||||
|
||||
class StageItemInputBase(BaseModelORM):
|
||||
id: int | None
|
||||
slot: int
|
||||
tournament_id: int
|
||||
stage_item_id: int | None
|
||||
|
||||
|
||||
class StageItemInputTentative(StageItemInputBase):
|
||||
team_id: None = None
|
||||
team_stage_item_id: int
|
||||
team_position_in_group: int = Field(ge=1)
|
||||
|
||||
|
||||
class StageItemInputFinal(StageItemInputBase):
|
||||
team_id: int
|
||||
team_stage_item_id: None = None
|
||||
team_position_in_group: None = None
|
||||
|
||||
|
||||
StageItemInput = StageItemInputTentative | StageItemInputFinal
|
||||
|
||||
|
||||
class StageItemInputCreateBodyTentative(BaseModel):
|
||||
slot: int
|
||||
team_stage_item_id: int
|
||||
team_position_in_group: int = Field(ge=1)
|
||||
|
||||
|
||||
class StageItemInputCreateBodyFinal(BaseModel):
|
||||
slot: int
|
||||
team_id: int
|
||||
|
||||
|
||||
StageItemInputCreateBody = StageItemInputCreateBodyTentative | StageItemInputCreateBodyFinal
|
||||
|
||||
|
||||
class StageItemInputOptionFinal(BaseModel):
|
||||
team_id: int
|
||||
|
||||
|
||||
class StageItemInputOptionTentative(BaseModel):
|
||||
team_stage_item_id: int
|
||||
team_position_in_group: int
|
||||
@@ -25,6 +25,9 @@ class TeamWithPlayers(BaseModel):
|
||||
players: list[Player]
|
||||
swiss_score: Decimal
|
||||
elo_score: Decimal
|
||||
wins: int
|
||||
draws: int
|
||||
losses: int
|
||||
|
||||
@classmethod
|
||||
def from_players(cls, players: list[Player]) -> TeamWithPlayers:
|
||||
@@ -32,6 +35,9 @@ class TeamWithPlayers(BaseModel):
|
||||
players=players,
|
||||
elo_score=Decimal(sum(p.elo_score for p in players) / len(players)),
|
||||
swiss_score=Decimal(sum(p.swiss_score for p in players) / len(players)),
|
||||
wins=sum(p.wins for p in players) // len(players),
|
||||
draws=sum(p.draws for p in players) // len(players),
|
||||
losses=sum(p.losses for p in players) // len(players),
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
63
backend/bracket/models/db/util.py
Normal file
63
backend/bracket/models/db/util.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# ruff: noqa: TCH001,TCH002
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import root_validator, validator
|
||||
|
||||
from bracket.models.db.match import Match, MatchWithDetails
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.stage import Stage
|
||||
from bracket.models.db.stage_item import StageItem, StageType
|
||||
from bracket.models.db.stage_item_inputs import StageItemInput
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
|
||||
class RoundWithMatches(Round):
|
||||
matches: list[MatchWithDetails]
|
||||
|
||||
@validator('matches', pre=True)
|
||||
def handle_matches(values: list[Match]) -> list[Match]: # type: ignore[misc]
|
||||
if values == [None]:
|
||||
return []
|
||||
return values
|
||||
|
||||
def get_team_ids(self) -> set[int]:
|
||||
return {assert_some(team.id) for match in self.matches for team in match.teams}
|
||||
|
||||
|
||||
class StageItemWithRounds(StageItem):
|
||||
rounds: list[RoundWithMatches]
|
||||
inputs: list[StageItemInput]
|
||||
type_name: str
|
||||
|
||||
@root_validator(pre=True)
|
||||
def fill_type_name(cls, values: Any) -> Any:
|
||||
match values['type']:
|
||||
case str() as type_:
|
||||
values['type_name'] = type_.lower().capitalize().replace('_', ' ')
|
||||
case StageType() as type_:
|
||||
values['type_name'] = type_.value.lower().capitalize().replace('_', ' ')
|
||||
|
||||
return values
|
||||
|
||||
@validator('rounds', 'inputs', pre=True)
|
||||
def handle_empty_list_elements(values: list[Any] | None) -> list[Any]: # type: ignore[misc]
|
||||
if values is None:
|
||||
return []
|
||||
return [value for value in values if value is not None]
|
||||
|
||||
|
||||
class StageWithStageItems(Stage):
|
||||
stage_items: list[StageItemWithRounds]
|
||||
|
||||
@validator('stage_items', pre=True)
|
||||
def handle_stage_items(values: list[StageItemWithRounds]) -> list[StageItemWithRounds]: # type: ignore[misc]
|
||||
if isinstance(values, str):
|
||||
values_json = json.loads(values)
|
||||
if values_json == [None]:
|
||||
return []
|
||||
return values_json
|
||||
|
||||
return values
|
||||
@@ -13,6 +13,7 @@ from bracket.database import database
|
||||
from bracket.models.db.tournament import Tournament
|
||||
from bracket.models.db.user import UserInDB, UserPublic
|
||||
from bracket.schema import tournaments
|
||||
from bracket.sql.tournaments import sql_get_tournament_by_endpoint_name
|
||||
from bracket.sql.users import get_user, get_user_access_to_club, get_user_access_to_tournament
|
||||
from bracket.utils.db import fetch_all_parsed
|
||||
from bracket.utils.security import pwd_context
|
||||
@@ -156,6 +157,21 @@ async def user_authenticated_or_public_dashboard(
|
||||
return None
|
||||
|
||||
|
||||
async def user_authenticated_or_public_dashboard_by_endpoint_name(
|
||||
token: str = Depends(oauth2_scheme), endpoint_name: str | None = None
|
||||
) -> UserPublic | None:
|
||||
if endpoint_name is not None:
|
||||
if await sql_get_tournament_by_endpoint_name(endpoint_name) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return None
|
||||
|
||||
return await user_authenticated(token)
|
||||
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()) -> Token:
|
||||
user = await authenticate_user(form_data.username, form_data.password)
|
||||
|
||||
@@ -30,7 +30,7 @@ async def delete_club(
|
||||
return SuccessResponse()
|
||||
|
||||
|
||||
@router.patch("/clubs/{club_id}", response_model=ClubResponse)
|
||||
@router.put("/clubs/{club_id}", response_model=ClubResponse)
|
||||
async def update_club(
|
||||
club_id: int, club: ClubUpdateBody, _: UserPublic = Depends(user_authenticated_for_club)
|
||||
) -> ClubResponse:
|
||||
|
||||
@@ -5,11 +5,14 @@ from starlette import status
|
||||
from bracket.database import database
|
||||
from bracket.models.db.court import Court, CourtBody, CourtToInsert
|
||||
from bracket.models.db.user import UserPublic
|
||||
from bracket.routes.auth import user_authenticated_for_tournament
|
||||
from bracket.routes.auth import (
|
||||
user_authenticated_for_tournament,
|
||||
user_authenticated_or_public_dashboard,
|
||||
)
|
||||
from bracket.routes.models import CourtsResponse, SingleCourtResponse, SuccessResponse
|
||||
from bracket.schema import courts
|
||||
from bracket.sql.courts import get_all_courts_in_tournament, update_court
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.sql.stages import get_full_tournament_details
|
||||
from bracket.utils.db import fetch_one_parsed
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
@@ -19,12 +22,12 @@ router = APIRouter()
|
||||
@router.get("/tournaments/{tournament_id}/courts", response_model=CourtsResponse)
|
||||
async def get_courts(
|
||||
tournament_id: int,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
_: UserPublic = Depends(user_authenticated_or_public_dashboard),
|
||||
) -> CourtsResponse:
|
||||
return CourtsResponse(data=await get_all_courts_in_tournament(tournament_id))
|
||||
|
||||
|
||||
@router.patch("/tournaments/{tournament_id}/courts/{court_id}", response_model=SingleCourtResponse)
|
||||
@router.put("/tournaments/{tournament_id}/courts/{court_id}", response_model=SingleCourtResponse)
|
||||
async def update_court_by_id(
|
||||
tournament_id: int,
|
||||
court_id: int,
|
||||
@@ -53,13 +56,14 @@ async def update_court_by_id(
|
||||
async def delete_court(
|
||||
tournament_id: int, court_id: int, _: UserPublic = Depends(user_authenticated_for_tournament)
|
||||
) -> SuccessResponse:
|
||||
stages = await get_stages_with_rounds_and_matches(tournament_id, no_draft_rounds=False)
|
||||
stages = await get_full_tournament_details(tournament_id, no_draft_rounds=False)
|
||||
used_in_matches_count = 0
|
||||
for stage in stages:
|
||||
for round_ in stage.rounds:
|
||||
for match in round_.matches:
|
||||
if match.court_id == court_id:
|
||||
used_in_matches_count += 1
|
||||
for stage_item in stage.stage_items:
|
||||
for round_ in stage_item.rounds:
|
||||
for match in round_.matches:
|
||||
if match.court_id == court_id:
|
||||
used_in_matches_count += 1
|
||||
|
||||
if used_in_matches_count > 0:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from bracket.logic.elo import recalculate_elo_for_tournament_id
|
||||
from bracket.logic.scheduling.ladder_players_iter import get_possible_upcoming_matches_for_players
|
||||
from bracket.logic.scheduling.round_robin import get_possible_upcoming_matches_round_robin
|
||||
from bracket.models.db.match import Match, MatchBody, MatchCreateBody, MatchFilter
|
||||
from bracket.logic.matches import create_match_and_assign_free_court
|
||||
from bracket.logic.scheduling.upcoming_matches import (
|
||||
get_upcoming_matches_for_swiss_round,
|
||||
)
|
||||
from bracket.models.db.match import Match, MatchBody, MatchCreateBody, MatchFilter, SuggestedMatch
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.stage import StageType
|
||||
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, round_dependency
|
||||
from bracket.sql.courts import get_all_free_courts_in_round
|
||||
from bracket.sql.matches import sql_create_match, sql_delete_match, sql_update_match
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.sql.tournaments import sql_get_tournament
|
||||
from bracket.sql.matches import sql_delete_match, sql_update_match
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
router = APIRouter()
|
||||
@@ -42,22 +40,9 @@ async def get_matches_to_schedule(
|
||||
if not round_.is_draft:
|
||||
raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
|
||||
|
||||
[stage] = await get_stages_with_rounds_and_matches(tournament_id, stage_id=round_.stage_id)
|
||||
match stage.type:
|
||||
case StageType.ROUND_ROBIN:
|
||||
upcoming_matches = await get_possible_upcoming_matches_round_robin(
|
||||
tournament_id, assert_some(stage.id), assert_some(round_.id)
|
||||
)
|
||||
|
||||
case StageType.SWISS:
|
||||
upcoming_matches = await get_possible_upcoming_matches_for_players(
|
||||
tournament_id, match_filter, assert_some(stage.id), assert_some(round_.id)
|
||||
)
|
||||
|
||||
case _:
|
||||
raise NotImplementedError(f'Cannot suggest matches for stage type {stage.type}')
|
||||
|
||||
return UpcomingMatchesResponse(data=upcoming_matches)
|
||||
return UpcomingMatchesResponse(
|
||||
data=await get_upcoming_matches_for_swiss_round(match_filter, round_, tournament_id)
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse)
|
||||
@@ -77,20 +62,59 @@ async def create_match(
|
||||
match_body: MatchCreateBody,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
) -> SingleMatchResponse:
|
||||
tournament = await sql_get_tournament(tournament_id)
|
||||
next_free_court_id = None
|
||||
|
||||
if tournament.auto_assign_courts:
|
||||
free_courts = await get_all_free_courts_in_round(tournament_id, match_body.round_id)
|
||||
if len(free_courts) > 0:
|
||||
next_free_court_id = free_courts[0].id
|
||||
|
||||
match_body = match_body.copy(update={'court_id': next_free_court_id})
|
||||
match = await sql_create_match(match_body)
|
||||
return SingleMatchResponse(data=match)
|
||||
return SingleMatchResponse(
|
||||
data=await create_match_and_assign_free_court(tournament_id, match_body)
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse)
|
||||
@router.post(
|
||||
"/tournaments/{tournament_id}/rounds/{round_id}/schedule_auto",
|
||||
response_model=SuccessResponse,
|
||||
)
|
||||
async def create_matches_automatically(
|
||||
tournament_id: int,
|
||||
elo_diff_threshold: int = 100,
|
||||
iterations: int = 200,
|
||||
only_behind_schedule: bool = False,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
round_: Round = Depends(round_dependency),
|
||||
) -> SuccessResponse:
|
||||
if not round_.is_draft:
|
||||
raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.')
|
||||
|
||||
match_filter = MatchFilter(
|
||||
elo_diff_threshold=elo_diff_threshold,
|
||||
only_behind_schedule=only_behind_schedule,
|
||||
limit=1,
|
||||
iterations=iterations,
|
||||
)
|
||||
|
||||
limit = 15
|
||||
for __ in range(limit):
|
||||
all_matches_to_schedule = await get_upcoming_matches_for_swiss_round(
|
||||
match_filter, round_, tournament_id
|
||||
)
|
||||
if len(all_matches_to_schedule) < 1:
|
||||
break
|
||||
|
||||
match = all_matches_to_schedule[0]
|
||||
assert isinstance(match, SuggestedMatch)
|
||||
|
||||
assert round_.id and match.team1.id and match.team2.id
|
||||
await create_match_and_assign_free_court(
|
||||
tournament_id,
|
||||
MatchCreateBody(
|
||||
round_id=round_.id,
|
||||
team1_id=match.team1.id,
|
||||
team2_id=match.team2.id,
|
||||
court_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
return SuccessResponse()
|
||||
|
||||
|
||||
@router.put("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse)
|
||||
async def update_match_by_id(
|
||||
tournament_id: int,
|
||||
match_body: MatchBody,
|
||||
|
||||
@@ -5,12 +5,16 @@ from pydantic.generics import GenericModel
|
||||
|
||||
from bracket.models.db.club import Club
|
||||
from bracket.models.db.court import Court
|
||||
from bracket.models.db.match import Match, SuggestedMatch
|
||||
from bracket.models.db.match import Match, SuggestedMatch, SuggestedVirtualMatch
|
||||
from bracket.models.db.player import Player
|
||||
from bracket.models.db.round import StageWithRounds
|
||||
from bracket.models.db.stage_item_inputs import (
|
||||
StageItemInputOptionFinal,
|
||||
StageItemInputOptionTentative,
|
||||
)
|
||||
from bracket.models.db.team import FullTeamWithPlayers, Team
|
||||
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 Token
|
||||
|
||||
DataT = TypeVar('DataT')
|
||||
@@ -48,11 +52,11 @@ class SinglePlayerResponse(DataResponse[Player]):
|
||||
pass
|
||||
|
||||
|
||||
class RoundsWithMatchesResponse(DataResponse[list[StageWithRounds]]):
|
||||
class StagesWithStageItemsResponse(DataResponse[list[StageWithStageItems]]):
|
||||
pass
|
||||
|
||||
|
||||
class UpcomingMatchesResponse(DataResponse[list[SuggestedMatch]]):
|
||||
class UpcomingMatchesResponse(DataResponse[list[SuggestedMatch | SuggestedVirtualMatch]]):
|
||||
pass
|
||||
|
||||
|
||||
@@ -82,3 +86,9 @@ class CourtsResponse(DataResponse[list[Court]]):
|
||||
|
||||
class SingleCourtResponse(DataResponse[Court]):
|
||||
pass
|
||||
|
||||
|
||||
class StageItemInputOptionsResponse(
|
||||
DataResponse[list[StageItemInputOptionTentative | StageItemInputOptionFinal]]
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -28,9 +28,7 @@ async def get_players(
|
||||
return PlayersResponse(data=await fetch_all_parsed(database, Player, query))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/tournaments/{tournament_id}/players/{player_id}", response_model=SinglePlayerResponse
|
||||
)
|
||||
@router.put("/tournaments/{tournament_id}/players/{player_id}", response_model=SinglePlayerResponse)
|
||||
async def update_player_by_id(
|
||||
tournament_id: int,
|
||||
player_id: int,
|
||||
|
||||
@@ -9,14 +9,15 @@ from bracket.models.db.round import (
|
||||
RoundCreateBody,
|
||||
RoundToInsert,
|
||||
RoundUpdateBody,
|
||||
RoundWithMatches,
|
||||
)
|
||||
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 round_dependency, round_with_matches_dependency
|
||||
from bracket.schema import rounds
|
||||
from bracket.sql.rounds import get_next_round_name
|
||||
from bracket.sql.stage_items import get_stage_item
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -49,18 +50,32 @@ async def create_round(
|
||||
round_body: RoundCreateBody,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
) -> SuccessResponse:
|
||||
stage_item = await get_stage_item(tournament_id, stage_item_id=round_body.stage_item_id)
|
||||
|
||||
if stage_item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Stage item doesn't exist",
|
||||
)
|
||||
|
||||
if not stage_item.type.supports_dynamic_number_of_rounds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Stage type {stage_item.type} doesn't support manual creation of rounds",
|
||||
)
|
||||
|
||||
await database.execute(
|
||||
query=rounds.insert(),
|
||||
values=RoundToInsert(
|
||||
created=datetime_utc.now(),
|
||||
stage_id=round_body.stage_id,
|
||||
name=await get_next_round_name(tournament_id, round_body.stage_id),
|
||||
stage_item_id=round_body.stage_item_id,
|
||||
name=await get_next_round_name(tournament_id, round_body.stage_item_id),
|
||||
).dict(),
|
||||
)
|
||||
return SuccessResponse()
|
||||
|
||||
|
||||
@router.patch("/tournaments/{tournament_id}/rounds/{round_id}", response_model=SuccessResponse)
|
||||
@router.put("/tournaments/{tournament_id}/rounds/{round_id}", response_model=SuccessResponse)
|
||||
async def update_round_by_id(
|
||||
tournament_id: int,
|
||||
round_id: int,
|
||||
@@ -83,7 +98,8 @@ async def update_round_by_id(
|
||||
WHERE rounds.id IN (
|
||||
SELECT rounds.id
|
||||
FROM rounds
|
||||
JOIN stages s on s.id = rounds.stage_id
|
||||
JOIN stage_items ON rounds.stage_item_id = stage_items.id
|
||||
JOIN stages s on s.id = stage_items.stage_id
|
||||
WHERE s.tournament_id = :tournament_id
|
||||
)
|
||||
'''
|
||||
@@ -97,7 +113,8 @@ async def update_round_by_id(
|
||||
WHERE rounds.id IN (
|
||||
SELECT rounds.id
|
||||
FROM rounds
|
||||
JOIN stages s on s.id = rounds.stage_id
|
||||
JOIN stage_items ON rounds.stage_item_id = stage_items.id
|
||||
JOIN stages s on s.id = stage_items.stage_id
|
||||
WHERE s.tournament_id = :tournament_id
|
||||
)
|
||||
AND rounds.id = :round_id
|
||||
|
||||
80
backend/bracket/routes/stage_items.py
Normal file
80
backend/bracket/routes/stage_items.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from starlette import status
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.logic.elo import recalculate_elo_for_tournament_id
|
||||
from bracket.logic.scheduling.builder import (
|
||||
build_matches_for_stage_item,
|
||||
)
|
||||
from bracket.models.db.stage_item import StageItemCreateBody, StageItemUpdateBody
|
||||
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.sql.matches import sql_delete_matches_for_stage_item_id
|
||||
from bracket.sql.rounds import sql_delete_rounds_for_stage_item_id
|
||||
from bracket.sql.stage_item_inputs import sql_delete_stage_item_inputs
|
||||
from bracket.sql.stage_items import sql_create_stage_item, sql_delete_stage_item
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/tournaments/{tournament_id}/stage_items/{stage_item_id}", response_model=SuccessResponse
|
||||
)
|
||||
async def delete_stage_item(
|
||||
tournament_id: int,
|
||||
stage_item_id: int,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
stage_item: StageItemWithRounds = Depends(stage_item_dependency),
|
||||
) -> SuccessResponse:
|
||||
async with database.transaction():
|
||||
await sql_delete_matches_for_stage_item_id(stage_item_id)
|
||||
await sql_delete_rounds_for_stage_item_id(stage_item_id)
|
||||
await sql_delete_stage_item_inputs(stage_item_id)
|
||||
await sql_delete_stage_item(stage_item_id)
|
||||
|
||||
await recalculate_elo_for_tournament_id(tournament_id)
|
||||
return SuccessResponse()
|
||||
|
||||
|
||||
@router.post("/tournaments/{tournament_id}/stage_items", response_model=SuccessResponse)
|
||||
async def create_stage_item(
|
||||
tournament_id: int,
|
||||
stage_body: StageItemCreateBody,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
) -> SuccessResponse:
|
||||
if stage_body.team_count != len(stage_body.inputs):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Team count doesn't match number of inputs",
|
||||
)
|
||||
|
||||
stage_item = await sql_create_stage_item(tournament_id, stage_body)
|
||||
await build_matches_for_stage_item(stage_item, tournament_id)
|
||||
return SuccessResponse()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/tournaments/{tournament_id}/stage_items/{stage_item_id}", response_model=SuccessResponse
|
||||
)
|
||||
async def update_stage_item(
|
||||
tournament_id: int,
|
||||
stage_item_id: int,
|
||||
stage_item_body: StageItemUpdateBody,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
stage_item: StageItemWithRounds = Depends(stage_item_dependency),
|
||||
) -> SuccessResponse:
|
||||
query = '''
|
||||
UPDATE stage_items
|
||||
SET name = :name
|
||||
WHERE stage_items.id = :stage_item_id
|
||||
'''
|
||||
await database.execute(
|
||||
query=query,
|
||||
values={'stage_item_id': stage_item_id, 'name': stage_item_body.name},
|
||||
)
|
||||
return SuccessResponse()
|
||||
@@ -3,42 +3,46 @@ from starlette import status
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.logic.elo import recalculate_elo_for_tournament_id
|
||||
from bracket.models.db.round import StageWithRounds
|
||||
from bracket.models.db.stage import Stage, StageActivateBody, StageCreateBody, StageUpdateBody
|
||||
from bracket.logic.scheduling.builder import determine_available_inputs
|
||||
from bracket.models.db.stage import Stage, StageActivateBody, StageUpdateBody
|
||||
from bracket.models.db.user import UserPublic
|
||||
from bracket.models.db.util import StageWithStageItems
|
||||
from bracket.routes.auth import (
|
||||
user_authenticated_for_tournament,
|
||||
user_authenticated_or_public_dashboard,
|
||||
)
|
||||
from bracket.routes.models import RoundsWithMatchesResponse, SuccessResponse
|
||||
from bracket.routes.models import (
|
||||
StageItemInputOptionsResponse,
|
||||
StagesWithStageItemsResponse,
|
||||
SuccessResponse,
|
||||
)
|
||||
from bracket.routes.util import stage_dependency
|
||||
from bracket.sql.stages import (
|
||||
get_full_tournament_details,
|
||||
get_next_stage_in_tournament,
|
||||
get_stages_with_rounds_and_matches,
|
||||
sql_activate_next_stage,
|
||||
sql_create_stage,
|
||||
sql_delete_stage,
|
||||
)
|
||||
from bracket.sql.teams import get_teams_with_members
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/tournaments/{tournament_id}/stages", response_model=RoundsWithMatchesResponse)
|
||||
@router.get("/tournaments/{tournament_id}/stages", response_model=StagesWithStageItemsResponse)
|
||||
async def get_stages(
|
||||
tournament_id: int,
|
||||
user: UserPublic = Depends(user_authenticated_or_public_dashboard),
|
||||
no_draft_rounds: bool = False,
|
||||
) -> RoundsWithMatchesResponse:
|
||||
) -> StagesWithStageItemsResponse:
|
||||
if no_draft_rounds is False and user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Can't view draft rounds when not authorized",
|
||||
)
|
||||
|
||||
stages_ = await get_stages_with_rounds_and_matches(
|
||||
tournament_id, no_draft_rounds=no_draft_rounds
|
||||
)
|
||||
return RoundsWithMatchesResponse(data=stages_)
|
||||
stages_ = await get_full_tournament_details(tournament_id, no_draft_rounds=no_draft_rounds)
|
||||
return StagesWithStageItemsResponse(data=stages_)
|
||||
|
||||
|
||||
@router.delete("/tournaments/{tournament_id}/stages/{stage_id}", response_model=SuccessResponse)
|
||||
@@ -46,12 +50,12 @@ async def delete_stage(
|
||||
tournament_id: int,
|
||||
stage_id: int,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
stage: StageWithRounds = Depends(stage_dependency),
|
||||
stage: StageWithStageItems = Depends(stage_dependency),
|
||||
) -> SuccessResponse:
|
||||
if len(stage.rounds) > 0:
|
||||
if len(stage.stage_items) > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Stage contains rounds, please delete those first",
|
||||
detail="Stage contains stage items, please delete those first",
|
||||
)
|
||||
|
||||
if stage.is_active:
|
||||
@@ -69,14 +73,13 @@ async def delete_stage(
|
||||
@router.post("/tournaments/{tournament_id}/stages", response_model=SuccessResponse)
|
||||
async def create_stage(
|
||||
tournament_id: int,
|
||||
stage_body: StageCreateBody,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
) -> SuccessResponse:
|
||||
await sql_create_stage(stage_body, tournament_id)
|
||||
await sql_create_stage(tournament_id)
|
||||
return SuccessResponse()
|
||||
|
||||
|
||||
@router.patch("/tournaments/{tournament_id}/stages/{stage_id}", response_model=SuccessResponse)
|
||||
@router.put("/tournaments/{tournament_id}/stages/{stage_id}", response_model=SuccessResponse)
|
||||
async def update_stage(
|
||||
tournament_id: int,
|
||||
stage_id: int,
|
||||
@@ -87,13 +90,13 @@ async def update_stage(
|
||||
values = {'tournament_id': tournament_id, 'stage_id': stage_id}
|
||||
query = '''
|
||||
UPDATE stages
|
||||
SET is_active = :is_active
|
||||
SET name = :name
|
||||
WHERE stages.id = :stage_id
|
||||
AND stages.tournament_id = :tournament_id
|
||||
'''
|
||||
await database.execute(
|
||||
query=query,
|
||||
values={**values, 'is_active': stage_body.is_active},
|
||||
values={**values, 'name': stage_body.name},
|
||||
)
|
||||
return SuccessResponse()
|
||||
|
||||
@@ -108,8 +111,24 @@ async def activate_next_stage(
|
||||
if new_active_stage_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="There is no next stage",
|
||||
detail=f"There is no {stage_body.direction} stage",
|
||||
)
|
||||
|
||||
await sql_activate_next_stage(new_active_stage_id, tournament_id)
|
||||
return SuccessResponse()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tournaments/{tournament_id}/stages/{stage_id}/available_inputs",
|
||||
response_model=StageItemInputOptionsResponse,
|
||||
)
|
||||
async def get_available_inputs(
|
||||
tournament_id: int,
|
||||
stage_id: int,
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
stage: Stage = Depends(stage_dependency),
|
||||
) -> StageItemInputOptionsResponse:
|
||||
stages = await get_full_tournament_details(tournament_id)
|
||||
teams = await get_teams_with_members(tournament_id)
|
||||
available_inputs = determine_available_inputs(stage_id, teams, stages)
|
||||
return StageItemInputOptionsResponse(data=available_inputs)
|
||||
|
||||
@@ -6,11 +6,14 @@ from bracket.database import database
|
||||
from bracket.logic.elo import recalculate_elo_for_tournament_id
|
||||
from bracket.models.db.team import FullTeamWithPlayers, Team, TeamBody, TeamToInsert
|
||||
from bracket.models.db.user import UserPublic
|
||||
from bracket.routes.auth import user_authenticated_for_tournament
|
||||
from bracket.routes.auth import (
|
||||
user_authenticated_for_tournament,
|
||||
user_authenticated_or_public_dashboard,
|
||||
)
|
||||
from bracket.routes.models import SingleTeamResponse, SuccessResponse, TeamsWithPlayersResponse
|
||||
from bracket.routes.util import team_dependency, team_with_players_dependency
|
||||
from bracket.schema import players_x_teams, teams
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.sql.stages import get_full_tournament_details
|
||||
from bracket.sql.teams import get_team_by_id, get_teams_with_members
|
||||
from bracket.utils.db import fetch_one_parsed
|
||||
from bracket.utils.types import assert_some
|
||||
@@ -41,12 +44,12 @@ async def update_team_members(team_id: int, tournament_id: int, player_ids: list
|
||||
|
||||
@router.get("/tournaments/{tournament_id}/teams", response_model=TeamsWithPlayersResponse)
|
||||
async def get_teams(
|
||||
tournament_id: int, _: UserPublic = Depends(user_authenticated_for_tournament)
|
||||
tournament_id: int, _: UserPublic = Depends(user_authenticated_or_public_dashboard)
|
||||
) -> TeamsWithPlayersResponse:
|
||||
return TeamsWithPlayersResponse.parse_obj({'data': await get_teams_with_members(tournament_id)})
|
||||
|
||||
|
||||
@router.patch("/tournaments/{tournament_id}/teams/{team_id}", response_model=SingleTeamResponse)
|
||||
@router.put("/tournaments/{tournament_id}/teams/{team_id}", response_model=SingleTeamResponse)
|
||||
async def update_team_by_id(
|
||||
tournament_id: int,
|
||||
team_body: TeamBody,
|
||||
@@ -60,6 +63,7 @@ async def update_team_by_id(
|
||||
values=team_body.dict(exclude={'player_ids'}),
|
||||
)
|
||||
await update_team_members(assert_some(team.id), tournament_id, team_body.player_ids)
|
||||
await recalculate_elo_for_tournament_id(tournament_id)
|
||||
|
||||
return SingleTeamResponse(
|
||||
data=assert_some(
|
||||
@@ -80,14 +84,15 @@ async def delete_team(
|
||||
_: UserPublic = Depends(user_authenticated_for_tournament),
|
||||
team: FullTeamWithPlayers = Depends(team_with_players_dependency),
|
||||
) -> SuccessResponse:
|
||||
stages = await get_stages_with_rounds_and_matches(tournament_id, no_draft_rounds=False)
|
||||
stages = await get_full_tournament_details(tournament_id, no_draft_rounds=False)
|
||||
for stage in stages:
|
||||
for round_ in stage.rounds:
|
||||
if team.id in round_.get_team_ids():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Could not delete team that participates in matches in the tournament",
|
||||
)
|
||||
for stage_item in stage.stage_items:
|
||||
for round_ in stage_item.rounds:
|
||||
if team.id in round_.get_team_ids():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Could not delete team that participates in matches",
|
||||
)
|
||||
|
||||
if len(team.players):
|
||||
raise HTTPException(
|
||||
@@ -112,7 +117,7 @@ async def create_team(
|
||||
) -> SingleTeamResponse:
|
||||
tournament_teams = await get_teams_with_members(tournament_id)
|
||||
for team in tournament_teams:
|
||||
if sorted(team.player_ids) == sorted(team_to_insert.player_ids):
|
||||
if team.player_ids != [] and sorted(team.player_ids) == sorted(team_to_insert.player_ids):
|
||||
return SingleTeamResponse(data=team)
|
||||
|
||||
last_record_id = await database.execute(
|
||||
|
||||
@@ -14,15 +14,21 @@ from bracket.routes.auth import (
|
||||
user_authenticated,
|
||||
user_authenticated_for_tournament,
|
||||
user_authenticated_or_public_dashboard,
|
||||
user_authenticated_or_public_dashboard_by_endpoint_name,
|
||||
)
|
||||
from bracket.routes.models import SuccessResponse, TournamentResponse, TournamentsResponse
|
||||
from bracket.schema import tournaments
|
||||
from bracket.sql.tournaments import sql_get_tournaments
|
||||
from bracket.sql.tournaments import sql_get_tournament_by_endpoint_name, sql_get_tournaments
|
||||
from bracket.sql.users import get_user_access_to_club, get_which_clubs_has_user_access_to
|
||||
from bracket.utils.db import fetch_one_parsed_certain
|
||||
from bracket.utils.types import assert_some
|
||||
|
||||
router = APIRouter()
|
||||
unauthorized_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="You don't have access to this tournament",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tournaments/{tournament_id}", response_model=TournamentResponse)
|
||||
@@ -33,24 +39,35 @@ async def get_tournament(
|
||||
database, Tournament, tournaments.select().where(tournaments.c.id == tournament_id)
|
||||
)
|
||||
if user is None and not tournament.dashboard_public:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="You don't have access to this tournament",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
raise unauthorized_exception
|
||||
|
||||
return TournamentResponse(data=tournament)
|
||||
|
||||
|
||||
@router.get("/tournaments", response_model=TournamentsResponse)
|
||||
async def get_tournaments(
|
||||
user: UserPublic = Depends(user_authenticated), endpoint_name: str | None = None
|
||||
user: UserPublic | None = Depends(user_authenticated_or_public_dashboard_by_endpoint_name),
|
||||
endpoint_name: str | None = None,
|
||||
) -> TournamentsResponse:
|
||||
user_club_ids = await get_which_clubs_has_user_access_to(assert_some(user.id))
|
||||
return TournamentsResponse(data=await sql_get_tournaments(tuple(user_club_ids), endpoint_name))
|
||||
match user, endpoint_name:
|
||||
case None, None:
|
||||
raise unauthorized_exception
|
||||
|
||||
case _, str(endpoint_name):
|
||||
return TournamentsResponse(
|
||||
data=[await sql_get_tournament_by_endpoint_name(endpoint_name)]
|
||||
)
|
||||
|
||||
case _, _ if isinstance(user, UserPublic):
|
||||
user_club_ids = await get_which_clubs_has_user_access_to(assert_some(user.id))
|
||||
return TournamentsResponse(
|
||||
data=await sql_get_tournaments(tuple(user_club_ids), endpoint_name)
|
||||
)
|
||||
|
||||
raise RuntimeError()
|
||||
|
||||
|
||||
@router.patch("/tournaments/{tournament_id}", response_model=SuccessResponse)
|
||||
@router.put("/tournaments/{tournament_id}", response_model=SuccessResponse)
|
||||
async def update_tournament_by_id(
|
||||
tournament_id: int,
|
||||
tournament_body: TournamentUpdateBody,
|
||||
|
||||
@@ -40,8 +40,8 @@ async def get_user(
|
||||
return UserPublicResponse(data=user_public)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserPublicResponse)
|
||||
async def patch_user(
|
||||
@router.put("/users/{user_id}", response_model=UserPublicResponse)
|
||||
async def put_user(
|
||||
user_id: int,
|
||||
user_to_update: UserToUpdate,
|
||||
user_public: UserPublic = Depends(user_authenticated),
|
||||
@@ -54,8 +54,8 @@ async def patch_user(
|
||||
return UserPublicResponse(data=assert_some(user_updated))
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}/password", response_model=SuccessResponse)
|
||||
async def patch_user_password(
|
||||
@router.put("/users/{user_id}/password", response_model=SuccessResponse)
|
||||
async def put_user_password(
|
||||
user_id: int,
|
||||
user_to_update: UserPasswordToUpdate,
|
||||
user_public: UserPublic = Depends(user_authenticated),
|
||||
|
||||
@@ -3,10 +3,12 @@ from starlette import status
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.models.db.match import Match
|
||||
from bracket.models.db.round import Round, RoundWithMatches, StageWithRounds
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.team import FullTeamWithPlayers, Team
|
||||
from bracket.models.db.util import RoundWithMatches, StageItemWithRounds, StageWithStageItems
|
||||
from bracket.schema import matches, rounds, teams
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
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.utils.db import fetch_one_parsed
|
||||
|
||||
@@ -28,14 +30,15 @@ async def round_dependency(tournament_id: int, round_id: int) -> Round:
|
||||
|
||||
|
||||
async def round_with_matches_dependency(tournament_id: int, round_id: int) -> RoundWithMatches:
|
||||
stages = await get_stages_with_rounds_and_matches(
|
||||
stages = await get_full_tournament_details(
|
||||
tournament_id, no_draft_rounds=False, round_id=round_id
|
||||
)
|
||||
|
||||
for stage in stages:
|
||||
for round_ in stage.rounds:
|
||||
if round_ is not None:
|
||||
return round_
|
||||
for stage_item in stage.stage_items:
|
||||
for round_ in stage_item.rounds:
|
||||
if round_ is not None:
|
||||
return round_
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -43,8 +46,8 @@ async def round_with_matches_dependency(tournament_id: int, round_id: int) -> Ro
|
||||
)
|
||||
|
||||
|
||||
async def stage_dependency(tournament_id: int, stage_id: int) -> StageWithRounds:
|
||||
stages = await get_stages_with_rounds_and_matches(
|
||||
async def stage_dependency(tournament_id: int, stage_id: int) -> StageWithStageItems:
|
||||
stages = await get_full_tournament_details(
|
||||
tournament_id, no_draft_rounds=False, stage_id=stage_id
|
||||
)
|
||||
|
||||
@@ -57,6 +60,18 @@ async def stage_dependency(tournament_id: int, stage_id: int) -> StageWithRounds
|
||||
return stages[0]
|
||||
|
||||
|
||||
async def stage_item_dependency(tournament_id: int, stage_item_id: int) -> StageItemWithRounds:
|
||||
stage_item = await get_stage_item(tournament_id, stage_item_id=stage_item_id)
|
||||
|
||||
if stage_item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Could not find stage item with id {stage_item_id}",
|
||||
)
|
||||
|
||||
return stage_item
|
||||
|
||||
|
||||
async def match_dependency(tournament_id: int, match_id: int) -> Match:
|
||||
match = await fetch_one_parsed(
|
||||
database,
|
||||
|
||||
@@ -20,7 +20,7 @@ tournaments = Table(
|
||||
Column('id', BigInteger, primary_key=True, index=True),
|
||||
Column('name', String, nullable=False, index=True),
|
||||
Column('created', DateTimeTZ, nullable=False),
|
||||
Column('club_id', BigInteger, ForeignKey('clubs.id'), nullable=False),
|
||||
Column('club_id', BigInteger, ForeignKey('clubs.id'), index=True, nullable=False),
|
||||
Column('dashboard_public', Boolean, nullable=False),
|
||||
Column('logo_path', String, nullable=True),
|
||||
Column('dashboard_endpoint', String, nullable=True),
|
||||
@@ -32,16 +32,25 @@ stages = Table(
|
||||
'stages',
|
||||
metadata,
|
||||
Column('id', BigInteger, primary_key=True, index=True),
|
||||
Column('name', String, nullable=False, index=True),
|
||||
Column('created', DateTimeTZ, nullable=False),
|
||||
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), nullable=False),
|
||||
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), index=True, nullable=False),
|
||||
Column('is_active', Boolean, nullable=False, server_default='false'),
|
||||
)
|
||||
|
||||
stage_items = Table(
|
||||
'stage_items',
|
||||
metadata,
|
||||
Column('id', BigInteger, primary_key=True, index=True),
|
||||
Column('name', Text, nullable=False),
|
||||
Column('created', DateTimeTZ, nullable=False, server_default='now()'),
|
||||
Column('stage_id', BigInteger, ForeignKey('stages.id'), index=True, nullable=False),
|
||||
Column('team_count', Integer, nullable=False),
|
||||
Column(
|
||||
'type',
|
||||
Enum(
|
||||
'SINGLE_ELIMINATION',
|
||||
'DOUBLE_ELIMINATION',
|
||||
'SWISS',
|
||||
'SWISS_DYNAMIC_TEAMS',
|
||||
'ROUND_ROBIN',
|
||||
name='stage_type',
|
||||
),
|
||||
@@ -49,6 +58,24 @@ stages = Table(
|
||||
),
|
||||
)
|
||||
|
||||
stage_item_inputs = Table(
|
||||
'stage_item_inputs',
|
||||
metadata,
|
||||
Column('id', BigInteger, primary_key=True, index=True),
|
||||
Column('slot', Integer, nullable=False),
|
||||
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), index=True, nullable=False),
|
||||
Column(
|
||||
'stage_item_id',
|
||||
BigInteger,
|
||||
ForeignKey('stage_items.id', ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
),
|
||||
Column('team_id', BigInteger, ForeignKey('teams.id'), nullable=True),
|
||||
Column('team_stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=True),
|
||||
Column('team_position_in_group', Integer, nullable=True),
|
||||
)
|
||||
|
||||
rounds = Table(
|
||||
'rounds',
|
||||
metadata,
|
||||
@@ -57,17 +84,22 @@ rounds = Table(
|
||||
Column('created', DateTimeTZ, nullable=False),
|
||||
Column('is_draft', Boolean, nullable=False),
|
||||
Column('is_active', Boolean, nullable=False, server_default='false'),
|
||||
Column('stage_id', BigInteger, ForeignKey('stages.id'), nullable=False),
|
||||
Column('stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
matches = Table(
|
||||
'matches',
|
||||
metadata,
|
||||
Column('id', BigInteger, primary_key=True, index=True),
|
||||
Column('created', DateTimeTZ, nullable=False),
|
||||
Column('round_id', BigInteger, ForeignKey('rounds.id'), nullable=False),
|
||||
Column('team1_id', BigInteger, ForeignKey('teams.id'), nullable=False),
|
||||
Column('team2_id', BigInteger, ForeignKey('teams.id'), nullable=False),
|
||||
Column('team1_id', BigInteger, ForeignKey('teams.id'), nullable=True),
|
||||
Column('team2_id', BigInteger, ForeignKey('teams.id'), nullable=True),
|
||||
Column('team1_stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=True),
|
||||
Column('team2_stage_item_id', BigInteger, ForeignKey('stage_items.id'), nullable=True),
|
||||
Column('team1_position_in_group', Integer, nullable=True),
|
||||
Column('team2_position_in_group', Integer, nullable=True),
|
||||
Column('court_id', BigInteger, ForeignKey('courts.id'), nullable=True),
|
||||
Column('team1_score', Integer, nullable=False),
|
||||
Column('team2_score', Integer, nullable=False),
|
||||
@@ -79,7 +111,7 @@ teams = Table(
|
||||
Column('id', BigInteger, primary_key=True, index=True),
|
||||
Column('name', String, nullable=False, index=True),
|
||||
Column('created', DateTimeTZ, nullable=False),
|
||||
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), nullable=False),
|
||||
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), index=True, nullable=False),
|
||||
Column('active', Boolean, nullable=False, index=True, server_default='t'),
|
||||
)
|
||||
|
||||
@@ -89,7 +121,7 @@ players = Table(
|
||||
Column('id', BigInteger, primary_key=True, index=True),
|
||||
Column('name', String, nullable=False, index=True, unique=True),
|
||||
Column('created', DateTimeTZ, nullable=False),
|
||||
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), nullable=False),
|
||||
Column('tournament_id', BigInteger, ForeignKey('tournaments.id'), index=True, nullable=False),
|
||||
Column('elo_score', Float, nullable=False),
|
||||
Column('swiss_score', Float, nullable=False),
|
||||
Column('wins', Integer, nullable=False),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from bracket.database import database
|
||||
from bracket.models.db.match import Match, MatchBody, MatchCreateBody
|
||||
from bracket.models.db.match import Match, MatchBody, MatchCreateBody, MatchVirtualCreateBody
|
||||
|
||||
|
||||
async def sql_delete_match(match_id: int) -> None:
|
||||
@@ -10,6 +10,19 @@ async def sql_delete_match(match_id: int) -> None:
|
||||
await database.execute(query=query, values={'match_id': match_id})
|
||||
|
||||
|
||||
async def sql_delete_matches_for_stage_item_id(stage_item_id: int) -> None:
|
||||
query = '''
|
||||
DELETE FROM matches
|
||||
WHERE matches.id IN (
|
||||
SELECT matches.id
|
||||
FROM matches
|
||||
LEFT JOIN rounds ON matches.round_id = rounds.id
|
||||
WHERE rounds.stage_item_id = :stage_item_id
|
||||
)
|
||||
'''
|
||||
await database.execute(query=query, values={'stage_item_id': stage_item_id})
|
||||
|
||||
|
||||
async def sql_create_match(match: MatchCreateBody) -> Match:
|
||||
query = '''
|
||||
INSERT INTO matches (
|
||||
@@ -23,7 +36,41 @@ async def sql_create_match(match: MatchCreateBody) -> Match:
|
||||
)
|
||||
VALUES (:round_id, :team1_id, :team2_id, 0, 0, :court_id, NOW())
|
||||
RETURNING *
|
||||
'''
|
||||
'''
|
||||
result = await database.fetch_one(query=query, values=match.dict())
|
||||
|
||||
if result is None:
|
||||
raise ValueError('Could not create stage')
|
||||
|
||||
return Match.parse_obj(result._mapping)
|
||||
|
||||
|
||||
async def todo_sql_create_virtual_match(match: MatchVirtualCreateBody) -> Match:
|
||||
query = '''
|
||||
INSERT INTO matches (
|
||||
round_id,
|
||||
court_id,
|
||||
team1_stage_item_id,
|
||||
team2_stage_item_id,
|
||||
team1_position_in_group,
|
||||
team2_position_in_group,
|
||||
team1_score,
|
||||
team2_score,
|
||||
created
|
||||
)
|
||||
VALUES (
|
||||
:round_id,
|
||||
:court_id,
|
||||
:team1_stage_item_id,
|
||||
:team2_stage_item_id,
|
||||
:team1_position_in_group,
|
||||
:team2_position_in_group,
|
||||
0,
|
||||
0,
|
||||
NOW()
|
||||
)
|
||||
RETURNING *
|
||||
'''
|
||||
result = await database.fetch_one(query=query, values=match.dict())
|
||||
|
||||
if result is None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from bracket.database import database
|
||||
from bracket.models.db.player import Player
|
||||
from bracket.models.db.players import PlayerStatistics
|
||||
|
||||
|
||||
async def get_all_players_in_tournament(tournament_id: int) -> list[Player]:
|
||||
@@ -21,3 +22,31 @@ async def get_active_players_in_tournament(tournament_id: int) -> list[Player]:
|
||||
'''
|
||||
result = await database.fetch_all(query=query, values={'tournament_id': tournament_id})
|
||||
return [Player.parse_obj(x._mapping) for x in result]
|
||||
|
||||
|
||||
async def update_player_stats(
|
||||
tournament_id: int, player_id: int, player_statistics: PlayerStatistics
|
||||
) -> None:
|
||||
query = '''
|
||||
UPDATE players
|
||||
SET
|
||||
wins = :wins,
|
||||
draws = :draws,
|
||||
losses = :losses,
|
||||
elo_score = :elo_score,
|
||||
swiss_score = :swiss_score
|
||||
WHERE players.tournament_id = :tournament_id
|
||||
AND players.id = :player_id
|
||||
'''
|
||||
await database.execute(
|
||||
query=query,
|
||||
values={
|
||||
'tournament_id': tournament_id,
|
||||
'player_id': player_id,
|
||||
'wins': player_statistics.wins,
|
||||
'draws': player_statistics.draws,
|
||||
'losses': player_statistics.losses,
|
||||
'elo_score': player_statistics.elo_score,
|
||||
'swiss_score': float(player_statistics.swiss_score),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
from bracket.database import database
|
||||
from bracket.models.db.round import RoundWithMatches
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.models.db.util import RoundWithMatches
|
||||
from bracket.sql.stage_items import get_stage_item
|
||||
|
||||
|
||||
async def get_rounds_for_stage(tournament_id: int, stage_id: int) -> list[RoundWithMatches]:
|
||||
stages = await get_stages_with_rounds_and_matches(tournament_id)
|
||||
result_stage = next((stage for stage in stages if stage.id == stage_id), None)
|
||||
if result_stage is None:
|
||||
raise ValueError(f'Could not find stage with id {stage_id} for tournament {tournament_id}')
|
||||
async def get_rounds_for_stage_item(
|
||||
tournament_id: int, stage_item_id: int
|
||||
) -> list[RoundWithMatches]:
|
||||
stage_item = await get_stage_item(tournament_id, stage_item_id)
|
||||
|
||||
return result_stage.rounds
|
||||
if stage_item is None:
|
||||
raise ValueError(
|
||||
f'Could not find stage item with id {stage_item_id} for tournament {tournament_id}'
|
||||
)
|
||||
|
||||
return stage_item.rounds
|
||||
|
||||
|
||||
async def get_next_round_name(tournament_id: int, stage_id: int) -> str:
|
||||
async def get_next_round_name(tournament_id: int, stage_item_id: int) -> str:
|
||||
query = '''
|
||||
SELECT count(*) FROM rounds
|
||||
JOIN stages s on s.id = rounds.stage_id
|
||||
JOIN stages s on s.id = rounds.stage_item_id
|
||||
WHERE s.tournament_id = :tournament_id
|
||||
AND rounds.stage_id = :stage_id
|
||||
AND rounds.stage_item_id = :stage_item_id
|
||||
'''
|
||||
round_count = int(
|
||||
await database.fetch_val(
|
||||
query=query, values={'tournament_id': tournament_id, 'stage_id': stage_id}
|
||||
query=query, values={'tournament_id': tournament_id, 'stage_item_id': stage_item_id}
|
||||
)
|
||||
)
|
||||
return f'Round {round_count + 1}'
|
||||
|
||||
|
||||
async def sql_delete_rounds_for_stage_item_id(stage_item_id: int) -> None:
|
||||
query = '''
|
||||
DELETE FROM rounds
|
||||
WHERE rounds.stage_item_id = :stage_item_id
|
||||
'''
|
||||
await database.execute(query=query, values={'stage_item_id': stage_item_id})
|
||||
|
||||
64
backend/bracket/sql/stage_item_inputs.py
Normal file
64
backend/bracket/sql/stage_item_inputs.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from bracket.database import database
|
||||
from bracket.models.db.stage_item_inputs import (
|
||||
StageItemInputBase,
|
||||
StageItemInputCreateBody,
|
||||
StageItemInputCreateBodyFinal,
|
||||
StageItemInputCreateBodyTentative,
|
||||
)
|
||||
|
||||
|
||||
async def sql_delete_stage_item_inputs(stage_item_id: int) -> None:
|
||||
query = '''
|
||||
DELETE FROM stage_item_inputs
|
||||
WHERE stage_item_id = :stage_item_id OR team_stage_item_id = :stage_item_id
|
||||
'''
|
||||
await database.execute(query=query, values={'stage_item_id': stage_item_id})
|
||||
|
||||
|
||||
async def sql_create_stage_item_input(
|
||||
tournament_id: int, stage_item_id: int, stage_item_input: StageItemInputCreateBody
|
||||
) -> StageItemInputBase:
|
||||
async with database.transaction():
|
||||
query = '''
|
||||
INSERT INTO stage_item_inputs
|
||||
(
|
||||
slot,
|
||||
tournament_id,
|
||||
stage_item_id,
|
||||
team_id,
|
||||
team_stage_item_id,
|
||||
team_position_in_group
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
:slot,
|
||||
:tournament_id,
|
||||
:stage_item_id,
|
||||
:team_id,
|
||||
:team_stage_item_id,
|
||||
:team_position_in_group
|
||||
)
|
||||
RETURNING *
|
||||
'''
|
||||
result = await database.fetch_one(
|
||||
query=query,
|
||||
values={
|
||||
'slot': stage_item_input.slot,
|
||||
'tournament_id': tournament_id,
|
||||
'stage_item_id': stage_item_id,
|
||||
'team_id': stage_item_input.team_id
|
||||
if isinstance(stage_item_input, StageItemInputCreateBodyFinal)
|
||||
else None,
|
||||
'team_stage_item_id': stage_item_input.team_stage_item_id
|
||||
if isinstance(stage_item_input, StageItemInputCreateBodyTentative)
|
||||
else None,
|
||||
'team_position_in_group': stage_item_input.team_position_in_group
|
||||
if isinstance(stage_item_input, StageItemInputCreateBodyTentative)
|
||||
else None,
|
||||
},
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise ValueError('Could not create stage')
|
||||
|
||||
return StageItemInputBase.parse_obj(result._mapping)
|
||||
49
backend/bracket/sql/stage_items.py
Normal file
49
backend/bracket/sql/stage_items.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from bracket.database import database
|
||||
from bracket.models.db.stage_item import StageItem, StageItemCreateBody
|
||||
from bracket.models.db.util import StageItemWithRounds
|
||||
from bracket.sql.stage_item_inputs import sql_create_stage_item_input
|
||||
from bracket.sql.stages import get_full_tournament_details
|
||||
|
||||
|
||||
async def sql_create_stage_item(tournament_id: int, stage_item: StageItemCreateBody) -> StageItem:
|
||||
async with database.transaction():
|
||||
query = '''
|
||||
INSERT INTO stage_items (type, stage_id, name, team_count)
|
||||
VALUES (:stage_item_type, :stage_id, :name, :team_count)
|
||||
RETURNING *
|
||||
'''
|
||||
result = await database.fetch_one(
|
||||
query=query,
|
||||
values={
|
||||
'stage_item_type': stage_item.type.value,
|
||||
'stage_id': stage_item.stage_id,
|
||||
'name': stage_item.get_name_or_default_name(),
|
||||
'team_count': stage_item.team_count,
|
||||
},
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise ValueError('Could not create stage')
|
||||
|
||||
stage_item_result = StageItem.parse_obj(result._mapping)
|
||||
|
||||
for input_ in stage_item.inputs:
|
||||
await sql_create_stage_item_input(tournament_id, stage_item_result.id, input_)
|
||||
|
||||
return stage_item_result
|
||||
|
||||
|
||||
async def sql_delete_stage_item(stage_item_id: int) -> None:
|
||||
query = '''
|
||||
DELETE FROM stage_items
|
||||
WHERE stage_items.id = :stage_item_id
|
||||
'''
|
||||
await database.execute(query=query, values={'stage_item_id': stage_item_id})
|
||||
|
||||
|
||||
async def get_stage_item(tournament_id: int, stage_item_id: int) -> StageItemWithRounds | None:
|
||||
stages = await get_full_tournament_details(tournament_id, stage_item_id=stage_item_id)
|
||||
if len(stages) < 1 or len(stages[0].stage_items) < 1:
|
||||
return None
|
||||
|
||||
return stages[0].stage_items[0]
|
||||
@@ -1,21 +1,29 @@
|
||||
from typing import Literal, cast
|
||||
|
||||
from bracket.database import database
|
||||
from bracket.models.db.round import StageWithRounds
|
||||
from bracket.models.db.stage import Stage, StageCreateBody
|
||||
from bracket.models.db.stage import Stage
|
||||
from bracket.models.db.util import StageWithStageItems
|
||||
from bracket.utils.types import dict_without_none
|
||||
|
||||
|
||||
async def get_stages_with_rounds_and_matches(
|
||||
async def get_full_tournament_details(
|
||||
tournament_id: int,
|
||||
round_id: int | None = None,
|
||||
stage_id: int | None = None,
|
||||
stage_item_id: int | None = None,
|
||||
*,
|
||||
no_draft_rounds: bool = False,
|
||||
) -> list[StageWithRounds]:
|
||||
) -> list[StageWithStageItems]:
|
||||
draft_filter = 'AND rounds.is_draft IS FALSE' if no_draft_rounds else ''
|
||||
round_filter = 'AND rounds.id = :round_id' if round_id is not None else ''
|
||||
stage_filter = 'AND stages.id = :stage_id' if stage_id is not None else ''
|
||||
stage_item_filter = 'AND stage_items.id = :stage_item_id' if stage_item_id is not None else ''
|
||||
stage_item_filter_join = (
|
||||
'LEFT JOIN stage_items on stages.id = stage_items.stage_id'
|
||||
if stage_item_id is not None
|
||||
else ''
|
||||
)
|
||||
|
||||
query = f'''
|
||||
WITH teams_with_players AS (
|
||||
SELECT DISTINCT ON (teams.id)
|
||||
@@ -28,7 +36,19 @@ async def get_stages_with_rounds_and_matches(
|
||||
(
|
||||
SELECT COALESCE(avg(elo_score), 0.0)
|
||||
FROM players
|
||||
) AS elo_score
|
||||
) AS elo_score,
|
||||
(
|
||||
SELECT COALESCE(avg(wins), 0.0)
|
||||
FROM players
|
||||
) AS wins,
|
||||
(
|
||||
SELECT COALESCE(avg(draws), 0.0)
|
||||
FROM players
|
||||
) AS draws,
|
||||
(
|
||||
SELECT COALESCE(avg(losses), 0.0)
|
||||
FROM players
|
||||
) AS losses
|
||||
FROM teams
|
||||
LEFT JOIN players_x_teams pt on pt.team_id = teams.id
|
||||
LEFT JOIN players p on pt.player_id = p.id
|
||||
@@ -44,56 +64,99 @@ async def get_stages_with_rounds_and_matches(
|
||||
LEFT JOIN teams_with_players t1 on t1.id = matches.team1_id
|
||||
LEFT JOIN teams_with_players t2 on t2.id = matches.team2_id
|
||||
LEFT JOIN rounds r on matches.round_id = r.id
|
||||
LEFT JOIN stages s2 on r.stage_id = s2.id
|
||||
LEFT JOIN stages st on r.stage_item_id = st.id
|
||||
LEFT JOIN stage_items si on st.id = si.stage_id
|
||||
LEFT JOIN courts c on matches.court_id = c.id
|
||||
WHERE s2.tournament_id = :tournament_id
|
||||
WHERE st.tournament_id = :tournament_id
|
||||
), rounds_with_matches AS (
|
||||
SELECT DISTINCT ON (rounds.id)
|
||||
rounds.*,
|
||||
to_json(array_agg(m.*)) AS matches
|
||||
FROM rounds
|
||||
LEFT JOIN matches_with_teams m on m.round_id = rounds.id
|
||||
LEFT JOIN stages s2 on rounds.stage_id = s2.id
|
||||
LEFT JOIN stages s2 on rounds.stage_item_id = s2.id
|
||||
WHERE s2.tournament_id = :tournament_id
|
||||
{draft_filter}
|
||||
{round_filter}
|
||||
GROUP BY rounds.id
|
||||
), stage_items_with_rounds AS (
|
||||
SELECT DISTINCT ON (stage_items.id)
|
||||
stage_items.*,
|
||||
to_json(array_agg(r.*)) AS rounds
|
||||
FROM stage_items
|
||||
JOIN stages st on stage_items.stage_id = st.id
|
||||
LEFT JOIN rounds_with_matches r on r.stage_item_id = stage_items.id
|
||||
WHERE st.tournament_id = :tournament_id
|
||||
{stage_item_filter}
|
||||
GROUP BY stage_items.id
|
||||
), stage_items_with_inputs AS (
|
||||
SELECT DISTINCT ON (stage_items.id)
|
||||
stage_items.id,
|
||||
to_json(array_agg(sii)) AS inputs
|
||||
FROM stage_items
|
||||
LEFT JOIN stage_item_inputs sii ON stage_items.id = sii.stage_item_id
|
||||
WHERE sii.tournament_id = :tournament_id
|
||||
{stage_item_filter}
|
||||
GROUP BY stage_items.id
|
||||
ORDER BY stage_items.id
|
||||
), stage_items_with_rounds_and_inputs AS (
|
||||
SELECT stage_items.*, stage_items_with_inputs.inputs, stage_items_with_rounds.rounds
|
||||
FROM stage_items
|
||||
JOIN stage_items_with_rounds ON stage_items_with_rounds.id = stage_items.id
|
||||
LEFT JOIN stage_items_with_inputs
|
||||
ON stage_items_with_inputs.id = stage_items_with_rounds.id
|
||||
)
|
||||
SELECT stages.*, to_json(array_agg(r.*)) AS rounds FROM stages
|
||||
LEFT JOIN rounds_with_matches r on stages.id = r.stage_id
|
||||
SELECT stages.*, to_json(array_agg(r.*)) AS stage_items
|
||||
FROM stages
|
||||
LEFT JOIN stage_items_with_rounds_and_inputs r on stages.id = r.stage_id
|
||||
{stage_item_filter_join}
|
||||
WHERE stages.tournament_id = :tournament_id
|
||||
{stage_filter}
|
||||
{stage_item_filter}
|
||||
GROUP BY stages.id
|
||||
ORDER BY stages.id
|
||||
'''
|
||||
values = dict_without_none(
|
||||
{'tournament_id': tournament_id, 'round_id': round_id, 'stage_id': stage_id}
|
||||
{
|
||||
'tournament_id': tournament_id,
|
||||
'round_id': round_id,
|
||||
'stage_id': stage_id,
|
||||
'stage_item_id': stage_item_id,
|
||||
}
|
||||
)
|
||||
result = await database.fetch_all(query=query, values=values)
|
||||
return [StageWithRounds.parse_obj(x._mapping) for x in result]
|
||||
return [StageWithStageItems.parse_obj(x._mapping) for x in result]
|
||||
|
||||
|
||||
async def sql_delete_stage(tournament_id: int, stage_id: int) -> None:
|
||||
query = '''
|
||||
DELETE FROM stages
|
||||
WHERE stages.id = :stage_id
|
||||
AND stages.tournament_id = :tournament_id
|
||||
'''
|
||||
await database.fetch_one(
|
||||
query=query, values={'stage_id': stage_id, 'tournament_id': tournament_id}
|
||||
)
|
||||
|
||||
|
||||
async def sql_create_stage(stage: StageCreateBody, tournament_id: int) -> Stage:
|
||||
async with database.transaction():
|
||||
query = '''
|
||||
INSERT INTO stages (type, created, is_active, tournament_id)
|
||||
VALUES (:stage_type, NOW(), false, :tournament_id)
|
||||
RETURNING *
|
||||
DELETE FROM stage_items
|
||||
WHERE stage_items.stage_id = :stage_id
|
||||
'''
|
||||
result = await database.fetch_one(
|
||||
query=query, values={'stage_type': stage.type.value, 'tournament_id': tournament_id}
|
||||
await database.execute(query=query, values={'stage_id': stage_id})
|
||||
|
||||
query = '''
|
||||
DELETE FROM stages
|
||||
WHERE stages.id = :stage_id
|
||||
AND stages.tournament_id = :tournament_id
|
||||
'''
|
||||
await database.execute(
|
||||
query=query, values={'stage_id': stage_id, 'tournament_id': tournament_id}
|
||||
)
|
||||
|
||||
|
||||
async def sql_create_stage(tournament_id: int) -> Stage:
|
||||
query = '''
|
||||
INSERT INTO stages (created, is_active, name, tournament_id)
|
||||
VALUES (NOW(), false, :name, :tournament_id)
|
||||
RETURNING *
|
||||
'''
|
||||
result = await database.fetch_one(
|
||||
query=query,
|
||||
values={'tournament_id': tournament_id, 'name': 'Stage'},
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise ValueError('Could not create stage')
|
||||
|
||||
|
||||
@@ -25,15 +25,19 @@ async def get_teams_with_members(
|
||||
SELECT
|
||||
teams.*,
|
||||
to_json(array_agg(p.*)) AS players,
|
||||
COALESCE(avg(p.elo_score), 0.0) AS elo_score,
|
||||
COALESCE(avg(p.swiss_score), 0.0) AS swiss_score
|
||||
COALESCE(avg(p.elo_score), 1200.0) AS elo_score,
|
||||
COALESCE(avg(p.swiss_score), 0.0) AS swiss_score,
|
||||
COALESCE(avg(p.wins), 0.0) AS wins,
|
||||
COALESCE(avg(p.draws), 0.0) AS draws,
|
||||
COALESCE(avg(p.losses), 0.0) AS losses
|
||||
FROM teams
|
||||
LEFT JOIN players_x_teams pt on pt.team_id = teams.id
|
||||
LEFT JOIN players p on pt.player_id = p.id
|
||||
WHERE teams.tournament_id = :tournament_id
|
||||
{active_team_filter}
|
||||
{team_id_filter}
|
||||
GROUP BY teams.id;
|
||||
GROUP BY teams.id
|
||||
ORDER BY elo_score DESC, wins DESC, name ASC
|
||||
'''
|
||||
values = dict_without_none({'tournament_id': tournament_id, 'team_id': team_id})
|
||||
result = await database.fetch_all(query=query, values=values)
|
||||
|
||||
@@ -15,6 +15,18 @@ async def sql_get_tournament(tournament_id: int) -> Tournament:
|
||||
return Tournament.parse_obj(result._mapping)
|
||||
|
||||
|
||||
async def sql_get_tournament_by_endpoint_name(endpoint_name: str) -> Tournament:
|
||||
query = '''
|
||||
SELECT *
|
||||
FROM tournaments
|
||||
WHERE dashboard_endpoint = :endpoint_name
|
||||
AND dashboard_public IS TRUE
|
||||
'''
|
||||
result = await database.fetch_one(query=query, values={'endpoint_name': endpoint_name})
|
||||
assert result is not None
|
||||
return Tournament.parse_obj(result._mapping)
|
||||
|
||||
|
||||
async def sql_get_tournaments(
|
||||
club_ids: tuple[int, ...], endpoint_name: str | None
|
||||
) -> list[Tournament]:
|
||||
|
||||
@@ -5,6 +5,7 @@ from heliclockter import datetime_utc
|
||||
from bracket.config import Environment, config, environment
|
||||
from bracket.database import database, engine
|
||||
from bracket.logic.elo import recalculate_elo_for_tournament_id
|
||||
from bracket.logic.scheduling.builder import build_matches_for_stage_item
|
||||
from bracket.models.db.club import Club
|
||||
from bracket.models.db.court import Court
|
||||
from bracket.models.db.match import Match
|
||||
@@ -12,6 +13,11 @@ from bracket.models.db.player import Player
|
||||
from bracket.models.db.player_x_team import PlayerXTeam
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.stage import Stage
|
||||
from bracket.models.db.stage_item import StageItem, StageItemCreateBody
|
||||
from bracket.models.db.stage_item_inputs import (
|
||||
StageItemInputCreateBodyFinal,
|
||||
StageItemInputCreateBodyTentative,
|
||||
)
|
||||
from bracket.models.db.team import Team
|
||||
from bracket.models.db.tournament import Tournament
|
||||
from bracket.models.db.user import User
|
||||
@@ -24,22 +30,20 @@ from bracket.schema import (
|
||||
players,
|
||||
players_x_teams,
|
||||
rounds,
|
||||
stage_items,
|
||||
stages,
|
||||
teams,
|
||||
tournaments,
|
||||
users,
|
||||
users_x_clubs,
|
||||
)
|
||||
from bracket.sql.stage_items import sql_create_stage_item
|
||||
from bracket.sql.users import get_user
|
||||
from bracket.utils.db import insert_generic
|
||||
from bracket.utils.dummy_records import (
|
||||
DUMMY_CLUB,
|
||||
DUMMY_COURT1,
|
||||
DUMMY_COURT2,
|
||||
DUMMY_MATCH1,
|
||||
DUMMY_MATCH2,
|
||||
DUMMY_MATCH3,
|
||||
DUMMY_MATCH4,
|
||||
DUMMY_PLAYER1,
|
||||
DUMMY_PLAYER2,
|
||||
DUMMY_PLAYER3,
|
||||
@@ -50,11 +54,10 @@ from bracket.utils.dummy_records import (
|
||||
DUMMY_PLAYER8,
|
||||
DUMMY_PLAYER9,
|
||||
DUMMY_PLAYER_X_TEAM,
|
||||
DUMMY_ROUND1,
|
||||
DUMMY_ROUND2,
|
||||
DUMMY_ROUND3,
|
||||
DUMMY_STAGE1,
|
||||
DUMMY_STAGE2,
|
||||
DUMMY_STAGE_ITEM1,
|
||||
DUMMY_STAGE_ITEM2,
|
||||
DUMMY_TEAM1,
|
||||
DUMMY_TEAM2,
|
||||
DUMMY_TEAM3,
|
||||
@@ -123,6 +126,7 @@ async def sql_create_dev_db() -> None:
|
||||
Match: matches,
|
||||
Tournament: tournaments,
|
||||
Court: courts,
|
||||
StageItem: stage_items,
|
||||
}
|
||||
|
||||
async def insert_dummy(obj_to_insert: BaseModelT) -> int:
|
||||
@@ -133,6 +137,7 @@ async def sql_create_dev_db() -> None:
|
||||
|
||||
user_id_1 = await insert_dummy(DUMMY_USER)
|
||||
club_id_1 = await insert_dummy(DUMMY_CLUB)
|
||||
|
||||
await insert_dummy(UserXClub(user_id=user_id_1, club_id=club_id_1))
|
||||
|
||||
if real_user_id is not None:
|
||||
@@ -141,6 +146,7 @@ async def sql_create_dev_db() -> None:
|
||||
tournament_id_1 = await insert_dummy(DUMMY_TOURNAMENT.copy(update={'club_id': club_id_1}))
|
||||
stage_id_1 = await insert_dummy(DUMMY_STAGE1.copy(update={'tournament_id': tournament_id_1}))
|
||||
stage_id_2 = await insert_dummy(DUMMY_STAGE2.copy(update={'tournament_id': tournament_id_1}))
|
||||
|
||||
team_id_1 = await insert_dummy(DUMMY_TEAM1.copy(update={'tournament_id': tournament_id_1}))
|
||||
team_id_2 = await insert_dummy(DUMMY_TEAM2.copy(update={'tournament_id': tournament_id_1}))
|
||||
team_id_3 = await insert_dummy(DUMMY_TEAM3.copy(update={'tournament_id': tournament_id_1}))
|
||||
@@ -181,48 +187,60 @@ async def sql_create_dev_db() -> None:
|
||||
DUMMY_PLAYER_X_TEAM.copy(update={'player_id': player_id_8, 'team_id': team_id_4})
|
||||
)
|
||||
|
||||
round_id_1 = await insert_dummy(DUMMY_ROUND1.copy(update={'stage_id': stage_id_1}))
|
||||
round_id_2 = await insert_dummy(DUMMY_ROUND2.copy(update={'stage_id': stage_id_1}))
|
||||
round_id_3 = await insert_dummy(DUMMY_ROUND3.copy(update={'stage_id': stage_id_2}))
|
||||
await insert_dummy(DUMMY_COURT1.copy(update={'tournament_id': tournament_id_1}))
|
||||
await insert_dummy(DUMMY_COURT2.copy(update={'tournament_id': tournament_id_1}))
|
||||
|
||||
court_id_1 = await insert_dummy(DUMMY_COURT1.copy(update={'tournament_id': tournament_id_1}))
|
||||
court_id_2 = await insert_dummy(DUMMY_COURT2.copy(update={'tournament_id': tournament_id_1}))
|
||||
stage_item_1 = await sql_create_stage_item(
|
||||
tournament_id_1,
|
||||
StageItemCreateBody(
|
||||
stage_id=stage_id_1,
|
||||
name=DUMMY_STAGE_ITEM1.name,
|
||||
team_count=DUMMY_STAGE_ITEM1.team_count,
|
||||
type=DUMMY_STAGE_ITEM1.type,
|
||||
inputs=[
|
||||
StageItemInputCreateBodyFinal(
|
||||
slot=1,
|
||||
team_id=team_id_1,
|
||||
),
|
||||
StageItemInputCreateBodyFinal(
|
||||
slot=2,
|
||||
team_id=team_id_2,
|
||||
),
|
||||
StageItemInputCreateBodyFinal(
|
||||
slot=3,
|
||||
team_id=team_id_3,
|
||||
),
|
||||
StageItemInputCreateBodyFinal(
|
||||
slot=4,
|
||||
team_id=team_id_4,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
stage_item_2 = await sql_create_stage_item(
|
||||
tournament_id_1,
|
||||
StageItemCreateBody(
|
||||
stage_id=stage_id_2,
|
||||
name=DUMMY_STAGE_ITEM2.name,
|
||||
team_count=DUMMY_STAGE_ITEM2.team_count,
|
||||
type=DUMMY_STAGE_ITEM2.type,
|
||||
inputs=[
|
||||
StageItemInputCreateBodyTentative(
|
||||
slot=1,
|
||||
team_stage_item_id=stage_item_1.id,
|
||||
team_position_in_group=1,
|
||||
),
|
||||
StageItemInputCreateBodyTentative(
|
||||
slot=2,
|
||||
team_stage_item_id=stage_item_1.id,
|
||||
team_position_in_group=2,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
await insert_dummy(
|
||||
DUMMY_MATCH1.copy(
|
||||
update={
|
||||
'round_id': round_id_1,
|
||||
'team1_id': team_id_1,
|
||||
'team2_id': team_id_2,
|
||||
'court_id': court_id_1,
|
||||
}
|
||||
),
|
||||
)
|
||||
await insert_dummy(
|
||||
DUMMY_MATCH2.copy(
|
||||
update={
|
||||
'round_id': round_id_1,
|
||||
'team1_id': team_id_3,
|
||||
'team2_id': team_id_4,
|
||||
'court_id': court_id_2,
|
||||
}
|
||||
),
|
||||
)
|
||||
await insert_dummy(
|
||||
DUMMY_MATCH3.copy(
|
||||
update={
|
||||
'round_id': round_id_2,
|
||||
'team1_id': team_id_2,
|
||||
'team2_id': team_id_4,
|
||||
'court_id': court_id_1,
|
||||
}
|
||||
),
|
||||
)
|
||||
await insert_dummy(
|
||||
DUMMY_MATCH4.copy(
|
||||
update={'round_id': round_id_3, 'team1_id': team_id_3, 'team2_id': team_id_1}
|
||||
),
|
||||
)
|
||||
await build_matches_for_stage_item(stage_item_1, tournament_id_1)
|
||||
await build_matches_for_stage_item(stage_item_2, tournament_id_1)
|
||||
|
||||
for tournament in await database.fetch_all(tournaments.select()):
|
||||
await recalculate_elo_for_tournament_id(tournament.id) # type: ignore[attr-defined]
|
||||
|
||||
@@ -8,7 +8,8 @@ from bracket.models.db.match import Match
|
||||
from bracket.models.db.player import Player
|
||||
from bracket.models.db.player_x_team import PlayerXTeam
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.stage import Stage, StageType
|
||||
from bracket.models.db.stage import Stage
|
||||
from bracket.models.db.stage_item import StageItemToInsert, StageType
|
||||
from bracket.models.db.team import Team
|
||||
from bracket.models.db.tournament import Tournament
|
||||
from bracket.models.db.user import User
|
||||
@@ -40,32 +41,48 @@ DUMMY_STAGE1 = Stage(
|
||||
tournament_id=DB_PLACEHOLDER_ID,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_active=True,
|
||||
type=StageType.ROUND_ROBIN,
|
||||
name='Group Stage',
|
||||
)
|
||||
|
||||
DUMMY_STAGE2 = Stage(
|
||||
tournament_id=DB_PLACEHOLDER_ID,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_active=False,
|
||||
type=StageType.SWISS,
|
||||
name='Knockout Stage',
|
||||
)
|
||||
|
||||
DUMMY_STAGE_ITEM1 = StageItemToInsert(
|
||||
stage_id=DB_PLACEHOLDER_ID,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
type=StageType.ROUND_ROBIN,
|
||||
team_count=4,
|
||||
name='Group A',
|
||||
)
|
||||
|
||||
DUMMY_STAGE_ITEM2 = StageItemToInsert(
|
||||
stage_id=DB_PLACEHOLDER_ID,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
type=StageType.SINGLE_ELIMINATION,
|
||||
team_count=2,
|
||||
name='Bracket A',
|
||||
)
|
||||
|
||||
DUMMY_ROUND1 = Round(
|
||||
stage_id=DB_PLACEHOLDER_ID,
|
||||
stage_item_id=DB_PLACEHOLDER_ID,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_draft=False,
|
||||
name='Round 1',
|
||||
)
|
||||
|
||||
DUMMY_ROUND2 = Round(
|
||||
stage_id=DB_PLACEHOLDER_ID,
|
||||
stage_item_id=DB_PLACEHOLDER_ID,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_draft=True,
|
||||
name='Round 2',
|
||||
)
|
||||
|
||||
DUMMY_ROUND3 = Round(
|
||||
stage_id=2,
|
||||
stage_item_id=DB_PLACEHOLDER_ID,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_draft=False,
|
||||
name='Round 3',
|
||||
|
||||
@@ -3,7 +3,7 @@ set -evo pipefail
|
||||
|
||||
black .
|
||||
ruff --fix .
|
||||
! vulture |grep "unused function\|unused class\|unused method"
|
||||
! vulture | grep "unused function\|unused class\|unused method"
|
||||
dmypy run -- --follow-imports=normal --junit-xml= .
|
||||
ENVIRONMENT=CI pytest --cov --cov-report=xml . -vvv
|
||||
pylint alembic bracket tests
|
||||
|
||||
@@ -63,19 +63,20 @@ ignore_missing_imports = true
|
||||
|
||||
[tool.pylint.'MESSAGES CONTROL']
|
||||
disable = [
|
||||
'missing-docstring',
|
||||
'too-few-public-methods',
|
||||
'no-name-in-module', # Gives false positives.
|
||||
'unused-argument', # Gives false positives.
|
||||
'invalid-name',
|
||||
'dangerous-default-value',
|
||||
'duplicate-code',
|
||||
'import-outside-toplevel',
|
||||
'invalid-name',
|
||||
'logging-fstring-interpolation',
|
||||
'missing-docstring',
|
||||
'no-name-in-module', # Gives false positives.
|
||||
'protected-access',
|
||||
'too-few-public-methods',
|
||||
'too-many-arguments',
|
||||
'too-many-locals',
|
||||
'too-many-nested-blocks',
|
||||
'protected-access',
|
||||
'logging-fstring-interpolation',
|
||||
'too-many-arguments',
|
||||
'unspecified-encoding',
|
||||
'unused-argument', # Gives false positives.
|
||||
'wrong-import-position',
|
||||
]
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ async def test_not_authenticated_for_tournament(
|
||||
) as tournament_inserted:
|
||||
response = JsonDict(
|
||||
await send_auth_request(
|
||||
HTTPMethod.GET, f'tournaments/{tournament_inserted.id}/teams', auth_context
|
||||
HTTPMethod.GET, f'tournaments/{tournament_inserted.id}/players', auth_context
|
||||
)
|
||||
)
|
||||
assert response == {'detail': 'Could not validate credentials'}
|
||||
|
||||
@@ -68,12 +68,12 @@ async def test_update_court(
|
||||
DUMMY_COURT1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as court_inserted:
|
||||
response = await send_tournament_request(
|
||||
HTTPMethod.PATCH, f'courts/{court_inserted.id}', auth_context, json=body
|
||||
HTTPMethod.PUT, f'courts/{court_inserted.id}', auth_context, json=body
|
||||
)
|
||||
patched_court = await fetch_one_parsed_certain(
|
||||
updated_court = await fetch_one_parsed_certain(
|
||||
database, Court, query=courts.select().where(courts.c.id == court_inserted.id)
|
||||
)
|
||||
assert patched_court.name == body['name']
|
||||
assert updated_court.name == body['name']
|
||||
assert response['data']['name'] == body['name']
|
||||
|
||||
await assert_row_count_and_clear(courts, 1)
|
||||
|
||||
43
backend/tests/integration_tests/api/inputs_test.py
Normal file
43
backend/tests/integration_tests/api/inputs_test.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from bracket.utils.dummy_records import (
|
||||
DUMMY_STAGE1,
|
||||
DUMMY_STAGE_ITEM1,
|
||||
DUMMY_TEAM1,
|
||||
)
|
||||
from bracket.utils.http import HTTPMethod
|
||||
from tests.integration_tests.api.shared import (
|
||||
send_tournament_request,
|
||||
)
|
||||
from tests.integration_tests.models import AuthContext
|
||||
from tests.integration_tests.sql import (
|
||||
inserted_stage,
|
||||
inserted_stage_item,
|
||||
inserted_team,
|
||||
)
|
||||
|
||||
|
||||
async def test_available_inputs(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
async with (
|
||||
inserted_team(
|
||||
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as team_inserted,
|
||||
inserted_stage(
|
||||
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as stage_inserted_1,
|
||||
# inserted_stage(
|
||||
# DUMMY_STAGE2.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
# ) as stage_inserted_2,
|
||||
inserted_stage_item(DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted_1.id})),
|
||||
):
|
||||
response = await send_tournament_request(
|
||||
HTTPMethod.GET, f'stages/{stage_inserted_1.id}/available_inputs', auth_context
|
||||
)
|
||||
|
||||
assert response == {
|
||||
'data': [
|
||||
{'team_id': team_inserted.id},
|
||||
# {'team_stage_item_id': 1, 'team_position_in_group': 1},
|
||||
# {'team_stage_item_id': 1, 'team_position_in_group': 2},
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
from bracket.database import database
|
||||
from bracket.models.db.match import Match
|
||||
from bracket.models.db.stage import StageType
|
||||
from bracket.models.db.stage_item import StageType
|
||||
from bracket.schema import matches
|
||||
from bracket.utils.db import fetch_one_parsed_certain
|
||||
from bracket.utils.dummy_records import (
|
||||
@@ -12,6 +12,7 @@ from bracket.utils.dummy_records import (
|
||||
DUMMY_PLAYER4,
|
||||
DUMMY_ROUND1,
|
||||
DUMMY_STAGE1,
|
||||
DUMMY_STAGE_ITEM1,
|
||||
DUMMY_TEAM1,
|
||||
DUMMY_TEAM2,
|
||||
)
|
||||
@@ -26,6 +27,7 @@ from tests.integration_tests.sql import (
|
||||
inserted_player_in_team,
|
||||
inserted_round,
|
||||
inserted_stage,
|
||||
inserted_stage_item,
|
||||
inserted_team,
|
||||
)
|
||||
|
||||
@@ -37,7 +39,12 @@ async def test_create_match(
|
||||
inserted_stage(
|
||||
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
inserted_stage_item(
|
||||
DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
|
||||
) as stage_item_inserted,
|
||||
inserted_round(
|
||||
DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
|
||||
) as round_inserted,
|
||||
inserted_team(
|
||||
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as team1_inserted,
|
||||
@@ -69,7 +76,12 @@ async def test_delete_match(
|
||||
inserted_stage(
|
||||
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
inserted_stage_item(
|
||||
DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
|
||||
) as stage_item_inserted,
|
||||
inserted_round(
|
||||
DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
|
||||
) as round_inserted,
|
||||
inserted_team(
|
||||
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as team1_inserted,
|
||||
@@ -106,7 +118,12 @@ async def test_update_match(
|
||||
inserted_stage(
|
||||
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
inserted_stage_item(
|
||||
DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
|
||||
) as stage_item_inserted,
|
||||
inserted_round(
|
||||
DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
|
||||
) as round_inserted,
|
||||
inserted_team(
|
||||
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as team1_inserted,
|
||||
@@ -135,7 +152,7 @@ async def test_update_match(
|
||||
}
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.PATCH,
|
||||
HTTPMethod.PUT,
|
||||
f'matches/{match_inserted.id}',
|
||||
auth_context,
|
||||
None,
|
||||
@@ -143,14 +160,14 @@ async def test_update_match(
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
patched_match = await fetch_one_parsed_certain(
|
||||
updated_match = await fetch_one_parsed_certain(
|
||||
database,
|
||||
Match,
|
||||
query=matches.select().where(matches.c.id == match_inserted.id),
|
||||
)
|
||||
assert patched_match.team1_score == body['team1_score']
|
||||
assert patched_match.team2_score == body['team2_score']
|
||||
assert patched_match.court_id == body['court_id']
|
||||
assert updated_match.team1_score == body['team1_score']
|
||||
assert updated_match.team2_score == body['team2_score']
|
||||
assert updated_match.court_id == body['court_id']
|
||||
|
||||
await assert_row_count_and_clear(matches, 1)
|
||||
|
||||
@@ -164,15 +181,17 @@ async def test_upcoming_matches_endpoint(
|
||||
update={
|
||||
'is_active': True,
|
||||
'tournament_id': auth_context.tournament.id,
|
||||
'type': StageType.SWISS,
|
||||
}
|
||||
)
|
||||
) as stage_inserted,
|
||||
inserted_stage_item(
|
||||
DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id, 'type': StageType.SWISS})
|
||||
) as stage_item_inserted,
|
||||
inserted_round(
|
||||
DUMMY_ROUND1.copy(
|
||||
update={
|
||||
'is_draft': True,
|
||||
'stage_id': stage_inserted.id,
|
||||
'stage_item_id': stage_item_inserted.id,
|
||||
}
|
||||
)
|
||||
) as round_inserted,
|
||||
@@ -243,6 +262,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'team2': {
|
||||
'id': None,
|
||||
@@ -274,6 +296,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'elo_diff': 0.0,
|
||||
'swiss_diff': 0.0,
|
||||
@@ -311,6 +336,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'team2': {
|
||||
'id': None,
|
||||
@@ -342,6 +370,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'elo_diff': 0.0,
|
||||
'swiss_diff': 0.0,
|
||||
@@ -379,6 +410,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'team2': {
|
||||
'id': None,
|
||||
@@ -410,6 +444,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'elo_diff': 0.0,
|
||||
'swiss_diff': 0.0,
|
||||
@@ -447,6 +484,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'team2': {
|
||||
'id': None,
|
||||
@@ -478,6 +518,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'elo_diff': 0.0,
|
||||
'swiss_diff': 0.0,
|
||||
@@ -515,6 +558,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'team2': {
|
||||
'id': None,
|
||||
@@ -546,6 +592,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'elo_diff': 0.0,
|
||||
'swiss_diff': 0.0,
|
||||
@@ -583,6 +632,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'team2': {
|
||||
'id': None,
|
||||
@@ -614,6 +666,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'elo_diff': 0.0,
|
||||
'swiss_diff': 0.0,
|
||||
@@ -651,6 +706,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'team2': {
|
||||
'id': None,
|
||||
@@ -682,6 +740,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'elo_diff': 0.0,
|
||||
'swiss_diff': 0.0,
|
||||
@@ -719,6 +780,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'team2': {
|
||||
'id': None,
|
||||
@@ -750,6 +814,9 @@ async def test_upcoming_matches_endpoint(
|
||||
],
|
||||
'swiss_score': 0.0,
|
||||
'elo_score': 1250.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
},
|
||||
'elo_diff': 0.0,
|
||||
'swiss_diff': 0.0,
|
||||
|
||||
@@ -74,12 +74,12 @@ async def test_update_player(
|
||||
DUMMY_PLAYER1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as player_inserted:
|
||||
response = await send_tournament_request(
|
||||
HTTPMethod.PATCH, f'players/{player_inserted.id}', auth_context, json=body
|
||||
HTTPMethod.PUT, f'players/{player_inserted.id}', auth_context, json=body
|
||||
)
|
||||
patched_player = await fetch_one_parsed_certain(
|
||||
updated_player = await fetch_one_parsed_certain(
|
||||
database, Player, query=players.select().where(players.c.id == player_inserted.id)
|
||||
)
|
||||
assert patched_player.name == body['name']
|
||||
assert updated_player.name == body['name']
|
||||
assert response['data']['name'] == body['name']
|
||||
|
||||
await assert_row_count_and_clear(players, 1)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from bracket.database import database
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.stage_item import StageType
|
||||
from bracket.schema import rounds
|
||||
from bracket.utils.db import fetch_one_parsed_certain
|
||||
from bracket.utils.dummy_records import DUMMY_ROUND1, DUMMY_STAGE1, DUMMY_TEAM1
|
||||
from bracket.utils.dummy_records import DUMMY_ROUND1, DUMMY_STAGE1, DUMMY_STAGE_ITEM1, DUMMY_TEAM1
|
||||
from bracket.utils.http import HTTPMethod
|
||||
from tests.integration_tests.api.shared import SUCCESS_RESPONSE, send_tournament_request
|
||||
from tests.integration_tests.models import AuthContext
|
||||
@@ -10,6 +11,7 @@ from tests.integration_tests.sql import (
|
||||
assert_row_count_and_clear,
|
||||
inserted_round,
|
||||
inserted_stage,
|
||||
inserted_stage_item,
|
||||
inserted_team,
|
||||
)
|
||||
|
||||
@@ -22,10 +24,16 @@ async def test_create_round(
|
||||
inserted_stage(
|
||||
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as stage_inserted,
|
||||
inserted_stage_item(
|
||||
DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id, 'type': StageType.SWISS})
|
||||
) as stage_item_inserted,
|
||||
):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.POST, 'rounds', auth_context, json={'stage_id': stage_inserted.id}
|
||||
HTTPMethod.POST,
|
||||
'rounds',
|
||||
auth_context,
|
||||
json={'stage_item_id': stage_item_inserted.id},
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
@@ -40,7 +48,12 @@ async def test_delete_round(
|
||||
inserted_stage(
|
||||
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
inserted_stage_item(
|
||||
DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
|
||||
) as stage_item_inserted,
|
||||
inserted_round(
|
||||
DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
|
||||
) as round_inserted,
|
||||
):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
@@ -60,19 +73,24 @@ async def test_update_round(
|
||||
inserted_stage(
|
||||
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
inserted_stage_item(
|
||||
DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
|
||||
) as stage_item_inserted,
|
||||
inserted_round(
|
||||
DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
|
||||
) as round_inserted,
|
||||
):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.PATCH, f'rounds/{round_inserted.id}', auth_context, None, body
|
||||
HTTPMethod.PUT, f'rounds/{round_inserted.id}', auth_context, None, body
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
patched_round = await fetch_one_parsed_certain(
|
||||
updated_round = await fetch_one_parsed_certain(
|
||||
database, Round, query=rounds.select().where(rounds.c.id == round_inserted.id)
|
||||
)
|
||||
assert patched_round.name == body['name']
|
||||
assert patched_round.is_draft == body['is_draft']
|
||||
assert patched_round.is_active == body['is_active']
|
||||
assert updated_round.name == body['name']
|
||||
assert updated_round.is_draft == body['is_draft']
|
||||
assert updated_round.is_active == body['is_active']
|
||||
|
||||
await assert_row_count_and_clear(rounds, 1)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import pytest
|
||||
|
||||
from bracket.models.db.stage import StageType
|
||||
from bracket.schema import stages
|
||||
from bracket.sql.stages import get_stages_with_rounds_and_matches
|
||||
from bracket.models.db.stage_item import StageType
|
||||
from bracket.schema import rounds, stage_items, stages
|
||||
from bracket.sql.stages import get_full_tournament_details
|
||||
from bracket.utils.dummy_records import (
|
||||
DUMMY_MOCK_TIME,
|
||||
DUMMY_ROUND1,
|
||||
DUMMY_STAGE1,
|
||||
DUMMY_STAGE2,
|
||||
DUMMY_STAGE_ITEM1,
|
||||
DUMMY_TEAM1,
|
||||
)
|
||||
from bracket.utils.http import HTTPMethod
|
||||
@@ -22,6 +23,7 @@ from tests.integration_tests.sql import (
|
||||
assert_row_count_and_clear,
|
||||
inserted_round,
|
||||
inserted_stage,
|
||||
inserted_stage_item,
|
||||
inserted_team,
|
||||
)
|
||||
|
||||
@@ -35,7 +37,12 @@ async def test_stages_endpoint(
|
||||
inserted_stage(
|
||||
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as stage_inserted,
|
||||
inserted_round(DUMMY_ROUND1.copy(update={'stage_id': stage_inserted.id})) as round_inserted,
|
||||
inserted_stage_item(
|
||||
DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})
|
||||
) as stage_item_inserted,
|
||||
inserted_round(
|
||||
DUMMY_ROUND1.copy(update={'stage_item_id': stage_item_inserted.id})
|
||||
) as round_inserted,
|
||||
):
|
||||
if with_auth:
|
||||
response = await send_tournament_request(HTTPMethod.GET, 'stages', auth_context, {})
|
||||
@@ -51,18 +58,29 @@ async def test_stages_endpoint(
|
||||
'id': stage_inserted.id,
|
||||
'tournament_id': 1,
|
||||
'created': DUMMY_MOCK_TIME.isoformat(),
|
||||
'type': 'ROUND_ROBIN',
|
||||
'type_name': 'Round robin',
|
||||
'is_active': True,
|
||||
'rounds': [
|
||||
'name': 'Group Stage',
|
||||
'stage_items': [
|
||||
{
|
||||
'id': round_inserted.id,
|
||||
'id': stage_item_inserted.id,
|
||||
'inputs': [],
|
||||
'stage_id': stage_inserted.id,
|
||||
'created': '2022-01-11T04:32:11+00:00',
|
||||
'is_draft': False,
|
||||
'is_active': False,
|
||||
'name': 'Round 1',
|
||||
'matches': [],
|
||||
'name': 'Group A',
|
||||
'created': DUMMY_MOCK_TIME.isoformat(),
|
||||
'type': 'ROUND_ROBIN',
|
||||
'team_count': 4,
|
||||
'rounds': [
|
||||
{
|
||||
'id': round_inserted.id,
|
||||
'stage_item_id': stage_item_inserted.id,
|
||||
'created': DUMMY_MOCK_TIME.isoformat(),
|
||||
'is_draft': False,
|
||||
'is_active': False,
|
||||
'name': 'Round 1',
|
||||
'matches': [],
|
||||
}
|
||||
],
|
||||
'type_name': 'Round robin',
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -81,10 +99,12 @@ async def test_create_stage(
|
||||
HTTPMethod.POST,
|
||||
'stages',
|
||||
auth_context,
|
||||
json={'type': StageType.SINGLE_ELIMINATION.value},
|
||||
json={'type': StageType.SINGLE_ELIMINATION.value, 'team_count': 2},
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
await assert_row_count_and_clear(rounds, 1)
|
||||
await assert_row_count_and_clear(stage_items, 1)
|
||||
await assert_row_count_and_clear(stages, 1)
|
||||
|
||||
|
||||
@@ -109,25 +129,25 @@ async def test_delete_stage(
|
||||
async def test_update_stage(
|
||||
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
|
||||
) -> None:
|
||||
body = {'type': StageType.ROUND_ROBIN.value, 'is_active': False}
|
||||
body = {'name': 'Optimus'}
|
||||
async with (
|
||||
inserted_team(DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})),
|
||||
inserted_stage(
|
||||
DUMMY_STAGE1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as stage_inserted,
|
||||
inserted_stage_item(DUMMY_STAGE_ITEM1.copy(update={'stage_id': stage_inserted.id})),
|
||||
):
|
||||
assert (
|
||||
await send_tournament_request(
|
||||
HTTPMethod.PATCH, f'stages/{stage_inserted.id}', auth_context, None, body
|
||||
HTTPMethod.PUT, f'stages/{stage_inserted.id}', auth_context, None, body
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
[patched_stage] = await get_stages_with_rounds_and_matches(
|
||||
assert_some(auth_context.tournament.id)
|
||||
)
|
||||
assert patched_stage.type.value == body['type']
|
||||
assert patched_stage.is_active == body['is_active']
|
||||
[updated_stage] = await get_full_tournament_details(assert_some(auth_context.tournament.id))
|
||||
assert len(updated_stage.stage_items) == 1
|
||||
assert updated_stage.name == body['name']
|
||||
|
||||
await assert_row_count_and_clear(stage_items, 1)
|
||||
await assert_row_count_and_clear(stages, 1)
|
||||
|
||||
|
||||
@@ -146,10 +166,11 @@ async def test_activate_stage(
|
||||
)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
[prev_stage, next_stage] = await get_stages_with_rounds_and_matches(
|
||||
[prev_stage, next_stage] = await get_full_tournament_details(
|
||||
assert_some(auth_context.tournament.id)
|
||||
)
|
||||
assert prev_stage.is_active is False
|
||||
assert next_stage.is_active is True
|
||||
|
||||
await assert_row_count_and_clear(stage_items, 1)
|
||||
await assert_row_count_and_clear(stages, 1)
|
||||
|
||||
@@ -24,8 +24,11 @@ async def test_teams_endpoint(
|
||||
'name': 'Team 1',
|
||||
'players': [],
|
||||
'tournament_id': team_inserted.tournament_id,
|
||||
'elo_score': 0.0,
|
||||
'elo_score': 1200.0,
|
||||
'swiss_score': 0.0,
|
||||
'wins': 0,
|
||||
'draws': 0,
|
||||
'losses': 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -63,12 +66,12 @@ async def test_update_team(
|
||||
DUMMY_TEAM1.copy(update={'tournament_id': auth_context.tournament.id})
|
||||
) as team_inserted:
|
||||
response = await send_tournament_request(
|
||||
HTTPMethod.PATCH, f'teams/{team_inserted.id}', auth_context, None, body
|
||||
HTTPMethod.PUT, f'teams/{team_inserted.id}', auth_context, None, body
|
||||
)
|
||||
patched_team = await fetch_one_parsed_certain(
|
||||
updated_team = await fetch_one_parsed_certain(
|
||||
database, Team, query=teams.select().where(teams.c.id == team_inserted.id)
|
||||
)
|
||||
assert patched_team.name == body['name']
|
||||
assert updated_team.name == body['name']
|
||||
assert response['data']['name'] == body['name']
|
||||
|
||||
await assert_row_count_and_clear(teams, 1)
|
||||
|
||||
@@ -78,16 +78,16 @@ async def test_update_tournament(
|
||||
'auto_assign_courts': True,
|
||||
}
|
||||
assert (
|
||||
await send_tournament_request(HTTPMethod.PATCH, '', auth_context, json=body)
|
||||
await send_tournament_request(HTTPMethod.PUT, '', auth_context, json=body)
|
||||
== SUCCESS_RESPONSE
|
||||
)
|
||||
patched_tournament = await fetch_one_parsed_certain(
|
||||
updated_tournament = await fetch_one_parsed_certain(
|
||||
database,
|
||||
Tournament,
|
||||
query=tournaments.select().where(tournaments.c.id == auth_context.tournament.id),
|
||||
)
|
||||
assert patched_tournament.name == body['name']
|
||||
assert patched_tournament.dashboard_public == body['dashboard_public']
|
||||
assert updated_tournament.name == body['name']
|
||||
assert updated_tournament.dashboard_public == body['dashboard_public']
|
||||
|
||||
await assert_row_count_and_clear(tournaments, 1)
|
||||
|
||||
|
||||
@@ -40,12 +40,12 @@ async def test_update_user(
|
||||
) -> None:
|
||||
body = {'name': 'Some new name', 'email': 'some_email@email.com'}
|
||||
response = await send_auth_request(
|
||||
HTTPMethod.PATCH, f'users/{auth_context.user.id}', auth_context, None, body
|
||||
HTTPMethod.PUT, f'users/{auth_context.user.id}', auth_context, None, body
|
||||
)
|
||||
patched_user = await fetch_one_parsed_certain(
|
||||
updated_user = await fetch_one_parsed_certain(
|
||||
database, User, query=users.select().where(users.c.id == auth_context.user.id)
|
||||
)
|
||||
assert patched_user.name == body['name']
|
||||
assert updated_user.name == body['name']
|
||||
assert response['data']['name'] == body['name']
|
||||
|
||||
await assert_row_count_and_clear(users, 1)
|
||||
|
||||
@@ -12,6 +12,7 @@ from bracket.models.db.player import Player
|
||||
from bracket.models.db.player_x_team import PlayerXTeam
|
||||
from bracket.models.db.round import Round
|
||||
from bracket.models.db.stage import Stage
|
||||
from bracket.models.db.stage_item import StageItem, StageItemToInsert
|
||||
from bracket.models.db.team import Team
|
||||
from bracket.models.db.tournament import Tournament
|
||||
from bracket.models.db.user import User, UserInDB
|
||||
@@ -23,6 +24,7 @@ from bracket.schema import (
|
||||
players,
|
||||
players_x_teams,
|
||||
rounds,
|
||||
stage_items,
|
||||
stages,
|
||||
teams,
|
||||
tournaments,
|
||||
@@ -106,6 +108,12 @@ async def inserted_stage(stage: Stage) -> AsyncIterator[Stage]:
|
||||
yield row_inserted
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def inserted_stage_item(stage_item: StageItemToInsert) -> AsyncIterator[StageItem]:
|
||||
async with inserted_generic(stage_item, stage_items, StageItem) as row_inserted:
|
||||
yield StageItem(**row_inserted.dict())
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def inserted_round(round_: Round) -> AsyncIterator[Round]:
|
||||
async with inserted_generic(round_, rounds, Round) as row_inserted:
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from bracket.logic.elo import PlayerStatistics, calculate_elo_per_player
|
||||
from bracket.logic.elo import calculate_elo_per_player
|
||||
from bracket.models.db.match import MatchWithDetails
|
||||
from bracket.models.db.round import RoundWithMatches
|
||||
from bracket.models.db.players import PlayerStatistics
|
||||
from bracket.models.db.team import FullTeamWithPlayers
|
||||
from bracket.models.db.util import RoundWithMatches
|
||||
from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_PLAYER1, DUMMY_PLAYER2
|
||||
|
||||
|
||||
def test_elo_calculation() -> None:
|
||||
round_ = RoundWithMatches(
|
||||
stage_id=1,
|
||||
stage_item_id=1,
|
||||
created=DUMMY_MOCK_TIME,
|
||||
is_draft=False,
|
||||
is_active=False,
|
||||
@@ -32,6 +33,9 @@ def test_elo_calculation() -> None:
|
||||
players=[DUMMY_PLAYER1.copy(update={'id': 1})],
|
||||
elo_score=DUMMY_PLAYER1.elo_score,
|
||||
swiss_score=DUMMY_PLAYER1.swiss_score,
|
||||
wins=DUMMY_PLAYER1.wins,
|
||||
draws=DUMMY_PLAYER1.draws,
|
||||
losses=DUMMY_PLAYER1.losses,
|
||||
),
|
||||
team2=FullTeamWithPlayers(
|
||||
name='Dummy team 2',
|
||||
@@ -41,6 +45,9 @@ def test_elo_calculation() -> None:
|
||||
players=[DUMMY_PLAYER2.copy(update={'id': 2})],
|
||||
elo_score=DUMMY_PLAYER2.elo_score,
|
||||
swiss_score=DUMMY_PLAYER2.swiss_score,
|
||||
wins=DUMMY_PLAYER2.wins,
|
||||
draws=DUMMY_PLAYER2.draws,
|
||||
losses=DUMMY_PLAYER2.losses,
|
||||
),
|
||||
)
|
||||
],
|
||||
|
||||
16
backend/tests/unit_tests/schedule_test.py
Normal file
16
backend/tests/unit_tests/schedule_test.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from bracket.logic.scheduling.elimination import get_number_of_rounds_to_create_single_elimination
|
||||
from bracket.logic.scheduling.round_robin import get_number_of_rounds_to_create_round_robin
|
||||
|
||||
|
||||
def test_number_of_rounds_round_robin() -> None:
|
||||
assert get_number_of_rounds_to_create_round_robin(0) == 0
|
||||
assert get_number_of_rounds_to_create_round_robin(2) == 1
|
||||
assert get_number_of_rounds_to_create_round_robin(4) == 3
|
||||
assert get_number_of_rounds_to_create_round_robin(6) == 5
|
||||
|
||||
|
||||
def test_number_of_rounds_single_elimination() -> None:
|
||||
assert get_number_of_rounds_to_create_single_elimination(0) == 0
|
||||
assert get_number_of_rounds_to_create_single_elimination(2) == 1
|
||||
assert get_number_of_rounds_to_create_single_elimination(4) == 2
|
||||
assert get_number_of_rounds_to_create_single_elimination(8) == 3
|
||||
@@ -35,10 +35,11 @@
|
||||
"next": "^13.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-ellipsis-text": "^1.2.1",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-redux": "^8.0.5",
|
||||
"swr": "^2.1.5",
|
||||
"react-qr-code": "^2.0.12"
|
||||
"swr": "^2.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.17",
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { Alert, Container, Grid, Skeleton } from '@mantine/core';
|
||||
import { Alert, Button, Container, Grid, Group, Skeleton } from '@mantine/core';
|
||||
import { GoPlus } from '@react-icons/all-files/go/GoPlus';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { RoundInterface } from '../../interfaces/round';
|
||||
import { StageWithStageItems } from '../../interfaces/stage';
|
||||
import { StageItemWithRounds, stageItemIsHandledAutomatically } from '../../interfaces/stage_item';
|
||||
import { TournamentMinimal } from '../../interfaces/tournament';
|
||||
import { createRound } from '../../services/round';
|
||||
import { responseIsValid } from '../utils/util';
|
||||
import Round from './round';
|
||||
|
||||
function getRoundsGridCols(
|
||||
stages_map: { [p: string]: any },
|
||||
selectedStageId: number,
|
||||
stageItem: StageItemWithRounds,
|
||||
tournamentData: TournamentMinimal,
|
||||
swrStagesResponse: SWRResponse,
|
||||
swrCourtsResponse: SWRResponse,
|
||||
swrUpcomingMatchesResponse: SWRResponse | null,
|
||||
readOnly: boolean
|
||||
) {
|
||||
return stages_map[selectedStageId].rounds
|
||||
let rounds: React.JSX.Element[] | React.JSX.Element = stageItem.rounds
|
||||
.sort((r1: any, r2: any) => (r1.name > r2.name ? 1 : 0))
|
||||
.map((round: RoundInterface) => (
|
||||
<Grid.Col sm={6} lg={4} xl={3} key={round.id}>
|
||||
@@ -28,9 +31,53 @@ function getRoundsGridCols(
|
||||
swrCourtsResponse={swrCourtsResponse}
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
readOnly={readOnly}
|
||||
dynamicSchedule={!stageItemIsHandledAutomatically(stageItem)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
));
|
||||
|
||||
if (rounds.length < 1) {
|
||||
rounds = (
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="No rounds" color="blue" radius="lg">
|
||||
There are no rounds in this stage item yet
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={stageItem.id}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Grid grow>
|
||||
<Grid.Col span={6}>
|
||||
<h2>{stageItem.name}</h2>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Group position="right">
|
||||
{stageItem == null || stageItemIsHandledAutomatically(stageItem) ? null : (
|
||||
<Button
|
||||
color="green"
|
||||
size="md"
|
||||
style={{ marginBottom: 10, marginRight: 10, marginLeft: 10 }}
|
||||
leftIcon={<GoPlus size={24} />}
|
||||
title="Add Round"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await createRound(tournamentData.id, stageItem.id);
|
||||
await swrStagesResponse.mutate();
|
||||
}}
|
||||
>
|
||||
Add Round
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</div>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Grid>{rounds}</Grid>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function NoRoundsAlert({ readOnly }: { readOnly: boolean }) {
|
||||
@@ -106,24 +153,18 @@ export default function Brackets({
|
||||
}
|
||||
|
||||
const stages_map = Object.fromEntries(
|
||||
swrStagesResponse.data.data.map((x: RoundInterface) => [x.id, x])
|
||||
swrStagesResponse.data.data.map((x: StageWithStageItems) => [x.id, x])
|
||||
);
|
||||
const rounds = stages_map[selectedStageId].stage_items.map((stageItem: StageItemWithRounds) =>
|
||||
getRoundsGridCols(
|
||||
stageItem,
|
||||
tournamentData,
|
||||
swrStagesResponse,
|
||||
swrCourtsResponse,
|
||||
swrUpcomingMatchesResponse,
|
||||
readOnly
|
||||
)
|
||||
);
|
||||
const rounds =
|
||||
stages_map[selectedStageId].rounds.length > 0 ? (
|
||||
getRoundsGridCols(
|
||||
stages_map,
|
||||
selectedStageId,
|
||||
tournamentData,
|
||||
swrStagesResponse,
|
||||
swrCourtsResponse,
|
||||
swrUpcomingMatchesResponse,
|
||||
readOnly
|
||||
)
|
||||
) : (
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="No rounds" color="blue" radius="lg">
|
||||
There are no rounds in this stage yet
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -18,6 +18,7 @@ function getRoundsGridCols(activeRound: RoundInterface, tournamentData: Tourname
|
||||
swrUpcomingMatchesResponse={null}
|
||||
match={match}
|
||||
readOnly
|
||||
dynamicSchedule={false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Grid, Title } from '@mantine/core';
|
||||
import { Grid } from '@mantine/core';
|
||||
import React from 'react';
|
||||
|
||||
import { RoundInterface } from '../../interfaces/round';
|
||||
@@ -32,7 +32,6 @@ export default function CourtsLarge({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Title>{activeRound.name}</Title>
|
||||
<Grid>{getRoundsGridCols(activeRound, tournamentData)}</Grid>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function Match({
|
||||
tournamentData,
|
||||
match,
|
||||
readOnly,
|
||||
dynamicSchedule,
|
||||
}: {
|
||||
swrRoundsResponse: SWRResponse | null;
|
||||
swrCourtsResponse: SWRResponse | null;
|
||||
@@ -69,12 +70,15 @@ export default function Match({
|
||||
tournamentData: TournamentMinimal;
|
||||
match: MatchInterface;
|
||||
readOnly: boolean;
|
||||
dynamicSchedule: boolean;
|
||||
}) {
|
||||
const { classes } = useStyles();
|
||||
const theme = useMantineTheme();
|
||||
const winner_style = {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.green[9] : theme.colors.green[4],
|
||||
};
|
||||
const showTeamMemberNames = false;
|
||||
|
||||
const team1_style = match.team1_score > match.team2_score ? winner_style : {};
|
||||
const team2_style = match.team1_score < match.team2_score ? winner_style : {};
|
||||
|
||||
@@ -84,6 +88,9 @@ export default function Match({
|
||||
const team1_players_label = team1_players === '' ? 'No players' : team1_players;
|
||||
const team2_players_label = team2_players === '' ? 'No players' : team2_players;
|
||||
|
||||
const team1_label = showTeamMemberNames ? team1_players_label : match.team1.name;
|
||||
const team2_label = showTeamMemberNames ? team2_players_label : match.team2.name;
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const bracket = (
|
||||
@@ -91,14 +98,14 @@ export default function Match({
|
||||
<MatchBadge match={match} theme={theme} />
|
||||
<div className={classes.top} style={team1_style}>
|
||||
<Grid grow>
|
||||
<Grid.Col span={10}>{team1_players_label}</Grid.Col>
|
||||
<Grid.Col span={10}>{team1_label}</Grid.Col>
|
||||
<Grid.Col span={2}>{match.team1_score}</Grid.Col>
|
||||
</Grid>
|
||||
</div>
|
||||
<div className={classes.divider} />
|
||||
<div className={classes.bottom} style={team2_style}>
|
||||
<Grid grow>
|
||||
<Grid.Col span={10}>{team2_players_label}</Grid.Col>
|
||||
<Grid.Col span={10}>{team2_label}</Grid.Col>
|
||||
<Grid.Col span={2}>{match.team2_score}</Grid.Col>
|
||||
</Grid>
|
||||
</div>
|
||||
@@ -124,6 +131,7 @@ export default function Match({
|
||||
match={match}
|
||||
opened={opened}
|
||||
setOpened={setOpened}
|
||||
dynamicSchedule={dynamicSchedule}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -102,6 +102,7 @@ export default function MatchLarge({
|
||||
match={match}
|
||||
opened={opened}
|
||||
setOpened={setOpened}
|
||||
dynamicSchedule={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function Round({
|
||||
swrCourtsResponse,
|
||||
swrUpcomingMatchesResponse,
|
||||
readOnly,
|
||||
dynamicSchedule,
|
||||
}: {
|
||||
tournamentData: TournamentMinimal;
|
||||
round: RoundInterface;
|
||||
@@ -21,6 +22,7 @@ export default function Round({
|
||||
swrCourtsResponse: SWRResponse;
|
||||
swrUpcomingMatchesResponse: SWRResponse | null;
|
||||
readOnly: boolean;
|
||||
dynamicSchedule: boolean;
|
||||
}) {
|
||||
const matches = round.matches
|
||||
.sort((m1, m2) => ((m1.court ? m1.court.name : 'y') > (m2.court ? m2.court.name : 'z') ? 1 : 0))
|
||||
@@ -33,6 +35,7 @@ export default function Round({
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
match={match}
|
||||
readOnly={readOnly}
|
||||
dynamicSchedule={dynamicSchedule}
|
||||
/>
|
||||
));
|
||||
const active_round_style = round.is_active
|
||||
@@ -58,15 +61,16 @@ export default function Round({
|
||||
round={round}
|
||||
swrRoundsResponse={swrRoundsResponse}
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
dynamicSchedule={dynamicSchedule}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: 500 }}>
|
||||
<div style={{ minHeight: 300 }}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
minHeight: 500,
|
||||
minHeight: 300,
|
||||
padding: '15px',
|
||||
borderRadius: '20px',
|
||||
...active_round_style,
|
||||
|
||||
244
frontend/src/components/builder/builder.tsx
Normal file
244
frontend/src/components/builder/builder.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { ActionIcon, Badge, Card, Grid, Group, Menu, Text, rem } from '@mantine/core';
|
||||
import { IconDots, IconPencil, IconTrash } from '@tabler/icons-react';
|
||||
import assert from 'assert';
|
||||
import React, { useState } from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { StageWithStageItems } from '../../interfaces/stage';
|
||||
import { StageItemWithRounds } from '../../interfaces/stage_item';
|
||||
import { StageItemInput } from '../../interfaces/stage_item_input';
|
||||
import { TeamInterface } from '../../interfaces/team';
|
||||
import { Tournament } from '../../interfaces/tournament';
|
||||
import { getStageItemLookup, getTeamsLookup } from '../../services/lookups';
|
||||
import { deleteStage } from '../../services/stage';
|
||||
import { deleteStageItem } from '../../services/stage_item';
|
||||
import CreateStageButton from '../buttons/create_stage';
|
||||
import { CreateStageItemModal, formatStageItemInput } from '../modals/create_stage_item';
|
||||
import { UpdateStageModal } from '../modals/update_stage';
|
||||
import { UpdateStageItemModal } from '../modals/update_stage_item';
|
||||
import RequestErrorAlert from '../utils/error_alert';
|
||||
|
||||
function StageItemInputSectionLast({
|
||||
input,
|
||||
team,
|
||||
teamStageItem,
|
||||
lastInList,
|
||||
}: {
|
||||
input: StageItemInput;
|
||||
team: TeamInterface | null;
|
||||
teamStageItem: TeamInterface | null;
|
||||
lastInList: boolean;
|
||||
}) {
|
||||
assert(team != null || teamStageItem != null);
|
||||
|
||||
const content = team
|
||||
? team.name
|
||||
: // @ts-ignore
|
||||
formatStageItemInput(input.team_position_in_group, teamStageItem.name);
|
||||
const opts = lastInList ? { pt: 'xs', mb: '-0.5rem' } : { py: 'xs', withBorder: true };
|
||||
|
||||
return (
|
||||
<Card.Section inheritPadding {...opts}>
|
||||
<Text weight={500}>{content}</Text>
|
||||
</Card.Section>
|
||||
);
|
||||
}
|
||||
|
||||
function StageItemRow({
|
||||
teamsMap,
|
||||
tournament,
|
||||
stageItem,
|
||||
swrStagesResponse,
|
||||
}: {
|
||||
teamsMap: any;
|
||||
tournament: Tournament;
|
||||
stageItem: StageItemWithRounds;
|
||||
swrStagesResponse: SWRResponse;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const stageItemsLookup = getStageItemLookup(swrStagesResponse);
|
||||
|
||||
const inputs = stageItem.inputs
|
||||
.sort((i1, i2) => (i1.slot > i2.slot ? 1 : 0))
|
||||
.map((input, i) => {
|
||||
const team = input.team_id ? teamsMap[input.team_id] : null;
|
||||
const teamStageItem = input.team_stage_item_id
|
||||
? stageItemsLookup[input.team_stage_item_id]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StageItemInputSectionLast
|
||||
key={i}
|
||||
team={team}
|
||||
input={input}
|
||||
teamStageItem={teamStageItem}
|
||||
lastInList={i === stageItem.inputs.length - 1}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm" radius="md" mb="1rem">
|
||||
<Card.Section withBorder inheritPadding py="xs" color="dimmed">
|
||||
<Group position="apart">
|
||||
<Text weight={800}>{stageItem.name}</Text>
|
||||
<UpdateStageItemModal
|
||||
swrStagesResponse={swrStagesResponse}
|
||||
stageItem={stageItem}
|
||||
tournament={tournament}
|
||||
opened={opened}
|
||||
setOpened={setOpened}
|
||||
/>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<IconDots size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
icon={<IconPencil size={rem(14)} />}
|
||||
onClick={() => {
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
Edit name
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<IconTrash size={rem(14)} />}
|
||||
onClick={async () => {
|
||||
await deleteStageItem(tournament.id, stageItem.id);
|
||||
await swrStagesResponse.mutate(null);
|
||||
}}
|
||||
color="red"
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
{inputs}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StageColumn({
|
||||
tournament,
|
||||
stage,
|
||||
swrStagesResponse,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
stage: StageWithStageItems;
|
||||
swrStagesResponse: SWRResponse;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const teamsMap = getTeamsLookup(tournament != null ? tournament.id : -1);
|
||||
|
||||
if (teamsMap == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rows = stage.stage_items.map((stageItem: StageItemWithRounds) => (
|
||||
<StageItemRow
|
||||
key={stageItem.id}
|
||||
teamsMap={teamsMap}
|
||||
tournament={tournament}
|
||||
stageItem={stageItem}
|
||||
swrStagesResponse={swrStagesResponse}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Grid.Col mb="1rem" sm={6} lg={4} xl={3} key={stage.id}>
|
||||
<UpdateStageModal
|
||||
swrStagesResponse={swrStagesResponse}
|
||||
stage={stage}
|
||||
tournament={tournament}
|
||||
opened={opened}
|
||||
setOpened={setOpened}
|
||||
/>
|
||||
<Group position="apart">
|
||||
<h4>
|
||||
{stage.name}
|
||||
{stage.is_active ? (
|
||||
<Badge ml="1rem" color="green">
|
||||
Active
|
||||
</Badge>
|
||||
) : null}
|
||||
</h4>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<IconDots size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
icon={<IconPencil size={rem(14)} />}
|
||||
onClick={() => {
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
Edit name
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
icon={<IconTrash size={rem(14)} />}
|
||||
onClick={async () => {
|
||||
await deleteStage(tournament.id, stage.id);
|
||||
await swrStagesResponse.mutate(null);
|
||||
}}
|
||||
color="red"
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
{rows}
|
||||
<CreateStageItemModal
|
||||
key={-1}
|
||||
tournament={tournament}
|
||||
stage={stage}
|
||||
swrStagesResponse={swrStagesResponse}
|
||||
/>
|
||||
</Grid.Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Builder({
|
||||
tournament,
|
||||
swrStagesResponse,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
swrStagesResponse: SWRResponse;
|
||||
}) {
|
||||
const stages: StageWithStageItems[] =
|
||||
swrStagesResponse.data != null ? swrStagesResponse.data.data : [];
|
||||
|
||||
if (swrStagesResponse.error) return <RequestErrorAlert error={swrStagesResponse.error} />;
|
||||
|
||||
const cols = stages
|
||||
.sort((s1: StageWithStageItems, s2: StageWithStageItems) => (s1.id > s2.id ? 1 : 0))
|
||||
.map((stage) => (
|
||||
<StageColumn
|
||||
key={stage.id}
|
||||
tournament={tournament}
|
||||
swrStagesResponse={swrStagesResponse}
|
||||
stage={stage}
|
||||
/>
|
||||
));
|
||||
|
||||
const button = (
|
||||
<Grid.Col mb="1rem" sm={6} lg={4} xl={4} key={-1}>
|
||||
<h4>
|
||||
<CreateStageButton tournament={tournament} swrStagesResponse={swrStagesResponse} />
|
||||
</h4>
|
||||
</Grid.Col>
|
||||
);
|
||||
const colsWithButton = cols.concat([button]);
|
||||
|
||||
return <Grid>{colsWithButton}</Grid>;
|
||||
}
|
||||
26
frontend/src/components/buttons/create_matches_auto.tsx
Normal file
26
frontend/src/components/buttons/create_matches_auto.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { IconTool } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
|
||||
import { createMatchesAuto } from '../../services/round';
|
||||
|
||||
export function AutoCreateMatchesButton({ tournamentData, swrStagesResponse, roundId }: any) {
|
||||
if (roundId == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="md"
|
||||
mt="1rem"
|
||||
mb="1rem"
|
||||
color="indigo"
|
||||
leftIcon={<IconTool size={24} />}
|
||||
onClick={async () => {
|
||||
await createMatchesAuto(tournamentData.id, roundId);
|
||||
swrStagesResponse.mutate();
|
||||
}}
|
||||
>
|
||||
Schedule new matches automatically
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/buttons/create_stage.tsx
Normal file
31
frontend/src/components/buttons/create_stage.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { GoPlus } from '@react-icons/all-files/go/GoPlus';
|
||||
import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { Tournament } from '../../interfaces/tournament';
|
||||
import { createStage } from '../../services/stage';
|
||||
|
||||
export default function CreateStageButton({
|
||||
tournament,
|
||||
swrStagesResponse,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
swrStagesResponse: SWRResponse;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="green"
|
||||
size="xs"
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={async () => {
|
||||
await createStage(tournament.id);
|
||||
await swrStagesResponse.mutate(null);
|
||||
}}
|
||||
leftIcon={<GoPlus size={24} />}
|
||||
>
|
||||
Add stage
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { Button } from '@mantine/core';
|
||||
|
||||
export default function SaveButton(props: any) {
|
||||
return (
|
||||
<Button color="green" size="md" style={{ marginBottom: 10, marginRight: 10 }} {...props}>
|
||||
<Button
|
||||
color="green"
|
||||
size="md"
|
||||
style={{ marginBottom: 10, marginRight: 10, marginLeft: 10 }}
|
||||
{...props}
|
||||
>
|
||||
{props.title}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -8,24 +8,24 @@ import { getBaseURL } from '../utils/util';
|
||||
|
||||
export function TournamentQRCode({ tournamentDataFull }: { tournamentDataFull: Tournament }) {
|
||||
return (
|
||||
<Center>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'white',
|
||||
marginTop: '3rem',
|
||||
maxWidth: '400px',
|
||||
height: 'auto',
|
||||
}}
|
||||
>
|
||||
<Center>
|
||||
<QRCode
|
||||
style={{ margin: '32px 0px 32px 0px' }}
|
||||
value={`${getBaseURL()}/tournaments/${tournamentDataFull.dashboard_endpoint}/dashboard`}
|
||||
/>
|
||||
</Center>
|
||||
</div>
|
||||
</Center>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'white',
|
||||
marginTop: '3rem',
|
||||
maxWidth: '400px',
|
||||
height: 'auto',
|
||||
}}
|
||||
>
|
||||
<Center>
|
||||
<QRCode
|
||||
style={{ margin: '24px' }}
|
||||
// @ts-ignore
|
||||
size="auto"
|
||||
value={`${getBaseURL()}/tournaments/${tournamentDataFull.dashboard_endpoint}/dashboard`}
|
||||
/>
|
||||
</Center>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ export function TournamentLogo({ tournamentDataFull }: { tournamentDataFull: Tou
|
||||
src={`${getBaseApiUrl()}/static/${tournamentDataFull.logo_path}`}
|
||||
style={{ maxWidth: '400px' }}
|
||||
/>
|
||||
<TournamentQRCode tournamentDataFull={tournamentDataFull} />
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -3,14 +3,15 @@ import { DefaultMantineColor } from '@mantine/styles/lib/theme/types/MantineColo
|
||||
|
||||
interface ScoreProps {
|
||||
score: number;
|
||||
min_score: number;
|
||||
max_score: number;
|
||||
color: DefaultMantineColor;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
export function PlayerScore({ score, max_score, color, decimals }: ScoreProps) {
|
||||
export function PlayerScore({ score, min_score, max_score, color, decimals }: ScoreProps) {
|
||||
const theme = useMantineTheme();
|
||||
const percentageScale = 100.0 / max_score;
|
||||
const percentageScale = 100.0 / (max_score - min_score);
|
||||
const base_color = theme.colors[color];
|
||||
|
||||
return (
|
||||
@@ -23,9 +24,9 @@ export function PlayerScore({ score, max_score, color, decimals }: ScoreProps) {
|
||||
<Progress
|
||||
sections={[
|
||||
{
|
||||
value: percentageScale * score,
|
||||
value: percentageScale * (score - min_score),
|
||||
color: theme.colorScheme === 'dark' ? base_color[9] : base_color[6],
|
||||
tooltip: `ELO Score (${(percentageScale * score).toFixed(decimals)}%)`,
|
||||
tooltip: `${(percentageScale * (score - min_score)).toFixed(decimals)}%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -14,8 +14,23 @@ interface PlayerStatisticsProps {
|
||||
losses: number;
|
||||
}
|
||||
|
||||
export function PlayerStatistics({ wins, draws, losses }: PlayerStatisticsProps) {
|
||||
const { classes, theme } = useStyles();
|
||||
export function getWinColor() {
|
||||
const { theme } = useStyles();
|
||||
return theme.colorScheme === 'dark' ? theme.colors.teal[9] : theme.colors.teal[6];
|
||||
}
|
||||
|
||||
export function getDrawColor() {
|
||||
const { theme } = useStyles();
|
||||
return theme.colorScheme === 'dark' ? theme.colors.orange[9] : theme.colors.orange[6];
|
||||
}
|
||||
|
||||
export function getLossColor() {
|
||||
const { theme } = useStyles();
|
||||
return theme.colorScheme === 'dark' ? theme.colors.red[9] : theme.colors.red[6];
|
||||
}
|
||||
|
||||
export function WinDistribution({ wins, draws, losses }: PlayerStatisticsProps) {
|
||||
const { classes } = useStyles();
|
||||
const percentageScale = 100.0 / (wins + draws + losses);
|
||||
|
||||
const draws_text =
|
||||
@@ -41,17 +56,17 @@ export function PlayerStatistics({ wins, draws, losses }: PlayerStatisticsProps)
|
||||
sections={[
|
||||
{
|
||||
value: percentageScale * wins,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.teal[9] : theme.colors.teal[6],
|
||||
color: getWinColor(),
|
||||
tooltip: `Wins (${(percentageScale * wins).toFixed(0)}%)`,
|
||||
},
|
||||
{
|
||||
value: percentageScale * draws,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.orange[9] : theme.colors.orange[6],
|
||||
color: getDrawColor(),
|
||||
tooltip: `Draws (${(percentageScale * draws).toFixed(0)}%)`,
|
||||
},
|
||||
{
|
||||
value: percentageScale * losses,
|
||||
color: theme.colorScheme === 'dark' ? theme.colors.red[9] : theme.colors.red[6],
|
||||
color: getLossColor(),
|
||||
tooltip: `Losses (${(percentageScale * losses).toFixed(0)}%)`,
|
||||
},
|
||||
]}
|
||||
|
||||
208
frontend/src/components/modals/create_stage_item.tsx
Normal file
208
frontend/src/components/modals/create_stage_item.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Button, Divider, Modal, NumberInput, Select } from '@mantine/core';
|
||||
import { UseFormReturnType, useForm } from '@mantine/form';
|
||||
import { GoPlus } from '@react-icons/all-files/go/GoPlus';
|
||||
import assert from 'assert';
|
||||
import React, { useState } from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { StageWithStageItems } from '../../interfaces/stage';
|
||||
import { StageItemInputOption, getPositionName } from '../../interfaces/stage_item_input';
|
||||
import { Tournament } from '../../interfaces/tournament';
|
||||
import { getAvailableStageItemInputs } from '../../services/adapter';
|
||||
import { getStageItemLookup, getTeamsLookup } from '../../services/lookups';
|
||||
import { createStageItem } from '../../services/stage_item';
|
||||
import { responseIsValid } from '../utils/util';
|
||||
|
||||
function TeamCountSelectElimination({ form }: { form: UseFormReturnType<any> }) {
|
||||
const data = [
|
||||
{ value: '2', label: '2' },
|
||||
{ value: '4', label: '4' },
|
||||
{ value: '8', label: '8' },
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
withAsterisk
|
||||
data={data}
|
||||
label="Number of teams advancing from the previous stage"
|
||||
placeholder="2, 4, 8 etc."
|
||||
searchable
|
||||
limit={20}
|
||||
mt={24}
|
||||
{...form.getInputProps('team_count_elimination')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamCountInputRoundRobin({ form }: { form: UseFormReturnType<any> }) {
|
||||
return (
|
||||
<NumberInput
|
||||
withAsterisk
|
||||
label="Number of teams advancing from the previous stage"
|
||||
placeholder=""
|
||||
mt={24}
|
||||
{...form.getInputProps('team_count_round_robin')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamCountInput({ form }: { form: UseFormReturnType<any> }) {
|
||||
if (form.values.type === 'SINGLE_ELIMINATION') {
|
||||
return <TeamCountSelectElimination form={form} />;
|
||||
}
|
||||
|
||||
return <TeamCountInputRoundRobin form={form} />;
|
||||
}
|
||||
|
||||
function StageItemInput({
|
||||
form,
|
||||
possibleOptions,
|
||||
index,
|
||||
}: {
|
||||
form: UseFormReturnType<any>;
|
||||
index: number;
|
||||
possibleOptions: any[];
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
withAsterisk
|
||||
data={possibleOptions}
|
||||
label={`Team ${index}`}
|
||||
placeholder="None"
|
||||
searchable
|
||||
limit={20}
|
||||
mt={24}
|
||||
{...form.getInputProps(`team_${index}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getTeamCount(values: any) {
|
||||
return Number(
|
||||
values.type === 'SINGLE_ELIMINATION'
|
||||
? values.team_count_elimination
|
||||
: values.team_count_round_robin
|
||||
);
|
||||
}
|
||||
|
||||
function StageItemInputs({
|
||||
form,
|
||||
possibleOptions,
|
||||
}: {
|
||||
form: UseFormReturnType<any>;
|
||||
possibleOptions: any[];
|
||||
}) {
|
||||
return Array.from(Array(Math.max(getTeamCount(form.values), 2)).keys()).map((x) => (
|
||||
<StageItemInput possibleOptions={possibleOptions} form={form} index={x + 1} key={x} />
|
||||
));
|
||||
}
|
||||
|
||||
export function formatStageItemInput(team_position_in_group: number, teamName: string) {
|
||||
// @ts-ignore
|
||||
return `${getPositionName(team_position_in_group)} of ${teamName}`;
|
||||
}
|
||||
|
||||
export function CreateStageItemModal({
|
||||
tournament,
|
||||
stage,
|
||||
swrStagesResponse,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
stage: StageWithStageItems;
|
||||
swrStagesResponse: SWRResponse;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: { type: 'ROUND_ROBIN', team_count_round_robin: 2, team_count_elimination: 2 },
|
||||
validate: {
|
||||
team_count_round_robin: (value) => (value >= 2 ? null : 'Need at least two teams'),
|
||||
team_count_elimination: (value) => (value >= 2 ? null : 'Need at least two teams'),
|
||||
},
|
||||
});
|
||||
|
||||
const teamsMap = getTeamsLookup(tournament != null ? tournament.id : -1);
|
||||
const stageItemMap = getStageItemLookup(swrStagesResponse);
|
||||
|
||||
if (teamsMap == null || stageItemMap == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const swrAvailableInputsResponse: SWRResponse = getAvailableStageItemInputs(
|
||||
tournament.id,
|
||||
stage.id
|
||||
);
|
||||
const availableInputs = responseIsValid(swrAvailableInputsResponse)
|
||||
? swrAvailableInputsResponse.data.data.map((option: StageItemInputOption) => {
|
||||
if (option.team_stage_item_id == null) {
|
||||
if (option.team_id == null) return null;
|
||||
const team = teamsMap[option.team_id];
|
||||
if (team == null) return null;
|
||||
return {
|
||||
value: option.team_id,
|
||||
label: team.name,
|
||||
};
|
||||
}
|
||||
assert(option.team_position_in_group != null);
|
||||
const stageItem = stageItemMap[option.team_stage_item_id];
|
||||
if (stageItem == null) return null;
|
||||
return {
|
||||
value: `${option.team_stage_item_id}_${option.team_position_in_group}`,
|
||||
label: `${formatStageItemInput(option.team_position_in_group, stageItem.name)}`,
|
||||
};
|
||||
})
|
||||
: {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Add stage item">
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
const teamCount = getTeamCount(values);
|
||||
const inputs = Array.from(Array(teamCount).keys()).map((i) => {
|
||||
const teamId = values[`team_${i + 1}` as keyof typeof values];
|
||||
return {
|
||||
slot: i + 1,
|
||||
team_id: Number(teamId),
|
||||
team_stage_item_id:
|
||||
typeof teamId === 'string' ? Number(teamId.split('_')[0]) : null,
|
||||
team_position_in_group:
|
||||
typeof teamId === 'string' ? Number(teamId.split('_')[1]) : null,
|
||||
};
|
||||
});
|
||||
await createStageItem(tournament.id, stage.id, values.type, teamCount, inputs);
|
||||
await swrStagesResponse.mutate(null);
|
||||
})}
|
||||
>
|
||||
<Select
|
||||
withAsterisk
|
||||
label="Stage Type"
|
||||
data={[
|
||||
{ value: 'ROUND_ROBIN', label: 'Round Robin' },
|
||||
{ value: 'SINGLE_ELIMINATION', label: 'Single Elimination' },
|
||||
{ value: 'SWISS', label: 'Swiss' },
|
||||
]}
|
||||
{...form.getInputProps('type')}
|
||||
/>
|
||||
<TeamCountInput form={form} />
|
||||
<Divider mt={24} />
|
||||
<StageItemInputs form={form} possibleOptions={availableInputs} />
|
||||
|
||||
<Button fullWidth style={{ marginTop: 16 }} color="green" type="submit">
|
||||
Create Stage Item
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
color="green"
|
||||
size="xs"
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={() => setOpened(true)}
|
||||
leftIcon={<GoPlus size={24} />}
|
||||
>
|
||||
Add stage item
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,35 @@ function CourtsSelect({ form, swrCourtsResponse }: { form: any; swrCourtsRespons
|
||||
);
|
||||
}
|
||||
|
||||
function MatchDeleteButton({
|
||||
tournamentData,
|
||||
match,
|
||||
swrRoundsResponse,
|
||||
swrUpcomingMatchesResponse,
|
||||
dynamicSchedule,
|
||||
}: {
|
||||
tournamentData: TournamentMinimal;
|
||||
match: MatchInterface;
|
||||
swrRoundsResponse: SWRResponse;
|
||||
swrUpcomingMatchesResponse: SWRResponse | null;
|
||||
dynamicSchedule: boolean;
|
||||
}) {
|
||||
if (!dynamicSchedule) return null;
|
||||
return (
|
||||
<DeleteButton
|
||||
fullWidth
|
||||
onClick={async () => {
|
||||
await deleteMatch(tournamentData.id, match.id);
|
||||
await swrRoundsResponse.mutate(null);
|
||||
if (swrUpcomingMatchesResponse != null) await swrUpcomingMatchesResponse.mutate(null);
|
||||
}}
|
||||
style={{ marginTop: '1rem' }}
|
||||
size="sm"
|
||||
title="Remove Match"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MatchModal({
|
||||
tournamentData,
|
||||
match,
|
||||
@@ -37,6 +66,7 @@ export default function MatchModal({
|
||||
swrUpcomingMatchesResponse,
|
||||
opened,
|
||||
setOpened,
|
||||
dynamicSchedule,
|
||||
}: {
|
||||
tournamentData: TournamentMinimal;
|
||||
match: MatchInterface;
|
||||
@@ -45,6 +75,7 @@ export default function MatchModal({
|
||||
swrUpcomingMatchesResponse: SWRResponse | null;
|
||||
opened: boolean;
|
||||
setOpened: any;
|
||||
dynamicSchedule: boolean;
|
||||
}) {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -96,16 +127,12 @@ export default function MatchModal({
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
<DeleteButton
|
||||
fullWidth
|
||||
onClick={async () => {
|
||||
await deleteMatch(tournamentData.id, match.id);
|
||||
await swrRoundsResponse.mutate(null);
|
||||
if (swrUpcomingMatchesResponse != null) await swrUpcomingMatchesResponse.mutate(null);
|
||||
}}
|
||||
style={{ marginTop: '1rem' }}
|
||||
size="sm"
|
||||
title="Remove Match"
|
||||
<MatchDeleteButton
|
||||
swrRoundsResponse={swrRoundsResponse}
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
tournamentData={tournamentData}
|
||||
match={match}
|
||||
dynamicSchedule={dynamicSchedule}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -17,16 +17,47 @@ import { TournamentMinimal } from '../../interfaces/tournament';
|
||||
import { deleteRound, updateRound } from '../../services/round';
|
||||
import DeleteButton from '../buttons/delete';
|
||||
|
||||
export default function RoundModal({
|
||||
function RoundDeleteButton({
|
||||
tournamentData,
|
||||
round,
|
||||
swrRoundsResponse,
|
||||
swrUpcomingMatchesResponse,
|
||||
dynamicSchedule,
|
||||
}: {
|
||||
tournamentData: TournamentMinimal;
|
||||
round: RoundInterface;
|
||||
swrRoundsResponse: SWRResponse;
|
||||
swrUpcomingMatchesResponse: SWRResponse | null;
|
||||
dynamicSchedule: boolean;
|
||||
}) {
|
||||
if (!dynamicSchedule) return null;
|
||||
return (
|
||||
<DeleteButton
|
||||
fullWidth
|
||||
onClick={async () => {
|
||||
await deleteRound(tournamentData.id, round.id);
|
||||
await swrRoundsResponse.mutate(null);
|
||||
if (swrUpcomingMatchesResponse != null) await swrUpcomingMatchesResponse.mutate(null);
|
||||
}}
|
||||
style={{ marginTop: '15px' }}
|
||||
size="sm"
|
||||
title="Delete Round"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoundModal({
|
||||
tournamentData,
|
||||
round,
|
||||
swrRoundsResponse,
|
||||
swrUpcomingMatchesResponse,
|
||||
dynamicSchedule,
|
||||
}: {
|
||||
tournamentData: TournamentMinimal;
|
||||
round: RoundInterface;
|
||||
swrRoundsResponse: SWRResponse;
|
||||
swrUpcomingMatchesResponse: SWRResponse | null;
|
||||
dynamicSchedule: boolean;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
@@ -72,16 +103,12 @@ export default function RoundModal({
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
<DeleteButton
|
||||
fullWidth
|
||||
onClick={async () => {
|
||||
await deleteRound(tournamentData.id, round.id);
|
||||
await swrRoundsResponse.mutate(null);
|
||||
if (swrUpcomingMatchesResponse != null) await swrUpcomingMatchesResponse.mutate(null);
|
||||
}}
|
||||
style={{ marginTop: '15px' }}
|
||||
size="sm"
|
||||
title="Delete Round"
|
||||
<RoundDeleteButton
|
||||
swrRoundsResponse={swrRoundsResponse}
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
tournamentData={tournamentData}
|
||||
round={round}
|
||||
dynamicSchedule={dynamicSchedule}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
||||
50
frontend/src/components/modals/update_stage.tsx
Normal file
50
frontend/src/components/modals/update_stage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Button, Modal, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { StageWithStageItems } from '../../interfaces/stage';
|
||||
import { Tournament } from '../../interfaces/tournament';
|
||||
import { updateStage } from '../../services/stage';
|
||||
|
||||
export function UpdateStageModal({
|
||||
tournament,
|
||||
opened,
|
||||
setOpened,
|
||||
stage,
|
||||
swrStagesResponse,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
opened: boolean;
|
||||
setOpened: any;
|
||||
stage: StageWithStageItems;
|
||||
swrStagesResponse: SWRResponse;
|
||||
}) {
|
||||
const form = useForm({
|
||||
initialValues: { name: stage.name },
|
||||
validate: {},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Edit stage">
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
await updateStage(tournament.id, stage.id, values.name);
|
||||
await swrStagesResponse.mutate(null);
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder=""
|
||||
required
|
||||
my="lg"
|
||||
type="text"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Button fullWidth style={{ marginTop: 16 }} color="green" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/modals/update_stage_item.tsx
Normal file
50
frontend/src/components/modals/update_stage_item.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Button, Modal, TextInput } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { StageItemWithRounds } from '../../interfaces/stage_item';
|
||||
import { Tournament } from '../../interfaces/tournament';
|
||||
import { updateStageItem } from '../../services/stage_item';
|
||||
|
||||
export function UpdateStageItemModal({
|
||||
tournament,
|
||||
opened,
|
||||
setOpened,
|
||||
stageItem,
|
||||
swrStagesResponse,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
opened: boolean;
|
||||
setOpened: any;
|
||||
stageItem: StageItemWithRounds;
|
||||
swrStagesResponse: SWRResponse;
|
||||
}) {
|
||||
const form = useForm({
|
||||
initialValues: { name: stageItem.name },
|
||||
validate: {},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Edit stage item">
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
await updateStageItem(tournament.id, stageItem.id, values.name);
|
||||
await swrStagesResponse.mutate(null);
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder=""
|
||||
required
|
||||
my="lg"
|
||||
type="text"
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<Button fullWidth style={{ marginTop: 16 }} color="green" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,12 @@ import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { SchedulerSettings } from '../../interfaces/match';
|
||||
import { StageWithRounds } from '../../interfaces/stage';
|
||||
import { StageWithStageItems, getStageItem } from '../../interfaces/stage';
|
||||
import { stageItemIsHandledAutomatically } from '../../interfaces/stage_item';
|
||||
import { Tournament } from '../../interfaces/tournament';
|
||||
import { AutoCreateMatchesButton } from '../buttons/create_matches_auto';
|
||||
import UpcomingMatchesTable from '../tables/upcoming_matches';
|
||||
import Elimination from './settings/elimination';
|
||||
import LadderFixed from './settings/ladder_fixed';
|
||||
import SchedulingPlaceholder from './settings/placeholder';
|
||||
import RoundRobin from './settings/round_robin';
|
||||
@@ -14,37 +17,40 @@ function StageSettings({
|
||||
activeStage,
|
||||
schedulerSettings,
|
||||
}: {
|
||||
activeStage?: StageWithRounds;
|
||||
activeStage?: StageWithStageItems;
|
||||
schedulerSettings: SchedulerSettings;
|
||||
}) {
|
||||
if (activeStage == null) {
|
||||
return <SchedulingPlaceholder />;
|
||||
}
|
||||
if (activeStage.type === 'ROUND_ROBIN') {
|
||||
const stageItem = getStageItem(activeStage);
|
||||
if (stageItem.type === 'ROUND_ROBIN') {
|
||||
return <RoundRobin />;
|
||||
}
|
||||
if (stageItem.type === 'SINGLE_ELIMINATION') {
|
||||
return <Elimination />;
|
||||
}
|
||||
return <LadderFixed schedulerSettings={schedulerSettings} />;
|
||||
}
|
||||
|
||||
export default function Scheduler({
|
||||
function SchedulingSystem({
|
||||
activeStage,
|
||||
tournamentData,
|
||||
round_id,
|
||||
swrRoundsResponse,
|
||||
swrUpcomingMatchesResponse,
|
||||
schedulerSettings,
|
||||
}: {
|
||||
activeStage?: StageWithRounds;
|
||||
activeStage?: StageWithStageItems;
|
||||
round_id: number;
|
||||
tournamentData: Tournament;
|
||||
swrRoundsResponse: SWRResponse;
|
||||
swrUpcomingMatchesResponse: SWRResponse;
|
||||
schedulerSettings: SchedulerSettings;
|
||||
}) {
|
||||
if (activeStage == null || stageItemIsHandledAutomatically(getStageItem(activeStage))) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h2>Schedule</h2>
|
||||
<StageSettings activeStage={activeStage} schedulerSettings={schedulerSettings} />
|
||||
<Divider mt={12} />
|
||||
<h4>Schedule new matches</h4>
|
||||
<UpcomingMatchesTable
|
||||
@@ -56,3 +62,38 @@ export default function Scheduler({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Scheduler({
|
||||
activeStage,
|
||||
tournamentData,
|
||||
roundId,
|
||||
swrRoundsResponse,
|
||||
swrUpcomingMatchesResponse,
|
||||
schedulerSettings,
|
||||
}: {
|
||||
activeStage?: StageWithStageItems;
|
||||
roundId: number;
|
||||
tournamentData: Tournament;
|
||||
swrRoundsResponse: SWRResponse;
|
||||
swrUpcomingMatchesResponse: SWRResponse;
|
||||
schedulerSettings: SchedulerSettings;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<h2>Schedule</h2>
|
||||
<StageSettings activeStage={activeStage} schedulerSettings={schedulerSettings} />
|
||||
<SchedulingSystem
|
||||
activeStage={activeStage}
|
||||
round_id={roundId}
|
||||
tournamentData={tournamentData}
|
||||
swrRoundsResponse={swrRoundsResponse}
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
/>
|
||||
<AutoCreateMatchesButton
|
||||
swrStagesResponse={swrRoundsResponse}
|
||||
tournamentData={tournamentData}
|
||||
roundId={roundId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
13
frontend/src/components/scheduling/settings/elimination.tsx
Normal file
13
frontend/src/components/scheduling/settings/elimination.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Alert } from '@mantine/core';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
|
||||
export default function Elimination() {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="No options" color="blue" radius="lg">
|
||||
For elimination, scheduling is handled automatically.
|
||||
<br />
|
||||
Therefore, no options are available.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import React from 'react';
|
||||
export default function RoundRobin() {
|
||||
return (
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="No options" color="blue" radius="lg">
|
||||
For round robin scheduling, no options are available
|
||||
For round robin scheduling, scheduling is handled automatically.
|
||||
<br />
|
||||
Therefore, no options are available.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import EllipsisText from 'react-ellipsis-text';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { Player } from '../../interfaces/player';
|
||||
@@ -7,13 +9,36 @@ import { TournamentMinimal } from '../../interfaces/tournament';
|
||||
import { deletePlayer } from '../../services/player';
|
||||
import DeleteButton from '../buttons/delete';
|
||||
import { PlayerScore } from '../info/player_score';
|
||||
import { PlayerStatistics } from '../info/player_statistics';
|
||||
import {
|
||||
WinDistribution,
|
||||
getDrawColor,
|
||||
getLossColor,
|
||||
getWinColor,
|
||||
} from '../info/player_statistics';
|
||||
import PlayerModal from '../modals/player_modal';
|
||||
import DateTime from '../utils/datetime';
|
||||
import { EmptyTableInfo } from '../utils/empty_table_info';
|
||||
import RequestErrorAlert from '../utils/error_alert';
|
||||
import TableLayout, { ThNotSortable, ThSortable, getTableState, sortTableEntries } from './table';
|
||||
|
||||
export function WinDistributionTitle() {
|
||||
return (
|
||||
<>
|
||||
<Text span color={getWinColor()} inherit>
|
||||
wins
|
||||
</Text>{' '}
|
||||
/{' '}
|
||||
<Text span color={getDrawColor()} inherit>
|
||||
draws
|
||||
</Text>{' '}
|
||||
/{' '}
|
||||
<Text span color={getLossColor()} inherit>
|
||||
losses
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlayersTable({
|
||||
swrPlayersResponse,
|
||||
tournamentData,
|
||||
@@ -24,6 +49,7 @@ export default function PlayersTable({
|
||||
const players: Player[] = swrPlayersResponse.data != null ? swrPlayersResponse.data.data : [];
|
||||
const tableState = getTableState('name');
|
||||
|
||||
const minELOScore = Math.min(...players.map((player) => player.elo_score));
|
||||
const maxELOScore = Math.max(...players.map((player) => player.elo_score));
|
||||
const maxSwissScore = Math.max(...players.map((player) => player.swiss_score));
|
||||
|
||||
@@ -40,16 +66,19 @@ export default function PlayersTable({
|
||||
<Badge color="red">Inactive</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>{player.name}</td>
|
||||
<td>
|
||||
<EllipsisText text={player.name} length={15} />
|
||||
</td>
|
||||
<td>
|
||||
<DateTime datetime={player.created} />
|
||||
</td>
|
||||
<td>
|
||||
<PlayerStatistics wins={player.wins} draws={player.draws} losses={player.losses} />
|
||||
<WinDistribution wins={player.wins} draws={player.draws} losses={player.losses} />
|
||||
</td>
|
||||
<td>
|
||||
<PlayerScore
|
||||
score={player.elo_score}
|
||||
min_score={minELOScore}
|
||||
max_score={maxELOScore}
|
||||
color="indigo"
|
||||
decimals={0}
|
||||
@@ -58,6 +87,7 @@ export default function PlayersTable({
|
||||
<td>
|
||||
<PlayerScore
|
||||
score={player.swiss_score}
|
||||
min_score={0}
|
||||
max_score={maxSwissScore}
|
||||
color="grape"
|
||||
decimals={1}
|
||||
@@ -95,7 +125,11 @@ export default function PlayersTable({
|
||||
<ThSortable state={tableState} field="created">
|
||||
Created
|
||||
</ThSortable>
|
||||
<ThNotSortable>Win distribution</ThNotSortable>
|
||||
<ThNotSortable>
|
||||
<>
|
||||
<WinDistributionTitle />
|
||||
</>
|
||||
</ThNotSortable>
|
||||
<ThSortable state={tableState} field="elo_score">
|
||||
ELO score
|
||||
</ThSortable>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { StageWithRounds } from '../../interfaces/stage';
|
||||
import { Tournament } from '../../interfaces/tournament';
|
||||
import { deleteStage } from '../../services/stage';
|
||||
import DeleteButton from '../buttons/delete';
|
||||
import { EmptyTableInfo } from '../utils/empty_table_info';
|
||||
import RequestErrorAlert from '../utils/error_alert';
|
||||
import TableLayout, { ThNotSortable, getTableState, sortTableEntries } from './table';
|
||||
|
||||
export default function StagesTable({
|
||||
tournament,
|
||||
swrStagesResponse,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
swrStagesResponse: SWRResponse;
|
||||
}) {
|
||||
const stages: StageWithRounds[] =
|
||||
swrStagesResponse.data != null ? swrStagesResponse.data.data : [];
|
||||
const tableState = getTableState('id');
|
||||
|
||||
if (swrStagesResponse.error) return <RequestErrorAlert error={swrStagesResponse.error} />;
|
||||
|
||||
const rows = stages
|
||||
.sort((s1: StageWithRounds, s2: StageWithRounds) => sortTableEntries(s1, s2, tableState))
|
||||
.map((stage) => (
|
||||
<tr key={stage.type_name}>
|
||||
<td>{stage.type_name}</td>
|
||||
<td>
|
||||
{' '}
|
||||
{stage.is_active ? (
|
||||
<Badge color="green">Active</Badge>
|
||||
) : (
|
||||
<Badge color="dark">Inactive</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<DeleteButton
|
||||
onClick={async () => {
|
||||
await deleteStage(tournament.id, stage.id);
|
||||
await swrStagesResponse.mutate(null);
|
||||
}}
|
||||
title="Delete Stage"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
||||
if (rows.length < 1) return <EmptyTableInfo entity_name="stages" />;
|
||||
|
||||
return (
|
||||
<TableLayout>
|
||||
<thead>
|
||||
<tr>
|
||||
<ThNotSortable>Title</ThNotSortable>
|
||||
<ThNotSortable>Status</ThNotSortable>
|
||||
<ThNotSortable>{null}</ThNotSortable>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</TableLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,53 @@
|
||||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import EllipsisText from 'react-ellipsis-text';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { TeamInterface } from '../../interfaces/team';
|
||||
import PlayerList from '../info/player_list';
|
||||
import { PlayerScore } from '../info/player_score';
|
||||
import { WinDistribution } from '../info/player_statistics';
|
||||
import { EmptyTableInfo } from '../utils/empty_table_info';
|
||||
import RequestErrorAlert from '../utils/error_alert';
|
||||
import { WinDistributionTitle } from './players';
|
||||
import { ThNotSortable, ThSortable, getTableState, sortTableEntries } from './table';
|
||||
import TableLayoutLarge from './table_large';
|
||||
|
||||
export default function StandingsTable({ swrTeamsResponse }: { swrTeamsResponse: SWRResponse }) {
|
||||
const teams: TeamInterface[] = swrTeamsResponse.data != null ? swrTeamsResponse.data.data : [];
|
||||
const tableState = getTableState('swiss_score', false);
|
||||
const tableState = getTableState('elo_score', false);
|
||||
|
||||
if (swrTeamsResponse.error) return <RequestErrorAlert error={swrTeamsResponse.error} />;
|
||||
|
||||
const minELOScore = Math.min(...teams.map((team) => team.elo_score));
|
||||
const maxELOScore = Math.max(...teams.map((team) => team.elo_score));
|
||||
const maxSwissScore = Math.max(...teams.map((team) => team.swiss_score));
|
||||
|
||||
const rows = teams
|
||||
.sort((p1: TeamInterface, p2: TeamInterface) => (p1.name < p2.name ? 1 : 0))
|
||||
.sort((p1: TeamInterface, p2: TeamInterface) => (p1.draws > p2.draws ? 1 : 0))
|
||||
.sort((p1: TeamInterface, p2: TeamInterface) => (p1.wins > p2.wins ? 1 : 0))
|
||||
.sort((p1: TeamInterface, p2: TeamInterface) => sortTableEntries(p1, p2, tableState))
|
||||
.map((team) => (
|
||||
.slice(0, 15)
|
||||
.map((team, index) => (
|
||||
<tr key={team.id}>
|
||||
<td>{team.name}</td>
|
||||
<td>{index + 1}</td>
|
||||
<td>
|
||||
<EllipsisText text={team.name} length={50} />
|
||||
</td>
|
||||
<td>
|
||||
<PlayerList team={team} />
|
||||
</td>
|
||||
<td>
|
||||
<PlayerScore score={team.elo_score} max_score={maxELOScore} color="indigo" decimals={0} />
|
||||
<PlayerScore
|
||||
score={team.elo_score}
|
||||
min_score={minELOScore}
|
||||
max_score={maxELOScore}
|
||||
color="indigo"
|
||||
decimals={0}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<PlayerScore
|
||||
score={team.swiss_score}
|
||||
max_score={maxSwissScore}
|
||||
color="grape"
|
||||
decimals={1}
|
||||
/>
|
||||
<WinDistribution wins={team.wins} draws={team.draws} losses={team.losses} />
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
@@ -46,6 +58,7 @@ export default function StandingsTable({ swrTeamsResponse }: { swrTeamsResponse:
|
||||
<TableLayoutLarge display_mode="presentation">
|
||||
<thead>
|
||||
<tr>
|
||||
<ThNotSortable>#</ThNotSortable>
|
||||
<ThSortable state={tableState} field="name">
|
||||
Name
|
||||
</ThSortable>
|
||||
@@ -53,9 +66,9 @@ export default function StandingsTable({ swrTeamsResponse }: { swrTeamsResponse:
|
||||
<ThSortable state={tableState} field="elo_score">
|
||||
ELO score
|
||||
</ThSortable>
|
||||
<ThSortable state={tableState} field="swiss_score">
|
||||
Swiss score
|
||||
</ThSortable>
|
||||
<ThNotSortable>
|
||||
<WinDistributionTitle />
|
||||
</ThNotSortable>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
|
||||
@@ -6,7 +6,7 @@ export const regularStyle = createStyles(() => ({
|
||||
tbody: {
|
||||
tr: {
|
||||
td: {
|
||||
fontSize: '2rem',
|
||||
fontSize: '1.5rem',
|
||||
div: {
|
||||
fontSize: '1.5rem',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Anchor, Button } from '@mantine/core';
|
||||
import { Button } from '@mantine/core';
|
||||
import { BiEditAlt } from '@react-icons/all-files/bi/BiEditAlt';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -33,9 +33,7 @@ export default function TournamentsTable({
|
||||
.map((tournament) => (
|
||||
<tr key={tournament.name}>
|
||||
<td>
|
||||
<Anchor lineClamp={1} size="sm">
|
||||
<Link href={`/tournaments/${tournament.id}`}>{tournament.name}</Link>
|
||||
</Anchor>
|
||||
<Link href={`/tournaments/${tournament.id}`}>{tournament.name}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<DateTime datetime={tournament.created} />
|
||||
|
||||
@@ -32,6 +32,10 @@ export default function UpcomingMatchesTable({
|
||||
swrUpcomingMatchesResponse.data != null ? swrUpcomingMatchesResponse.data.data : [];
|
||||
const tableState = getTableState('elo_diff');
|
||||
|
||||
if (round_id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (swrUpcomingMatchesResponse.error) {
|
||||
return <RequestErrorAlert error={swrUpcomingMatchesResponse.error} />;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { Tabs, TabsProps, rem } from '@mantine/core';
|
||||
import { BiCircle } from '@react-icons/all-files/bi/BiCircle';
|
||||
import { MdPlayCircleFilled } from '@react-icons/all-files/md/MdPlayCircleFilled';
|
||||
|
||||
import { StageWithRounds } from '../../interfaces/stage';
|
||||
import { StageWithStageItems } from '../../interfaces/stage';
|
||||
import { responseIsValid } from './util';
|
||||
|
||||
function StyledTabs(props: TabsProps & { setSelectedStageId: any }) {
|
||||
const { setSelectedStageId, ..._props } = props;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
unstyled
|
||||
@@ -61,7 +63,7 @@ function StyledTabs(props: TabsProps & { setSelectedStageId: any }) {
|
||||
display: 'flex',
|
||||
},
|
||||
})}
|
||||
{...props}
|
||||
{..._props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -70,13 +72,13 @@ export default function StagesTab({ swrStagesResponse, selectedStageId, setSelec
|
||||
if (!responseIsValid(swrStagesResponse)) {
|
||||
return <></>;
|
||||
}
|
||||
const items = swrStagesResponse.data.data.map((item: StageWithRounds) => (
|
||||
const items = swrStagesResponse.data.data.map((item: StageWithStageItems) => (
|
||||
<Tabs.Tab
|
||||
value={item.id.toString()}
|
||||
key={item.id.toString()}
|
||||
icon={item.is_active ? <MdPlayCircleFilled size="1rem" /> : <BiCircle size="1rem" />}
|
||||
>
|
||||
{item.type_name}
|
||||
{item.name}
|
||||
</Tabs.Tab>
|
||||
));
|
||||
return (
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import assert from 'assert';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { RoundInterface } from './round';
|
||||
import { StageItemWithRounds } from './stage_item';
|
||||
|
||||
export interface StageWithRounds {
|
||||
export interface StageWithStageItems {
|
||||
id: number;
|
||||
tournament_id: number;
|
||||
created: string;
|
||||
type: string;
|
||||
type_name: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
rounds: RoundInterface[];
|
||||
stage_items: StageItemWithRounds[];
|
||||
}
|
||||
|
||||
export function getActiveStages(swrStagesResponse: SWRResponse) {
|
||||
return swrStagesResponse.data.data.filter((stage: StageWithStageItems) => stage.is_active);
|
||||
}
|
||||
|
||||
export function getActiveStage(swrStagesResponse: SWRResponse) {
|
||||
return swrStagesResponse.data.data.filter((stage: StageWithRounds) => stage.is_active)[0];
|
||||
return getActiveStages(swrStagesResponse)[0];
|
||||
}
|
||||
|
||||
export function getActiveRound(stage: StageWithRounds) {
|
||||
return stage.rounds.filter((round: RoundInterface) => round.is_active)[0];
|
||||
export function getStageItem(stage: StageWithStageItems) {
|
||||
assert(stage.stage_items.length === 1);
|
||||
return stage.stage_items[0];
|
||||
}
|
||||
|
||||
19
frontend/src/interfaces/stage_item.tsx
Normal file
19
frontend/src/interfaces/stage_item.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { RoundInterface } from './round';
|
||||
import { StageItemInput } from './stage_item_input';
|
||||
|
||||
export interface StageItemWithRounds {
|
||||
id: number;
|
||||
tournament_id: number;
|
||||
created: string;
|
||||
type: string;
|
||||
name: string;
|
||||
type_name: string;
|
||||
team_count: number;
|
||||
is_active: boolean;
|
||||
rounds: RoundInterface[];
|
||||
inputs: StageItemInput[];
|
||||
}
|
||||
|
||||
export function stageItemIsHandledAutomatically(activeStage: StageItemWithRounds) {
|
||||
return ['ROUND_ROBIN', 'SINGLE_ELIMINATION'].includes(activeStage.type);
|
||||
}
|
||||
33
frontend/src/interfaces/stage_item_input.tsx
Normal file
33
frontend/src/interfaces/stage_item_input.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface StageItemInput {
|
||||
id: number;
|
||||
slot: number;
|
||||
tournament_id: number;
|
||||
stage_item_id: number;
|
||||
team_id: number | null;
|
||||
team_stage_item_id: number | null;
|
||||
team_position_in_group: number | null;
|
||||
}
|
||||
|
||||
export interface StageItemInputCreateBody {
|
||||
slot: number;
|
||||
team_id: number | null;
|
||||
team_stage_item_id: number | null;
|
||||
team_position_in_group: number | null;
|
||||
}
|
||||
|
||||
export interface StageItemInputOption {
|
||||
team_id: number | null;
|
||||
team_stage_item_id: number | null;
|
||||
team_position_in_group: number | null;
|
||||
}
|
||||
|
||||
export function getPositionName(position: number) {
|
||||
// TODO: handle inputs like `21` (21st)
|
||||
return (
|
||||
{
|
||||
1: '1st',
|
||||
2: '2nd',
|
||||
3: '3rd',
|
||||
}[position] || `${position}th`
|
||||
);
|
||||
}
|
||||
@@ -8,4 +8,7 @@ export interface TeamInterface {
|
||||
players: Player[];
|
||||
elo_score: number;
|
||||
swiss_score: number;
|
||||
wins: number;
|
||||
draws: number;
|
||||
losses: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button, Center, Grid, Group, Title } from '@mantine/core';
|
||||
import { GoPlus } from '@react-icons/all-files/go/GoPlus';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import React, { useState } from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
@@ -7,13 +6,12 @@ import { SWRResponse } from 'swr';
|
||||
import NotFoundTitle from '../404';
|
||||
import Brackets from '../../components/brackets/brackets';
|
||||
import { NextStageButton } from '../../components/buttons/next_stage_button';
|
||||
import SaveButton from '../../components/buttons/save';
|
||||
import Scheduler from '../../components/scheduling/scheduling';
|
||||
import StagesTab from '../../components/utils/stages_tab';
|
||||
import { getTournamentIdFromRouter, responseIsValid } from '../../components/utils/util';
|
||||
import { SchedulerSettings } from '../../interfaces/match';
|
||||
import { RoundInterface } from '../../interfaces/round';
|
||||
import { StageWithRounds, getActiveStage } from '../../interfaces/stage';
|
||||
import { StageWithStageItems, getActiveStages } from '../../interfaces/stage';
|
||||
import { Tournament, getTournamentEndpoint } from '../../interfaces/tournament';
|
||||
import {
|
||||
checkForAuthError,
|
||||
@@ -22,7 +20,6 @@ import {
|
||||
getTournaments,
|
||||
getUpcomingMatches,
|
||||
} from '../../services/adapter';
|
||||
import { createRound } from '../../services/round';
|
||||
import TournamentLayout from './_tournament_layout';
|
||||
|
||||
export default function TournamentPage() {
|
||||
@@ -36,7 +33,7 @@ export default function TournamentPage() {
|
||||
const [eloThreshold, setEloThreshold] = useState(100);
|
||||
const [iterations, setIterations] = useState(200);
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [selectedStageId, setSelectedStageId] = useState(null);
|
||||
const [selectedStageId, setSelectedStageId] = useState<number | null>(null);
|
||||
|
||||
const schedulerSettings: SchedulerSettings = {
|
||||
eloThreshold,
|
||||
@@ -54,19 +51,21 @@ export default function TournamentPage() {
|
||||
const tournamentDataFull = tournaments.filter((tournament) => tournament.id === id)[0];
|
||||
|
||||
const isResponseValid = responseIsValid(swrStagesResponse);
|
||||
const activeStage = isResponseValid ? getActiveStage(swrStagesResponse) : null;
|
||||
let activeStage = null;
|
||||
let draftRound = null;
|
||||
|
||||
if (isResponseValid) {
|
||||
const draftRounds = swrStagesResponse.data.data.map((stage: StageWithRounds) =>
|
||||
stage.rounds.filter((round: RoundInterface) => round.is_draft)
|
||||
);
|
||||
if (draftRounds != null && draftRounds.length > 0) {
|
||||
[[draftRound]] = draftRounds;
|
||||
[activeStage] = getActiveStages(swrStagesResponse);
|
||||
|
||||
if (activeStage != null && activeStage.rounds != null) {
|
||||
const draftRounds = activeStage.rounds.filter((round: RoundInterface) => round.is_draft);
|
||||
if (draftRounds != null && draftRounds.length > 0) {
|
||||
[draftRound] = draftRounds;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTab = swrStagesResponse.data.data.filter(
|
||||
(stage: RoundInterface) => stage.is_active
|
||||
(stage: StageWithStageItems) => stage.is_active
|
||||
);
|
||||
if (selectedTab.length > 0 && selectedStageId == null && selectedTab[0].id != null) {
|
||||
setSelectedStageId(selectedTab[0].id.toString());
|
||||
@@ -89,7 +88,7 @@ export default function TournamentPage() {
|
||||
<>
|
||||
<Scheduler
|
||||
activeStage={activeStage}
|
||||
round_id={draftRound.id}
|
||||
roundId={draftRound.id}
|
||||
tournamentData={tournamentDataFull}
|
||||
swrRoundsResponse={swrStagesResponse}
|
||||
swrUpcomingMatchesResponse={swrUpcomingMatchesResponse}
|
||||
@@ -109,6 +108,7 @@ export default function TournamentPage() {
|
||||
<Button
|
||||
color="blue"
|
||||
size="md"
|
||||
variant="outline"
|
||||
style={{ marginBottom: 10 }}
|
||||
leftIcon={<IconExternalLink size={24} />}
|
||||
onClick={() => {
|
||||
@@ -122,20 +122,10 @@ export default function TournamentPage() {
|
||||
tournamentData={tournamentData}
|
||||
swrStagesResponse={swrStagesResponse}
|
||||
/>
|
||||
{selectedStageId == null ? null : (
|
||||
<SaveButton
|
||||
onClick={async () => {
|
||||
await createRound(tournamentData.id, selectedStageId);
|
||||
await swrStagesResponse.mutate();
|
||||
}}
|
||||
leftIcon={<GoPlus size={24} />}
|
||||
title="Add Round"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<div style={{ marginTop: '15px' }}>
|
||||
<div style={{ marginTop: '1rem', marginLeft: '1rem', marginRight: '1rem' }}>
|
||||
<Center>
|
||||
<StagesTab
|
||||
swrStagesResponse={swrStagesResponse}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Grid } from '@mantine/core';
|
||||
import { Alert, Center, Grid } from '@mantine/core';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import Head from 'next/head';
|
||||
import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
@@ -8,22 +9,26 @@ import CourtsLarge from '../../../../components/brackets/courts_large';
|
||||
import {
|
||||
TournamentHeadTitle,
|
||||
TournamentLogo,
|
||||
TournamentQRCode,
|
||||
TournamentTitle,
|
||||
} from '../../../../components/dashboard/layout';
|
||||
import { responseIsValid } from '../../../../components/utils/util';
|
||||
import { getActiveRound, getActiveStage } from '../../../../interfaces/stage';
|
||||
import { getStages } from '../../../../services/adapter';
|
||||
import { RoundInterface } from '../../../../interfaces/round';
|
||||
import { getActiveStage } from '../../../../interfaces/stage';
|
||||
import { getStagesLive } from '../../../../services/adapter';
|
||||
import { getActiveRounds } from '../../../../services/lookups';
|
||||
import { getTournamentResponseByEndpointName } from '../../../../services/tournament';
|
||||
|
||||
export default function CourtsPage() {
|
||||
const tournamentResponse = getTournamentResponseByEndpointName();
|
||||
|
||||
// Hack to avoid unequal number of rendered hooks.
|
||||
const tournamentId = tournamentResponse != null ? tournamentResponse[0].id : -1;
|
||||
const notFound = tournamentResponse == null || tournamentResponse[0] == null;
|
||||
const tournamentId = !notFound ? tournamentResponse[0].id : -1;
|
||||
|
||||
const swrStagesResponse: SWRResponse = getStages(tournamentId, true);
|
||||
const swrStagesResponse: SWRResponse = getStagesLive(tournamentId, true);
|
||||
|
||||
if (tournamentResponse == null) {
|
||||
if (notFound) {
|
||||
return <NotFoundTitle />;
|
||||
}
|
||||
|
||||
@@ -34,7 +39,25 @@ export default function CourtsPage() {
|
||||
return <NotFoundTitle />;
|
||||
}
|
||||
|
||||
const activeRound = getActiveRound(activeStage);
|
||||
const activeRounds = getActiveRounds(swrStagesResponse);
|
||||
if (activeRounds.length < 1) {
|
||||
return (
|
||||
<Center>
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
title="No active round"
|
||||
color="blue"
|
||||
radius="lg"
|
||||
mt={8}
|
||||
>
|
||||
There is currently no active round
|
||||
</Alert>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
const rows = activeRounds.map((activeRound: RoundInterface) => (
|
||||
<CourtsLarge tournamentData={tournamentDataFull} activeRound={activeRound} />
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -45,10 +68,9 @@ export default function CourtsPage() {
|
||||
<Grid.Col span={2}>
|
||||
<TournamentTitle tournamentDataFull={tournamentDataFull} />
|
||||
<TournamentLogo tournamentDataFull={tournamentDataFull} />
|
||||
<TournamentQRCode tournamentDataFull={tournamentDataFull} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={10}>
|
||||
<CourtsLarge tournamentData={tournamentDataFull} activeRound={activeRound} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={10}>{rows}</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,25 +8,27 @@ import Brackets from '../../../../components/brackets/brackets';
|
||||
import {
|
||||
TournamentHeadTitle,
|
||||
TournamentLogo,
|
||||
TournamentQRCode,
|
||||
TournamentTitle,
|
||||
} from '../../../../components/dashboard/layout';
|
||||
import StagesTab from '../../../../components/utils/stages_tab';
|
||||
import { responseIsValid } from '../../../../components/utils/util';
|
||||
import { StageWithRounds } from '../../../../interfaces/stage';
|
||||
import { getCourts, getStages } from '../../../../services/adapter';
|
||||
import { StageWithStageItems } from '../../../../interfaces/stage';
|
||||
import { getCourts, getStagesLive } from '../../../../services/adapter';
|
||||
import { getTournamentResponseByEndpointName } from '../../../../services/tournament';
|
||||
|
||||
export default function Index() {
|
||||
const tournamentResponse = getTournamentResponseByEndpointName();
|
||||
|
||||
// Hack to avoid unequal number of rendered hooks.
|
||||
const tournamentId = tournamentResponse != null ? tournamentResponse[0].id : -1;
|
||||
const notFound = tournamentResponse == null || tournamentResponse[0] == null;
|
||||
const tournamentId = !notFound ? tournamentResponse[0].id : -1;
|
||||
|
||||
const swrStagesResponse: SWRResponse = getStages(tournamentId, true);
|
||||
const swrStagesResponse: SWRResponse = getStagesLive(tournamentId, true);
|
||||
const swrCourtsResponse: SWRResponse = getCourts(tournamentId);
|
||||
const [selectedStageId, setSelectedStageId] = useState(null);
|
||||
|
||||
if (tournamentResponse == null) {
|
||||
if (notFound) {
|
||||
return <NotFoundTitle />;
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ export default function Index() {
|
||||
|
||||
if (responseIsValid(swrStagesResponse)) {
|
||||
const activeTab = swrStagesResponse.data.data.filter(
|
||||
(stage: StageWithRounds) => stage.is_active
|
||||
(stage: StageWithStageItems) => stage.is_active
|
||||
);
|
||||
|
||||
if (activeTab.length > 0 && selectedStageId == null && activeTab[0].id != null) {
|
||||
@@ -51,6 +53,7 @@ export default function Index() {
|
||||
<Grid.Col span={2}>
|
||||
<TournamentTitle tournamentDataFull={tournamentDataFull} />
|
||||
<TournamentLogo tournamentDataFull={tournamentDataFull} />
|
||||
<TournamentQRCode tournamentDataFull={tournamentDataFull} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={10}>
|
||||
<Center>
|
||||
|
||||
@@ -7,21 +7,23 @@ import NotFoundTitle from '../../../404';
|
||||
import {
|
||||
TournamentHeadTitle,
|
||||
TournamentLogo,
|
||||
TournamentQRCode,
|
||||
TournamentTitle,
|
||||
} from '../../../../components/dashboard/layout';
|
||||
import StandingsTable from '../../../../components/tables/standings';
|
||||
import { getTeams } from '../../../../services/adapter';
|
||||
import { getTeamsLive } from '../../../../services/adapter';
|
||||
import { getTournamentResponseByEndpointName } from '../../../../services/tournament';
|
||||
|
||||
export default function Standings() {
|
||||
const tournamentResponse = getTournamentResponseByEndpointName();
|
||||
|
||||
// Hack to avoid unequal number of rendered hooks.
|
||||
const tournamentId = tournamentResponse != null ? tournamentResponse[0].id : -1;
|
||||
const notFound = tournamentResponse == null || tournamentResponse[0] == null;
|
||||
const tournamentId = !notFound ? tournamentResponse[0].id : -1;
|
||||
|
||||
const swrTeamsResponse: SWRResponse = getTeams(tournamentId);
|
||||
const swrTeamsResponse: SWRResponse = getTeamsLive(tournamentId);
|
||||
|
||||
if (tournamentResponse == null) {
|
||||
if (notFound) {
|
||||
return <NotFoundTitle />;
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ export default function Standings() {
|
||||
<Grid.Col span={2}>
|
||||
<TournamentTitle tournamentDataFull={tournamentDataFull} />
|
||||
<TournamentLogo tournamentDataFull={tournamentDataFull} />
|
||||
<TournamentQRCode tournamentDataFull={tournamentDataFull} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={10}>
|
||||
<StandingsTable swrTeamsResponse={swrTeamsResponse} />
|
||||
|
||||
@@ -1,51 +1,16 @@
|
||||
import { Button, Container, Divider, Group, Select } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { Container, Group } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import Builder from '../../../components/builder/builder';
|
||||
import {
|
||||
NextStageButton,
|
||||
PreviousStageButton,
|
||||
} from '../../../components/buttons/next_stage_button';
|
||||
import StagesTable from '../../../components/tables/stages';
|
||||
import { getTournamentIdFromRouter } from '../../../components/utils/util';
|
||||
import { Tournament } from '../../../interfaces/tournament';
|
||||
import { getStages, getTournaments } from '../../../services/adapter';
|
||||
import { createStage } from '../../../services/stage';
|
||||
import TournamentLayout from '../_tournament_layout';
|
||||
|
||||
function CreateStageForm(tournament: Tournament, swrClubsResponse: SWRResponse) {
|
||||
const form = useForm({
|
||||
initialValues: { type: 'ROUND_ROBIN' },
|
||||
validate: {},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
await createStage(tournament.id, values.type);
|
||||
await swrClubsResponse.mutate(null);
|
||||
})}
|
||||
>
|
||||
<Divider mt={12} />
|
||||
<h3>Add Stage</h3>
|
||||
<Select
|
||||
label="Stage Type"
|
||||
data={[
|
||||
{ value: 'ROUND_ROBIN', label: 'Round Robin' },
|
||||
{ value: 'SINGLE_ELIMINATION', label: 'Single Elimination' },
|
||||
{ value: 'DOUBLE_ELIMINATION', label: 'Double Elimination' },
|
||||
{ value: 'SWISS', label: 'Swiss' },
|
||||
]}
|
||||
{...form.getInputProps('type')}
|
||||
/>
|
||||
<Button fullWidth style={{ marginTop: 16 }} color="green" type="submit">
|
||||
Create Stage
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StagesPage() {
|
||||
const { tournamentData } = getTournamentIdFromRouter();
|
||||
const swrStagesResponse = getStages(tournamentData.id);
|
||||
@@ -59,9 +24,8 @@ export default function StagesPage() {
|
||||
|
||||
return (
|
||||
<TournamentLayout tournament_id={tournamentData.id}>
|
||||
<Container>
|
||||
<StagesTable tournament={tournamentDataFull} swrStagesResponse={swrStagesResponse} />
|
||||
{CreateStageForm(tournamentDataFull, swrStagesResponse)}
|
||||
<Container size="1600px">
|
||||
<Builder tournament={tournamentDataFull} swrStagesResponse={swrStagesResponse} />
|
||||
<Group grow mt="1rem">
|
||||
<PreviousStageButton
|
||||
tournamentData={tournamentData}
|
||||
|
||||
@@ -65,9 +65,26 @@ export function getTeams(tournament_id: number): SWRResponse {
|
||||
return useSWR(`tournaments/${tournament_id}/teams`, fetcher);
|
||||
}
|
||||
|
||||
export function getTeamsLive(tournament_id: number): SWRResponse {
|
||||
return useSWR(`tournaments/${tournament_id}/teams`, fetcher, {
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAvailableStageItemInputs(tournament_id: number, stage_id: number): SWRResponse {
|
||||
return useSWR(`tournaments/${tournament_id}/stages/${stage_id}/available_inputs`, fetcher);
|
||||
}
|
||||
|
||||
export function getStages(tournament_id: number, no_draft_rounds: boolean = false): SWRResponse {
|
||||
return useSWR(`tournaments/${tournament_id}/stages?no_draft_rounds=${no_draft_rounds}`, fetcher);
|
||||
}
|
||||
|
||||
export function getStagesLive(
|
||||
tournament_id: number,
|
||||
no_draft_rounds: boolean = false
|
||||
): SWRResponse {
|
||||
return useSWR(`tournaments/${tournament_id}/stages?no_draft_rounds=${no_draft_rounds}`, fetcher, {
|
||||
refreshInterval: 3000,
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function deleteClub(club_id: number) {
|
||||
|
||||
export async function updateClub(club_id: number, name: string) {
|
||||
return createAxios()
|
||||
.patch(`clubs/${club_id}`, {
|
||||
.put(`clubs/${club_id}`, {
|
||||
name,
|
||||
})
|
||||
.catch((response: any) => handleRequestError(response));
|
||||
|
||||
41
frontend/src/services/lookups.tsx
Normal file
41
frontend/src/services/lookups.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { SWRResponse } from 'swr';
|
||||
|
||||
import { responseIsValid } from '../components/utils/util';
|
||||
import { RoundInterface } from '../interfaces/round';
|
||||
import { StageWithStageItems } from '../interfaces/stage';
|
||||
import { TeamInterface } from '../interfaces/team';
|
||||
import { getTeams } from './adapter';
|
||||
|
||||
export function getTeamsLookup(tournamentId: number) {
|
||||
const swrTeamsResponse: SWRResponse = getTeams(tournamentId);
|
||||
const isResponseValid = responseIsValid(swrTeamsResponse);
|
||||
|
||||
if (!isResponseValid) {
|
||||
return null;
|
||||
}
|
||||
return Object.fromEntries(swrTeamsResponse.data.data.map((x: TeamInterface) => [x.id, x]));
|
||||
}
|
||||
|
||||
export function getStageItemLookup(swrStagesResponse: SWRResponse) {
|
||||
let result: any[] = [];
|
||||
|
||||
swrStagesResponse.data.data.map((stage: StageWithStageItems) =>
|
||||
stage.stage_items.forEach((stage_item) => {
|
||||
result = result.concat([[stage_item.id, stage_item]]);
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(result);
|
||||
}
|
||||
|
||||
export function getActiveRounds(swrStagesResponse: SWRResponse) {
|
||||
let result: RoundInterface[] = [];
|
||||
|
||||
swrStagesResponse.data.data.map((stage: StageWithStageItems) =>
|
||||
stage.stage_items.forEach((stage_item) => {
|
||||
stage_item.rounds.forEach((round) => {
|
||||
if (round.is_active) result = result.concat([round]);
|
||||
});
|
||||
})
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@@ -19,6 +19,6 @@ export async function updateMatch(
|
||||
match: MatchBodyInterface
|
||||
) {
|
||||
return createAxios()
|
||||
.patch(`tournaments/${tournament_id}/matches/${match_id}`, match)
|
||||
.put(`tournaments/${tournament_id}/matches/${match_id}`, match)
|
||||
.catch((response: any) => handleRequestError(response));
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function updatePlayer(
|
||||
team_id: string | null
|
||||
) {
|
||||
return createAxios()
|
||||
.patch(`tournaments/${tournament_id}/players/${player_id}`, {
|
||||
.put(`tournaments/${tournament_id}/players/${player_id}`, {
|
||||
name,
|
||||
active,
|
||||
team_id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user