Add cursor pagination to admin lists

This commit is contained in:
Codex
2026-07-02 13:13:09 +03:00
parent 4c07cb1f29
commit f9506be2df
8 changed files with 70 additions and 16 deletions

View File

@@ -1,4 +1,5 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
@@ -7,6 +8,15 @@ from src.application.domain.exceptions import NotFoundException
from src.infrastructure.database.decorators import transactional
def _parse_cursor(cursor: str | None) -> datetime | None:
if not cursor:
return None
parsed = datetime.fromisoformat(cursor.replace('Z','+00:00'))
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
class ListUserOrdersCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
@@ -14,11 +24,12 @@ class ListUserOrdersCommand:
@transactional
async def __call__(self, *, user_id: str, limit: int, offset: int) -> tuple[list[OrderEntity], int]:
async def __call__(self, *, user_id: str, limit: int, offset: int, cursor: str | None = None) -> tuple[list[OrderEntity], int]:
orders = await self._unit_of_work.order_repository.list_by_user_id(
user_id=user_id,
limit=limit,
offset=offset,
cursor_created_at=_parse_cursor(cursor),
)
total = await self._unit_of_work.order_repository.count_all(search=user_id)
return orders, total
@@ -32,11 +43,12 @@ class ListOrdersCommand:
@transactional
async def __call__(self, *, limit: int, offset: int, search: str | None = None) -> tuple[list[OrderEntity], int]:
async def __call__(self, *, limit: int, offset: int, search: str | None = None, cursor: str | None = None) -> tuple[list[OrderEntity], int]:
orders = await self._unit_of_work.order_repository.list_all(
limit=limit,
offset=offset,
search=search
search=search,
cursor_created_at=_parse_cursor(cursor),
)
total = await self._unit_of_work.order_repository.count_all(search=search)
@@ -57,13 +69,15 @@ class ListOrdersByDateCommand:
limit: int,
offset: int,
year: int,
month: int | None = None
month: int | None = None,
cursor: str | None = None,
) -> tuple[list[OrderEntity], int, Decimal]:
orders = await self._unit_of_work.order_repository.list_by_date(
year=year,
month=month,
limit=limit,
offset=offset
offset=offset,
cursor_created_at=_parse_cursor(cursor),
)
total = await self._unit_of_work.order_repository.count_by_month(year=year, month=month)

View File

@@ -1,11 +1,22 @@
from __future__ import annotations
from datetime import datetime, timezone
from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger
from src.application.domain.entities.user import UserEntity
from src.infrastructure.database.decorators import transactional
def _parse_cursor(cursor: str | None) -> datetime | None:
if not cursor:
return None
parsed = datetime.fromisoformat(cursor.replace('Z','+00:00'))
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
class ListUsersCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work
@@ -18,11 +29,13 @@ class ListUsersCommand:
limit: int = 50,
offset: int = 0,
search: str | None = None,
cursor: str | None = None,
) -> tuple[list[UserEntity], int]:
items = await self._unit_of_work.user_repository.list_all(
limit=limit,
offset=offset,
search=search,
cursor_created_at=_parse_cursor(cursor),
)
total = await self._unit_of_work.user_repository.count_all(search=search)
return items, total

View File

@@ -71,15 +71,19 @@ class OrderRepository(IOrderRepository):
*,
user_id: str,
limit: int,
offset: int
offset: int,
cursor_created_at=None
) -> list[OrderEntity]:
stmt=(
select(OrderModel)
.where(OrderModel.user_id==user_id)
.order_by(desc(OrderModel.created_at))
.limit(limit)
.offset(offset)
)
if cursor_created_at is not None:
stmt = stmt.where(OrderModel.created_at < cursor_created_at)
else:
stmt = stmt.offset(offset)
result=await self._session.scalars(stmt)
return [self._to_entity(model) for model in result.all()]
@@ -89,14 +93,18 @@ class OrderRepository(IOrderRepository):
*,
limit: int,
offset: int,
search: str | None = None
search: str | None = None,
cursor_created_at=None
) -> list[OrderEntity]:
stmt=(
select(OrderModel)
.order_by(desc(OrderModel.created_at))
.limit(limit)
.offset(offset)
)
if cursor_created_at is not None:
stmt = stmt.where(OrderModel.created_at < cursor_created_at)
else:
stmt = stmt.offset(offset)
search_filter = self._search_filter(search)
if search_filter is not None:
stmt = stmt.where(search_filter)
@@ -111,6 +119,7 @@ class OrderRepository(IOrderRepository):
month: int,
limit: int,
offset: int,
cursor_created_at=None,
) -> list[OrderEntity]:
stmt=(
select(OrderModel)
@@ -120,8 +129,11 @@ class OrderRepository(IOrderRepository):
)
.order_by(desc(OrderModel.created_at))
.limit(limit)
.offset(offset)
)
if cursor_created_at is not None:
stmt = stmt.where(OrderModel.created_at < cursor_created_at)
else:
stmt = stmt.offset(offset)
res = await self._session.execute(stmt)
return [self._to_entity(model) for model in res.scalars().all()]

