Add schedule builder (#267)

This commit is contained in:
Erik Vroon
2023-11-02 20:34:49 +01:00
committed by GitHub
parent 894f99c34a
commit ab86f7ea77
107 changed files with 2849 additions and 724 deletions

View File

@@ -30,7 +30,6 @@ def upgrade() -> None:
'type',
ENUM(
'SINGLE_ELIMINATION',
'DOUBLE_ELIMINATION',
'SWISS',
'SWISS_DYNAMIC_TEAMS',
'ROUND_ROBIN',

View File

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

View File

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

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

View 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

View 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]

View File

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

View File

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

View File

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

View 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

View File

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

View 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')

View File

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

View File

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

View 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

View 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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
]

View File

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

View File

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

View 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},
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ function getRoundsGridCols(activeRound: RoundInterface, tournamentData: Tourname
swrUpcomingMatchesResponse={null}
match={match}
readOnly
dynamicSchedule={false}
/>
</Grid.Col>
));

View File

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

View File

@@ -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}
/>
</>
);

View File

@@ -102,6 +102,7 @@ export default function MatchLarge({
match={match}
opened={opened}
setOpened={setOpened}
dynamicSchedule={false}
/>
</>
);

View File

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

View 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>;
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

@@ -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)}%`,
},
]}
/>

View File

@@ -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)}%)`,
},
]}

View 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>
</>
);
}

View File

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

View File

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

View 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>
);
}

View 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>
);
}

View File

@@ -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}
/>
</>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ export const regularStyle = createStyles(() => ({
tbody: {
tr: {
td: {
fontSize: '2rem',
fontSize: '1.5rem',
div: {
fontSize: '1.5rem',
},

View File

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

View File

@@ -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} />;
}

View File

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

View File

@@ -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];
}

View 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);
}

View 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`
);
}

View File

@@ -8,4 +8,7 @@ export interface TeamInterface {
players: Player[];
elo_score: number;
swiss_score: number;
wins: number;
draws: number;
losses: number;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
});
}

View File

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

View 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;
}

View File

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

View File

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