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 __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger from src.application.contracts import ILogger
@@ -7,6 +8,15 @@ from src.application.domain.exceptions import NotFoundException
from src.infrastructure.database.decorators import transactional 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: class ListUserOrdersCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work self._unit_of_work = unit_of_work
@@ -14,11 +24,12 @@ class ListUserOrdersCommand:
@transactional @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( orders = await self._unit_of_work.order_repository.list_by_user_id(
user_id=user_id, user_id=user_id,
limit=limit, limit=limit,
offset=offset, offset=offset,
cursor_created_at=_parse_cursor(cursor),
) )
total = await self._unit_of_work.order_repository.count_all(search=user_id) total = await self._unit_of_work.order_repository.count_all(search=user_id)
return orders, total return orders, total
@@ -32,11 +43,12 @@ class ListOrdersCommand:
@transactional @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( orders = await self._unit_of_work.order_repository.list_all(
limit=limit, limit=limit,
offset=offset, offset=offset,
search=search search=search,
cursor_created_at=_parse_cursor(cursor),
) )
total = await self._unit_of_work.order_repository.count_all(search=search) total = await self._unit_of_work.order_repository.count_all(search=search)
@@ -57,13 +69,15 @@ class ListOrdersByDateCommand:
limit: int, limit: int,
offset: int, offset: int,
year: int, year: int,
month: int | None = None month: int | None = None,
cursor: str | None = None,
) -> tuple[list[OrderEntity], int, Decimal]: ) -> tuple[list[OrderEntity], int, Decimal]:
orders = await self._unit_of_work.order_repository.list_by_date( orders = await self._unit_of_work.order_repository.list_by_date(
year=year, year=year,
month=month, month=month,
limit=limit, 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) 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 __future__ import annotations
from datetime import datetime, timezone
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import ILogger from src.application.contracts import ILogger
from src.application.domain.entities.user import UserEntity from src.application.domain.entities.user import UserEntity
from src.infrastructure.database.decorators import transactional 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: class ListUsersCommand:
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger): def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
self._unit_of_work = unit_of_work self._unit_of_work = unit_of_work
@@ -18,11 +29,13 @@ class ListUsersCommand:
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
search: str | None = None, search: str | None = None,
cursor: str | None = None,
) -> tuple[list[UserEntity], int]: ) -> tuple[list[UserEntity], int]:
items = await self._unit_of_work.user_repository.list_all( items = await self._unit_of_work.user_repository.list_all(
limit=limit, limit=limit,
offset=offset, offset=offset,
search=search, search=search,
cursor_created_at=_parse_cursor(cursor),
) )
total = await self._unit_of_work.user_repository.count_all(search=search) total = await self._unit_of_work.user_repository.count_all(search=search)
return items, total return items, total

View File

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

View File

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

View File

@@ -26,14 +26,16 @@ orders_router = APIRouter(prefix='/orders', tags=['orders'])
async def list_orders( async def list_orders(
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0), 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), q: str | None = Query(default=None, min_length=1, max_length=255),
auth: AdminAuthContext = Depends(require_admin_access), auth: AdminAuthContext = Depends(require_admin_access),
command: ListOrdersCommand = Depends(get_list_orders_command), 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( return OrdersResponse(
items=[order_to_response(x) for x in items], items=[order_to_response(x) for x in items],
total=total, 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), month: int = Query(..., ge=1, le=12),
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
cursor: str | None = Query(default=None),
auth: AdminAuthContext = Depends(require_admin_access), auth: AdminAuthContext = Depends(require_admin_access),
command: ListOrdersByDateCommand = Depends(get_list_orders_by_date_command), 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( return OrdersByDateResponse(
items=[order_to_response(o) for o in orders], items=[order_to_response(o) for o in orders],
total=total, total=total,
summed_service_fees=summary, 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( async def list_users(
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0), 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), q: str | None = Query(default=None, min_length=1, max_length=255),
auth: AdminAuthContext = Depends(require_admin_access), auth: AdminAuthContext = Depends(require_admin_access),
command: ListUsersCommand = Depends(get_list_users_command), 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( return UserListResponse(
items=[user_to_response(x) for x in items], items=[user_to_response(x) for x in items],
total=total, 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) @users_router.get('/{user_id}', response_model=UserResponse)
@@ -88,13 +90,15 @@ async def list_user_orders(
user_id: str, user_id: str,
limit: int = Query(default=50, ge=1, le=200), limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
cursor: str | None = Query(default=None),
auth: AdminAuthContext = Depends(require_admin_access), auth: AdminAuthContext = Depends(require_admin_access),
command: ListUserOrdersCommand = Depends(get_list_user_orders_command), 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( return OrdersResponse(
items=[order_to_response(o) for o in orders], items=[order_to_response(o) for o in orders],
total=total, 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): class OrdersResponse(BaseModel):
items: list[OrderResponse] items: list[OrderResponse]
total: int total: int
next_cursor: str | None = None
class OrdersByDateResponse(BaseModel): class OrdersByDateResponse(BaseModel):
items: list[OrderResponse] items: list[OrderResponse]
total: int total: int
summed_service_fees: Decimal summed_service_fees: Decimal
next_cursor: str | None = None

View File

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