diff --git a/backend/alembic/versions/77de1c773dba_create_rankings_table.py b/backend/alembic/versions/77de1c773dba_create_rankings_table.py new file mode 100644 index 00000000..1a025026 --- /dev/null +++ b/backend/alembic/versions/77de1c773dba_create_rankings_table.py @@ -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") diff --git a/backend/bracket/app.py b/backend/bracket/app.py index e95bd8f9..274b9270 100644 --- a/backend/bracket/app.py +++ b/backend/bracket/app.py @@ -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, diff --git a/backend/bracket/logic/ranking/elo.py b/backend/bracket/logic/ranking/elo.py index 8abb1e66..8973be40 100644 --- a/backend/bracket/logic/ranking/elo.py +++ b/backend/bracket/logic/ranking/elo.py @@ -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]) diff --git a/backend/bracket/logic/ranking/ranking.py b/backend/bracket/logic/ranking/ranking.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/bracket/logic/ranking/statistics.py b/backend/bracket/logic/ranking/statistics.py new file mode 100644 index 00000000..033e260e --- /dev/null +++ b/backend/bracket/logic/ranking/statistics.py @@ -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") diff --git a/backend/bracket/logic/scheduling/handle_stage_activation.py b/backend/bracket/logic/scheduling/handle_stage_activation.py index 5b6fafb6..60f5e76e 100644 --- a/backend/bracket/logic/scheduling/handle_stage_activation.py +++ b/backend/bracket/logic/scheduling/handle_stage_activation.py @@ -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) diff --git a/backend/bracket/logic/subscriptions.py b/backend/bracket/logic/subscriptions.py index b40d2ee6..befdda7a 100644 --- a/backend/bracket/logic/subscriptions.py +++ b/backend/bracket/logic/subscriptions.py @@ -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 = { diff --git a/backend/bracket/logic/tournaments.py b/backend/bracket/logic/tournaments.py index 0e3e9da8..c035d9a5 100644 --- a/backend/bracket/logic/tournaments.py +++ b/backend/bracket/logic/tournaments.py @@ -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) diff --git a/backend/bracket/models/db/players.py b/backend/bracket/models/db/players.py deleted file mode 100644 index 831d3a87..00000000 --- a/backend/bracket/models/db/players.py +++ /dev/null @@ -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") diff --git a/backend/bracket/models/db/ranking.py b/backend/bracket/models/db/ranking.py new file mode 100644 index 00000000..34ab6fc4 --- /dev/null +++ b/backend/bracket/models/db/ranking.py @@ -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 diff --git a/backend/bracket/models/db/stage_item.py b/backend/bracket/models/db/stage_item.py index c2b28057..a4f2fe77 100644 --- a/backend/bracket/models/db/stage_item.py +++ b/backend/bracket/models/db/stage_item.py @@ -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() diff --git a/backend/bracket/models/db/stage_item_inputs.py b/backend/bracket/models/db/stage_item_inputs.py index da03a9b0..d208349e 100644 --- a/backend/bracket/models/db/stage_item_inputs.py +++ b/backend/bracket/models/db/stage_item_inputs.py @@ -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 ( diff --git a/backend/bracket/models/db/team.py b/backend/bracket/models/db/team.py index 96338d73..f43d1424 100644 --- a/backend/bracket/models/db/team.py +++ b/backend/bracket/models/db/team.py @@ -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 diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index 80807de6..c11b4e40 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -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() diff --git a/backend/bracket/routes/models.py b/backend/bracket/routes/models.py index 991db893..77d14281 100644 --- a/backend/bracket/routes/models.py +++ b/backend/bracket/routes/models.py @@ -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 diff --git a/backend/bracket/routes/rankings.py b/backend/bracket/routes/rankings.py new file mode 100644 index 00000000..fa1bb429 --- /dev/null +++ b/backend/bracket/routes/rankings.py @@ -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() diff --git a/backend/bracket/routes/rounds.py b/backend/bracket/routes/rounds.py index 800b0e03..c89b3038 100644 --- a/backend/bracket/routes/rounds.py +++ b/backend/bracket/routes/rounds.py @@ -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() diff --git a/backend/bracket/routes/stage_items.py b/backend/bracket/routes/stage_items.py index 69f8656b..d8a2f41d 100644 --- a/backend/bracket/routes/stage_items.py +++ b/backend/bracket/routes/stage_items.py @@ -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() diff --git a/backend/bracket/routes/stages.py b/backend/bracket/routes/stages.py index 6ab47e16..fa6b0eda 100644 --- a/backend/bracket/routes/stages.py +++ b/backend/bracket/routes/stages.py @@ -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) diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index a159d1ea..309b9c38 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -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() diff --git a/backend/bracket/routes/tournaments.py b/backend/bracket/routes/tournaments.py index 0a3547ba..51b985fd 100644 --- a/backend/bracket/routes/tournaments.py +++ b/backend/bracket/routes/tournaments.py @@ -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() diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index 5364f3ab..9fdabad1 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -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), +) diff --git a/backend/bracket/sql/players.py b/backend/bracket/sql/players.py index 5fae5f2e..d8769c8f 100644 --- a/backend/bracket/sql/players.py +++ b/backend/bracket/sql/players.py @@ -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(), ) diff --git a/backend/bracket/sql/rankings.py b/backend/bracket/sql/rankings.py new file mode 100644 index 00000000..b7ff59c4 --- /dev/null +++ b/backend/bracket/sql/rankings.py @@ -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, + }, + ) diff --git a/backend/bracket/sql/stage_items.py b/backend/bracket/sql/stage_items.py index 4e5127b9..a5fb2683 100644 --- a/backend/bracket/sql/stage_items.py +++ b/backend/bracket/sql/stage_items.py @@ -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, }, ) diff --git a/backend/bracket/sql/teams.py b/backend/bracket/sql/teams.py index 3902630f..06268255 100644 --- a/backend/bracket/sql/teams.py +++ b/backend/bracket/sql/teams.py @@ -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), }, ) diff --git a/backend/bracket/sql/tournaments.py b/backend/bracket/sql/tournaments.py index dae227a9..901f8aea 100644 --- a/backend/bracket/sql/tournaments.py +++ b/backend/bracket/sql/tournaments.py @@ -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) diff --git a/backend/bracket/utils/db_init.py b/backend/bracket/utils/db_init.py index 4675910e..6870e6dc 100644 --- a/backend/bracket/utils/db_init.py +++ b/backend/bracket/utils/db_init.py @@ -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 diff --git a/backend/bracket/utils/dummy_records.py b/backend/bracket/utils/dummy_records.py index 9682b76e..1a0f1478 100644 --- a/backend/bracket/utils/dummy_records.py +++ b/backend/bracket/utils/dummy_records.py @@ -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, +) diff --git a/backend/bracket/utils/id_types.py b/backend/bracket/utils/id_types.py index 3ce10c11..4ab604b1 100644 --- a/backend/bracket/utils/id_types.py +++ b/backend/bracket/utils/id_types.py @@ -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) diff --git a/backend/precommit.sh b/backend/precommit.sh index fbd7d163..9b9f1f79 100755 --- a/backend/precommit.sh +++ b/backend/precommit.sh @@ -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 diff --git a/backend/tests/integration_tests/api/inputs_test.py b/backend/tests/integration_tests/api/inputs_test.py index 489001be..6e6caef4 100644 --- a/backend/tests/integration_tests/api/inputs_test.py +++ b/backend/tests/integration_tests/api/inputs_test.py @@ -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 diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py index 1faefcd5..3b642a34 100644 --- a/backend/tests/integration_tests/api/matches_test.py +++ b/backend/tests/integration_tests/api/matches_test.py @@ -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( diff --git a/backend/tests/integration_tests/api/rankings_test.py b/backend/tests/integration_tests/api/rankings_test.py new file mode 100644 index 00000000..6b7cf7f5 --- /dev/null +++ b/backend/tests/integration_tests/api/rankings_test.py @@ -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") diff --git a/backend/tests/integration_tests/api/rescheduling_matches_test.py b/backend/tests/integration_tests/api/rescheduling_matches_test.py index e967ffa6..8d5fd056 100644 --- a/backend/tests/integration_tests/api/rescheduling_matches_test.py +++ b/backend/tests/integration_tests/api/rescheduling_matches_test.py @@ -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}) diff --git a/backend/tests/integration_tests/api/rounds_test.py b/backend/tests/integration_tests/api/rounds_test.py index cdc428f7..6b634248 100644 --- a/backend/tests/integration_tests/api/rounds_test.py +++ b/backend/tests/integration_tests/api/rounds_test.py @@ -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}) diff --git a/backend/tests/integration_tests/api/stage_items_test.py b/backend/tests/integration_tests/api/stage_items_test.py index d36181f0..5c7dfbb6 100644 --- a/backend/tests/integration_tests/api/stage_items_test.py +++ b/backend/tests/integration_tests/api/stage_items_test.py @@ -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 ( diff --git a/backend/tests/integration_tests/api/stages_test.py b/backend/tests/integration_tests/api/stages_test.py index c879f67d..692b0b3f 100644 --- a/backend/tests/integration_tests/api/stages_test.py +++ b/backend/tests/integration_tests/api/stages_test.py @@ -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}": []}} diff --git a/backend/tests/integration_tests/api/tournaments_test.py b/backend/tests/integration_tests/api/tournaments_test.py index 89e617d3..3c364e52 100644 --- a/backend/tests/integration_tests/api/tournaments_test.py +++ b/backend/tests/integration_tests/api/tournaments_test.py @@ -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( diff --git a/backend/tests/integration_tests/models.py b/backend/tests/integration_tests/models.py index b74caa63..44e79847 100644 --- a/backend/tests/integration_tests/models.py +++ b/backend/tests/integration_tests/models.py @@ -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 diff --git a/backend/tests/integration_tests/sql.py b/backend/tests/integration_tests/sql.py index 45ae60d2..4383b3cf 100644 --- a/backend/tests/integration_tests/sql.py +++ b/backend/tests/integration_tests/sql.py @@ -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, ) diff --git a/backend/tests/unit_tests/elo_test.py b/backend/tests/unit_tests/elo_test.py deleted file mode 100644 index 32387c4e..00000000 --- a/backend/tests/unit_tests/elo_test.py +++ /dev/null @@ -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")), - } diff --git a/backend/tests/unit_tests/ranking_calculation_test.py b/backend/tests/unit_tests/ranking_calculation_test.py new file mode 100644 index 00000000..4ca6bf00 --- /dev/null +++ b/backend/tests/unit_tests/ranking_calculation_test.py @@ -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 diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 6172466a..085e7648 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -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" } \ No newline at end of file diff --git a/frontend/src/components/builder/builder.tsx b/frontend/src/components/builder/builder.tsx index 8ca89592..fdab1539 100644 --- a/frontend/src/components/builder/builder.tsx +++ b/frontend/src/components/builder/builder.tsx @@ -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} />