Add pagination (#472)

Adds pagination (backend and frontend) to teams and players GET
endpoints
This commit is contained in:
Erik Vroon
2024-02-12 19:08:50 +01:00
committed by GitHub
parent 549243b1c3
commit f834fab2de
17 changed files with 238 additions and 61 deletions

View File

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

View File

@@ -5,10 +5,21 @@ from bracket.logic.subscriptions import check_requirement
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 @@ router = APIRouter()
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),
)
)

View File

@@ -11,12 +11,23 @@ from bracket.routes.auth import (
user_authenticated_for_tournament,
user_authenticated_or_public_dashboard,
)
from bracket.routes.models import SingleTeamResponse, SuccessResponse, TeamsWithPlayersResponse
from bracket.routes.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),
)
)

View File

@@ -1,4 +1,5 @@
from decimal import Decimal
from typing import cast
from heliclockter import datetime_utc
@@ -6,21 +7,57 @@ from bracket.database import database
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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <RequestErrorAlert error={swrTeamsResponse.error} />;

View File

@@ -96,3 +96,8 @@ export function HCaptchaInput({
</Center>
);
}
export interface Pagination {
offset: number;
limit: number;
}

View File

@@ -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 (
<TournamentLayout tournament_id={tournamentData.id}>
@@ -26,6 +33,9 @@ export default function Players() {
</Grid.Col>
</Grid>
<PlayersTable swrPlayersResponse={swrPlayersResponse} tournamentData={tournamentData} />
<Center mt="1rem">
<Pagination value={page} onChange={setPage} total={1 + playerCount / pageSize} size="lg" />
</Center>
</TournamentLayout>
);
}

View File

@@ -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}
/>
<Center mt="1rem">
<Pagination value={page} onChange={setPage} total={1 + teamCount / pageSize} size="lg" />
</Center>
</TournamentLayout>
);
}

View File

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

View File

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