From f03bf6cb927ca04e6065e9cd199925ddf0f47eb6 Mon Sep 17 00:00:00 2001 From: Erik Vroon Date: Mon, 16 Jan 2023 07:35:44 -0800 Subject: [PATCH] Various bugfixes (#77) --- .github/dependabot.yml | 6 + README.md | 2 +- backend/Pipfile | 54 ++++----- backend/bracket/logic/elo.py | 37 +++--- backend/bracket/logic/upcoming_matches.py | 6 +- backend/bracket/models/db/match.py | 3 + backend/bracket/models/db/round.py | 4 + backend/bracket/routes/matches.py | 14 ++- backend/bracket/routes/rounds.py | 22 +++- backend/bracket/routes/teams.py | 41 +++++-- backend/bracket/routes/util.py | 84 +++++++++++++ backend/bracket/schema.py | 1 + backend/bracket/utils/dummy_records.py | 4 + backend/bracket/utils/sql.py | 33 +++--- backend/bracket/utils/types.py | 4 + .../integration_tests/api/matches_test.py | 9 +- backend/tests/unit_tests/elo_test.py | 3 +- codecov.yml | 2 - frontend/public/favicon.svg | 111 +++++++++++++++++- frontend/src/components/brackets/brackets.tsx | 2 +- .../brackets/{game.tsx => match.tsx} | 38 +++++- frontend/src/components/brackets/round.tsx | 26 ++-- .../src/components/info/player_elo_score.tsx | 30 ----- frontend/src/components/info/player_score.tsx | 34 ++++++ .../src/components/modals/match_modal.tsx | 11 +- frontend/src/components/navbar/_brand.tsx | 21 ++-- frontend/src/components/tables/players.tsx | 21 +++- frontend/src/components/tables/table.tsx | 4 +- .../components/tables/upcoming_matches.tsx | 7 +- frontend/src/interfaces/match.tsx | 3 + frontend/src/interfaces/player.tsx | 1 + frontend/src/pages/login.tsx | 16 +-- .../src/pages/tournaments/[id]/dashboard.tsx | 17 +-- frontend/src/services/adapter.tsx | 30 ++--- frontend/src/services/club.tsx | 32 ----- frontend/src/services/match.tsx | 14 ++- frontend/src/services/player.tsx | 26 ++-- frontend/src/services/round.tsx | 14 ++- frontend/src/services/team.tsx | 18 +-- frontend/src/services/user.tsx | 15 ++- 40 files changed, 573 insertions(+), 247 deletions(-) create mode 100644 backend/bracket/routes/util.py rename frontend/src/components/brackets/{game.tsx => match.tsx} (74%) delete mode 100644 frontend/src/components/info/player_elo_score.tsx create mode 100644 frontend/src/components/info/player_score.tsx delete mode 100644 frontend/src/services/club.tsx diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 40193f70..ff21dea4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,13 +9,19 @@ updates: directory: "/backend" schedule: interval: "daily" + ignore: + - update-types: ["version-update:semver-patch"] - package-ecosystem: "npm" directory: "/frontend" schedule: interval: "daily" + ignore: + - update-types: ["version-update:semver-patch"] - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + ignore: + - update-types: ["version-update:semver-patch"] diff --git a/README.md b/README.md index 6192fab8..e282ffe4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ sudo pg_createcluster -u postgres -p 5532 13 bracket pg_ctlcluster 13 bracket start ``` -Subsequently, create a new `bracket_dev` database (and `bracket_ci` for tests): +Subsequently, create a new `bracket_dev` database: ```shell sudo -Hu postgres psql -p 5532 CREATE USER bracket_ci WITH PASSWORD 'bracket_ci'; diff --git a/backend/Pipfile b/backend/Pipfile index c29031cf..f020342b 100644 --- a/backend/Pipfile +++ b/backend/Pipfile @@ -4,37 +4,37 @@ verify_ssl = true name = "pypi" [packages] -aiopg = "1.4.0" -fastapi = "0.88.0" -fastapi-cache2 = "0.2.0" -gunicorn = "20.1.0" -uvicorn = "0.20.0" -starlette = "0.22.0" +aiopg = ">=1.4.0" +fastapi = ">=0.88.0" +fastapi-cache2 = ">=0.2.0" +gunicorn = ">=20.1.0" +uvicorn = ">=0.20.0" +starlette = ">=0.22.0" sqlalchemy = "<2.0" -sqlalchemy-stubs = "0.4" -pydantic = "1.10.4" -heliclockter = "1.0.4" -alembic = "1.9.1" -types-simplejson = "3.18.0" -python-dotenv = "0.21.0" -databases = {extras = ["asyncpg"], version = "0.7.0"} -passlib = "1.7.4" +sqlalchemy-stubs = ">=0.4" +pydantic = ">=1.10.4" +heliclockter = ">=1.0.4" +alembic = ">=1.9.1" +types-simplejson = ">=3.18.0" +python-dotenv = ">=0.21.0" +databases = {extras = ["asyncpg"], version = ">=0.7.0"} +passlib = ">=1.7.4" types-passlib = "*" -pyjwt = "2.6.0" -click = "8.1.3" -python-multipart = "*" -parameterized = "*" +pyjwt = ">=2.6.0" +click = ">=8.1.3" +python-multipart = ">=0.0.5" +parameterized = ">=0.8.1" [dev-packages] -mypy = "0.991" -black = "22.12.0" -isort = "5.11.4" -pylint = "2.15.10" -pytest = "7.2.0" -pytest-cov = "4.0.0" -pytest-asyncio = "0.20.3" -aiohttp = "3.8.3" -aioresponses = "0.7.4" +mypy = ">=0.991" +black = ">=22.12.0" +isort = ">=5.11.4" +pylint = ">=2.15.10" +pytest = ">=7.2.0" +pytest-cov = ">=4.0.0" +pytest-asyncio = ">=0.20.3" +aiohttp = ">=3.8.3" +aioresponses = ">=0.7.4" [requires] python_version = "3.10" diff --git a/backend/bracket/logic/elo.py b/backend/bracket/logic/elo.py index e50fb407..de400167 100644 --- a/backend/bracket/logic/elo.py +++ b/backend/bracket/logic/elo.py @@ -22,32 +22,33 @@ def calculate_elo_per_player(rounds: list[RoundWithMatches]) -> defaultdict[int, player_x_elo: defaultdict[int, PlayerStatistics] = defaultdict(PlayerStatistics) for round in rounds: - for match in round.matches: - for team_index, team in enumerate(match.teams): - for player in team.players: - team_score = match.team1_score if team_index == 0 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 - ) + if not round.is_draft: + for match in round.matches: + for team_index, team in enumerate(match.teams): + for player in team.players: + team_score = match.team1_score if team_index == 0 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 + ) - if has_won: - player_x_elo[assert_some(player.id)].wins += 1 - player_x_elo[assert_some(player.id)].swiss_score += Decimal('1.00') - elif was_draw: - player_x_elo[assert_some(player.id)].draws += 1 - player_x_elo[assert_some(player.id)].swiss_score += Decimal('0.50') - else: - player_x_elo[assert_some(player.id)].losses += 1 + if has_won: + player_x_elo[assert_some(player.id)].wins += 1 + player_x_elo[assert_some(player.id)].swiss_score += Decimal('1.00') + elif was_draw: + player_x_elo[assert_some(player.id)].draws += 1 + player_x_elo[assert_some(player.id)].swiss_score += Decimal('0.50') + else: + player_x_elo[assert_some(player.id)].losses += 1 - player_x_elo[assert_some(player.id)].elo_score += team_score + player_x_elo[assert_some(player.id)].elo_score += team_score return player_x_elo async def recalculate_elo_for_tournament_id(tournament_id: int) -> None: rounds_response = await get_rounds_with_matches(tournament_id) - elo_per_player = calculate_elo_per_player(rounds_response.data) + elo_per_player = calculate_elo_per_player(rounds_response) for player_id, statistics in elo_per_player.items(): await database.execute( diff --git a/backend/bracket/logic/upcoming_matches.py b/backend/bracket/logic/upcoming_matches.py index da655b9c..3188742e 100644 --- a/backend/bracket/logic/upcoming_matches.py +++ b/backend/bracket/logic/upcoming_matches.py @@ -22,14 +22,14 @@ async def get_possible_upcoming_matches( ) -> list[SuggestedMatch]: suggestions: list[SuggestedMatch] = [] rounds_response = await get_rounds_with_matches(tournament_id) - draft_round = next((round for round in rounds_response.data if round.is_draft), None) + draft_round = next((round for round in rounds_response if round.is_draft), None) if draft_round is None: raise HTTPException(400, 'There is no draft round, so no matches can be scheduled.') teams = await get_teams_with_members(tournament_id, only_active_teams=True) - for i, team1 in enumerate(teams.data): - for j, team2 in enumerate(teams.data[i + 1 :]): + for i, team1 in enumerate(teams): + for j, team2 in enumerate(teams[i + 1 :]): team_already_scheduled = any( ( True diff --git a/backend/bracket/models/db/match.py b/backend/bracket/models/db/match.py index 6d223eb7..64b0e346 100644 --- a/backend/bracket/models/db/match.py +++ b/backend/bracket/models/db/match.py @@ -16,6 +16,7 @@ class Match(BaseModelORM): team2_id: int team1_score: int team2_score: int + label: str class UpcomingMatch(BaseModel): @@ -40,12 +41,14 @@ class MatchBody(BaseModelORM): round_id: int team1_score: int = 0 team2_score: int = 0 + label: str class MatchCreateBody(BaseModelORM): round_id: int team1_id: int team2_id: int + label: str class MatchToInsert(MatchCreateBody): diff --git a/backend/bracket/models/db/round.py b/backend/bracket/models/db/round.py index 2715c01c..83f59ede 100644 --- a/backend/bracket/models/db/round.py +++ b/backend/bracket/models/db/round.py @@ -5,6 +5,7 @@ from pydantic import validator from bracket.models.db.match import Match, MatchWithTeamDetails from bracket.models.db.shared import BaseModelORM +from bracket.utils.types import assert_some class Round(BaseModelORM): @@ -29,6 +30,9 @@ class RoundWithMatches(Round): return values + def get_team_ids(self) -> set[int]: + return {assert_some(team.id) for match in self.matches for team in match.teams} + class RoundBody(BaseModelORM): name: str diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index 052df640..a895bcc0 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -4,10 +4,11 @@ from heliclockter import datetime_utc from bracket.database import database from bracket.logic.elo import recalculate_elo_for_tournament_id from bracket.logic.upcoming_matches import get_possible_upcoming_matches -from bracket.models.db.match import MatchBody, MatchCreateBody, MatchFilter, MatchToInsert +from bracket.models.db.match import Match, MatchBody, MatchCreateBody, MatchFilter, MatchToInsert from bracket.models.db.user import UserPublic from bracket.routes.auth import user_authenticated_for_tournament from bracket.routes.models import SuccessResponse, UpcomingMatchesResponse +from bracket.routes.util import match_dependency from bracket.schema import matches router = APIRouter() @@ -24,11 +25,13 @@ async def get_matches_to_schedule( @router.delete("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse) async def delete_match( - tournament_id: int, match_id: int, _: UserPublic = Depends(user_authenticated_for_tournament) + tournament_id: int, + _: UserPublic = Depends(user_authenticated_for_tournament), + match: Match = Depends(match_dependency), ) -> SuccessResponse: await database.execute( query=matches.delete().where( - matches.c.id == match_id and matches.c.tournament_id == tournament_id + matches.c.id == match.id and matches.c.tournament_id == tournament_id ), ) await recalculate_elo_for_tournament_id(tournament_id) @@ -37,7 +40,6 @@ async def delete_match( @router.post("/tournaments/{tournament_id}/matches", response_model=SuccessResponse) async def create_match( - tournament_id: int, match_body: MatchCreateBody, _: UserPublic = Depends(user_authenticated_for_tournament), ) -> SuccessResponse: @@ -54,12 +56,12 @@ async def create_match( @router.patch("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse) async def update_match_by_id( tournament_id: int, - match_id: int, match_body: MatchBody, _: UserPublic = Depends(user_authenticated_for_tournament), + match: Match = Depends(match_dependency), ) -> SuccessResponse: await database.execute( - query=matches.update().where(matches.c.id == match_id), + query=matches.update().where(matches.c.id == match.id), values=match_body.dict(), ) await recalculate_elo_for_tournament_id(tournament_id) diff --git a/backend/bracket/routes/rounds.py b/backend/bracket/routes/rounds.py index 2887fcb0..67a9e448 100644 --- a/backend/bracket/routes/rounds.py +++ b/backend/bracket/routes/rounds.py @@ -1,15 +1,17 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from heliclockter import datetime_utc +from starlette import status from bracket.database import database from bracket.logic.elo import recalculate_elo_for_tournament_id -from bracket.models.db.round import RoundBody, RoundToInsert +from bracket.models.db.round import Round, RoundBody, RoundToInsert, RoundWithMatches 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 RoundsWithMatchesResponse, SuccessResponse +from bracket.routes.util import round_dependency, round_with_matches_dependency from bracket.schema import rounds from bracket.utils.sql import get_next_round_name, get_rounds_with_matches @@ -26,15 +28,24 @@ async def get_rounds( tournament_id, no_draft_rounds=user is None or no_draft_rounds ) if user is not None: - return rounds + return RoundsWithMatchesResponse(data=rounds) - return RoundsWithMatchesResponse(data=[round_ for round_ in rounds.data if not round_.is_draft]) + return RoundsWithMatchesResponse(data=[round_ for round_ in rounds if not round_.is_draft]) @router.delete("/tournaments/{tournament_id}/rounds/{round_id}", response_model=SuccessResponse) async def delete_round( - tournament_id: int, round_id: int, _: UserPublic = Depends(user_authenticated_for_tournament) + tournament_id: int, + round_id: int, + _: UserPublic = Depends(user_authenticated_for_tournament), + round_with_matches: RoundWithMatches = Depends(round_with_matches_dependency), ) -> SuccessResponse: + if len(round_with_matches.matches) > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Round contains matches, delete those first", + ) + await database.execute( query=rounds.delete().where( rounds.c.id == round_id and rounds.c.tournament_id == tournament_id @@ -65,6 +76,7 @@ async def update_round_by_id( round_id: int, round_body: RoundBody, _: UserPublic = Depends(user_authenticated_for_tournament), + round: Round = Depends(round_dependency), ) -> SuccessResponse: values = {'tournament_id': tournament_id, 'round_id': round_id} query = ''' diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index df830a52..7952ce3c 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -1,15 +1,18 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from heliclockter import datetime_utc +from starlette import status from bracket.database import database from bracket.logic.elo import recalculate_elo_for_tournament_id -from bracket.models.db.team import Team, TeamBody, TeamToInsert +from bracket.models.db.team import Team, TeamBody, TeamToInsert, TeamWithPlayers from bracket.models.db.user import UserPublic from bracket.routes.auth import user_authenticated_for_tournament from bracket.routes.models import SingleTeamResponse, SuccessResponse, TeamsWithPlayersResponse +from bracket.routes.util import team_dependency, team_with_players_dependency from bracket.schema import players, teams from bracket.utils.db import fetch_one_parsed -from bracket.utils.sql import get_teams_with_members +from bracket.utils.sql import get_rounds_with_matches, get_teams_with_members +from bracket.utils.types import assert_some router = APIRouter() @@ -38,30 +41,30 @@ async def update_team_members(team_id: int, tournament_id: int, player_ids: list async def get_teams( tournament_id: int, _: UserPublic = Depends(user_authenticated_for_tournament) ) -> TeamsWithPlayersResponse: - return await get_teams_with_members(tournament_id) + return TeamsWithPlayersResponse.parse_obj({'data': await get_teams_with_members(tournament_id)}) @router.patch("/tournaments/{tournament_id}/teams/{team_id}", response_model=SingleTeamResponse) async def update_team_by_id( tournament_id: int, - team_id: int, team_body: TeamBody, _: UserPublic = Depends(user_authenticated_for_tournament), + team: Team = Depends(team_dependency), ) -> SingleTeamResponse: await database.execute( query=teams.update().where( - (teams.c.id == team_id) & (teams.c.tournament_id == tournament_id) + (teams.c.id == team.id) & (teams.c.tournament_id == tournament_id) ), values=team_body.dict(exclude={'player_ids'}), ) - await update_team_members(team_id, tournament_id, team_body.player_ids) + await update_team_members(assert_some(team.id), tournament_id, team_body.player_ids) return SingleTeamResponse( data=await fetch_one_parsed( database, Team, teams.select().where( - (teams.c.id == team_id) & (teams.c.tournament_id == tournament_id) + (teams.c.id == team.id) & (teams.c.tournament_id == tournament_id) ), ) ) @@ -69,11 +72,27 @@ async def update_team_by_id( @router.delete("/tournaments/{tournament_id}/teams/{team_id}", response_model=SuccessResponse) async def delete_team( - tournament_id: int, team_id: int, _: UserPublic = Depends(user_authenticated_for_tournament) + tournament_id: int, + _: UserPublic = Depends(user_authenticated_for_tournament), + team: TeamWithPlayers = Depends(team_with_players_dependency), ) -> SuccessResponse: + rounds = await get_rounds_with_matches(tournament_id, no_draft_rounds=False) + for round in rounds: + if team.id in round.get_team_ids(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Could not delete team that participates in matches in the tournament", + ) + + if len(team.players): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Could not delete team that still has players in it", + ) + await database.execute( query=teams.delete().where( - teams.c.id == team_id and teams.c.tournament_id == tournament_id + teams.c.id == team.id and teams.c.tournament_id == tournament_id ), ) await recalculate_elo_for_tournament_id(tournament_id) @@ -91,7 +110,7 @@ async def create_team( values=TeamToInsert( **team_to_insert.dict(exclude={'player_ids'}), created=datetime_utc.now(), - tournament_id=tournament_id + tournament_id=tournament_id, ).dict(), ) await update_team_members(last_record_id, tournament_id, team_to_insert.player_ids) diff --git a/backend/bracket/routes/util.py b/backend/bracket/routes/util.py new file mode 100644 index 00000000..3546834b --- /dev/null +++ b/backend/bracket/routes/util.py @@ -0,0 +1,84 @@ +from fastapi import HTTPException +from starlette import status + +from bracket.database import database +from bracket.models.db.match import Match +from bracket.models.db.round import Round, RoundWithMatches +from bracket.models.db.team import Team, TeamWithPlayers +from bracket.schema import matches, rounds, teams +from bracket.utils.db import fetch_one_parsed +from bracket.utils.sql import get_rounds_with_matches, get_teams_with_members + + +async def round_dependency(tournament_id: int, round_id: int) -> Round: + round_ = await fetch_one_parsed( + database, + Round, + rounds.select().where(rounds.c.id == round_id and matches.c.tournament_id == tournament_id), + ) + + if round_ is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Could not find round with id {round_id}", + ) + + return round_ + + +async def round_with_matches_dependency(tournament_id: int, round_id: int) -> RoundWithMatches: + rounds = await get_rounds_with_matches(tournament_id, no_draft_rounds=False, round_id=round_id) + + if len(rounds) < 1: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Could not find round with id {round_id}", + ) + + return rounds[0] + + +async def match_dependency(tournament_id: int, match_id: int) -> Match: + match = await fetch_one_parsed( + database, + Match, + matches.select().where( + matches.c.id == match_id and matches.c.tournament_id == tournament_id + ), + ) + + if match is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Could not find match with id {match_id}", + ) + + return match + + +async def team_dependency(tournament_id: int, team_id: int) -> Team: + team = await fetch_one_parsed( + database, + Team, + teams.select().where(teams.c.id == team_id and teams.c.tournament_id == tournament_id), + ) + + if team is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Could not find team with id {team_id}", + ) + + return team + + +async def team_with_players_dependency(tournament_id: int, team_id: int) -> TeamWithPlayers: + teams = await get_teams_with_members(tournament_id, team_id=team_id) + + if len(teams) < 1: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Could not find team with id {team_id}", + ) + + return teams[0] diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index 3b3ca7d3..d25b4533 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -47,6 +47,7 @@ matches = Table( Column('team2_id', BigInteger, ForeignKey('teams.id'), nullable=False), Column('team1_score', Integer, nullable=False), Column('team2_score', Integer, nullable=False), + Column('label', String, nullable=False), ) teams = Table( diff --git a/backend/bracket/utils/dummy_records.py b/backend/bracket/utils/dummy_records.py index 0aa8748b..3dbd79c6 100644 --- a/backend/bracket/utils/dummy_records.py +++ b/backend/bracket/utils/dummy_records.py @@ -56,6 +56,7 @@ DUMMY_MATCH1 = Match( team2_id=2, team1_score=11, team2_score=22, + label='Court 1 | 11:00 - 11:20', ) DUMMY_MATCH2 = Match( @@ -65,6 +66,7 @@ DUMMY_MATCH2 = Match( team2_id=4, team1_score=9, team2_score=6, + label='Court 2 | 11:00 - 11:20', ) DUMMY_MATCH3 = Match( @@ -74,6 +76,7 @@ DUMMY_MATCH3 = Match( team2_id=4, team1_score=23, team2_score=26, + label='Court 1 | 11:30 - 11:50', ) DUMMY_MATCH4 = Match( @@ -83,6 +86,7 @@ DUMMY_MATCH4 = Match( team2_id=3, team1_score=43, team2_score=45, + label='Court 2 | 11:30 - 11:50', ) DUMMY_USER = User( diff --git a/backend/bracket/utils/sql.py b/backend/bracket/utils/sql.py index 667d8133..53e9e2ca 100644 --- a/backend/bracket/utils/sql.py +++ b/backend/bracket/utils/sql.py @@ -4,12 +4,16 @@ from bracket.database import database from bracket.models.db.round import RoundWithMatches from bracket.models.db.team import TeamWithPlayers from bracket.routes.models import RoundsWithMatchesResponse, TeamsWithPlayersResponse +from bracket.utils.types import dict_without_none async def get_rounds_with_matches( - tournament_id: int, no_draft_rounds: bool = False -) -> RoundsWithMatchesResponse: + tournament_id: int, + no_draft_rounds: bool = False, + round_id: int | None = None, +) -> list[RoundWithMatches]: draft_filter = 'AND rounds.is_draft IS FALSE' if no_draft_rounds else '' + round_filter = 'AND rounds.id = :round_id' if round_id is not None else '' query = f''' WITH teams_with_players AS ( SELECT DISTINCT ON (teams.id) @@ -34,12 +38,12 @@ async def get_rounds_with_matches( LEFT JOIN matches_with_teams m on rounds.id = m.round_id WHERE rounds.tournament_id = :tournament_id {draft_filter} + {round_filter} GROUP BY rounds.id ''' - result = await database.fetch_all(query=query, values={'tournament_id': tournament_id}) - return RoundsWithMatchesResponse.parse_obj( - {'data': [RoundWithMatches.parse_obj(x._mapping) for x in result]} - ) + values = dict_without_none({'tournament_id': tournament_id, 'round_id': round_id}) + result = await database.fetch_all(query=query, values=values) + return [RoundWithMatches.parse_obj(x._mapping) for x in result] async def get_next_round_name(database: Database, tournament_id: int) -> str: @@ -54,21 +58,22 @@ async def get_next_round_name(database: Database, tournament_id: int) -> str: async def get_teams_with_members( - tournament_id: int, *, only_active_teams: bool = False -) -> TeamsWithPlayersResponse: - teams_filter = 'AND teams.active IS TRUE' if only_active_teams else '' + tournament_id: int, *, only_active_teams: bool = False, team_id: int | None = None +) -> list[TeamWithPlayers]: + active_team_filter = 'AND teams.active IS TRUE' if only_active_teams else '' + team_id_filter = 'AND teams.id = :team_id' if team_id is not None else '' query = f''' SELECT teams.*, to_json(array_agg(players.*)) AS players FROM teams LEFT JOIN players ON players.team_id = teams.id WHERE teams.tournament_id = :tournament_id - {teams_filter} + {active_team_filter} + {team_id_filter} GROUP BY teams.id; ''' - result = await database.fetch_all(query=query, values={'tournament_id': tournament_id}) - return TeamsWithPlayersResponse.parse_obj( - {'data': [TeamWithPlayers.parse_obj(x._mapping) for x in result]} - ) + values = dict_without_none({'tournament_id': tournament_id, 'team_id': team_id}) + result = await database.fetch_all(query=query, values=values) + return [TeamWithPlayers.parse_obj(x._mapping) for x in result] async def get_user_access_to_tournament(tournament_id: int, user_id: int) -> bool: diff --git a/backend/bracket/utils/types.py b/backend/bracket/utils/types.py index 11991c2f..0e0bc0fe 100644 --- a/backend/bracket/utils/types.py +++ b/backend/bracket/utils/types.py @@ -31,3 +31,7 @@ class EnumAutoStr(EnumValues): def assert_some(result: T | None) -> T: assert result is not None return result + + +def dict_without_none(input: dict[Any, Any]) -> dict[Any, Any]: + return {k: v for k, v in input.items() if v is not None} diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py index 867a63bb..9e5ee51f 100644 --- a/backend/tests/integration_tests/api/matches_test.py +++ b/backend/tests/integration_tests/api/matches_test.py @@ -32,6 +32,7 @@ async def test_create_match( 'team1_id': team1_inserted.id, 'team2_id': team2_inserted.id, 'round_id': round_inserted.id, + 'label': 'Some label', } assert ( await send_tournament_request( @@ -81,7 +82,12 @@ async def test_update_match( } ) ) as match_inserted: - body = {'team1_score': 42, 'team2_score': 24, 'round_id': round_inserted.id} + body = { + 'team1_score': 42, + 'team2_score': 24, + 'round_id': round_inserted.id, + 'label': 'Some label', + } assert ( await send_tournament_request( HTTPMethod.PATCH, @@ -99,6 +105,7 @@ async def test_update_match( ) assert patched_match.team1_score == body['team1_score'] assert patched_match.team2_score == body['team2_score'] + assert patched_match.label == body['label'] await assert_row_count_and_clear(matches, 1) diff --git a/backend/tests/unit_tests/elo_test.py b/backend/tests/unit_tests/elo_test.py index 609b9ccb..bf3cd13e 100644 --- a/backend/tests/unit_tests/elo_test.py +++ b/backend/tests/unit_tests/elo_test.py @@ -11,7 +11,7 @@ def test_elo_calculation() -> None: round_ = RoundWithMatches( tournament_id=1, created=DUMMY_MOCK_TIME, - is_draft=True, + is_draft=False, is_active=False, name='Some round', matches=[ @@ -22,6 +22,7 @@ def test_elo_calculation() -> None: team1_score=3, team2_score=4, round_id=1, + label='Some label', team1=TeamWithPlayers( name='Dummy team 1', tournament_id=1, diff --git a/codecov.yml b/codecov.yml index dfc4b4eb..9ca22cb9 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,8 +2,6 @@ coverage: status: project: default: - target: 100% - threshold: 100% informational: true wait_for_ci: false require_ci_to_pass: false diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 22bab82e..34df8f6c 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1 +1,110 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/brackets/brackets.tsx b/frontend/src/components/brackets/brackets.tsx index 75938954..efd35efa 100644 --- a/frontend/src/components/brackets/brackets.tsx +++ b/frontend/src/components/brackets/brackets.tsx @@ -21,7 +21,7 @@ export default function Brackets({ } const rounds = swrRoundsResponse.data.data.map((round: RoundInterface) => ( - + ({ root: { width: '100%', - marginTop: '20px', + marginTop: '30px', }, divider: { backgroundColor: 'darkgray', @@ -29,7 +40,18 @@ const useStyles = createStyles((theme) => ({ }, })); -export default function Game({ +function MatchBadge({ match }: { match: MatchInterface }) { + const visibility: Visibility = match.label === '' ? 'hidden' : 'visible'; + return ( +
+ + {match.label} + +
+ ); +} + +export default function Match({ swrRoundsResponse, swrUpcomingMatchesResponse, tournamentData, @@ -53,12 +75,16 @@ export default function Game({ const team1_players = match.team1.players.map((player) => player.name).join(', '); const team2_players = match.team2.players.map((player) => player.name).join(', '); + const team1_players_label = team1_players === '' ? 'No players' : team1_players; + const team2_players_label = team2_players === '' ? 'No players' : team2_players; + const [opened, setOpened] = useState(false); const bracket = ( <> +
- + {match.team1.name} {match.team1_score} @@ -67,7 +93,7 @@ export default function Game({
- + {match.team2.name} {match.team2_score} diff --git a/frontend/src/components/brackets/round.tsx b/frontend/src/components/brackets/round.tsx index e3eea9ad..7be66b54 100644 --- a/frontend/src/components/brackets/round.tsx +++ b/frontend/src/components/brackets/round.tsx @@ -5,7 +5,7 @@ import { SWRResponse } from 'swr'; import { RoundInterface } from '../../interfaces/round'; import { TournamentMinimal } from '../../interfaces/tournament'; import RoundModal from '../modals/round_modal'; -import Game from './game'; +import Match from './match'; export default function Round({ tournamentData, @@ -20,16 +20,18 @@ export default function Round({ swrUpcomingMatchesResponse: SWRResponse | null; readOnly: boolean; }) { - const games = round.matches.map((match) => ( - - )); + const matches = round.matches + .sort((m1, m2) => (m1.label > m2.label ? 1 : 0)) + .map((match) => ( + + )); const active_round_style = round.is_active ? { borderStyle: 'solid', @@ -68,7 +70,7 @@ export default function Round({ }} >
{modal}
- {games} + {matches}
); diff --git a/frontend/src/components/info/player_elo_score.tsx b/frontend/src/components/info/player_elo_score.tsx deleted file mode 100644 index c9424c17..00000000 --- a/frontend/src/components/info/player_elo_score.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Group, Progress, Text, useMantineTheme } from '@mantine/core'; - -interface ELOProps { - elo_score: number; - max_elo_score: number; -} - -export function PlayerELOScore({ elo_score, max_elo_score }: ELOProps) { - const theme = useMantineTheme(); - const percentageScale = 100.0 / max_elo_score; - - return ( - <> - - - {elo_score.toFixed(0)} - - - - - ); -} diff --git a/frontend/src/components/info/player_score.tsx b/frontend/src/components/info/player_score.tsx new file mode 100644 index 00000000..da97380e --- /dev/null +++ b/frontend/src/components/info/player_score.tsx @@ -0,0 +1,34 @@ +import { Group, Progress, Text, useMantineTheme } from '@mantine/core'; +import { DefaultMantineColor } from '@mantine/styles/lib/theme/types/MantineColor'; + +interface ScoreProps { + score: number; + max_score: number; + color: DefaultMantineColor; + decimals: number; +} + +export function PlayerScore({ score, max_score, color, decimals }: ScoreProps) { + const theme = useMantineTheme(); + const percentageScale = 100.0 / max_score; + const base_color = theme.colors[color]; + + return ( + <> + + + {score.toFixed(0)} + + + + + ); +} diff --git a/frontend/src/components/modals/match_modal.tsx b/frontend/src/components/modals/match_modal.tsx index 80166bb9..f0630db2 100644 --- a/frontend/src/components/modals/match_modal.tsx +++ b/frontend/src/components/modals/match_modal.tsx @@ -1,4 +1,4 @@ -import { Button, Modal, NumberInput } from '@mantine/core'; +import { Button, Modal, NumberInput, TextInput } from '@mantine/core'; import { useForm } from '@mantine/form'; import React from 'react'; import { SWRResponse } from 'swr'; @@ -27,6 +27,7 @@ export default function MatchModal({ initialValues: { team1_score: match != null ? match.team1_score : 0, team2_score: match != null ? match.team2_score : 0, + label: match != null ? match.label : '', }, validate: { @@ -45,6 +46,7 @@ export default function MatchModal({ round_id: match.round_id, team1_score: values.team1_score, team2_score: values.team2_score, + label: values.label, }; await updateMatch(tournamentData.id, match.id, newMatch); await swrRoundsResponse.mutate(null); @@ -65,6 +67,13 @@ export default function MatchModal({ placeholder={`Score of ${match.team2.name}`} {...form.getInputProps('team2_score')} /> + diff --git a/frontend/src/components/navbar/_brand.tsx b/frontend/src/components/navbar/_brand.tsx index 3fd20766..9e0d935d 100644 --- a/frontend/src/components/navbar/_brand.tsx +++ b/frontend/src/components/navbar/_brand.tsx @@ -2,11 +2,12 @@ import { ActionIcon, Box, Group, + Image, Title, UnstyledButton, useMantineColorScheme, } from '@mantine/core'; -import { IconBrackets, IconMoonStars, IconSun } from '@tabler/icons'; +import { IconMoonStars, IconSun } from '@tabler/icons'; import { useRouter } from 'next/router'; import React from 'react'; @@ -26,15 +27,17 @@ export function Brand() { })} > - - { - router.push('/'); - }} - > - Bracket - + + + { + router.push('/'); + }} + > + Bracket + + toggleColorScheme()} size={30}> {colorScheme === 'dark' ? : } diff --git a/frontend/src/components/tables/players.tsx b/frontend/src/components/tables/players.tsx index 4750b4e1..6e33cc28 100644 --- a/frontend/src/components/tables/players.tsx +++ b/frontend/src/components/tables/players.tsx @@ -5,7 +5,7 @@ import { Player } from '../../interfaces/player'; import { TournamentMinimal } from '../../interfaces/tournament'; import { deletePlayer } from '../../services/player'; import DeleteButton from '../buttons/delete'; -import { PlayerELOScore } from '../info/player_elo_score'; +import { PlayerScore } from '../info/player_score'; import { PlayerStatistics } from '../info/player_statistics'; import PlayerModal from '../modals/player_modal'; import DateTime from '../utils/datetime'; @@ -23,6 +23,7 @@ export default function PlayersTable({ const tableState = getTableState('name'); const maxELOScore = Math.max(...players.map((player) => player.elo_score)); + const maxSwissScore = Math.max(...players.map((player) => player.swiss_score)); if (swrPlayersResponse.error) return ; @@ -38,7 +39,20 @@ export default function PlayersTable({ - + + + + ELO score + + Swiss score + {null} diff --git a/frontend/src/components/tables/table.tsx b/frontend/src/components/tables/table.tsx index 7d242752..a432ee9b 100644 --- a/frontend/src/components/tables/table.tsx +++ b/frontend/src/components/tables/table.tsx @@ -57,9 +57,9 @@ export const setSorting = (state: TableState, newSortField: string) => { export const getTableState = ( initial_sort_field: string, - initial_sort_direection: boolean = true + initial_sort_direction: boolean = true ) => { - const [reversed, setReversed] = useState(initial_sort_direection); + const [reversed, setReversed] = useState(initial_sort_direction); const [sortField, setSortField] = useState(initial_sort_field); return { sortField, diff --git a/frontend/src/components/tables/upcoming_matches.tsx b/frontend/src/components/tables/upcoming_matches.tsx index 09e442b9..abe9b1ca 100644 --- a/frontend/src/components/tables/upcoming_matches.tsx +++ b/frontend/src/components/tables/upcoming_matches.tsx @@ -34,6 +34,7 @@ export default function UpcomingMatchesTable({ team1_id: upcoming_match.team1.id, team2_id: upcoming_match.team2.id, round_id, + label: '', }; await createMatch(tournamentData.id, match_to_schedule); await swrRoundsResponse.mutate(null); @@ -45,7 +46,7 @@ export default function UpcomingMatchesTable({ sortTableEntries(m1, m2, tableState) ) .map((upcoming_match: UpcomingMatchInterface) => ( - + @@ -74,10 +75,10 @@ export default function UpcomingMatchesTable({ - + Team 1 - + Team 2 diff --git a/frontend/src/interfaces/match.tsx b/frontend/src/interfaces/match.tsx index d4f29cef..c43a09a7 100644 --- a/frontend/src/interfaces/match.tsx +++ b/frontend/src/interfaces/match.tsx @@ -8,6 +8,7 @@ export interface MatchInterface { team2_score: number; team1: TeamInterface; team2: TeamInterface; + label: string; } export interface MatchBodyInterface { @@ -15,6 +16,7 @@ export interface MatchBodyInterface { round_id: number; team1_score: number; team2_score: number; + label: string; } export interface UpcomingMatchInterface { @@ -28,4 +30,5 @@ export interface MatchCreateBodyInterface { round_id: number; team1_id: number; team2_id: number; + label: string; } diff --git a/frontend/src/interfaces/player.tsx b/frontend/src/interfaces/player.tsx index 1ce09016..51fcebea 100644 --- a/frontend/src/interfaces/player.tsx +++ b/frontend/src/interfaces/player.tsx @@ -5,6 +5,7 @@ export interface Player { tournament_id: number; team_id: number; elo_score: number; + swiss_score: number; wins: number; draws: number; losses: number; diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index b6dbb3d0..40334327 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -12,14 +12,16 @@ export default function Login() { const router = useRouter(); async function attemptLogin(email: string, password: string) { - await performLogin(email, password); - showNotification({ - color: 'green', - title: 'Login successful', - message: '', - }); + const success = await performLogin(email, password); + if (success) { + showNotification({ + color: 'green', + title: 'Login successful', + message: '', + }); - await router.push('/'); + await router.push('/'); + } } const form = useForm({ initialValues: { diff --git a/frontend/src/pages/tournaments/[id]/dashboard.tsx b/frontend/src/pages/tournaments/[id]/dashboard.tsx index e1a9ed95..44553d55 100644 --- a/frontend/src/pages/tournaments/[id]/dashboard.tsx +++ b/frontend/src/pages/tournaments/[id]/dashboard.tsx @@ -8,6 +8,12 @@ import { getTournamentIdFromRouter } from '../../../components/utils/util'; import { Tournament } from '../../../interfaces/tournament'; import { getBaseApiUrl, getRounds, getTournaments } from '../../../services/adapter'; +function TournamentLogo({ tournamentDataFull }: { tournamentDataFull: Tournament }) { + return tournamentDataFull.logo_path ? ( + + ) : null; +} + export default function Dashboard() { const { tournamentData } = getTournamentIdFromRouter(); const swrRoundsResponse: SWRResponse = getRounds(tournamentData.id, true); @@ -24,15 +30,12 @@ export default function Dashboard() { } return ( - - + + {tournamentDataFull.name} - + - + { +export function getTournaments(): SWRResponse { return useSWR('tournaments', fetcher); } -export function getPlayers( - tournament_id: number, - not_in_team: boolean = false -): SWRResponse { +export function getPlayers(tournament_id: number, not_in_team: boolean = false): SWRResponse { return useSWR(`tournaments/${tournament_id}/players?not_in_team=${not_in_team}`, fetcher); } -export function getTeams(tournament_id: number): SWRResponse { +export function getTeams(tournament_id: number): SWRResponse { return useSWR(`tournaments/${tournament_id}/teams`, fetcher); } -export function getRounds( - tournament_id: number, - no_draft_rounds: boolean = false -): SWRResponse { +export function getRounds(tournament_id: number, no_draft_rounds: boolean = false): SWRResponse { return useSWR(`tournaments/${tournament_id}/rounds?no_draft_rounds=${no_draft_rounds}`, fetcher); } -export function getUpcomingMatches(tournament_id: number): SWRResponse { +export function getUpcomingMatches(tournament_id: number): SWRResponse { return useSWR(`tournaments/${tournament_id}/upcoming_matches`, fetcher); } diff --git a/frontend/src/services/club.tsx b/frontend/src/services/club.tsx deleted file mode 100644 index 278ef2f3..00000000 --- a/frontend/src/services/club.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { createAxios } from './adapter'; - -export async function createTeam( - tournament_id: number, - name: string, - active: boolean, - player_ids: number[] -) { - await createAxios().post(`tournaments/${tournament_id}/teams`, { - name, - active, - player_ids, - }); -} - -export async function deleteTeam(tournament_id: number, team_id: number) { - await createAxios().delete(`tournaments/${tournament_id}/teams/${team_id}`); -} - -export async function updateTeam( - tournament_id: number, - team_id: number, - name: string, - active: boolean, - player_ids: number[] -) { - await createAxios().patch(`tournaments/${tournament_id}/teams/${team_id}`, { - name, - active, - player_ids, - }); -} diff --git a/frontend/src/services/match.tsx b/frontend/src/services/match.tsx index 39aef08a..e35d69c8 100644 --- a/frontend/src/services/match.tsx +++ b/frontend/src/services/match.tsx @@ -1,12 +1,16 @@ import { MatchBodyInterface, MatchCreateBodyInterface } from '../interfaces/match'; -import { createAxios } from './adapter'; +import { createAxios, handleRequestError } from './adapter'; export async function createMatch(tournament_id: number, match: MatchCreateBodyInterface) { - return createAxios().post(`tournaments/${tournament_id}/matches`, match); + return createAxios() + .post(`tournaments/${tournament_id}/matches`, match) + .catch((response: any) => handleRequestError(response)); } export async function deleteMatch(tournament_id: number, match_id: number) { - return createAxios().delete(`tournaments/${tournament_id}/matches/${match_id}`); + return createAxios() + .delete(`tournaments/${tournament_id}/matches/${match_id}`) + .catch((response: any) => handleRequestError(response)); } export async function updateMatch( @@ -14,5 +18,7 @@ export async function updateMatch( match_id: number, match: MatchBodyInterface ) { - return createAxios().patch(`tournaments/${tournament_id}/matches/${match_id}`, match); + return createAxios() + .patch(`tournaments/${tournament_id}/matches/${match_id}`, match) + .catch((response: any) => handleRequestError(response)); } diff --git a/frontend/src/services/player.tsx b/frontend/src/services/player.tsx index a1687d41..52589ecf 100644 --- a/frontend/src/services/player.tsx +++ b/frontend/src/services/player.tsx @@ -1,14 +1,18 @@ -import { createAxios } from './adapter'; +import { createAxios, handleRequestError } from './adapter'; export async function createPlayer(tournament_id: number, name: string, team_id: string | null) { - return createAxios().post(`tournaments/${tournament_id}/players`, { - name, - team_id, - }); + return createAxios() + .post(`tournaments/${tournament_id}/players`, { + name, + team_id, + }) + .catch((response: any) => handleRequestError(response)); } export async function deletePlayer(tournament_id: number, player_id: number) { - return createAxios().delete(`tournaments/${tournament_id}/players/${player_id}`); + return createAxios() + .delete(`tournaments/${tournament_id}/players/${player_id}`) + .catch((response: any) => handleRequestError(response)); } export async function updatePlayer( @@ -17,8 +21,10 @@ export async function updatePlayer( name: string, team_id: string | null ) { - return createAxios().patch(`tournaments/${tournament_id}/players/${player_id}`, { - name, - team_id, - }); + return createAxios() + .patch(`tournaments/${tournament_id}/players/${player_id}`, { + name, + team_id, + }) + .catch((response: any) => handleRequestError(response)); } diff --git a/frontend/src/services/round.tsx b/frontend/src/services/round.tsx index 9f3e05e9..d0b7a259 100644 --- a/frontend/src/services/round.tsx +++ b/frontend/src/services/round.tsx @@ -1,14 +1,20 @@ import { RoundInterface } from '../interfaces/round'; -import { createAxios } from './adapter'; +import { createAxios, handleRequestError } from './adapter'; export async function createRound(tournament_id: number) { - return createAxios().post(`tournaments/${tournament_id}/rounds`); + return createAxios() + .post(`tournaments/${tournament_id}/rounds`) + .catch((response: any) => handleRequestError(response)); } export async function deleteRound(tournament_id: number, round_id: number) { - return createAxios().delete(`tournaments/${tournament_id}/rounds/${round_id}`); + return createAxios() + .delete(`tournaments/${tournament_id}/rounds/${round_id}`) + .catch((response: any) => handleRequestError(response)); } export async function updateRound(tournament_id: number, round_id: number, round: RoundInterface) { - return createAxios().patch(`tournaments/${tournament_id}/rounds/${round_id}`, round); + return createAxios() + .patch(`tournaments/${tournament_id}/rounds/${round_id}`, round) + .catch((response: any) => handleRequestError(response)); } diff --git a/frontend/src/services/team.tsx b/frontend/src/services/team.tsx index 278ef2f3..f1c659eb 100644 --- a/frontend/src/services/team.tsx +++ b/frontend/src/services/team.tsx @@ -1,4 +1,4 @@ -import { createAxios } from './adapter'; +import { createAxios, handleRequestError } from './adapter'; export async function createTeam( tournament_id: number, @@ -14,7 +14,9 @@ export async function createTeam( } export async function deleteTeam(tournament_id: number, team_id: number) { - await createAxios().delete(`tournaments/${tournament_id}/teams/${team_id}`); + await createAxios() + .delete(`tournaments/${tournament_id}/teams/${team_id}`) + .catch((response: any) => handleRequestError(response)); } export async function updateTeam( @@ -24,9 +26,11 @@ export async function updateTeam( active: boolean, player_ids: number[] ) { - await createAxios().patch(`tournaments/${tournament_id}/teams/${team_id}`, { - name, - active, - player_ids, - }); + await createAxios() + .patch(`tournaments/${tournament_id}/teams/${team_id}`, { + name, + active, + player_ids, + }) + .catch((response: any) => handleRequestError(response)); } diff --git a/frontend/src/services/user.tsx b/frontend/src/services/user.tsx index 2a650c4f..10db271a 100644 --- a/frontend/src/services/user.tsx +++ b/frontend/src/services/user.tsx @@ -1,4 +1,4 @@ -import { createAxios } from './adapter'; +import { createAxios, handleRequestError } from './adapter'; export async function performLogin(username: string, password: string) { const bodyFormData = new FormData(); @@ -6,12 +6,21 @@ export async function performLogin(username: string, password: string) { bodyFormData.append('username', username); bodyFormData.append('password', password); - const response = await createAxios().post('token', bodyFormData); + const response = await createAxios() + .post('token', bodyFormData) + .catch((err_response: any) => handleRequestError(err_response)); + + if (response == null) { + return false; + } + localStorage.setItem('login', JSON.stringify(response.data)); + handleRequestError(response); + // Reload axios object. createAxios(); - return response; + return true; } export function performLogout() {