From f834fab2dee0c09ba67a6ed3da651a1f5387b0ed Mon Sep 17 00:00:00 2001 From: Erik Vroon Date: Mon, 12 Feb 2024 19:08:50 +0100 Subject: [PATCH] Add pagination (#472) Adds pagination (backend and frontend) to teams and players GET endpoints --- backend/bracket/routes/models.py | 14 +++++- backend/bracket/routes/players.py | 23 +++++++-- backend/bracket/routes/teams.py | 26 ++++++++-- backend/bracket/sql/players.py | 49 ++++++++++++++++--- backend/bracket/sql/teams.py | 42 ++++++++++++++-- backend/bracket/utils/pagination.py | 13 +++++ .../integration_tests/api/players_test.py | 31 ++++++------ .../tests/integration_tests/api/teams_test.py | 33 +++++++------ .../components/modals/team_create_modal.tsx | 2 +- .../components/modals/team_update_modal.tsx | 2 +- frontend/src/components/tables/players.tsx | 3 +- frontend/src/components/tables/standings.tsx | 3 +- frontend/src/components/utils/util.tsx | 5 ++ .../src/pages/tournaments/[id]/players.tsx | 16 ++++-- frontend/src/pages/tournaments/[id]/teams.tsx | 20 ++++++-- frontend/src/services/adapter.tsx | 15 ++++++ frontend/src/services/lookups.tsx | 2 +- 17 files changed, 238 insertions(+), 61 deletions(-) create mode 100644 backend/bracket/utils/pagination.py diff --git a/backend/bracket/routes/models.py b/backend/bracket/routes/models.py index 99a30dd20..991db893e 100644 --- a/backend/bracket/routes/models.py +++ b/backend/bracket/routes/models.py @@ -43,7 +43,12 @@ class TournamentsResponse(DataResponse[list[Tournament]]): pass -class PlayersResponse(DataResponse[list[Player]]): +class PaginatedPlayers(BaseModel): + count: int + players: list[Player] + + +class PlayersResponse(DataResponse[PaginatedPlayers]): pass @@ -63,7 +68,12 @@ class SingleMatchResponse(DataResponse[Match]): pass -class TeamsWithPlayersResponse(DataResponse[list[FullTeamWithPlayers]]): +class PaginatedTeams(BaseModel): + count: int + teams: list[FullTeamWithPlayers] + + +class TeamsWithPlayersResponse(DataResponse[PaginatedTeams]): pass diff --git a/backend/bracket/routes/players.py b/backend/bracket/routes/players.py index a05c30347..00921d891 100644 --- a/backend/bracket/routes/players.py +++ b/backend/bracket/routes/players.py @@ -5,10 +5,21 @@ from bracket.models.db.player import Player, PlayerBody, PlayerMultiBody from bracket.models.db.user import UserPublic from bracket.routes.auth import user_authenticated_for_tournament -from bracket.routes.models import PlayersResponse, SinglePlayerResponse, SuccessResponse +from bracket.routes.models import ( + PaginatedPlayers, + PlayersResponse, + SinglePlayerResponse, + SuccessResponse, +) from bracket.schema import players -from bracket.sql.players import get_all_players_in_tournament, insert_player, sql_delete_player +from bracket.sql.players import ( + get_all_players_in_tournament, + get_player_count, + insert_player, + sql_delete_player, +) from bracket.utils.db import fetch_one_parsed +from bracket.utils.pagination import Pagination from bracket.utils.types import assert_some router = APIRouter() @@ -18,10 +29,16 @@ async def get_players( tournament_id: int, not_in_team: bool = False, + pagination: Pagination = Depends(), _: UserPublic = Depends(user_authenticated_for_tournament), ) -> PlayersResponse: return PlayersResponse( - data=await get_all_players_in_tournament(tournament_id, not_in_team=not_in_team) + data=PaginatedPlayers( + players=await get_all_players_in_tournament( + tournament_id, not_in_team=not_in_team, pagination=pagination + ), + count=await get_player_count(tournament_id, not_in_team=not_in_team), + ) ) diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index 9a336b226..98dae8034 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -11,12 +11,23 @@ user_authenticated_for_tournament, user_authenticated_or_public_dashboard, ) -from bracket.routes.models import SingleTeamResponse, SuccessResponse, TeamsWithPlayersResponse +from bracket.routes.models import ( + PaginatedTeams, + SingleTeamResponse, + SuccessResponse, + TeamsWithPlayersResponse, +) from bracket.routes.util import team_dependency, team_with_players_dependency from bracket.schema import players_x_teams, teams from bracket.sql.stages import get_full_tournament_details -from bracket.sql.teams import get_team_by_id, get_teams_with_members, sql_delete_team +from bracket.sql.teams import ( + get_team_by_id, + get_team_count, + get_teams_with_members, + sql_delete_team, +) from bracket.utils.db import fetch_one_parsed +from bracket.utils.pagination import Pagination from bracket.utils.types import assert_some router = APIRouter() @@ -45,10 +56,15 @@ async def update_team_members(team_id: int, tournament_id: int, player_ids: set[ @router.get("/tournaments/{tournament_id}/teams", response_model=TeamsWithPlayersResponse) async def get_teams( - tournament_id: int, _: UserPublic = Depends(user_authenticated_or_public_dashboard) + tournament_id: int, + pagination: Pagination = Depends(), + _: UserPublic = Depends(user_authenticated_or_public_dashboard), ) -> TeamsWithPlayersResponse: - return TeamsWithPlayersResponse.model_validate( - {"data": await get_teams_with_members(tournament_id)} + return TeamsWithPlayersResponse( + data=PaginatedTeams( + teams=await get_teams_with_members(tournament_id, pagination=pagination), + count=await get_team_count(tournament_id), + ) ) diff --git a/backend/bracket/sql/players.py b/backend/bracket/sql/players.py index 9eb90e38d..d678da345 100644 --- a/backend/bracket/sql/players.py +++ b/backend/bracket/sql/players.py @@ -1,4 +1,5 @@ from decimal import Decimal +from typing import cast from heliclockter import datetime_utc @@ -6,21 +7,57 @@ 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.pagination import Pagination +from bracket.utils.types import dict_without_none async def get_all_players_in_tournament( - tournament_id: int, *, not_in_team: bool = False + tournament_id: int, + *, + not_in_team: bool = False, + pagination: Pagination | None = None, ) -> list[Player]: - query = """ + not_in_team_filter = "AND players.team_id IS NULL" if not_in_team else "" + limit_filter = "LIMIT :limit" if pagination is not None and pagination.limit is not None else "" + offset_filter = ( + "OFFSET :offset" if pagination is not None and pagination.offset is not None else "" + ) + query = f""" SELECT * FROM players WHERE players.tournament_id = :tournament_id + {not_in_team_filter} + ORDER BY name + {limit_filter} + {offset_filter} """ - if not_in_team: - query += "AND players.team_id IS NULL" - result = await database.fetch_all(query=query, values={"tournament_id": tournament_id}) - return [Player.model_validate(dict(x._mapping)) for x in result] + result = await database.fetch_all( + query=query, + values=dict_without_none( + { + "tournament_id": tournament_id, + "offset": pagination.offset if pagination is not None else None, + "limit": pagination.limit if pagination is not None else None, + } + ), + ) + return [Player.model_validate(x) for x in result] + + +async def get_player_count( + tournament_id: int, + *, + not_in_team: bool = False, +) -> int: + not_in_team_filter = "AND players.team_id IS NULL" if not_in_team else "" + query = f""" + SELECT count(*) + FROM players + WHERE players.tournament_id = :tournament_id + {not_in_team_filter} + """ + return cast(int, await database.fetch_val(query=query, values={"tournament_id": tournament_id})) async def update_player_stats( diff --git a/backend/bracket/sql/teams.py b/backend/bracket/sql/teams.py index abf8da70a..e1ff0eddd 100644 --- a/backend/bracket/sql/teams.py +++ b/backend/bracket/sql/teams.py @@ -1,6 +1,9 @@ +from typing import cast + from bracket.database import database from bracket.models.db.players import PlayerStatistics from bracket.models.db.team import FullTeamWithPlayers, Team +from bracket.utils.pagination import Pagination from bracket.utils.types import dict_without_none @@ -18,10 +21,18 @@ async def get_team_by_id(team_id: int, tournament_id: int) -> Team | None: async def get_teams_with_members( - tournament_id: int, *, only_active_teams: bool = False, team_id: int | None = None + tournament_id: int, + *, + only_active_teams: bool = False, + team_id: int | None = None, + pagination: Pagination | None = None, ) -> list[FullTeamWithPlayers]: 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 "" + limit_filter = "LIMIT :limit" if pagination is not None and pagination.limit is not None else "" + offset_filter = ( + "OFFSET :offset" if pagination is not None and pagination.offset is not None else "" + ) query = f""" SELECT teams.*, @@ -34,10 +45,35 @@ async def get_teams_with_members( {team_id_filter} GROUP BY teams.id ORDER BY teams.elo_score DESC, teams.wins DESC, name ASC + {limit_filter} + {offset_filter} """ - values = dict_without_none({"tournament_id": tournament_id, "team_id": team_id}) + values = dict_without_none( + { + "tournament_id": tournament_id, + "team_id": team_id, + "limit": pagination.limit if pagination is not None else None, + "offset": pagination.offset if pagination is not None else None, + } + ) result = await database.fetch_all(query=query, values=values) - return [FullTeamWithPlayers.model_validate(dict(x._mapping)) for x in result] + return [FullTeamWithPlayers.model_validate(x) for x in result] + + +async def get_team_count( + tournament_id: int, + *, + only_active_teams: bool = False, +) -> int: + active_team_filter = "AND teams.active IS TRUE" if only_active_teams else "" + query = f""" + SELECT count(*) + FROM teams + WHERE teams.tournament_id = :tournament_id + {active_team_filter} + """ + values = dict_without_none({"tournament_id": tournament_id}) + return cast(int, await database.fetch_val(query=query, values=values)) async def update_team_stats( diff --git a/backend/bracket/utils/pagination.py b/backend/bracket/utils/pagination.py new file mode 100644 index 000000000..b42671dc7 --- /dev/null +++ b/backend/bracket/utils/pagination.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +from fastapi import Query + + +@dataclass +class Limit: + limit: int = Query(25, ge=1, le=100, description="Max number of results in a single page.") + + +@dataclass +class Pagination(Limit): + offset: int = Query(0, ge=0, description="Filter results starting from this offset.") diff --git a/backend/tests/integration_tests/api/players_test.py b/backend/tests/integration_tests/api/players_test.py index 773a9ef5f..98190aed9 100644 --- a/backend/tests/integration_tests/api/players_test.py +++ b/backend/tests/integration_tests/api/players_test.py @@ -19,20 +19,23 @@ async def test_players_endpoint( DUMMY_PLAYER1.model_copy(update={"tournament_id": auth_context.tournament.id}) ) as player_inserted: assert await send_tournament_request(HTTPMethod.GET, "players", auth_context, {}) == { - "data": [ - { - "created": DUMMY_MOCK_TIME.isoformat().replace("+00:00", "Z"), - "id": player_inserted.id, - "active": True, - "elo_score": "0.0", - "swiss_score": "0.0", - "wins": 0, - "draws": 0, - "losses": 0, - "name": "Player 01", - "tournament_id": auth_context.tournament.id, - } - ], + "data": { + "players": [ + { + "created": DUMMY_MOCK_TIME.isoformat().replace("+00:00", "Z"), + "id": player_inserted.id, + "active": True, + "elo_score": "0.0", + "swiss_score": "0.0", + "wins": 0, + "draws": 0, + "losses": 0, + "name": "Player 01", + "tournament_id": auth_context.tournament.id, + } + ], + "count": 1, + }, } diff --git a/backend/tests/integration_tests/api/teams_test.py b/backend/tests/integration_tests/api/teams_test.py index c94b390f4..3da759a0d 100644 --- a/backend/tests/integration_tests/api/teams_test.py +++ b/backend/tests/integration_tests/api/teams_test.py @@ -16,21 +16,24 @@ async def test_teams_endpoint( DUMMY_TEAM1.model_copy(update={"tournament_id": auth_context.tournament.id}) ) as team_inserted: assert await send_tournament_request(HTTPMethod.GET, "teams", auth_context, {}) == { - "data": [ - { - "active": True, - "created": DUMMY_MOCK_TIME.isoformat().replace("+00:00", "Z"), - "id": team_inserted.id, - "name": "Team 1", - "players": [], - "tournament_id": team_inserted.tournament_id, - "elo_score": "1200.0", - "swiss_score": "0.0", - "wins": 0, - "draws": 0, - "losses": 0, - } - ], + "data": { + "teams": [ + { + "active": True, + "created": DUMMY_MOCK_TIME.isoformat().replace("+00:00", "Z"), + "id": team_inserted.id, + "name": "Team 1", + "players": [], + "tournament_id": team_inserted.tournament_id, + "elo_score": "1200.0", + "swiss_score": "0.0", + "wins": 0, + "draws": 0, + "losses": 0, + } + ], + "count": 1, + }, } diff --git a/frontend/src/components/modals/team_create_modal.tsx b/frontend/src/components/modals/team_create_modal.tsx index 80a5c9eeb..7668c54ca 100644 --- a/frontend/src/components/modals/team_create_modal.tsx +++ b/frontend/src/components/modals/team_create_modal.tsx @@ -64,7 +64,7 @@ function SingleTeamTab({ }) { const { t } = useTranslation(); const { data } = getPlayers(tournament_id, false); - const players: Player[] = data != null ? data.data : []; + const players: Player[] = data != null ? data.data.players : []; const form = useForm({ initialValues: { name: '', diff --git a/frontend/src/components/modals/team_update_modal.tsx b/frontend/src/components/modals/team_update_modal.tsx index b7daefd9b..d1e41fc5b 100644 --- a/frontend/src/components/modals/team_update_modal.tsx +++ b/frontend/src/components/modals/team_update_modal.tsx @@ -21,7 +21,7 @@ export default function TeamUpdateModal({ }) { const { t } = useTranslation(); const { data } = getPlayers(tournament_id, false); - const players: Player[] = data != null ? data.data : []; + const players: Player[] = data != null ? data.data.players : []; const [opened, setOpened] = useState(false); const form = useForm({ diff --git a/frontend/src/components/tables/players.tsx b/frontend/src/components/tables/players.tsx index 02f24f4f0..ed46f86ee 100644 --- a/frontend/src/components/tables/players.tsx +++ b/frontend/src/components/tables/players.tsx @@ -42,7 +42,8 @@ export default function PlayersTable({ tournamentData: TournamentMinimal; }) { const { t } = useTranslation(); - const players: Player[] = swrPlayersResponse.data != null ? swrPlayersResponse.data.data : []; + const players: Player[] = + swrPlayersResponse.data != null ? swrPlayersResponse.data.data.players : []; const tableState = getTableState('name'); const minELOScore = Math.min(...players.map((player) => Number(player.elo_score))); diff --git a/frontend/src/components/tables/standings.tsx b/frontend/src/components/tables/standings.tsx index ab8e7ba49..278f44f71 100644 --- a/frontend/src/components/tables/standings.tsx +++ b/frontend/src/components/tables/standings.tsx @@ -15,7 +15,8 @@ import TableLayoutLarge from './table_large'; export default function StandingsTable({ swrTeamsResponse }: { swrTeamsResponse: SWRResponse }) { const { t } = useTranslation(); - const teams: TeamInterface[] = swrTeamsResponse.data != null ? swrTeamsResponse.data.data : []; + const teams: TeamInterface[] = + swrTeamsResponse.data != null ? swrTeamsResponse.data.data.teams : []; const tableState = getTableState('elo_score', false); if (swrTeamsResponse.error) return ; diff --git a/frontend/src/components/utils/util.tsx b/frontend/src/components/utils/util.tsx index 804ab80ad..473c216e6 100644 --- a/frontend/src/components/utils/util.tsx +++ b/frontend/src/components/utils/util.tsx @@ -96,3 +96,8 @@ export function HCaptchaInput({ ); } + +export interface Pagination { + offset: number; + limit: number; +} diff --git a/frontend/src/pages/tournaments/[id]/players.tsx b/frontend/src/pages/tournaments/[id]/players.tsx index d0907e31f..565aeab6a 100644 --- a/frontend/src/pages/tournaments/[id]/players.tsx +++ b/frontend/src/pages/tournaments/[id]/players.tsx @@ -1,16 +1,23 @@ -import { Grid, Title } from '@mantine/core'; +import { Center, Grid, Pagination, Title } from '@mantine/core'; import { useTranslation } from 'next-i18next'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import React, { useState } from 'react'; import PlayerCreateModal from '../../../components/modals/player_create_modal'; import PlayersTable from '../../../components/tables/players'; import { capitalize, getTournamentIdFromRouter } from '../../../components/utils/util'; -import { getPlayers } from '../../../services/adapter'; +import { getPlayersPaginated } from '../../../services/adapter'; import TournamentLayout from '../_tournament_layout'; export default function Players() { + const pageSize = 25; + const [page, setPage] = useState(1); const { tournamentData } = getTournamentIdFromRouter(); - const swrPlayersResponse = getPlayers(tournamentData.id); + const swrPlayersResponse = getPlayersPaginated(tournamentData.id, { + limit: pageSize, + offset: pageSize * (page - 1), + }); + const playerCount = swrPlayersResponse.data != null ? swrPlayersResponse.data.data.count : 1; const { t } = useTranslation(); return ( @@ -26,6 +33,9 @@ export default function Players() { +
+ +
); } diff --git a/frontend/src/pages/tournaments/[id]/teams.tsx b/frontend/src/pages/tournaments/[id]/teams.tsx index 343dc3749..c9a9b9b50 100644 --- a/frontend/src/pages/tournaments/[id]/teams.tsx +++ b/frontend/src/pages/tournaments/[id]/teams.tsx @@ -1,4 +1,4 @@ -import { Grid, Select, Title } from '@mantine/core'; +import { Center, Grid, Pagination, Select, Title } from '@mantine/core'; import { useTranslation } from 'next-i18next'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import React, { useState } from 'react'; @@ -14,7 +14,7 @@ import { import { StageItemWithRounds } from '../../../interfaces/stage_item'; import { StageItemInput } from '../../../interfaces/stage_item_input'; import { TeamInterface } from '../../../interfaces/team'; -import { getStages, getTeams } from '../../../services/adapter'; +import { getStages, getTeamsPaginated } from '../../../services/adapter'; import { getStageItemList, getStageItemTeamIdsLookup } from '../../../services/lookups'; import TournamentLayout from '../_tournament_layout'; @@ -46,10 +46,15 @@ function StageItemSelect({ } export default function Teams() { + const pageSize = 25; + const [page, setPage] = useState(1); const { t } = useTranslation(); const [filteredStageItemId, setFilteredStageItemId] = useState(null); const { tournamentData } = getTournamentIdFromRouter(); - const swrTeamsResponse: SWRResponse = getTeams(tournamentData.id); + const swrTeamsResponse: SWRResponse = getTeamsPaginated(tournamentData.id, { + limit: pageSize, + offset: pageSize * (page - 1), + }); const swrStagesResponse = getStages(tournamentData.id); const stageItemInputLookup = responseIsValid(swrStagesResponse) ? getStageItemList(swrStagesResponse) @@ -58,10 +63,12 @@ export default function Teams() { ? getStageItemTeamIdsLookup(swrStagesResponse) : {}; - let teams: TeamInterface[] = swrTeamsResponse.data != null ? swrTeamsResponse.data.data : []; + let teams: TeamInterface[] = + swrTeamsResponse.data != null ? swrTeamsResponse.data.data.teams : []; + const teamCount = swrTeamsResponse.data != null ? swrTeamsResponse.data.data.count : 1; if (filteredStageItemId != null) { - teams = swrTeamsResponse.data.data.filter( + teams = swrTeamsResponse.data.data.teams.filter( (team: StageItemInput) => stageItemTeamLookup[filteredStageItemId].indexOf(team.id) !== -1 ); } @@ -94,6 +101,9 @@ export default function Teams() { tournamentData={tournamentData} teams={teams} /> +
+ +
); } diff --git a/frontend/src/services/adapter.tsx b/frontend/src/services/adapter.tsx index e1aeca1c7..64651071e 100644 --- a/frontend/src/services/adapter.tsx +++ b/frontend/src/services/adapter.tsx @@ -3,6 +3,7 @@ import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { useRouter } from 'next/router'; import useSWR, { SWRResponse } from 'swr'; +import { Pagination } from '../components/utils/util'; import { SchedulerSettings } from '../interfaces/match'; import { getLogin, performLogout, tokenPresent } from './local_storage'; @@ -114,10 +115,24 @@ export function getPlayers(tournament_id: number, not_in_team: boolean = false): return useSWR(`tournaments/${tournament_id}/players?not_in_team=${not_in_team}`, fetcher); } +export function getPlayersPaginated(tournament_id: number, pagination: Pagination): SWRResponse { + return useSWR( + `tournaments/${tournament_id}/players?limit=${pagination.limit}&offset=${pagination.offset}`, + fetcher + ); +} + export function getTeams(tournament_id: number): SWRResponse { return useSWR(`tournaments/${tournament_id}/teams`, fetcher); } +export function getTeamsPaginated(tournament_id: number, pagination: Pagination): SWRResponse { + return useSWR( + `tournaments/${tournament_id}/teams?limit=${pagination.limit}&offset=${pagination.offset}`, + fetcher + ); +} + export function getTeamsLive(tournament_id: number): SWRResponse { return useSWR(`tournaments/${tournament_id}/teams`, fetcher, { refreshInterval: 5_000, diff --git a/frontend/src/services/lookups.tsx b/frontend/src/services/lookups.tsx index ffa0d5482..a712fbb42 100644 --- a/frontend/src/services/lookups.tsx +++ b/frontend/src/services/lookups.tsx @@ -15,7 +15,7 @@ export function getTeamsLookup(tournamentId: number) { if (!isResponseValid) { return null; } - return Object.fromEntries(swrTeamsResponse.data.data.map((x: TeamInterface) => [x.id, x])); + return Object.fromEntries(swrTeamsResponse.data.data.teams.map((x: TeamInterface) => [x.id, x])); } export function getStageItemLookup(swrStagesResponse: SWRResponse) {