Customize rankings (#797)

Allows you to add rankings that specify how the ranking per stage item
is calculated.
Points are now stored per stage item input.
This commit is contained in:
Erik Vroon
2024-09-07 12:03:16 +02:00
committed by GitHub
parent 4a81262280
commit d6449e8d05
69 changed files with 1488 additions and 436 deletions

View File

@@ -0,0 +1,131 @@
"""create rankings table
Revision ID: 77de1c773dba
Revises: 1961954c0320
Create Date: 2024-06-29 14:13:18.278876
"""
from typing import Any
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str | None = "77de1c773dba"
down_revision: str | None = "1961954c0320"
branch_labels: str | None = None
depends_on: str | None = None
def add_missing_rankings(tournaments_without_ranking: list[Any]) -> None:
for tournament in tournaments_without_ranking:
ranking_id = (
op.get_bind()
.execute(
sa.text(
"""
INSERT INTO rankings (
tournament_id,
position,
win_points,
draw_points,
loss_points,
add_score_points
)
VALUES (:tournament_id, 0, 1, 0.5, 0, false)
RETURNING id
"""
),
tournament_id=tournament.id,
)
.scalar_one()
)
op.get_bind().execute(
sa.text(
"""
UPDATE stage_items
SET ranking_id = :ranking_id
WHERE stage_items.ranking_id IS NULL AND stage_items.stage_id IN (
SELECT id FROM stages
WHERE stages.tournament_id = :tournament_id
)
"""
),
tournament_id=tournament.id,
ranking_id=ranking_id,
)
def upgrade() -> None:
op.create_table(
"rankings",
sa.Column("id", sa.BigInteger(), nullable=False),
sa.Column(
"created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.Column("tournament_id", sa.BigInteger(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("win_points", sa.Float(), nullable=False),
sa.Column("draw_points", sa.Float(), nullable=False),
sa.Column("loss_points", sa.Float(), nullable=False),
sa.Column("add_score_points", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["tournament_id"],
["tournaments.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_rankings_id"), "rankings", ["id"], unique=False)
op.create_index(op.f("ix_rankings_tournament_id"), "rankings", ["tournament_id"], unique=False)
tournaments_without_ranking = (
op.get_bind()
.execute(
"""
SELECT * FROM tournaments WHERE (
SELECT NOT EXISTS (
SELECT 1 FROM rankings WHERE rankings.tournament_id = tournaments.id
)
)
"""
)
.fetchall()
)
op.add_column("stage_items", sa.Column("ranking_id", sa.BigInteger(), nullable=True))
add_missing_rankings(tournaments_without_ranking)
op.alter_column("stage_items", "ranking_id", nullable=False)
op.create_foreign_key(
"stage_items_x_rankings_id_fkey", "stage_items", "rankings", ["ranking_id"], ["id"]
)
op.add_column(
"stage_item_inputs", sa.Column("points", sa.Float(), server_default="0", nullable=False)
)
op.add_column(
"stage_item_inputs", sa.Column("wins", sa.Integer(), server_default="0", nullable=False)
)
op.add_column(
"stage_item_inputs", sa.Column("draws", sa.Integer(), server_default="0", nullable=False)
)
op.add_column(
"stage_item_inputs", sa.Column("losses", sa.Integer(), server_default="0", nullable=False)
)
def downgrade() -> None:
op.drop_column("stage_item_inputs", "losses")
op.drop_column("stage_item_inputs", "draws")
op.drop_column("stage_item_inputs", "wins")
op.drop_column("stage_item_inputs", "points")
op.drop_constraint("stage_items_x_rankings_id_fkey", "stage_items", type_="foreignkey")
op.drop_column("stage_items", "ranking_id")
op.drop_index(op.f("ix_rankings_tournament_id"), table_name="rankings")
op.drop_index(op.f("ix_rankings_id"), table_name="rankings")
op.drop_table("rankings")

View File

@@ -20,6 +20,7 @@ from bracket.routes import (
internals,
matches,
players,
rankings,
rounds,
stage_items,
stages,
@@ -58,12 +59,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
routers = {
"Internals": internals.router,
"Auth": auth.router,
"Clubs": clubs.router,
"Courts": courts.router,
"Internals": internals.router,
"Matches": matches.router,
"Players": players.router,
"Rankings": rankings.router,
"Rounds": rounds.router,
"Stage Items": stage_items.router,
"Stages": stages.router,

View File

@@ -3,15 +3,15 @@ from collections import defaultdict
from decimal import Decimal
from typing import TypeVar
from bracket.database import database
from bracket.logic.ranking.statistics import START_ELO, TeamStatistics
from bracket.models.db.match import MatchWithDetailsDefinitive
from bracket.models.db.players import START_ELO, PlayerStatistics
from bracket.models.db.ranking import Ranking
from bracket.models.db.stage_item import StageType
from bracket.models.db.util import StageItemWithRounds
from bracket.schema import players, teams
from bracket.sql.players import get_all_players_in_tournament, update_player_stats
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.teams import get_teams_in_tournament, update_team_stats
from bracket.utils.id_types import PlayerId, TeamId, TournamentId
from bracket.sql.rankings import get_ranking_for_stage_item
from bracket.sql.stage_items import get_stage_item
from bracket.sql.teams import update_team_stats
from bracket.utils.id_types import PlayerId, StageItemId, TeamId, TournamentId
from bracket.utils.types import assert_some
K = 32
@@ -21,44 +21,58 @@ D = 400
TeamIdOrPlayerId = TypeVar("TeamIdOrPlayerId", bound=PlayerId | TeamId)
def set_statistics_for_player_or_team(
def set_statistics_for_team(
team_index: int,
stats: defaultdict[TeamIdOrPlayerId, PlayerStatistics],
stats: defaultdict[TeamId, TeamStatistics],
match: MatchWithDetailsDefinitive,
team_or_player_id: TeamIdOrPlayerId,
rating_team1_before: float,
rating_team2_before: float,
team_id: TeamId,
rating_team1_before: Decimal,
rating_team2_before: Decimal,
ranking: Ranking,
stage_item: StageItemWithRounds,
) -> None:
is_team1 = team_index == 0
team_score = match.team1_score if is_team1 else match.team2_score
was_draw = match.team1_score == match.team2_score
has_won = not was_draw and team_score == max(match.team1_score, match.team2_score)
# Set default for SWISS teams
if stage_item.type is StageType.SWISS and team_id not in stats:
stats[team_id].points = START_ELO
if has_won:
stats[team_or_player_id].wins += 1
swiss_score_diff = Decimal("1.00")
stats[team_id].wins += 1
swiss_score_diff = ranking.win_points
elif was_draw:
stats[team_or_player_id].draws += 1
swiss_score_diff = Decimal("0.50")
stats[team_id].draws += 1
swiss_score_diff = ranking.draw_points
else:
stats[team_or_player_id].losses += 1
swiss_score_diff = Decimal("0.00")
stats[team_id].losses += 1
swiss_score_diff = ranking.loss_points
stats[team_or_player_id].swiss_score += swiss_score_diff
if ranking.add_score_points:
swiss_score_diff += match.team1_score if is_team1 else match.team2_score
rating_diff = (rating_team2_before - rating_team1_before) * (1 if is_team1 else -1)
expected_score = Decimal(1.0 / (1.0 + math.pow(10.0, rating_diff / D)))
stats[team_or_player_id].elo_score += int(K * (swiss_score_diff - expected_score))
match stage_item.type:
case StageType.ROUND_ROBIN | StageType.SINGLE_ELIMINATION:
stats[team_id].points += swiss_score_diff
case StageType.SWISS:
rating_diff = (rating_team2_before - rating_team1_before) * (1 if is_team1 else -1)
expected_score = Decimal(1.0 / (1.0 + math.pow(10.0, rating_diff / D)))
stats[team_id].points += int(K * (swiss_score_diff - expected_score))
case _:
raise ValueError(f"Unsupported stage type: {stage_item.type}")
def determine_ranking_for_stage_items(
stage_items: list[StageItemWithRounds],
) -> tuple[defaultdict[PlayerId, PlayerStatistics], defaultdict[TeamId, PlayerStatistics]]:
player_x_stats: defaultdict[PlayerId, PlayerStatistics] = defaultdict(PlayerStatistics)
team_x_stats: defaultdict[TeamId, PlayerStatistics] = defaultdict(PlayerStatistics)
def determine_ranking_for_stage_item(
stage_item: StageItemWithRounds,
ranking: Ranking,
) -> defaultdict[TeamId, TeamStatistics]:
team_x_stats: defaultdict[TeamId, TeamStatistics] = defaultdict(TeamStatistics)
matches = [
match
for stage_item in stage_items
for round_ in stage_item.rounds
if not round_.is_draft
for match in round_.matches
@@ -66,83 +80,46 @@ def determine_ranking_for_stage_items(
if match.team1_score != 0 or match.team2_score != 0
]
for match in matches:
rating_team1_before = (
sum(player_x_stats[player_id].elo_score for player_id in match.team1.player_ids)
/ len(match.team1.player_ids)
if len(match.team1.player_ids) > 0
else START_ELO
)
rating_team2_before = (
sum(player_x_stats[player_id].elo_score for player_id in match.team2.player_ids)
/ len(match.team2.player_ids)
if len(match.team2.player_ids) > 0
else START_ELO
)
for team_index, team in enumerate(match.teams):
if team.id is not None:
set_statistics_for_player_or_team(
set_statistics_for_team(
team_index,
team_x_stats,
match,
team.id,
rating_team1_before,
rating_team2_before,
match.team1.elo_score,
match.team2.elo_score,
ranking,
stage_item,
)
for player in team.players:
set_statistics_for_player_or_team(
team_index,
player_x_stats,
match,
assert_some(player.id),
rating_team1_before,
rating_team2_before,
)
return player_x_stats, team_x_stats
return team_x_stats
def determine_team_ranking_for_stage_item(
async def determine_team_ranking_for_stage_item(
stage_item: StageItemWithRounds,
) -> list[tuple[TeamId, PlayerStatistics]]:
_, team_ranking = determine_ranking_for_stage_items([stage_item])
return sorted(team_ranking.items(), key=lambda x: x[1].elo_score, reverse=True)
ranking: Ranking,
) -> list[tuple[TeamId, TeamStatistics]]:
team_ranking = determine_ranking_for_stage_item(stage_item, ranking)
return sorted(team_ranking.items(), key=lambda x: x[1].points, reverse=True)
async def recalculate_ranking_for_tournament_id(tournament_id: TournamentId) -> None:
stages = await get_full_tournament_details(tournament_id)
stage_items = [stage_item for stage in stages for stage_item in stage.stage_items]
await recalculate_ranking_for_stage_items(tournament_id, stage_items)
async def recalculate_ranking_for_stage_items(
tournament_id: TournamentId, stage_items: list[StageItemWithRounds]
async def recalculate_ranking_for_stage_item_id(
tournament_id: TournamentId,
stage_item_id: StageItemId,
) -> None:
elo_per_player, elo_per_team = determine_ranking_for_stage_items(stage_items)
stage_item = await get_stage_item(tournament_id, stage_item_id)
ranking = await get_ranking_for_stage_item(tournament_id, stage_item_id)
assert stage_item, "Stage item not found"
assert ranking, "Ranking not found"
for player_id, statistics in elo_per_player.items():
await update_player_stats(tournament_id, player_id, statistics)
team_x_stage_item_input_lookup = {
stage_item_input.team_id: assert_some(stage_item_input.id)
for stage_item_input in stage_item.inputs
if stage_item_input.team_id is not None
}
for team_id, statistics in elo_per_team.items():
await update_team_stats(tournament_id, team_id, statistics)
elo_per_team = determine_ranking_for_stage_item(stage_item, ranking)
all_players = await get_all_players_in_tournament(tournament_id)
for player in all_players:
if player.id not in elo_per_player:
await database.execute(
query=players.update().where(
(players.c.id == player.id) & (players.c.tournament_id == tournament_id)
),
values=PlayerStatistics().model_dump(),
)
all_teams = await get_teams_in_tournament(tournament_id)
for team in all_teams:
if team.id not in elo_per_team:
await database.execute(
query=teams.update().where(
(teams.c.id == team.id) & (teams.c.tournament_id == tournament_id)
),
values=PlayerStatistics().model_dump(),
)
for team_id, stage_item_input_id in team_x_stage_item_input_lookup.items():
await update_team_stats(tournament_id, stage_item_input_id, elo_per_team[team_id])

View File

View File

@@ -0,0 +1,12 @@
from decimal import Decimal
from pydantic import BaseModel
START_ELO = Decimal("1200")
class TeamStatistics(BaseModel):
wins: int = 0
draws: int = 0
losses: int = 0
points: Decimal = Decimal("0.00")

View File

@@ -1,25 +1,34 @@
from bracket.logic.ranking.elo import (
determine_team_ranking_for_stage_item,
)
from bracket.logic.ranking.statistics import TeamStatistics
from bracket.models.db.match import MatchWithDetails
from bracket.sql.matches import sql_get_match, sql_update_team_ids_for_match
from bracket.sql.rankings import get_ranking_for_stage_item
from bracket.sql.stage_items import get_stage_item
from bracket.sql.stages import get_full_tournament_details
from bracket.utils.id_types import MatchId, StageId, StageItemId, TeamId, TournamentId
from bracket.utils.types import assert_some
StageItemXTeamRanking = dict[StageItemId, list[tuple[TeamId, TeamStatistics]]]
async def determine_team_id(
tournament_id: TournamentId,
winner_from_stage_item_id: StageItemId | None,
winner_position: int | None,
winner_from_match_id: MatchId | None,
stage_item_x_team_rankings: StageItemXTeamRanking,
) -> TeamId | None:
if winner_from_stage_item_id is not None and winner_position is not None:
stage_item = await get_stage_item(tournament_id, winner_from_stage_item_id)
assert stage_item is not None
"""
Determine the team ID for a match that didn't have a team assigned yet.
team_ranking = determine_team_ranking_for_stage_item(stage_item)
Either return:
- A team that was chosen from a previous stage item ranking, or
- A team that was chosen from a previous match
"""
if winner_from_stage_item_id is not None and winner_position is not None:
team_ranking = stage_item_x_team_rankings[winner_from_stage_item_id]
if len(team_ranking) >= winner_position:
return team_ranking[winner_position - 1][0]
@@ -28,38 +37,57 @@ async def determine_team_id(
if winner_from_match_id is not None:
match = await sql_get_match(winner_from_match_id)
winner_index = match.get_winner_index()
if winner_index is not None:
team_id = match.team1_id if match.get_winner_index() == 1 else match.team2_id
assert team_id is not None
return team_id
return match.team1_id if winner_index == 1 else match.team2_id
return None
raise ValueError("Unexpected match type")
async def set_team_ids_for_match(tournament_id: TournamentId, match: MatchWithDetails) -> None:
async def set_team_ids_for_match(
match: MatchWithDetails, stage_item_x_team_rankings: StageItemXTeamRanking
) -> None:
team1_id = await determine_team_id(
tournament_id,
match.team1_winner_from_stage_item_id,
match.team1_winner_position,
match.team1_winner_from_match_id,
stage_item_x_team_rankings,
)
team2_id = await determine_team_id(
tournament_id,
match.team2_winner_from_stage_item_id,
match.team2_winner_position,
match.team2_winner_from_match_id,
stage_item_x_team_rankings,
)
await sql_update_team_ids_for_match(assert_some(match.id), team1_id, team2_id)
async def get_team_rankings_lookup(tournament_id: TournamentId) -> StageItemXTeamRanking:
stages = await get_full_tournament_details(tournament_id)
stage_items = {
stage_item.id: assert_some(await get_stage_item(tournament_id, stage_item.id))
for stage in stages
for stage_item in stage.stage_items
}
return {
stage_item_id: await determine_team_ranking_for_stage_item(
stage_item,
assert_some(await get_ranking_for_stage_item(tournament_id, stage_item.id)),
)
for stage_item_id, stage_item in stage_items.items()
}
async def update_matches_in_activated_stage(tournament_id: TournamentId, stage_id: StageId) -> None:
[stage] = await get_full_tournament_details(tournament_id, stage_id=stage_id)
stage_item_x_team_rankings = await get_team_rankings_lookup(tournament_id)
for stage_item in stage.stage_items:
for round_ in stage_item.rounds:
for match in round_.matches:
if isinstance(match, MatchWithDetails):
await set_team_ids_for_match(tournament_id, match)
await set_team_ids_for_match(match, stage_item_x_team_rankings)

View File

@@ -27,6 +27,7 @@ class Subscription(BaseModel):
max_stages: int
max_stage_items: int
max_rounds: int
max_rankings: int
demo_subscription = Subscription(
@@ -38,6 +39,7 @@ demo_subscription = Subscription(
max_stages=4,
max_stage_items=6,
max_rounds=6,
max_rankings=2,
)
@@ -50,6 +52,7 @@ regular_subscription = Subscription(
max_stages=16,
max_stage_items=64,
max_rounds=64,
max_rankings=16,
)
subscription_lookup = {

View File

@@ -2,6 +2,7 @@ import aiofiles.os
from bracket.sql.courts import sql_delete_courts_of_tournament
from bracket.sql.players import sql_delete_players_of_tournament
from bracket.sql.rankings import get_all_rankings_in_tournament, sql_delete_ranking
from bracket.sql.shared import sql_delete_stage_item_relations
from bracket.sql.stage_items import sql_delete_stage_item
from bracket.sql.stages import get_full_tournament_details, sql_delete_stage
@@ -37,6 +38,9 @@ async def sql_delete_tournament_completely(tournament_id: TournamentId) -> None:
await sql_delete_stage(tournament_id, assert_some(stage.id))
for ranking in await get_all_rankings_in_tournament(tournament_id):
await sql_delete_ranking(tournament_id, ranking.id)
await sql_delete_players_of_tournament(tournament_id)
await sql_delete_courts_of_tournament(tournament_id)
await sql_delete_teams_of_tournament(tournament_id)

View File

@@ -1,13 +0,0 @@
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

@@ -0,0 +1,36 @@
from decimal import Decimal
from heliclockter import datetime_utc
from pydantic import BaseModel
from bracket.models.db.shared import BaseModelORM
from bracket.utils.id_types import RankingId, TournamentId
class RankingInsertable(BaseModel):
tournament_id: TournamentId
win_points: Decimal
draw_points: Decimal
loss_points: Decimal
add_score_points: bool
position: int
class Ranking(BaseModelORM, RankingInsertable):
id: RankingId
created: datetime_utc
class RankingBody(BaseModel):
win_points: Decimal
draw_points: Decimal
loss_points: Decimal
add_score_points: bool
position: int
class RankingCreateBody(BaseModel):
win_points: Decimal = Decimal("3.0")
draw_points: Decimal = Decimal("1.0")
loss_points: Decimal = Decimal("0.0")
add_score_points: bool = False

View File

@@ -6,7 +6,7 @@ from pydantic import Field, model_validator
from bracket.models.db.shared import BaseModelORM
from bracket.models.db.stage_item_inputs import StageItemInputCreateBody
from bracket.utils.id_types import StageId, StageItemId
from bracket.utils.id_types import RankingId, StageId, StageItemId
from bracket.utils.types import EnumAutoStr
@@ -27,6 +27,7 @@ class StageItemToInsert(BaseModelORM):
created: datetime_utc
type: StageType
team_count: int = Field(ge=2, le=64)
ranking_id: RankingId | None = None
class StageItem(StageItemToInsert):
@@ -35,6 +36,7 @@ class StageItem(StageItemToInsert):
class StageItemUpdateBody(BaseModelORM):
name: str
ranking_id: RankingId
class StageItemActivateNextBody(BaseModelORM):
@@ -47,6 +49,7 @@ class StageItemCreateBody(BaseModelORM):
type: StageType
team_count: int = Field(ge=2, le=64)
inputs: list[StageItemInputCreateBody]
ranking_id: RankingId | None = None
def get_name_or_default_name(self) -> str:
return self.name if self.name is not None else self.type.value.replace("_", " ").title()

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from pydantic import BaseModel, Field
from bracket.models.db.shared import BaseModelORM
@@ -16,6 +18,10 @@ class StageItemInputGeneric(BaseModel):
winner_from_stage_item_id: StageItemId | None = None
winner_position: int | None = None
winner_from_match_id: MatchId | None = None
points: Decimal = Decimal("0.0")
wins: int = 0
draws: int = 0
losses: int = 0
def __hash__(self) -> int:
return (

View File

@@ -8,8 +8,8 @@ from typing import Annotated
from heliclockter import datetime_utc
from pydantic import BaseModel, Field, StringConstraints, field_validator
from bracket.logic.ranking.statistics import START_ELO
from bracket.models.db.player import Player
from bracket.models.db.players import START_ELO
from bracket.models.db.shared import BaseModelORM
from bracket.utils.id_types import PlayerId, TeamId, TournamentId
from bracket.utils.types import assert_some
@@ -21,7 +21,7 @@ class Team(BaseModelORM):
name: str
tournament_id: TournamentId
active: bool
elo_score: Decimal = Decimal(START_ELO)
elo_score: Decimal = START_ELO
swiss_score: Decimal = Decimal("0.0")
wins: int = 0
draws: int = 0
@@ -32,7 +32,7 @@ class Team(BaseModelORM):
class TeamWithPlayers(BaseModel):
id: TeamId | None = None
players: list[Player]
elo_score: Decimal = Decimal(START_ELO)
elo_score: Decimal = START_ELO
swiss_score: Decimal = Decimal("0.0")
wins: int = 0
draws: int = 0

View File

@@ -6,7 +6,9 @@ from bracket.logic.planning.matches import (
reorder_matches_for_court,
schedule_all_unscheduled_matches,
)
from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id
from bracket.logic.ranking.elo import (
recalculate_ranking_for_stage_item_id,
)
from bracket.logic.scheduling.upcoming_matches import (
get_upcoming_matches_for_swiss_round,
)
@@ -27,6 +29,7 @@ from bracket.routes.models import SingleMatchResponse, SuccessResponse, Upcoming
from bracket.routes.util import match_dependency, round_dependency, round_with_matches_dependency
from bracket.sql.courts import get_all_courts_in_tournament
from bracket.sql.matches import sql_create_match, sql_delete_match, sql_update_match
from bracket.sql.rounds import get_round_by_id
from bracket.sql.stages import get_full_tournament_details
from bracket.sql.tournaments import sql_get_tournament
from bracket.sql.validation import check_foreign_keys_belong_to_tournament
@@ -70,8 +73,11 @@ async def delete_match(
_: UserPublic = Depends(user_authenticated_for_tournament),
match: Match = Depends(match_dependency),
) -> SuccessResponse:
round_ = await get_round_by_id(tournament_id, match.round_id)
await sql_delete_match(assert_some(match.id))
await recalculate_ranking_for_tournament_id(tournament_id)
await recalculate_ranking_for_stage_item_id(tournament_id, assert_some(round_).stage_item_id)
return SuccessResponse()
@@ -178,6 +184,7 @@ async def create_matches_automatically(
@router.put("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse)
async def update_match_by_id(
tournament_id: TournamentId,
match_id: MatchId,
match_body: MatchBody,
_: UserPublic = Depends(user_authenticated_for_tournament),
match: Match = Depends(match_dependency),
@@ -185,8 +192,11 @@ async def update_match_by_id(
await check_foreign_keys_belong_to_tournament(match_body, tournament_id)
tournament = await sql_get_tournament(tournament_id)
await sql_update_match(assert_some(match.id), match_body, tournament)
await recalculate_ranking_for_tournament_id(tournament_id)
await sql_update_match(match_id, match_body, tournament)
round_ = await get_round_by_id(tournament_id, match.round_id)
await recalculate_ranking_for_stage_item_id(tournament_id, assert_some(round_).stage_item_id)
if (
match_body.custom_duration_minutes != match.custom_duration_minutes
or match_body.custom_margin_minutes != match.custom_margin_minutes
@@ -194,4 +204,5 @@ async def update_match_by_id(
tournament = await sql_get_tournament(tournament_id)
scheduled_matches = get_scheduled_matches(await get_full_tournament_details(tournament_id))
await reorder_matches_for_court(tournament, scheduled_matches, assert_some(match.court_id))
return SuccessResponse()

View File

@@ -2,10 +2,12 @@ from typing import Generic, TypeVar
from pydantic import BaseModel
from bracket.logic.ranking.statistics import TeamStatistics
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.player import Player
from bracket.models.db.ranking import Ranking
from bracket.models.db.stage_item_inputs import (
StageItemInputOptionFinal,
StageItemInputOptionTentative,
@@ -15,6 +17,7 @@ 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
from bracket.utils.id_types import StageItemId, TeamId
DataT = TypeVar("DataT")
@@ -97,7 +100,15 @@ class SingleCourtResponse(DataResponse[Court]):
pass
class RankingsResponse(DataResponse[list[Ranking]]):
pass
class StageItemInputOptionsResponse(
DataResponse[list[StageItemInputOptionTentative | StageItemInputOptionFinal]]
):
pass
class StageRankingResponse(DataResponse[dict[StageItemId, list[tuple[TeamId, TeamStatistics]]]]):
pass

View File

@@ -0,0 +1,71 @@
from fastapi import APIRouter, Depends
from bracket.logic.subscriptions import check_requirement
from bracket.models.db.ranking import RankingBody, RankingCreateBody
from bracket.models.db.user import UserPublic
from bracket.routes.auth import (
user_authenticated_for_tournament,
user_authenticated_or_public_dashboard,
)
from bracket.routes.models import (
RankingsResponse,
SuccessResponse,
)
from bracket.sql.rankings import (
get_all_rankings_in_tournament,
sql_create_ranking,
sql_delete_ranking,
sql_update_ranking,
)
from bracket.utils.id_types import RankingId, TournamentId
router = APIRouter()
@router.get("/tournaments/{tournament_id}/rankings")
async def get_rankings(
tournament_id: TournamentId,
_: UserPublic = Depends(user_authenticated_or_public_dashboard),
) -> RankingsResponse:
return RankingsResponse(data=await get_all_rankings_in_tournament(tournament_id))
@router.put("/tournaments/{tournament_id}/rankings/{ranking_id}")
async def update_ranking_by_id(
tournament_id: TournamentId,
ranking_id: RankingId,
ranking_body: RankingBody,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
await sql_update_ranking(
tournament_id=tournament_id,
ranking_id=ranking_id,
ranking_body=ranking_body,
)
return SuccessResponse()
@router.delete("/tournaments/{tournament_id}/rankings/{ranking_id}")
async def delete_ranking(
tournament_id: TournamentId,
ranking_id: RankingId,
_: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
await sql_delete_ranking(tournament_id, ranking_id)
return SuccessResponse()
@router.post("/tournaments/{tournament_id}/rankings")
async def create_ranking(
ranking_body: RankingCreateBody,
tournament_id: TournamentId,
user: UserPublic = Depends(user_authenticated_for_tournament),
) -> SuccessResponse:
existing_rankings = await get_all_rankings_in_tournament(tournament_id)
check_requirement(existing_rankings, user, "max_rankings")
highest_position = (
max(x.position for x in existing_rankings) if len(existing_rankings) > 0 else -1
)
await sql_create_ranking(tournament_id, ranking_body, highest_position + 1)
return SuccessResponse()

View File

@@ -2,7 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException
from starlette import status
from bracket.database import database
from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id
from bracket.logic.ranking.elo import (
recalculate_ranking_for_stage_item_id,
)
from bracket.logic.subscriptions import check_requirement
from bracket.models.db.round import (
Round,
@@ -46,7 +48,7 @@ async def delete_round(
rounds.c.id == round_id and rounds.c.tournament_id == tournament_id
),
)
await recalculate_ranking_for_tournament_id(tournament_id)
await recalculate_ranking_for_stage_item_id(tournament_id, round_with_matches.stage_item_id)
return SuccessResponse()

View File

@@ -7,7 +7,6 @@ from bracket.logic.planning.rounds import (
get_active_and_next_rounds,
schedule_all_matches_for_swiss_round,
)
from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id
from bracket.logic.scheduling.builder import (
build_matches_for_stage_item,
)
@@ -53,7 +52,6 @@ async def delete_stage_item(
}
):
await sql_delete_stage_item_with_foreign_keys(stage_item_id)
await recalculate_ranking_for_tournament_id(tournament_id)
return SuccessResponse()

View File

@@ -2,7 +2,9 @@ from fastapi import APIRouter, Depends, HTTPException
from starlette import status
from bracket.database import database
from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id
from bracket.logic.ranking.elo import (
determine_team_ranking_for_stage_item,
)
from bracket.logic.scheduling.builder import determine_available_inputs
from bracket.logic.scheduling.handle_stage_activation import update_matches_in_activated_stage
from bracket.logic.subscriptions import check_requirement
@@ -15,10 +17,13 @@ from bracket.routes.auth import (
)
from bracket.routes.models import (
StageItemInputOptionsResponse,
StageRankingResponse,
StagesWithStageItemsResponse,
SuccessResponse,
)
from bracket.routes.util import stage_dependency
from bracket.sql.rankings import get_ranking_for_stage_item
from bracket.sql.stage_items import get_stage_item
from bracket.sql.stages import (
get_full_tournament_details,
get_next_stage_in_tournament,
@@ -28,6 +33,7 @@ from bracket.sql.stages import (
)
from bracket.sql.teams import get_teams_with_members
from bracket.utils.id_types import StageId, TournamentId
from bracket.utils.types import assert_some
router = APIRouter()
@@ -69,7 +75,6 @@ async def delete_stage(
await sql_delete_stage(tournament_id, stage_id)
await recalculate_ranking_for_tournament_id(tournament_id)
return SuccessResponse()
@@ -140,3 +145,30 @@ async def get_available_inputs(
teams = await get_teams_with_members(tournament_id)
available_inputs = determine_available_inputs(stage_id, teams, stages)
return StageItemInputOptionsResponse(data=available_inputs)
@router.get("/tournaments/{tournament_id}/stages/{stage_id}/rankings")
async def get_rankings(
tournament_id: TournamentId,
stage_id: StageId,
_: UserPublic = Depends(user_authenticated_for_tournament),
stage_without_details: Stage = Depends(stage_dependency),
) -> StageRankingResponse:
"""
Get the rankings for the stage items in this stage.
"""
[stage] = await get_full_tournament_details(tournament_id, stage_id=stage_id)
stage_items = {
stage_item.id: assert_some(await get_stage_item(tournament_id, stage_item.id))
for stage_item in stage.stage_items
}
stage_item_x_ranking = {
stage_item_id: await determine_team_ranking_for_stage_item(
stage_item,
assert_some(await get_ranking_for_stage_item(tournament_id, stage_item.id)),
)
for stage_item_id, stage_item in stage_items.items()
}
return StageRankingResponse(data=stage_item_x_ranking)

View File

@@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, UploadFile
from heliclockter import datetime_utc
from bracket.database import database
from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id
from bracket.logic.subscriptions import check_requirement
from bracket.logic.teams import get_team_logo_path
from bracket.models.db.team import FullTeamWithPlayers, Team, TeamBody, TeamMultiBody, TeamToInsert
@@ -61,7 +60,6 @@ async def update_team_members(
& (players_x_teams.c.team_id == team_id)
),
)
await recalculate_ranking_for_tournament_id(tournament_id)
@router.get("/tournaments/{tournament_id}/teams", response_model=TeamsWithPlayersResponse)
@@ -94,7 +92,6 @@ async def update_team_by_id(
values=team_body.model_dump(exclude={"player_ids"}),
)
await update_team_members(assert_some(team.id), tournament_id, team_body.player_ids)
await recalculate_ranking_for_tournament_id(tournament_id)
return SingleTeamResponse(
data=assert_some(
@@ -162,7 +159,6 @@ async def delete_team(
):
await sql_delete_team(tournament_id, assert_some(team.id))
await recalculate_ranking_for_tournament_id(tournament_id)
return SuccessResponse()

View File

@@ -10,6 +10,7 @@ from bracket.database import database
from bracket.logic.planning.matches import update_start_times_of_matches
from bracket.logic.subscriptions import check_requirement
from bracket.logic.tournaments import get_tournament_logo_path
from bracket.models.db.ranking import RankingCreateBody
from bracket.models.db.tournament import (
TournamentBody,
TournamentUpdateBody,
@@ -23,6 +24,7 @@ from bracket.routes.auth import (
)
from bracket.routes.models import SuccessResponse, TournamentResponse, TournamentsResponse
from bracket.schema import tournaments
from bracket.sql.rankings import sql_create_ranking
from bracket.sql.tournaments import (
sql_create_tournament,
sql_delete_tournament,
@@ -139,10 +141,14 @@ async def create_tournament(
headers={"WWW-Authenticate": "Bearer"},
)
try:
await sql_create_tournament(tournament_to_insert)
except asyncpg.exceptions.UniqueViolationError as exc:
check_unique_constraint_violation(exc, {UniqueIndex.ix_tournaments_dashboard_endpoint})
async with database.transaction():
try:
tournament_id = await sql_create_tournament(tournament_to_insert)
except asyncpg.exceptions.UniqueViolationError as exc:
check_unique_constraint_violation(exc, {UniqueIndex.ix_tournaments_dashboard_endpoint})
ranking = RankingCreateBody()
await sql_create_ranking(tournament_id, ranking, position=0)
return SuccessResponse()

View File

@@ -49,6 +49,7 @@ stage_items = Table(
Column("created", DateTimeTZ, nullable=False, server_default=func.now()),
Column("stage_id", BigInteger, ForeignKey("stages.id"), index=True, nullable=False),
Column("team_count", Integer, nullable=False),
Column("ranking_id", BigInteger, ForeignKey("rankings.id"), nullable=False),
Column(
"type",
Enum(
@@ -77,6 +78,10 @@ stage_item_inputs = Table(
Column("team_id", BigInteger, ForeignKey("teams.id"), nullable=True),
Column("winner_from_stage_item_id", BigInteger, ForeignKey("stage_items.id"), nullable=True),
Column("winner_position", Integer, nullable=True),
Column("points", Float, nullable=False, server_default="0"),
Column("wins", Integer, nullable=False, server_default="0"),
Column("draws", Integer, nullable=False, server_default="0"),
Column("losses", Integer, nullable=False, server_default="0"),
)
rounds = Table(
@@ -204,3 +209,16 @@ courts = Table(
Column("created", DateTimeTZ, nullable=False, server_default=func.now()),
Column("tournament_id", BigInteger, ForeignKey("tournaments.id"), nullable=False, index=True),
)
rankings = Table(
"rankings",
metadata,
Column("id", BigInteger, primary_key=True, index=True),
Column("created", DateTimeTZ, nullable=False, server_default=func.now()),
Column("tournament_id", BigInteger, ForeignKey("tournaments.id"), nullable=False, index=True),
Column("position", Integer, nullable=False),
Column("win_points", Float, nullable=False),
Column("draw_points", Float, nullable=False),
Column("loss_points", Float, nullable=False),
Column("add_score_points", Boolean, nullable=False),
)

View File

@@ -4,8 +4,8 @@ from typing import cast
from heliclockter import datetime_utc
from bracket.database import database
from bracket.logic.ranking.statistics import START_ELO
from bracket.models.db.player import Player, PlayerBody, PlayerToInsert
from bracket.models.db.players import START_ELO, PlayerStatistics
from bracket.schema import players
from bracket.utils.id_types import PlayerId, TournamentId
from bracket.utils.pagination import PaginationPlayers
@@ -77,34 +77,6 @@ async def get_player_count(
return cast(int, await database.fetch_val(query=query, values={"tournament_id": tournament_id}))
async def update_player_stats(
tournament_id: TournamentId, player_id: PlayerId, 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),
},
)
async def sql_delete_player(tournament_id: TournamentId, player_id: PlayerId) -> None:
query = "DELETE FROM players WHERE id = :player_id AND tournament_id = :tournament_id"
await database.fetch_one(
@@ -124,7 +96,7 @@ async def insert_player(player_body: PlayerBody, tournament_id: TournamentId) ->
**player_body.model_dump(),
created=datetime_utc.now(),
tournament_id=tournament_id,
elo_score=Decimal(START_ELO),
elo_score=START_ELO,
swiss_score=Decimal("0.0"),
).model_dump(),
)

View File

@@ -0,0 +1,107 @@
from bracket.database import database
from bracket.models.db.ranking import Ranking, RankingBody, RankingCreateBody
from bracket.utils.id_types import RankingId, StageItemId, TournamentId
async def get_all_rankings_in_tournament(tournament_id: TournamentId) -> list[Ranking]:
query = """
SELECT *
FROM rankings
WHERE rankings.tournament_id = :tournament_id
ORDER BY position
"""
result = await database.fetch_all(query=query, values={"tournament_id": tournament_id})
return [Ranking.model_validate(dict(x._mapping)) for x in result]
async def get_default_rankings_in_tournament(tournament_id: TournamentId) -> Ranking:
query = """
SELECT *
FROM rankings
WHERE rankings.tournament_id = :tournament_id
ORDER BY position
LIMIT 1
"""
result = await database.fetch_one(query=query, values={"tournament_id": tournament_id})
assert result is not None, "No default ranking found"
return Ranking.model_validate(dict(result._mapping))
async def get_ranking_for_stage_item(
tournament_id: TournamentId, stage_item_id: StageItemId
) -> Ranking | None:
query = """
SELECT rankings.*
FROM rankings
JOIN stage_items ON stage_items.ranking_id = rankings.id
WHERE rankings.tournament_id = :tournament_id
AND stage_items.id = :stage_item_id
"""
result = await database.fetch_one(
query=query, values={"tournament_id": tournament_id, "stage_item_id": stage_item_id}
)
return Ranking.model_validate(dict(result._mapping)) if result else None
async def sql_update_ranking(
tournament_id: TournamentId, ranking_id: RankingId, ranking_body: RankingBody
) -> list[Ranking]:
query = """
UPDATE rankings
SET position = :position,
win_points = :win_points,
draw_points = :draw_points,
loss_points = :loss_points,
add_score_points = :add_score_points
WHERE rankings.tournament_id = :tournament_id
AND rankings.id = :ranking_id
"""
result = await database.fetch_all(
query=query,
values={
"ranking_id": ranking_id,
"tournament_id": tournament_id,
"win_points": float(ranking_body.win_points),
"draw_points": float(ranking_body.draw_points),
"loss_points": float(ranking_body.loss_points),
"add_score_points": ranking_body.add_score_points,
"position": ranking_body.position,
},
)
return [Ranking.model_validate(dict(x._mapping)) for x in result]
async def sql_delete_ranking(tournament_id: TournamentId, ranking_id: RankingId) -> None:
query = "DELETE FROM rankings WHERE id = :ranking_id AND tournament_id = :tournament_id"
await database.fetch_one(
query=query, values={"ranking_id": ranking_id, "tournament_id": tournament_id}
)
async def sql_create_ranking(
tournament_id: TournamentId, ranking_body: RankingCreateBody, position: int
) -> None:
query = """
INSERT INTO rankings
(tournament_id, position, win_points, draw_points, loss_points, add_score_points)
VALUES (
:tournament_id,
:position,
:win_points,
:draw_points,
:loss_points,
:add_score_points
)
"""
await database.execute(
query=query,
values={
"tournament_id": tournament_id,
"win_points": float(ranking_body.win_points),
"draw_points": float(ranking_body.draw_points),
"loss_points": float(ranking_body.loss_points),
"add_score_points": ranking_body.add_score_points,
"position": position,
},
)

View File

@@ -1,6 +1,7 @@
from bracket.database import database
from bracket.models.db.stage_item import StageItem, StageItemCreateBody
from bracket.models.db.util import StageItemWithRounds
from bracket.sql.rankings import get_default_rankings_in_tournament
from bracket.sql.stage_item_inputs import sql_create_stage_item_input
from bracket.sql.stages import get_full_tournament_details
from bracket.utils.id_types import StageItemId, TournamentId
@@ -11,8 +12,8 @@ async def sql_create_stage_item(
) -> 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)
INSERT INTO stage_items (type, stage_id, name, team_count, ranking_id)
VALUES (:stage_item_type, :stage_id, :name, :team_count, :ranking_id)
RETURNING *
"""
result = await database.fetch_one(
@@ -22,6 +23,9 @@ async def sql_create_stage_item(
"stage_id": stage_item.stage_id,
"name": stage_item.get_name_or_default_name(),
"team_count": stage_item.team_count,
"ranking_id": stage_item.ranking_id
if stage_item.ranking_id
else (await get_default_rankings_in_tournament(tournament_id)).id,
},
)

View File

@@ -1,9 +1,9 @@
from typing import cast
from bracket.database import database
from bracket.models.db.players import PlayerStatistics
from bracket.logic.ranking.statistics import TeamStatistics
from bracket.models.db.team import FullTeamWithPlayers, Team
from bracket.utils.id_types import TeamId, TournamentId
from bracket.utils.id_types import StageItemInputId, TeamId, TournamentId
from bracket.utils.pagination import PaginationTeams
from bracket.utils.types import dict_without_none
@@ -74,24 +74,6 @@ async def get_teams_with_members(
return [FullTeamWithPlayers.model_validate(x) for x in result]
async def get_teams_in_tournament(
tournament_id: TournamentId,
) -> list[Team]:
query = """
SELECT *
FROM teams
WHERE teams.tournament_id = :tournament_id
ORDER BY name ASC
"""
values = dict_without_none(
{
"tournament_id": tournament_id,
}
)
result = await database.fetch_all(query=query, values=values)
return [Team.model_validate(x) for x in result]
async def get_team_count(
tournament_id: TournamentId,
*,
@@ -109,29 +91,29 @@ async def get_team_count(
async def update_team_stats(
tournament_id: TournamentId, team_id: TeamId, team_statistics: PlayerStatistics
tournament_id: TournamentId,
stage_item_input_id: StageItemInputId,
team_statistics: TeamStatistics,
) -> None:
query = """
UPDATE teams
UPDATE stage_item_inputs
SET
wins = :wins,
draws = :draws,
losses = :losses,
elo_score = :elo_score,
swiss_score = :swiss_score
WHERE teams.tournament_id = :tournament_id
AND teams.id = :team_id
points = :points
WHERE stage_item_inputs.tournament_id = :tournament_id
AND stage_item_inputs.id = :stage_item_input_id
"""
await database.execute(
query=query,
values={
"tournament_id": tournament_id,
"team_id": team_id,
"stage_item_input_id": stage_item_input_id,
"wins": team_statistics.wins,
"draws": team_statistics.draws,
"losses": team_statistics.losses,
"elo_score": team_statistics.elo_score,
"swiss_score": float(team_statistics.swiss_score),
"points": float(team_statistics.points),
},
)

View File

@@ -76,7 +76,7 @@ async def sql_update_tournament(
)
async def sql_create_tournament(tournament: TournamentBody) -> None:
async def sql_create_tournament(tournament: TournamentBody) -> TournamentId:
query = """
INSERT INTO tournaments (
name,
@@ -102,8 +102,7 @@ async def sql_create_tournament(tournament: TournamentBody) -> None:
:duration_minutes,
:margin_minutes
)
RETURNING id
"""
await database.execute(
query=query,
values=tournament.model_dump(),
)
new_id = await database.fetch_val(query=query, values=tournament.model_dump())
return TournamentId(new_id)

View File

@@ -5,7 +5,9 @@ from heliclockter import datetime_utc
from bracket.config import Environment, config, environment
from bracket.database import database, engine
from bracket.logic.ranking.elo import recalculate_ranking_for_tournament_id
from bracket.logic.ranking.elo import (
recalculate_ranking_for_stage_item_id,
)
from bracket.logic.scheduling.builder import build_matches_for_stage_item
from bracket.models.db.account import UserAccountType
from bracket.models.db.club import Club
@@ -13,6 +15,7 @@ from bracket.models.db.court import Court
from bracket.models.db.match import Match, MatchBody
from bracket.models.db.player import Player
from bracket.models.db.player_x_team import PlayerXTeam
from bracket.models.db.ranking import RankingInsertable
from bracket.models.db.round import Round
from bracket.models.db.stage import Stage
from bracket.models.db.stage_item import StageItem, StageItemCreateBody
@@ -31,6 +34,7 @@ from bracket.schema import (
metadata,
players,
players_x_teams,
rankings,
rounds,
stage_items,
stages,
@@ -59,6 +63,7 @@ from bracket.utils.dummy_records import (
DUMMY_PLAYER7,
DUMMY_PLAYER8,
DUMMY_PLAYER_X_TEAM,
DUMMY_RANKING1,
DUMMY_STAGE1,
DUMMY_STAGE2,
DUMMY_STAGE_ITEM1,
@@ -76,6 +81,7 @@ from bracket.utils.id_types import (
CourtId,
PlayerId,
PlayerXTeamId,
RankingId,
StageId,
TeamId,
TournamentId,
@@ -150,6 +156,7 @@ async def sql_create_dev_db() -> UserId:
Tournament: tournaments,
Court: courts,
StageItem: stage_items,
RankingInsertable: rankings,
}
async def insert_dummy(
@@ -180,6 +187,8 @@ async def sql_create_dev_db() -> UserId:
stage_id_1 = await insert_dummy(DUMMY_STAGE1, StageId, {"tournament_id": tournament_id_1})
stage_id_2 = await insert_dummy(DUMMY_STAGE2, StageId, {"tournament_id": tournament_id_1})
await insert_dummy(DUMMY_RANKING1, RankingId, {"tournament_id": tournament_id_1})
team_id_1 = await insert_dummy(DUMMY_TEAM1, TeamId, {"tournament_id": tournament_id_1})
team_id_2 = await insert_dummy(DUMMY_TEAM2, TeamId, {"tournament_id": tournament_id_1})
team_id_3 = await insert_dummy(DUMMY_TEAM3, TeamId, {"tournament_id": tournament_id_1})
@@ -391,7 +400,7 @@ async def sql_create_dev_db() -> UserId:
tournament=tournament_details,
)
for tournament in await database.fetch_all(tournaments.select()):
await recalculate_ranking_for_tournament_id(tournament.id) # type: ignore[attr-defined]
for _stage_item in (stage_item_1, stage_item_2, stage_item_3):
await recalculate_ranking_for_stage_item_id(tournament_id_1, _stage_item.id)
return user_id_1

View File

@@ -1,3 +1,4 @@
from decimal import Decimal
from zoneinfo import ZoneInfo
from heliclockter import datetime_utc
@@ -8,6 +9,7 @@ from bracket.models.db.court import Court
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.ranking import RankingInsertable
from bracket.models.db.round import Round
from bracket.models.db.stage import Stage
from bracket.models.db.stage_item import StageItemToInsert, StageType
@@ -18,6 +20,7 @@ from bracket.utils.id_types import (
ClubId,
CourtId,
PlayerId,
RankingId,
RoundId,
StageId,
StageItemId,
@@ -66,6 +69,7 @@ DUMMY_STAGE2 = Stage(
DUMMY_STAGE_ITEM1 = StageItemToInsert(
stage_id=StageId(DB_PLACEHOLDER_ID),
ranking_id=RankingId(DB_PLACEHOLDER_ID),
created=DUMMY_MOCK_TIME,
type=StageType.ROUND_ROBIN,
team_count=4,
@@ -74,6 +78,7 @@ DUMMY_STAGE_ITEM1 = StageItemToInsert(
DUMMY_STAGE_ITEM2 = StageItemToInsert(
stage_id=StageId(DB_PLACEHOLDER_ID),
ranking_id=RankingId(DB_PLACEHOLDER_ID),
created=DUMMY_MOCK_TIME,
type=StageType.ROUND_ROBIN,
team_count=4,
@@ -82,6 +87,7 @@ DUMMY_STAGE_ITEM2 = StageItemToInsert(
DUMMY_STAGE_ITEM3 = StageItemToInsert(
stage_id=StageId(DB_PLACEHOLDER_ID),
ranking_id=RankingId(DB_PLACEHOLDER_ID),
created=DUMMY_MOCK_TIME,
type=StageType.SINGLE_ELIMINATION,
team_count=4,
@@ -240,3 +246,12 @@ DUMMY_COURT2 = Court(
created=DUMMY_MOCK_TIME,
tournament_id=TournamentId(DB_PLACEHOLDER_ID),
)
DUMMY_RANKING1 = RankingInsertable(
tournament_id=TournamentId(DB_PLACEHOLDER_ID),
win_points=Decimal("1.0"),
draw_points=Decimal("0.5"),
loss_points=Decimal("0.0"),
add_score_points=False,
position=0,
)

View File

@@ -1,15 +1,16 @@
from typing import NewType
ClubId = NewType("ClubId", int)
TournamentId = NewType("TournamentId", int)
CourtId = NewType("CourtId", int)
MatchId = NewType("MatchId", int)
PlayerId = NewType("PlayerId", int)
PlayerXTeamId = NewType("PlayerXTeamId", int)
RankingId = NewType("RankingId", int)
RoundId = NewType("RoundId", int)
StageId = NewType("StageId", int)
StageItemId = NewType("StageItemId", int)
StageItemInputId = NewType("StageItemInputId", int)
MatchId = NewType("MatchId", int)
RoundId = NewType("RoundId", int)
TeamId = NewType("TeamId", int)
PlayerId = NewType("PlayerId", int)
CourtId = NewType("CourtId", int)
TournamentId = NewType("TournamentId", int)
UserId = NewType("UserId", int)
PlayerXTeamId = NewType("PlayerXTeamId", int)
UserXClubId = NewType("UserXClubId", int)

View File

@@ -2,7 +2,7 @@
set -evo pipefail
ruff format .
ruff --fix .
ruff check --fix .
! vulture | grep "unused function\|unused class\|unused method"
dmypy run -- --follow-imports=normal --junit-xml= .
ENVIRONMENT=CI pytest --cov --cov-report=xml . -vvv

View File

@@ -28,7 +28,11 @@ async def test_available_inputs(
# inserted_stage(
# DUMMY_STAGE2.model_copy(update={'tournament_id': auth_context.tournament.id})
# ) as stage_inserted_2,
inserted_stage_item(DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted_1.id})),
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted_1.id, "ranking_id": auth_context.ranking.id}
)
),
):
response = await send_tournament_request(
HTTPMethod.GET, f"stages/{stage_inserted_1.id}/available_inputs", auth_context

View File

@@ -42,7 +42,9 @@ async def test_create_match(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
inserted_round(
DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id})
@@ -79,7 +81,9 @@ async def test_delete_match(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
inserted_round(
DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id})
@@ -121,7 +125,9 @@ async def test_update_match(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
inserted_round(
DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id})
@@ -182,7 +188,9 @@ async def test_update_endpoint_custom_duration_margin(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
inserted_round(
DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id})
@@ -249,7 +257,11 @@ async def test_upcoming_matches_endpoint(
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "type": StageType.SWISS}
update={
"stage_id": stage_inserted.id,
"type": StageType.SWISS,
"ranking_id": auth_context.ranking.id,
}
)
) as stage_item_inserted,
inserted_round(

View File

@@ -0,0 +1,96 @@
from decimal import Decimal
from unittest.mock import ANY
from bracket.database import database
from bracket.models.db.ranking import Ranking
from bracket.schema import rankings
from bracket.sql.rankings import (
get_all_rankings_in_tournament,
sql_delete_ranking,
)
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.dummy_records import DUMMY_RANKING1, DUMMY_TEAM1
from bracket.utils.http import HTTPMethod
from bracket.utils.types import assert_some
from tests.integration_tests.api.shared import SUCCESS_RESPONSE, send_tournament_request
from tests.integration_tests.models import AuthContext
from tests.integration_tests.sql import inserted_ranking, inserted_team
async def test_rankings_endpoint(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with inserted_team(
DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id})
):
assert await send_tournament_request(HTTPMethod.GET, "rankings", auth_context, {}) == {
"data": [
{
"created": ANY,
"id": auth_context.ranking.id,
"position": 0,
"win_points": "1.0",
"draw_points": "0.5",
"loss_points": "0.0",
"add_score_points": False,
"tournament_id": auth_context.tournament.id,
}
],
}
async def test_create_ranking(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
response = await send_tournament_request(HTTPMethod.POST, "rankings", auth_context, json={})
assert response.get("success") is True, response
tournament_id = assert_some(auth_context.tournament.id)
for ranking in await get_all_rankings_in_tournament(tournament_id):
if ranking.position != 0:
await sql_delete_ranking(tournament_id, ranking.id)
async def test_delete_ranking(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with inserted_team(
DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id})
):
async with inserted_ranking(
DUMMY_RANKING1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as ranking_inserted:
assert (
await send_tournament_request(
HTTPMethod.DELETE, f"rankings/{ranking_inserted.id}", auth_context
)
== SUCCESS_RESPONSE
)
async def test_update_ranking(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
body = {
"win_points": "7.5",
"draw_points": "2.5",
"loss_points": "6.0",
"add_score_points": True,
"position": 42,
}
async with inserted_team(
DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id})
):
async with inserted_ranking(
DUMMY_RANKING1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as ranking_inserted:
response = await send_tournament_request(
HTTPMethod.PUT, f"rankings/{ranking_inserted.id}", auth_context, json=body
)
updated_ranking = await fetch_one_parsed_certain(
database,
Ranking,
query=rankings.select().where(rankings.c.id == ranking_inserted.id),
)
assert response["success"] is True
assert updated_ranking.win_points == Decimal("7.5")

View File

@@ -34,7 +34,9 @@ async def test_reschedule_match(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
inserted_round(
DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id})

View File

@@ -26,7 +26,11 @@ async def test_create_round(
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "type": StageType.SWISS}
update={
"stage_id": stage_inserted.id,
"type": StageType.SWISS,
"ranking_id": auth_context.ranking.id,
}
)
) as stage_item_inserted,
):
@@ -51,7 +55,9 @@ async def test_delete_round(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
inserted_round(
DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id})
@@ -76,7 +82,9 @@ async def test_update_round(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
inserted_round(
DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id})

View File

@@ -70,7 +70,9 @@ async def test_delete_stage_item(
DUMMY_STAGE2.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted_1,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted_1.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted_1.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
):
assert (
@@ -85,13 +87,15 @@ async def test_delete_stage_item(
async def test_update_stage_item(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
body = {"name": "Optimus"}
body = {"name": "Optimus", "ranking_id": auth_context.ranking.id}
async with (
inserted_stage(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
):
assert (

View File

@@ -37,7 +37,9 @@ async def test_stages_endpoint(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
inserted_round(
DUMMY_ROUND1.model_copy(update={"stage_item_id": stage_item_inserted.id})
@@ -62,6 +64,7 @@ async def test_stages_endpoint(
{
"id": stage_item_inserted.id,
"stage_id": stage_inserted.id,
"ranking_id": auth_context.ranking.id,
"name": "Group A",
"created": DUMMY_MOCK_TIME.isoformat().replace("+00:00", "Z"),
"type": "ROUND_ROBIN",
@@ -132,7 +135,11 @@ async def test_update_stage(
inserted_stage(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(DUMMY_STAGE_ITEM1.model_copy(update={"stage_id": stage_inserted.id})),
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
),
):
assert (
await send_tournament_request(
@@ -174,3 +181,24 @@ async def test_activate_stage(
await assert_row_count_and_clear(stage_items, 1)
await assert_row_count_and_clear(stages, 1)
async def test_get_rankings(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
async with (
inserted_team(DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id})),
inserted_stage(
DUMMY_STAGE1.model_copy(update={"tournament_id": auth_context.tournament.id})
) as stage_inserted,
inserted_stage_item(
DUMMY_STAGE_ITEM1.model_copy(
update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id}
)
) as stage_item_inserted,
):
response = await send_tournament_request(
HTTPMethod.GET, f"stages/{stage_inserted.id}/rankings", auth_context
)
assert response == {"data": {f"{stage_item_inserted.id}": []}}

View File

@@ -3,9 +3,10 @@ import aiofiles.os
import aiohttp
from bracket.database import database
from bracket.logic.tournaments import sql_delete_tournament_completely
from bracket.models.db.tournament import Tournament
from bracket.schema import tournaments
from bracket.sql.tournaments import sql_delete_tournament
from bracket.sql.tournaments import sql_delete_tournament, sql_get_tournament_by_endpoint_name
from bracket.utils.db import fetch_one_parsed_certain
from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_TOURNAMENT
from bracket.utils.http import HTTPMethod
@@ -68,11 +69,13 @@ async def test_tournament_endpoint(
async def test_create_tournament(
startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext
) -> None:
dashboard_endpoint = "some-new-endpoint"
body = {
"name": "Some new name",
"start_time": DUMMY_MOCK_TIME.isoformat().replace("+00:00", "Z"),
"club_id": auth_context.club.id,
"dashboard_public": False,
"dashboard_public": True,
"dashboard_endpoint": dashboard_endpoint,
"players_can_be_in_multiple_teams": True,
"auto_assign_courts": True,
"duration_minutes": 12,
@@ -82,7 +85,10 @@ async def test_create_tournament(
await send_auth_request(HTTPMethod.POST, "tournaments", auth_context, json=body)
== SUCCESS_RESPONSE
)
await database.execute(tournaments.delete().where(tournaments.c.name == body["name"]))
# Cleanup
tournament = assert_some(await sql_get_tournament_by_endpoint_name(dashboard_endpoint))
await sql_delete_tournament_completely(assert_some(tournament.id))
async def test_update_tournament(

View File

@@ -1,6 +1,7 @@
from pydantic import BaseModel
from bracket.models.db.club import Club
from bracket.models.db.ranking import Ranking
from bracket.models.db.tournament import Tournament
from bracket.models.db.user import User
from bracket.models.db.user_x_club import UserXClub
@@ -12,3 +13,4 @@ class AuthContext(BaseModel):
user: User
user_x_club: UserXClub
headers: dict[str, str]
ranking: Ranking

View File

@@ -10,6 +10,7 @@ from bracket.models.db.court import Court
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.ranking import Ranking, RankingInsertable
from bracket.models.db.round import Round
from bracket.models.db.stage import Stage
from bracket.models.db.stage_item import StageItem, StageItemToInsert
@@ -23,6 +24,7 @@ from bracket.schema import (
matches,
players,
players_x_teams,
rankings,
rounds,
stage_items,
stages,
@@ -32,7 +34,7 @@ from bracket.schema import (
users_x_clubs,
)
from bracket.utils.db import insert_generic
from bracket.utils.dummy_records import DUMMY_CLUB, DUMMY_TOURNAMENT
from bracket.utils.dummy_records import DUMMY_CLUB, DUMMY_RANKING1, DUMMY_TOURNAMENT
from bracket.utils.id_types import TeamId
from bracket.utils.types import BaseModelT, assert_some
from tests.integration_tests.mocks import get_mock_token, get_mock_user
@@ -86,6 +88,12 @@ async def inserted_court(court: Court) -> AsyncIterator[Court]:
yield row_inserted
@asynccontextmanager
async def inserted_ranking(ranking: RankingInsertable) -> AsyncIterator[Ranking]:
async with inserted_generic(ranking, rankings, Ranking) as row_inserted:
yield cast(Ranking, row_inserted)
@asynccontextmanager
async def inserted_player(player: Player) -> AsyncIterator[Player]:
async with inserted_generic(player, players, Player) as row_inserted:
@@ -143,6 +151,9 @@ async def inserted_auth_context() -> AsyncIterator[AuthContext]:
inserted_tournament(
DUMMY_TOURNAMENT.model_copy(update={"club_id": club_inserted.id})
) as tournament_inserted,
inserted_ranking(
DUMMY_RANKING1.model_copy(update={"tournament_id": tournament_inserted.id})
) as ranking_inserted,
inserted_user_x_club(
UserXClub(
user_id=user_inserted.id,
@@ -157,4 +168,5 @@ async def inserted_auth_context() -> AsyncIterator[AuthContext]:
club=club_inserted,
tournament=tournament_inserted,
user_x_club=user_x_club_inserted,
ranking=ranking_inserted,
)

View File

@@ -1,91 +0,0 @@
from decimal import Decimal
from bracket.logic.ranking.elo import (
determine_ranking_for_stage_items,
)
from bracket.models.db.match import MatchWithDetailsDefinitive
from bracket.models.db.players import PlayerStatistics
from bracket.models.db.team import FullTeamWithPlayers
from bracket.models.db.util import RoundWithMatches, StageItemWithRounds
from bracket.utils.dummy_records import (
DUMMY_MOCK_TIME,
DUMMY_PLAYER1,
DUMMY_PLAYER2,
DUMMY_STAGE_ITEM1,
)
from bracket.utils.id_types import RoundId, StageItemId, TeamId, TournamentId
def test_elo_calculation() -> None:
round_ = RoundWithMatches(
stage_item_id=StageItemId(1),
created=DUMMY_MOCK_TIME,
is_draft=False,
is_active=False,
name="Some round",
matches=[
MatchWithDetailsDefinitive(
created=DUMMY_MOCK_TIME,
start_time=DUMMY_MOCK_TIME,
team1_id=TeamId(1),
team2_id=TeamId(1),
team1_winner_from_stage_item_id=None,
team1_winner_position=None,
team1_winner_from_match_id=None,
team2_winner_from_stage_item_id=None,
team2_winner_position=None,
team2_winner_from_match_id=None,
team1_score=3,
team2_score=4,
round_id=RoundId(1),
court_id=None,
court=None,
duration_minutes=10,
margin_minutes=5,
custom_duration_minutes=None,
custom_margin_minutes=None,
position_in_schedule=0,
team1=FullTeamWithPlayers(
id=TeamId(3),
name="Dummy team 1",
tournament_id=TournamentId(1),
active=True,
created=DUMMY_MOCK_TIME,
players=[DUMMY_PLAYER1.model_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(
id=TeamId(4),
name="Dummy team 2",
tournament_id=TournamentId(1),
active=True,
created=DUMMY_MOCK_TIME,
players=[DUMMY_PLAYER2.model_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,
),
)
],
)
stage_item = StageItemWithRounds(
**DUMMY_STAGE_ITEM1.model_dump(exclude={"id"}),
id=StageItemId(-1),
inputs=[],
rounds=[round_],
)
player_stats, team_stats = determine_ranking_for_stage_items([stage_item])
assert player_stats == {
1: PlayerStatistics(losses=1, elo_score=1184, swiss_score=Decimal("0.00")),
2: PlayerStatistics(wins=1, elo_score=1216, swiss_score=Decimal("1.00")),
}
assert team_stats == {
3: PlayerStatistics(losses=1, elo_score=1184, swiss_score=Decimal("0.00")),
4: PlayerStatistics(wins=1, elo_score=1216, swiss_score=Decimal("1.00")),
}

View File

@@ -0,0 +1,220 @@
from decimal import Decimal
from heliclockter import datetime_utc
from bracket.logic.ranking.elo import determine_ranking_for_stage_item
from bracket.logic.ranking.statistics import TeamStatistics
from bracket.models.db.match import MatchWithDetails, MatchWithDetailsDefinitive
from bracket.models.db.ranking import Ranking
from bracket.models.db.stage_item import StageType
from bracket.models.db.stage_item_inputs import StageItemInputFinal
from bracket.models.db.team import FullTeamWithPlayers
from bracket.models.db.util import RoundWithMatches, StageItemWithRounds
from bracket.utils.dummy_records import DUMMY_TEAM1, DUMMY_TEAM2
from bracket.utils.id_types import RankingId, RoundId, StageId, StageItemId, TeamId, TournamentId
def test_determine_ranking_for_stage_item_elimination() -> None:
tournament_id = TournamentId(-1)
now = datetime_utc.now()
ranking = determine_ranking_for_stage_item(
StageItemWithRounds(
rounds=[
RoundWithMatches(
matches=[
MatchWithDetailsDefinitive(
team1=FullTeamWithPlayers(
**DUMMY_TEAM1.model_dump(), players=[], id=TeamId(-1)
),
team2=FullTeamWithPlayers(
**DUMMY_TEAM2.model_dump(), players=[], id=TeamId(-2)
),
created=now,
duration_minutes=90,
margin_minutes=15,
round_id=RoundId(-1),
team1_score=2,
team2_score=0,
),
MatchWithDetailsDefinitive(
team1=FullTeamWithPlayers(
**DUMMY_TEAM1.model_dump(), players=[], id=TeamId(-1)
),
team2=FullTeamWithPlayers(
**DUMMY_TEAM2.model_dump(), players=[], id=TeamId(-2)
),
created=now,
duration_minutes=90,
margin_minutes=15,
round_id=RoundId(-1),
team1_score=2,
team2_score=2,
),
MatchWithDetails( # This gets ignored in ranking calculation
created=now,
duration_minutes=90,
margin_minutes=15,
round_id=RoundId(-1),
team1_score=3,
team2_score=2,
),
],
stage_item_id=StageItemId(-1),
created=now,
is_draft=False,
name="",
)
],
inputs=[
StageItemInputFinal(team_id=TeamId(-1), slot=1, tournament_id=tournament_id),
StageItemInputFinal(team_id=TeamId(-2), slot=2, tournament_id=tournament_id),
],
type_name="Single Elimination",
team_count=4,
ranking_id=None,
id=StageItemId(-1),
stage_id=StageId(-1),
name="",
created=now,
type=StageType.SINGLE_ELIMINATION,
),
Ranking(
id=RankingId(-1),
tournament_id=tournament_id,
created=now,
win_points=Decimal("3.5"),
draw_points=Decimal("1.25"),
loss_points=Decimal("0.0"),
add_score_points=False,
position=0,
),
)
assert ranking == {
-2: TeamStatistics(wins=0, draws=1, losses=1, points=Decimal("1.25")),
-1: TeamStatistics(wins=1, draws=1, losses=0, points=Decimal("4.75")),
}
def test_determine_ranking_for_stage_item_swiss() -> None:
tournament_id = TournamentId(-1)
now = datetime_utc.now()
ranking = determine_ranking_for_stage_item(
StageItemWithRounds(
rounds=[
RoundWithMatches(
matches=[
MatchWithDetailsDefinitive(
team1=FullTeamWithPlayers(
**DUMMY_TEAM1.model_dump(), players=[], id=TeamId(-1)
),
team2=FullTeamWithPlayers(
**DUMMY_TEAM2.model_dump(), players=[], id=TeamId(-2)
),
created=now,
duration_minutes=90,
margin_minutes=15,
round_id=RoundId(-1),
team1_score=2,
team2_score=0,
),
MatchWithDetailsDefinitive(
team1=FullTeamWithPlayers(
**DUMMY_TEAM1.model_dump(), players=[], id=TeamId(-1)
),
team2=FullTeamWithPlayers(
**DUMMY_TEAM2.model_dump(), players=[], id=TeamId(-2)
),
created=now,
duration_minutes=90,
margin_minutes=15,
round_id=RoundId(-1),
team1_score=2,
team2_score=2,
),
MatchWithDetails( # This gets ignored in ranking calculation
created=now,
duration_minutes=90,
margin_minutes=15,
round_id=RoundId(-1),
team1_score=3,
team2_score=2,
),
],
stage_item_id=StageItemId(-1),
created=now,
is_draft=False,
name="",
)
],
inputs=[
StageItemInputFinal(team_id=TeamId(-1), slot=1, tournament_id=tournament_id),
StageItemInputFinal(team_id=TeamId(-2), slot=2, tournament_id=tournament_id),
],
type_name="Swiss",
team_count=4,
ranking_id=None,
id=StageItemId(-1),
stage_id=StageId(-1),
name="",
created=now,
type=StageType.SWISS,
),
Ranking(
id=RankingId(-1),
tournament_id=tournament_id,
created=now,
win_points=Decimal("3.5"),
draw_points=Decimal("1.25"),
loss_points=Decimal("0.0"),
add_score_points=False,
position=0,
),
)
assert ranking == {
-2: TeamStatistics(wins=0, draws=1, losses=1, points=Decimal("1208")),
-1: TeamStatistics(wins=1, draws=1, losses=0, points=Decimal("1320")),
}
def test_determine_ranking_for_stage_item_swiss_no_matches() -> None:
tournament_id = TournamentId(-1)
now = datetime_utc.now()
ranking = determine_ranking_for_stage_item(
StageItemWithRounds(
rounds=[
RoundWithMatches(
matches=[],
stage_item_id=StageItemId(-1),
created=now,
is_draft=False,
name="",
)
],
inputs=[
StageItemInputFinal(team_id=TeamId(-1), slot=1, tournament_id=tournament_id),
StageItemInputFinal(team_id=TeamId(-2), slot=2, tournament_id=tournament_id),
],
type_name="Swiss",
team_count=4,
ranking_id=None,
id=StageItemId(-1),
stage_id=StageId(-1),
name="",
created=now,
type=StageType.SWISS,
),
Ranking(
id=RankingId(-1),
tournament_id=tournament_id,
created=now,
win_points=Decimal("3.5"),
draw_points=Decimal("1.25"),
loss_points=Decimal("0.0"),
add_score_points=False,
position=0,
),
)
assert not ranking

View File

@@ -15,6 +15,8 @@
"active_teams_checkbox_label": "These teams are active",
"add_court_title": "Add Court",
"add_player_button": "Add Player",
"add_ranking_button": "Add Ranking",
"add_ranking_title": "Add Ranking",
"add_round_button": "Add Round",
"add_stage_button": "Add Stage",
"add_stage_item_modal_title": "Add Stage Item",
@@ -65,6 +67,9 @@
"delete_button": "Delete",
"delete_club_button": "Delete Club",
"delete_court_button": "Delete Court",
"default_ranking_badge": "Default Ranking",
"default_label": "Default",
"delete_ranking_button": "Delete Ranking",
"delete_player_button": "Delete Player",
"delete_round_button": "Delete Round",
"delete_team_button": "Delete Team",
@@ -72,6 +77,7 @@
"demo_description": "To test Bracket, you can start a demo. A demo will last for 30 minutes, after which your demo account be deleted. Please make fair use of it.",
"demo_policy_title": "Demo policy",
"draft_round_checkbox_label": "This round is a draft round",
"draw_points_input_label": "Points for a draw",
"drop_match_alert_title": "Drop a match here",
"dropzone_accept_text": "Drop files here",
"dropzone_idle_text": "Upload Logo",
@@ -112,6 +118,8 @@
"logo_settings_title": "Logo Settings",
"logout_success_title": "Logout successful",
"logout_title": "Logout",
"loss_points_input_label": "Points for a loss",
"add_score_points_label": "Award points for match score",
"lowercase_required": "Includes lowercase letter",
"margin_minutes_choose_title": "Please choose a margin between matches",
"match_duration_label": "Match duration (minutes)",
@@ -120,6 +128,7 @@
"match_filter_option_past": "Hide past matches",
"max_results_input_label": "Max results",
"members_table_header": "Members",
"points_table_header": "Points",
"minutes": "minutes",
"miscellaneous_label": "Allow players to be in multiple teams",
"miscellaneous_title": "Miscellaneous",
@@ -144,13 +153,13 @@
"no_matches_description": "First, add matches by creating stages and stage items. Then, schedule them using the button in the topright corner.",
"no_matches_title": "No matches scheduled yet",
"no_players_title": "No players yet",
"no_teams_title": "No teams yet",
"no_round_description": "There are no rounds in this stage item yet",
"no_round_found_description": "Please wait for the organiser to add them.",
"no_round_found_in_stage_description": "There are no rounds in this stage yet",
"no_round_found_title": "No rounds found",
"no_round_title": "No round",
"no_team_members_description": "No members",
"no_teams_title": "No teams yet",
"none": "None",
"not_found_description": "Unfortunately, this is only a 404 page. You may have mistyped the address, or the page has been moved to another URL.",
"not_found_title": "You have found a secret place.",
@@ -171,6 +180,9 @@
"players_title": "players",
"policy_not_accepted": "Please indicate that you have read the policy",
"previous_stage_button": "Previous Stage",
"ranking_title": "Ranking",
"rankings_title": "Rankings",
"rankings_spotlight_description": "Add/edit rankings",
"recommended_badge_title": "Recommended",
"remove_logo": "Remove logo",
"remove_match_button": "Remove Match",
@@ -180,6 +192,7 @@
"round_robin_label": "Round Robin",
"save_button": "Save",
"save_players_button": "Save players",
"save_ranking_button": "Save Ranking",
"schedule_description": "Schedule All Unscheduled Matches",
"schedule_title": "Schedule",
"score_of_label": "Score of",
@@ -199,6 +212,8 @@
"status": "Status",
"swiss_difference": "Swiss Difference",
"swiss_label": "Swiss",
"edit_stage_label": "Edit Stage",
"edit_stage_item_label": "Edit Stage Item",
"swiss_score": "Swiss score",
"team_count_input_round_robin_label": "Number of teams advancing from the previous stage",
"team_count_select_elimination_label": "Number of teams advancing from the previous stage",
@@ -232,5 +247,6 @@
"welcome_title": "Welcome to",
"win_distribution_text_draws": "draws",
"win_distribution_text_losses": "losses",
"win_distribution_text_win": "wins"
"win_distribution_text_win": "wins",
"win_points_input_label": "Points for a win"
}

View File

@@ -7,6 +7,7 @@ import React, { useState } from 'react';
import { BiSolidWrench } from 'react-icons/bi';
import { SWRResponse } from 'swr';
import { Ranking } from '../../interfaces/ranking';
import { StageWithStageItems } from '../../interfaces/stage';
import { StageItemWithRounds } from '../../interfaces/stage_item';
import { StageItemInput, formatStageItemInput } from '../../interfaces/stage_item_input';
@@ -52,11 +53,13 @@ function StageItemRow({
tournament,
stageItem,
swrStagesResponse,
rankings,
}: {
teamsMap: any;
tournament: Tournament;
stageItem: StageItemWithRounds;
swrStagesResponse: SWRResponse;
rankings: Ranking[];
}) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
@@ -92,6 +95,7 @@ function StageItemRow({
tournament={tournament}
opened={opened}
setOpened={setOpened}
rankings={rankings}
/>
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
@@ -107,7 +111,7 @@ function StageItemRow({
setOpened(true);
}}
>
{t('edit_name_button')}
{t('edit_stage_item_label')}
</Menu.Item>
{stageItem.type === 'SWISS' ? (
<Menu.Item
@@ -141,10 +145,12 @@ function StageColumn({
tournament,
stage,
swrStagesResponse,
rankings,
}: {
tournament: Tournament;
stage: StageWithStageItems;
swrStagesResponse: SWRResponse;
rankings: Ranking[];
}) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
@@ -163,6 +169,7 @@ function StageColumn({
tournament={tournament}
stageItem={stageItem}
swrStagesResponse={swrStagesResponse}
rankings={rankings}
/>
));
@@ -176,14 +183,10 @@ function StageColumn({
setOpened={setOpened}
/>
<Group justify="space-between">
<h4 style={{ marginBottom: '0rem' }}>
<Group>
{stage.name}
{stage.is_active ? (
<Badge ml="1rem" color="green">
{t('active_badge_label')}
</Badge>
) : null}
</h4>
{stage.is_active ? <Badge color="green">{t('active_badge_label')}</Badge> : null}
</Group>
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon variant="transparent" color="gray">
@@ -198,7 +201,7 @@ function StageColumn({
setOpened(true);
}}
>
{t('edit_name_button')}
{t('edit_stage_label')}
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size="1.5rem" />}
@@ -227,9 +230,11 @@ function StageColumn({
export default function Builder({
tournament,
swrStagesResponse,
rankings,
}: {
tournament: Tournament;
swrStagesResponse: SWRResponse;
rankings: Ranking[];
}) {
const stages: StageWithStageItems[] =
swrStagesResponse.data != null ? swrStagesResponse.data.data : [];
@@ -244,6 +249,7 @@ export default function Builder({
tournament={tournament}
swrStagesResponse={swrStagesResponse}
stage={stage}
rankings={rankings}
/>
));

View File

@@ -9,7 +9,7 @@ interface ScoreProps {
export function PlayerScore({ score, min_score, max_score, decimals }: ScoreProps) {
const percentageScale = 100.0 / (max_score - min_score);
const empty = score - min_score === 0;
const empty = max_score - min_score === 0;
return (
<Progress.Root size={20}>

View File

@@ -4,6 +4,7 @@ import {
IconBrackets,
IconCalendarEvent,
IconHome,
IconScoreboard,
IconSearch,
IconSettings,
IconSoccerField,
@@ -97,6 +98,13 @@ export function BracketSpotlight() {
onClick: () => router.push(`/tournaments/${tournamentId}/settings`),
leftSection: <IconSettings size="1.2rem" />,
},
{
id: 'rankings',
title: t('rankings_title'),
description: t('rankings_spotlight_description'),
onClick: () => router.push(`/tournaments/${tournamentId}/rankings`),
leftSection: <IconScoreboard size="1.2rem" />,
},
];
const allActions = tournamentId >= 0 ? actions.concat(tournamentActions) : actions;
return (

View File

@@ -28,7 +28,7 @@ export function UpdateStageModal({
});
return (
<Modal opened={opened} onClose={() => setOpened(false)} title="Edit stage">
<Modal opened={opened} onClose={() => setOpened(false)} title={t('edit_stage_label')}>
<form
onSubmit={form.onSubmit(async (values) => {
await updateStage(tournament.id, stage.id, values.name);

View File

@@ -4,9 +4,11 @@ import { useTranslation } from 'next-i18next';
import React from 'react';
import { SWRResponse } from 'swr';
import { Ranking } from '../../interfaces/ranking';
import { StageItemWithRounds } from '../../interfaces/stage_item';
import { Tournament } from '../../interfaces/tournament';
import { updateStageItem } from '../../services/stage_item';
import { RankingSelect } from '../select/ranking_select';
export function UpdateStageItemModal({
tournament,
@@ -14,24 +16,29 @@ export function UpdateStageItemModal({
setOpened,
stageItem,
swrStagesResponse,
rankings,
}: {
tournament: Tournament;
opened: boolean;
setOpened: any;
stageItem: StageItemWithRounds;
swrStagesResponse: SWRResponse;
rankings: Ranking[];
}) {
const { t } = useTranslation();
const form = useForm({
initialValues: { name: stageItem.name },
initialValues: {
name: stageItem.name,
ranking_id: rankings.filter((ranking) => ranking.position === 0)[0].id.toString(),
},
validate: {},
});
return (
<Modal opened={opened} onClose={() => setOpened(false)} title="Edit stage item">
<Modal opened={opened} onClose={() => setOpened(false)} title={t('edit_stage_item_label')}>
<form
onSubmit={form.onSubmit(async (values) => {
await updateStageItem(tournament.id, stageItem.id, values.name);
await updateStageItem(tournament.id, stageItem.id, values.name, values.ranking_id);
await swrStagesResponse.mutate();
setOpened(false);
})}
@@ -44,6 +51,7 @@ export function UpdateStageItemModal({
type="text"
{...form.getInputProps('name')}
/>
<RankingSelect form={form} rankings={rankings} />
<Button fullWidth style={{ marginTop: 16 }} color="green" type="submit">
{t('save_button')}
</Button>

View File

@@ -8,6 +8,7 @@ import {
IconCalendar,
IconDots,
IconHome,
IconScoreboard,
IconSettings,
IconSoccerField,
IconTrophy,
@@ -144,6 +145,11 @@ export function TournamentLinks({ tournament_id }: any) {
label: capitalize(t('results_title')),
link: `${tm_prefix}/results`,
},
{
icon: IconScoreboard,
label: capitalize(t('rankings_title')),
link: `${tm_prefix}/rankings`,
},
{
icon: IconSettings,
label: capitalize(t('tournament_setting_title')),

View File

@@ -0,0 +1,27 @@
import { Select } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import React from 'react';
import { Ranking } from '../../interfaces/ranking';
export function RankingSelect({ form, rankings }: { form: any; rankings: Ranking[] }) {
const { t } = useTranslation();
const data = rankings.map((ranking: Ranking, i: number) => ({
value: ranking.id.toString(),
label: `${t('ranking_title')} ${ranking.position + 1} ${i === 0 ? `(${t('default_label')})` : ''}`,
}));
return (
<Select
withAsterisk
data={data}
label={t('ranking_title')}
searchable
allowDeselect={false}
limit={16}
mt={24}
{...form.getInputProps('ranking_id')}
/>
);
}

View File

@@ -7,8 +7,6 @@ import { Player } from '../../interfaces/player';
import { TournamentMinimal } from '../../interfaces/tournament';
import { deletePlayer } from '../../services/player';
import DeleteButton from '../buttons/delete';
import { PlayerScore } from '../info/player_score';
import { WinDistribution } from '../info/player_statistics';
import PlayerUpdateModal from '../modals/player_update_modal';
import { NoContent } from '../no_content/empty_table_info';
import { DateTime } from '../utils/datetime';
@@ -50,9 +48,9 @@ export default function PlayersTable({
const players: Player[] =
swrPlayersResponse.data != null ? swrPlayersResponse.data.data.players : [];
const minELOScore = Math.min(...players.map((player) => Number(player.elo_score)));
const maxELOScore = Math.max(...players.map((player) => Number(player.elo_score)));
const maxSwissScore = Math.max(...players.map((player) => Number(player.swiss_score)));
// const minELOScore = Math.min(...players.map((player) => Number(player.elo_score)));
// const maxELOScore = Math.max(...players.map((player) => Number(player.elo_score)));
// const maxSwissScore = Math.max(...players.map((player) => Number(player.swiss_score)));
if (swrPlayersResponse.error) return <RequestErrorAlert error={swrPlayersResponse.error} />;
@@ -77,25 +75,6 @@ export default function PlayersTable({
<Table.Td>
<DateTime datetime={player.created} />
</Table.Td>
<Table.Td>
<WinDistribution wins={player.wins} draws={player.draws} losses={player.losses} />
</Table.Td>
<Table.Td>
<PlayerScore
score={Number(player.elo_score)}
min_score={minELOScore}
max_score={maxELOScore}
decimals={0}
/>
</Table.Td>
<Table.Td>
<PlayerScore
score={Number(player.swiss_score)}
min_score={0}
max_score={maxSwissScore}
decimals={1}
/>
</Table.Td>
<Table.Td>
<PlayerUpdateModal
swrPlayersResponse={swrPlayersResponse}
@@ -129,17 +108,6 @@ export default function PlayersTable({
<ThSortable state={tableState} field="created">
{t('created')}
</ThSortable>
<ThNotSortable>
<>
<WinDistributionTitle />
</>
</ThNotSortable>
<ThSortable state={tableState} field="elo_score">
{t('elo_score')}
</ThSortable>
<ThSortable state={tableState} field="swiss_score">
{t('swiss_score')}
</ThSortable>
<ThNotSortable>{null}</ThNotSortable>
</Table.Tr>
</Table.Thead>

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'next-i18next';
import React from 'react';
import { StageItemWithRounds } from '../../interfaces/stage_item';
import { StageItemInput } from '../../interfaces/stage_item_input';
import { TeamInterface } from '../../interfaces/team';
import PlayerList from '../info/player_list';
import { PlayerScore } from '../info/player_score';
@@ -71,47 +72,58 @@ export default function StandingsTable({ teams }: { teams: TeamInterface[] }) {
);
}
type TeamWithInput = { team: TeamInterface; input: StageItemInput };
export function StandingsTableForStageItem({
teams,
teams_with_inputs,
stageItem,
}: {
teams: TeamInterface[];
teams_with_inputs: { team: TeamInterface; input: StageItemInput }[];
stageItem: StageItemWithRounds;
}) {
const { t } = useTranslation();
const tableState = getTableState('elo_score', false);
const tableState = getTableState('points', false);
const minELOScore = Math.min(...teams.map((team) => team.elo_score));
const maxELOScore = Math.max(...teams.map((team) => team.elo_score));
const minPoints = Math.min(...teams_with_inputs.map((item) => item.input.points));
const maxPoints = Math.max(...teams_with_inputs.map((item) => item.input.points));
const rows = teams
.sort((p1: TeamInterface, p2: TeamInterface) => (p1.name < p2.name ? 1 : -1))
.sort((p1: TeamInterface, p2: TeamInterface) => (p1.draws > p2.draws ? 1 : -1))
.sort((p1: TeamInterface, p2: TeamInterface) => (p1.wins > p2.wins ? 1 : -1))
.sort((p1: TeamInterface, p2: TeamInterface) => sortTableEntries(p1, p2, tableState))
.map((team, index) => (
<Table.Tr key={team.id}>
const rows = teams_with_inputs
.sort((p1: TeamWithInput, p2: TeamWithInput) => (p1.input.points > p2.input.points ? 1 : -1))
.sort((p1: TeamWithInput, p2: TeamWithInput) =>
sortTableEntries(p1.input, p2.input, tableState)
)
.map((team_with_input, index) => (
<Table.Tr key={team_with_input.team.id}>
<Table.Td style={{ width: '2rem' }}>{index + 1}</Table.Td>
<Table.Td style={{ width: '20rem' }}>
<Text truncate="end" lineClamp={1}>
{team.name}
{team_with_input.team.name}
</Text>
</Table.Td>
<Table.Td visibleFrom="sm" style={{ width: '16rem' }}>
<PlayerList team={team} />
<PlayerList team={team_with_input.team} />
</Table.Td>
<Table.Td visibleFrom="sm" style={{ width: '6rem' }}>
<Text truncate="end" lineClamp={1}>
{team_with_input.input.points}
</Text>
</Table.Td>
{stageItem.type === 'SWISS' ? (
<Table.Td>
<PlayerScore
score={team.elo_score}
min_score={minELOScore}
max_score={maxELOScore}
score={team_with_input.input.points}
min_score={minPoints}
max_score={maxPoints}
decimals={0}
/>
</Table.Td>
) : (
<Table.Td style={{ minWidth: '10rem' }}>
<WinDistribution wins={team.wins} draws={team.draws} losses={team.losses} />
<WinDistribution
wins={team_with_input.input.wins}
draws={team_with_input.input.draws}
losses={team_with_input.input.losses}
/>
</Table.Td>
)}
</Table.Tr>
@@ -128,6 +140,9 @@ export function StandingsTableForStageItem({
{t('name_table_header')}
</ThSortable>
<ThNotSortable visibleFrom="sm">{t('members_table_header')}</ThNotSortable>
<ThSortable visibleFrom="sm" state={tableState} field="points">
{t('points_table_header')}
</ThSortable>
{stageItem.type === 'SWISS' ? (
<ThSortable state={tableState} field="elo_score">
{t('elo_score')}

View File

@@ -20,6 +20,7 @@ export interface ThProps {
children: React.ReactNode;
state: TableState;
field: string;
visibleFrom?: string;
}
export const setSorting = (state: TableState, newSortField: string) => {
@@ -70,11 +71,11 @@ export function getSortIcon(sorted: boolean, reversed: boolean) {
return <HiSortAscending />;
}
export function ThSortable({ children, field, state }: ThProps) {
export function ThSortable({ children, field, visibleFrom, state }: ThProps) {
const sorted = state.sortField === field;
const onSort = () => setSorting(state, field);
return (
<Table.Th className={classes.th}>
<Table.Th className={classes.th} visibleFrom={visibleFrom}>
<UnstyledButton onClick={onSort} className={classes.control}>
<Group justify="apart">
<Text fw={800} size="sm" ml="0.5rem" my="0.25rem">

View File

@@ -53,8 +53,6 @@ export default function TeamsTable({
<Table.Td>
<DateTime datetime={team.created} />
</Table.Td>
<Table.Td>{Number(team.swiss_score).toFixed(1)}</Table.Td>
<Table.Td>{Number(team.elo_score).toFixed(0)}</Table.Td>
<Table.Td>
<TeamUpdateModal
tournament_id={tournamentData.id}
@@ -89,12 +87,6 @@ export default function TeamsTable({
<ThSortable state={tableState} field="created">
{t('created')}
</ThSortable>
<ThSortable state={tableState} field="swiss_score">
{t('swiss_score')}
</ThSortable>
<ThSortable state={tableState} field="elo_score">
{t('elo_score')}
</ThSortable>
<ThNotSortable>{null}</ThNotSortable>
</Table.Tr>
</Table.Thead>

View File

@@ -0,0 +1,10 @@
export interface Ranking {
id: number;
tournament_id: number;
created: string;
win_points: number;
draw_points: number;
loss_points: number;
add_score_points: boolean;
position: number;
}

View File

@@ -3,7 +3,6 @@ import { StageItemInput } from './stage_item_input';
export interface StageItemWithRounds {
id: number;
tournament_id: number;
created: string;
type: string;
name: string;

View File

@@ -6,6 +6,10 @@ export interface StageItemInput {
team_id: number | null;
winner_from_stage_item_id: number | null;
winner_position: number | null;
wins: number;
draws: number;
losses: number;
points: number;
}
export interface StageItemInputCreateBody {

View File

@@ -191,9 +191,8 @@ export default function SchedulePage() {
const { t } = useTranslation();
const tournamentResponse = getTournamentResponseByEndpointName();
// Hack to avoid unequal number of rendered hooks.
const notFound = tournamentResponse == null || tournamentResponse[0] == null;
const tournamentId = !notFound ? tournamentResponse[0].id : -1;
const tournamentId = !notFound ? tournamentResponse[0].id : null;
const tournamentDataFull = tournamentResponse != null ? tournamentResponse[0] : null;
const swrStagesResponse = getStagesLive(tournamentId);

View File

@@ -31,7 +31,7 @@ export default function CourtsPage() {
// Hack to avoid unequal number of rendered hooks.
const notFound = tournamentResponse == null || tournamentResponse[0] == null;
const tournamentId = !notFound ? tournamentResponse[0].id : -1;
const tournamentId = !notFound ? tournamentResponse[0].id : null;
const swrStagesResponse: SWRResponse = getStagesLive(tournamentId);
const swrCourtsResponse: SWRResponse = getCourtsLive(tournamentId);

View File

@@ -22,7 +22,7 @@ export default function Standings() {
// Hack to avoid unequal number of rendered hooks.
const notFound = tournamentResponse == null || tournamentResponse[0] == null;
const tournamentId = !notFound ? tournamentResponse[0].id : -1;
const tournamentId = !notFound ? tournamentResponse[0].id : null;
const swrTeamsResponse: SWRResponse = getTeamsLive(tournamentId);

View File

@@ -38,15 +38,15 @@ function StandingsContent({
stageItemsLookup[si1].name > stageItemsLookup[si2].name ? 1 : -1
)
.map((stageItemId) => (
<>
<div key={stageItemId}>
<Text size="xl" mt="md" mb="xs">
{stageItemsLookup[stageItemId].name}
</Text>
<StandingsTableForStageItem
teams={stageItemTeamLookup[stageItemId]}
teams_with_inputs={stageItemTeamLookup[stageItemId]}
stageItem={stageItemsLookup[stageItemId]}
/>
</>
</div>
));
if (rows.length < 1) {
@@ -62,15 +62,19 @@ function StandingsContent({
export default function Standings() {
const tournamentResponse = getTournamentResponseByEndpointName();
const tournamentDataFull = tournamentResponse[0];
// Hack to avoid unequal number of rendered hooks.
const notFound = tournamentResponse == null || tournamentDataFull == null;
const tournamentId = !notFound ? tournamentDataFull.id : -1;
const tournamentDataFull = tournamentResponse ? tournamentResponse[0] : null;
const notFound = tournamentDataFull == null;
const tournamentId = !notFound ? tournamentDataFull.id : null;
const swrStagesResponse = getStagesLive(tournamentId);
const swrTeamsResponse: SWRResponse = getTeamsLive(tournamentId);
if (!tournamentResponse) {
return <TableSkeletonTwoColumns />;
}
if (swrTeamsResponse.isLoading || swrStagesResponse.isLoading) {
return <TableSkeletonTwoColumns />;
}
@@ -85,7 +89,7 @@ export default function Standings() {
<TournamentHeadTitle tournamentDataFull={tournamentDataFull} />
</Head>
<DoubleHeader tournamentData={tournamentDataFull} />
<Container mt="1rem" style={{ overflow: 'scroll' }} px="0rem">
<Container mt="1rem" px="0rem">
<Container style={{ width: '100%' }} px="sm">
<StandingsContent
swrTeamsResponse={swrTeamsResponse}

View File

@@ -0,0 +1,209 @@
import { Accordion, Badge, Button, Center, Checkbox, Container, NumberInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import React from 'react';
import { SWRResponse } from 'swr';
import DeleteButton from '../../../components/buttons/delete';
import { EmptyTableInfo } from '../../../components/no_content/empty_table_info';
import RequestErrorAlert from '../../../components/utils/error_alert';
import { TableSkeletonSingleColumn } from '../../../components/utils/skeletons';
import { Translator } from '../../../components/utils/types';
import { getTournamentIdFromRouter } from '../../../components/utils/util';
import { Ranking } from '../../../interfaces/ranking';
import { Tournament } from '../../../interfaces/tournament';
import { getRankings, getTournamentById } from '../../../services/adapter';
import { createRanking, deleteRanking, editRanking } from '../../../services/ranking';
import TournamentLayout from '../_tournament_layout';
function RankingDeleteButton({
t,
tournament,
ranking,
swrRankingsResponse,
}: {
t: Translator;
tournament: Tournament;
ranking: Ranking;
swrRankingsResponse: SWRResponse;
}) {
if (ranking.position === 0) {
return (
<Center ml="1rem" miw="10rem">
<Badge color="indigo">{t('default_ranking_badge')}</Badge>
</Center>
);
}
return (
<DeleteButton
onClick={async () => {
await deleteRanking(tournament.id, ranking.id);
await swrRankingsResponse.mutate();
}}
title={t('delete_ranking_button')}
ml="1rem"
variant="outline"
miw="10rem"
/>
);
}
function EditRankingForm({
t,
tournament,
ranking,
swrRankingsResponse,
}: {
t: Translator;
tournament: Tournament;
ranking: Ranking;
swrRankingsResponse: SWRResponse;
}) {
const form = useForm({
initialValues: {
win_points: ranking.win_points,
draw_points: ranking.draw_points,
loss_points: ranking.loss_points,
add_score_points: ranking.add_score_points,
position: ranking.position,
},
validate: {},
});
const rankingTitle = `${t('ranking_title')} ${ranking.position + 1}`;
return (
<form
onSubmit={form.onSubmit(async (values) => {
await editRanking(
tournament.id,
ranking.id,
values.win_points,
values.draw_points,
values.loss_points,
values.add_score_points,
values.position
);
await swrRankingsResponse.mutate();
})}
>
<Accordion.Item key={ranking.id} value={`${ranking.position}`}>
<Center>
<Accordion.Control>{rankingTitle}</Accordion.Control>
<Center>
<RankingDeleteButton
t={t}
tournament={tournament}
ranking={ranking}
swrRankingsResponse={swrRankingsResponse}
/>
</Center>
</Center>
<Accordion.Panel>
<NumberInput
withAsterisk
label={t('win_points_input_label')}
{...form.getInputProps('win_points')}
/>
<NumberInput
mt="1rem"
withAsterisk
label={t('draw_points_input_label')}
{...form.getInputProps('draw_points')}
/>
<NumberInput
mt="1rem"
withAsterisk
label={t('loss_points_input_label')}
{...form.getInputProps('loss_points')}
/>
<Checkbox
mt="lg"
label={t('add_score_points_label')}
{...form.getInputProps('add_score_points', { type: 'checkbox' })}
/>
<Button fullWidth style={{ marginTop: 16 }} color="green" type="submit">
{`${t('save_button')} ${rankingTitle}`}
</Button>
</Accordion.Panel>
</Accordion.Item>
</form>
);
}
function RankingForm({
t,
tournament,
swrRankingsResponse,
}: {
t: Translator;
tournament: Tournament;
swrRankingsResponse: SWRResponse;
}) {
const rankings: Ranking[] = swrRankingsResponse.data != null ? swrRankingsResponse.data.data : [];
const rows = rankings
.sort((s1: Ranking, s2: Ranking) => s1.position - s2.position)
.map((ranking) => (
<EditRankingForm
t={t}
tournament={tournament}
ranking={ranking}
swrRankingsResponse={swrRankingsResponse}
/>
));
if (swrRankingsResponse.isLoading) {
return <TableSkeletonSingleColumn />;
}
if (swrRankingsResponse.error) return <RequestErrorAlert error={swrRankingsResponse.error} />;
if (rows.length < 1) return <EmptyTableInfo entity_name={t('rankings_title')} />;
return (
<Accordion multiple defaultValue={['0']}>
{rows}
</Accordion>
);
}
export default function RankingsPage() {
const { tournamentData } = getTournamentIdFromRouter();
const swrRankingsResponse = getRankings(tournamentData.id);
const swrTournamentResponse = getTournamentById(tournamentData.id);
const tournamentDataFull =
swrTournamentResponse.data != null ? swrTournamentResponse.data.data : null;
const { t } = useTranslation();
return (
<TournamentLayout tournament_id={tournamentData.id}>
<Container maw="50rem">
<RankingForm
t={t}
tournament={tournamentDataFull}
swrRankingsResponse={swrRankingsResponse}
/>
<Button
fullWidth
mt="1rem"
color="green"
variant="outline"
onClick={async () => {
await createRanking(tournamentDataFull.id);
await swrRankingsResponse.mutate();
}}
>
{t('add_ranking_button')}
</Button>
</Container>
</TournamentLayout>
);
}
export const getServerSideProps = async ({ locale }: { locale: string }) => ({
props: {
...(await serverSideTranslations(locale, ['common'])),
},
});

View File

@@ -12,17 +12,20 @@ import {
import { NoContent } from '../../../components/no_content/empty_table_info';
import { TableSkeletonTwoColumnsSmall } from '../../../components/utils/skeletons';
import { getTournamentIdFromRouter } from '../../../components/utils/util';
import { Ranking } from '../../../interfaces/ranking';
import { StageWithStageItems } from '../../../interfaces/stage';
import { getStages, getTournamentById } from '../../../services/adapter';
import { getRankings, getStages, getTournamentById } from '../../../services/adapter';
import TournamentLayout from '../_tournament_layout';
export default function StagesPage() {
const { t } = useTranslation();
const { tournamentData } = getTournamentIdFromRouter();
const swrStagesResponse = getStages(tournamentData.id);
const swrRankingsResponse = getRankings(tournamentData.id);
const swrTournamentResponse = getTournamentById(tournamentData.id);
const tournamentDataFull =
swrTournamentResponse.data != null ? swrTournamentResponse.data.data : null;
const rankings: Ranking[] = swrRankingsResponse.data != null ? swrRankingsResponse.data.data : [];
const stages: StageWithStageItems[] =
swrStagesResponse.data != null ? swrStagesResponse.data.data : [];
@@ -51,7 +54,11 @@ export default function StagesPage() {
<NextStageButton tournamentData={tournamentData} swrStagesResponse={swrStagesResponse} />
</Group>
<Group mt="1rem" align="top">
<Builder tournament={tournamentDataFull} swrStagesResponse={swrStagesResponse} />
<Builder
tournament={tournamentDataFull}
swrStagesResponse={swrStagesResponse}
rankings={rankings}
/>
</Group>
</>
);

View File

@@ -131,8 +131,11 @@ export function getPlayersPaginated(tournament_id: number, pagination: Paginatio
);
}
export function getTeams(tournament_id: number): SWRResponse {
return useSWR(`tournaments/${tournament_id}/teams?limit=100`, fetcher);
export function getTeams(tournament_id: number | null): SWRResponse {
return useSWR(
tournament_id == null ? null : `tournaments/${tournament_id}/teams?limit=100`,
fetcher
);
}
export function getTeamsPaginated(tournament_id: number, pagination: Pagination): SWRResponse {
@@ -142,8 +145,8 @@ export function getTeamsPaginated(tournament_id: number, pagination: Pagination)
);
}
export function getTeamsLive(tournament_id: number): SWRResponse {
return useSWR(`tournaments/${tournament_id}/teams`, fetcher, {
export function getTeamsLive(tournament_id: number | null): SWRResponse {
return useSWR(tournament_id == null ? null : `tournaments/${tournament_id}/teams`, fetcher, {
refreshInterval: 5_000,
});
}
@@ -152,18 +155,21 @@ export function getAvailableStageItemInputs(tournament_id: number, stage_id: num
return useSWR(`tournaments/${tournament_id}/stages/${stage_id}/available_inputs`, fetcher);
}
export function getStages(tournament_id: number, no_draft_rounds: boolean = false): SWRResponse {
export function getStages(
tournament_id: number | null,
no_draft_rounds: boolean = false
): SWRResponse {
return useSWR(
tournament_id === -1
tournament_id == null || tournament_id === -1
? null
: `tournaments/${tournament_id}/stages?no_draft_rounds=${no_draft_rounds}`,
fetcher
);
}
export function getStagesLive(tournament_id: number): SWRResponse {
export function getStagesLive(tournament_id: number | null): SWRResponse {
return useSWR(
tournament_id === -1 ? null : `tournaments/${tournament_id}/stages?no_draft_rounds=true`,
tournament_id == null ? null : `tournaments/${tournament_id}/stages?no_draft_rounds=true`,
fetcherWithTimestamp,
{
refreshInterval: 5_000,
@@ -171,6 +177,10 @@ export function getStagesLive(tournament_id: number): SWRResponse {
);
}
export function getRankings(tournament_id: number): SWRResponse {
return useSWR(`tournaments/${tournament_id}/rankings`, fetcher);
}
export function getCourts(tournament_id: number): SWRResponse {
return useSWR(`tournaments/${tournament_id}/courts`, fetcher);
}

View File

@@ -65,13 +65,12 @@ export function getStageItemTeamsLookup(
stage.stage_items
.sort((si1: any, si2: any) => (si1.name > si2.name ? 1 : -1))
.forEach((stageItem) => {
const teams = stageItem.inputs
.map((input) => input.team_id)
.map((id) => teamsLookup![id!])
.filter((team: TeamInterface) => team != null);
const teams_with_inputs = stageItem.inputs
.filter((input) => input.team_id != null)
.map((input) => ({ team: teamsLookup[input.team_id!], input }));
if (teams.length > 0) {
result = result.concat([[stageItem.id, teams]]);
if (teams_with_inputs.length > 0) {
result = result.concat([[stageItem.id, teams_with_inputs]]);
}
})
);

View File

@@ -0,0 +1,33 @@
import { createAxios, handleRequestError } from './adapter';
export async function createRanking(tournament_id: number) {
return createAxios()
.post(`tournaments/${tournament_id}/rankings`, {})
.catch((response: any) => handleRequestError(response));
}
export async function editRanking(
tournament_id: number,
ranking_id: number,
win_points: number,
draw_points: number,
loss_points: number,
add_score_points: boolean,
position: number
) {
return createAxios()
.put(`tournaments/${tournament_id}/rankings/${ranking_id}`, {
win_points,
draw_points,
loss_points,
add_score_points,
position,
})
.catch((response: any) => handleRequestError(response));
}
export async function deleteRanking(tournament_id: number, ranking_id: number) {
return createAxios()
.delete(`tournaments/${tournament_id}/rankings/${ranking_id}`)
.catch((response: any) => handleRequestError(response));
}

View File

@@ -13,9 +13,14 @@ export async function createStageItem(
.catch((response: any) => handleRequestError(response));
}
export async function updateStageItem(tournament_id: number, stage_item_id: number, name: string) {
export async function updateStageItem(
tournament_id: number,
stage_item_id: number,
name: string,
ranking_id: string
) {
return createAxios()
.put(`tournaments/${tournament_id}/stage_items/${stage_item_id}`, { name })
.put(`tournaments/${tournament_id}/stage_items/${stage_item_id}`, { name, ranking_id })
.catch((response: any) => handleRequestError(response));
}

View File

@@ -60,5 +60,5 @@ export function getTournamentResponseByEndpointName() {
const endpointName = getTournamentEndpointFromRouter();
const swrTournamentsResponse = getTournamentByEndpointName(endpointName);
return swrTournamentsResponse.data != null ? swrTournamentsResponse.data.data : null;
return swrTournamentsResponse.data?.data;
}