diff --git a/backend/bracket/routes/models.py b/backend/bracket/routes/models.py
index 99a30dd2..991db893 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 a05c3034..00921d89 100644
--- a/backend/bracket/routes/players.py
+++ b/backend/bracket/routes/players.py
@@ -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),
+ )
)
diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py
index 9a336b22..98dae803 100644
--- a/backend/bracket/routes/teams.py
+++ b/backend/bracket/routes/teams.py
@@ -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),
+ )
)
diff --git a/backend/bracket/sql/players.py b/backend/bracket/sql/players.py
index 9eb90e38..d678da34 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.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(
diff --git a/backend/bracket/sql/teams.py b/backend/bracket/sql/teams.py
index abf8da70..e1ff0edd 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 00000000..b42671dc
--- /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 773a9ef5..98190aed 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 c94b390f..3da759a0 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 80a5c9ee..7668c54c 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 b7daefd9..d1e41fc5 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 02f24f4f..ed46f86e 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 ab8e7ba4..278f44f7 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