From 3b5fe3e260c7a681ecab78025812e789c1396f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Fri, 12 Jun 2026 23:05:59 +0300 Subject: [PATCH] feat: paginate admin listing endpoint --- app/api/routes/admin/admins.py | 12 ++++++--- app/repositories/admin/user.py | 29 +++++++++++++++----- app/schemas/admin.py | 5 +++- app/services/admin/admin_service.py | 15 ++++++++--- app/tests/admin/test_admins.py | 42 +++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/app/api/routes/admin/admins.py b/app/api/routes/admin/admins.py index 4a3c7e7..0def84f 100644 --- a/app/api/routes/admin/admins.py +++ b/app/api/routes/admin/admins.py @@ -1,6 +1,7 @@ import uuid +from typing import Annotated -from fastapi import APIRouter, Request, status +from fastapi import APIRouter, Query, Request, status from app.api.decorators import audit_unexpected_failure from app.api.deps import CurrentSuperAdmin, SessionDep @@ -15,7 +16,7 @@ RootTransferRequest, ) from app.schemas.msg import Message -from app.schemas.user import Language +from app.schemas.user import Language, SystemRole from app.schemas.user_activity import ActivityType, ResourceType from app.services.admin.admin_service import ( confirm_root_transfer_service, @@ -42,9 +43,12 @@ async def list_admins_endpoint( _request: Request, _admin: CurrentSuperAdmin, session: SessionDep, + skip: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + role: SystemRole | None = None, ) -> AdminListResponse: - """List every admin-tier account with its permissions (superadmin only).""" - return await list_admins_service(session=session) + """List admin-tier accounts with pagination and an optional role filter (superadmin only).""" + return await list_admins_service(session=session, skip=skip, limit=limit, role=role) @router.get("/permissions", response_model=PermissionCatalogResponse) diff --git a/app/repositories/admin/user.py b/app/repositories/admin/user.py index c396b3f..caf9fd1 100644 --- a/app/repositories/admin/user.py +++ b/app/repositories/admin/user.py @@ -66,14 +66,29 @@ async def list_users_admin( return users, total -async def list_admins(session: AsyncSession) -> Sequence[User]: - """Return all admin and superadmin accounts, newest first.""" - stmt = ( - select(User) - .where(User.role.in_([SystemRole.ADMIN.value, SystemRole.SUPERADMIN.value])) - .order_by(User.created_at.desc()) +async def list_admins( + session: AsyncSession, + *, + skip: int = 0, + limit: int = 50, + role: SystemRole | None = None, +) -> tuple[Sequence[User], int]: + """Return a paginated page of admin-tier accounts plus the total count.""" + base_stmt = select(User).where( + User.role.in_([SystemRole.ADMIN.value, SystemRole.SUPERADMIN.value]) ) - return (await session.execute(stmt)).scalars().all() + if role is not None: + base_stmt = base_stmt.where(User.role == role.value) + + count_stmt = base_stmt.with_only_columns( + func.count(), maintain_column_froms=True + ).order_by(None) + total = (await session.execute(count_stmt)).scalar_one() + + rows_stmt = base_stmt.order_by(User.created_at.desc()).offset(skip).limit(limit) + admins = (await session.execute(rows_stmt)).scalars().all() + + return admins, total async def superadmin_exists(session: AsyncSession) -> bool: diff --git a/app/schemas/admin.py b/app/schemas/admin.py index a917514..d989cd6 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -194,13 +194,16 @@ class AdminListItem(BaseModel): is_active: bool is_root_superadmin: bool = False permissions: list[Permission] = Field(default_factory=list) + created_at: datetime class AdminListResponse(BaseModel): - """Listing of every admin-tier account.""" + """Paginated listing of admin-tier accounts.""" data: list[AdminListItem] total: int + skip: int + limit: int class AdminCreate(BaseModel): diff --git a/app/services/admin/admin_service.py b/app/services/admin/admin_service.py index 0af4ff0..acfd41b 100644 --- a/app/services/admin/admin_service.py +++ b/app/services/admin/admin_service.py @@ -66,6 +66,7 @@ def _to_list_item(user: User, permissions: list[Permission]) -> AdminListItem: is_active=user.is_active, is_root_superadmin=user.is_root_superadmin, permissions=permissions, + created_at=user.created_at, ) @@ -104,9 +105,15 @@ def get_permission_catalog_service() -> PermissionCatalogResponse: return PermissionCatalogResponse(permissions=list(Permission)) -async def list_admins_service(session: AsyncSession) -> AdminListResponse: - """List every admin-tier account with the permissions it holds.""" - admins = await list_admins(session) +async def list_admins_service( + session: AsyncSession, + *, + skip: int = 0, + limit: int = 50, + role: SystemRole | None = None, +) -> AdminListResponse: + """List a paginated page of admin-tier accounts with their permissions.""" + admins, total = await list_admins(session, skip=skip, limit=limit, role=role) permissions_map = await get_permissions_for_users( session, [admin.id for admin in admins] ) @@ -116,7 +123,7 @@ async def list_admins_service(session: AsyncSession) -> AdminListResponse: ) for admin in admins ] - return AdminListResponse(data=data, total=len(data)) + return AdminListResponse(data=data, total=total, skip=skip, limit=limit) async def create_admin_service( diff --git a/app/tests/admin/test_admins.py b/app/tests/admin/test_admins.py index 7a6a817..fc4a9ed 100644 --- a/app/tests/admin/test_admins.py +++ b/app/tests/admin/test_admins.py @@ -36,6 +36,48 @@ async def test_list_admins_reports_superadmin_with_all_permissions( assert superadmin_row["is_root_superadmin"] is True +@pytest.mark.asyncio +async def test_list_admins_paginates_and_reports_total(superadmin_client: AsyncClient): + """``skip``/``limit`` slice the listing while ``total`` counts every admin.""" + for i in range(3): + await register_and_verify(superadmin_client, f"pageadmin{i}@test.com") + await promote_to_admin(f"pageadmin{i}@test.com") + + full = await superadmin_client.get("/admin/admins") + expected_total = full.json()["total"] + assert expected_total >= 4 # 3 admins + the seeded superadmin + + first = await superadmin_client.get("/admin/admins", params={"skip": 0, "limit": 2}) + assert first.status_code == 200 + body = first.json() + assert body["skip"] == 0 + assert body["limit"] == 2 + assert len(body["data"]) == 2 + assert body["total"] == expected_total + + second = await superadmin_client.get( + "/admin/admins", params={"skip": 2, "limit": 2} + ) + first_ids = {row["id"] for row in body["data"]} + second_ids = {row["id"] for row in second.json()["data"]} + assert first_ids.isdisjoint(second_ids) + + +@pytest.mark.asyncio +async def test_list_admins_role_filter(superadmin_client: AsyncClient): + """The optional role filter narrows the listing to a single tier.""" + await register_and_verify(superadmin_client, "roleadmin@test.com") + await promote_to_admin("roleadmin@test.com") + + response = await superadmin_client.get( + "/admin/admins", params={"role": SystemRole.SUPERADMIN.value} + ) + assert response.status_code == 200 + rows = response.json()["data"] + assert rows + assert all(row["role"] == SystemRole.SUPERADMIN.value for row in rows) + + # --- Create admin -----------------------------------------------------------