Skip to content

Commit

Permalink
Add pagination (#472)
Browse files Browse the repository at this point in the history
Adds pagination (backend and frontend) to teams and players GET
endpoints
  • Loading branch information
evroon committed Feb 12, 2024
1 parent 549243b commit f834fab
Show file tree
Hide file tree
Showing 17 changed files with 238 additions and 61 deletions.
14 changes: 12 additions & 2 deletions backend/bracket/routes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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


Expand Down
23 changes: 20 additions & 3 deletions backend/bracket/routes/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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),
)
)


Expand Down
26 changes: 21 additions & 5 deletions backend/bracket/routes/teams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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),
)
)


Expand Down
49 changes: 43 additions & 6 deletions backend/bracket/sql/players.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,63 @@
from decimal import Decimal
from typing import cast

from heliclockter import datetime_utc

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(
Expand Down
42 changes: 39 additions & 3 deletions backend/bracket/sql/teams.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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.*,
Expand All @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions backend/bracket/utils/pagination.py
Original file line number Diff line number Diff line change
@@ -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.")
31 changes: 17 additions & 14 deletions backend/tests/integration_tests/api/players_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}


Expand Down
33 changes: 18 additions & 15 deletions backend/tests/integration_tests/api/teams_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}


Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/modals/team_create_modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/modals/team_update_modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/tables/players.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/tables/standings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />;
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/utils/util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,8 @@ export function HCaptchaInput({
</Center>
);
}

export interface Pagination {
offset: number;
limit: number;
}
Loading

0 comments on commit f834fab

Please sign in to comment.