View File

@@ -156,14 +156,18 @@ class UserRepository(IUserRepository):
limit: int,
offset: int,
search: str | None = None,
cursor_created_at=None,
) -> list[UserEntity]:
stmt = (
select(UserModel)
.where(UserModel.is_deleted.is_(False))
.order_by(UserModel.created_at.desc())
.limit(limit)
.offset(offset)
)
if cursor_created_at is not None:
stmt = stmt.where(UserModel.created_at < cursor_created_at)
else:
stmt = stmt.offset(offset)
search_filter = self._search_filter(search)
if search_filter is not None:
stmt = stmt.where(search_filter)

View File

@@ -26,14 +26,16 @@ orders_router = APIRouter(prefix='/orders', tags=['orders'])
async def list_orders(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
cursor: str | None = Query(default=None),
q: str | None = Query(default=None, min_length=1, max_length=255),
auth: AdminAuthContext = Depends(require_admin_access),
command: ListOrdersCommand = Depends(get_list_orders_command),
):
items, total = await command(limit=limit, offset=offset, search=q)
items, total = await command(limit=limit, offset=offset, search=q, cursor=cursor)
return OrdersResponse(
items=[order_to_response(x) for x in items],
total=total,
next_cursor=items[-1].created_at.isoformat() if items and items[-1].created_at else None,
)
@@ -75,12 +77,14 @@ async def list_orders_by_date(
month: int = Query(..., ge=1, le=12),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
cursor: str | None = Query(default=None),
auth: AdminAuthContext = Depends(require_admin_access),
command: ListOrdersByDateCommand = Depends(get_list_orders_by_date_command),
):
orders, total, summary = await command(year=year, month=month, limit=limit, offset=offset)
orders, total, summary = await command(year=year, month=month, limit=limit, offset=offset, cursor=cursor)
return OrdersByDateResponse(
items=[order_to_response(o) for o in orders],
total=total,
summed_service_fees=summary,
next_cursor=orders[-1].created_at.isoformat() if orders and orders[-1].created_at else None,
)

View File

@@ -54,14 +54,16 @@ users_router = APIRouter(prefix='/users', tags=['users'])
async def list_users(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
cursor: str | None = Query(default=None),
q: str | None = Query(default=None, min_length=1, max_length=255),
auth: AdminAuthContext = Depends(require_admin_access),
command: ListUsersCommand = Depends(get_list_users_command),
):
items, total = await command(limit=limit, offset=offset, search=q)
items, total = await command(limit=limit, offset=offset, search=q, cursor=cursor)
return UserListResponse(
items=[user_to_response(x) for x in items],
total=total,
next_cursor=items[-1].created_at.isoformat() if items and items[-1].created_at else None,
)
@users_router.get('/{user_id}', response_model=UserResponse)
@@ -88,13 +90,15 @@ async def list_user_orders(
user_id: str,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
cursor: str | None = Query(default=None),
auth: AdminAuthContext = Depends(require_admin_access),
command: ListUserOrdersCommand = Depends(get_list_user_orders_command),
):
orders, total = await command(user_id=user_id, limit=limit, offset=offset)
orders, total = await command(user_id=user_id, limit=limit, offset=offset, cursor=cursor)
return OrdersResponse(
items=[order_to_response(o) for o in orders],
total=total,
next_cursor=orders[-1].created_at.isoformat() if orders and orders[-1].created_at else None,
)

View File

@@ -41,8 +41,10 @@ class OrderResponse(BaseModel):
class OrdersResponse(BaseModel):
items: list[OrderResponse]
total: int
next_cursor: str | None = None
class OrdersByDateResponse(BaseModel):
items: list[OrderResponse]
total: int
summed_service_fees: Decimal
next_cursor: str | None = None

View File

@@ -27,3 +27,4 @@ class UserResponse(BaseModel):
class UserListResponse(BaseModel):
items: list[UserResponse]
total: int
next_cursor: str | None = None