mirror of
https://github.com/evroon/bracket.git
synced 2026-06-12 02:34:27 -04:00
Add pagination (#472)
Adds pagination (backend and frontend) to teams and players GET endpoints
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
13
backend/bracket/utils/pagination.py
Normal file
13
backend/bracket/utils/pagination.py
Normal 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.")
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -96,3 +96,8 @@ export function HCaptchaInput({
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export interface Pagination {
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user