diff --git a/app/routes/activities.py b/app/routes/activities.py index 22ea818..8e393aa 100644 --- a/app/routes/activities.py +++ b/app/routes/activities.py @@ -7,7 +7,7 @@ from app.schemas import ( ActivityHistoryAdminItem, ActivityHistoryStatus, - CursorPage, + Page, Response, UserBrief, ) @@ -18,25 +18,19 @@ @router.get( "", - response_model=Response[CursorPage[ActivityHistoryAdminItem]], + response_model=Response[Page[ActivityHistoryAdminItem]], summary="List all users' activities", - description=( - "Returns persisted activities for all users using an ID-based cursor. " - "Admin only." - ), + description="Returns persisted activities for all users with offset-based pagination. Admin only.", ) async def list_activities( - cursor: int - | None = Query( - default=None, - ge=1, - description="Last activity ID from the previous page.", + page: int = Query(default=1, ge=1, description="Page number (1-based)."), + size: int = Query( + default=10, ge=1, le=100, description="Number of items per page." ), - limit: int = Query(default=20, ge=1, le=100), _admin: User = Depends(require_admin), db: Session = Depends(get_db), ): - activities, next_cursor = ActivityService.list_all(db, cursor=cursor, limit=limit) + activities, total = ActivityService.list_all_paged(db, page=page, size=size) pending_updates = ActivityService.pending_updates_by_activity( db, [activity.id for activity in activities] ) @@ -69,5 +63,5 @@ async def list_activities( ] return Response( ok=True, - data=CursorPage(items=items, next_cursor=next_cursor), + data=Page(items=items, total=total, page=page, size=size), ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 2f5455a..7b588ee 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -24,7 +24,7 @@ DraftCertificateCreate, SignatureDetail, ) -from app.schemas.common import CursorPage, Response, Website +from app.schemas.common import CursorPage, Page, Response, Website from app.schemas.project import ( MemberDetail, MemberInput, @@ -93,6 +93,7 @@ "CertificateOptions", "CertificateSummary", "CursorPage", + "Page", "DevSigninRequest", "DraftCertificateCreate", "GoogleTokenRequest", diff --git a/app/schemas/common.py b/app/schemas/common.py index cb09da5..8040f76 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -50,6 +50,20 @@ class CursorPage(BaseModel, Generic[T]): ) +class Page(BaseModel, Generic[T]): + """ + Offset-based pagination wrapper. + + Use `page` and `size` query parameters to navigate pages. + `total` is the total number of items matching the query. + """ + + items: list[T] = Field(description="List of items in the current page") + total: int = Field(description="Total number of items across all pages") + page: int = Field(description="Current page number (1-based)") + size: int = Field(description="Number of items per page") + + class Website(BaseModel): """External website or link associated with a user or project.""" diff --git a/app/services/activity.py b/app/services/activity.py index 28046eb..5f226f5 100644 --- a/app/services/activity.py +++ b/app/services/activity.py @@ -22,22 +22,22 @@ def list_by_user(db: Session, user_id: int) -> list[UserActivity]: ) @staticmethod - def list_all( - db: Session, *, cursor: int | None, limit: int - ) -> tuple[list[UserActivity], int | None]: - """List persisted activities using the indexed primary key as a cursor.""" + def list_all_paged( + db: Session, *, page: int, size: int + ) -> tuple[list[UserActivity], int]: + """List persisted activities using offset-based pagination.""" query = db.query(UserActivity).options( joinedload(UserActivity.user), joinedload(UserActivity.project), ) - if cursor is not None: - query = query.filter(UserActivity.id < cursor) - - items = query.order_by(UserActivity.id.desc()).limit(limit + 1).all() - has_more = len(items) > limit - page_items = items[:limit] - next_cursor = page_items[-1].id if has_more and page_items else None - return page_items, next_cursor + total = query.count() + items = ( + query.order_by(UserActivity.id.desc()) + .offset((page - 1) * size) + .limit(size) + .all() + ) + return items, total @staticmethod def pending_updates_by_activity( diff --git a/tests/test_activity_history_management.py b/tests/test_activity_history_management.py index 2fe5ae2..8455592 100644 --- a/tests/test_activity_history_management.py +++ b/tests/test_activity_history_management.py @@ -364,22 +364,25 @@ def test_admin_can_paginate_all_users_activities_by_id( assert forbidden.status_code == 403 first_response = client.get( - "/activities?limit=2", + "/activities?page=1&size=2", headers=auth(admin_token), ) assert first_response.status_code == 200 first_page = first_response.json()["data"] + assert first_page["total"] == 3 + assert first_page["page"] == 1 + assert first_page["size"] == 2 assert [item["id"] for item in first_page["items"]] == list( reversed(created_ids[1:]) ) - assert first_page["next_cursor"] == created_ids[1] assert all(item["status"] == "active" for item in first_page["items"]) second_response = client.get( - f"/activities?limit=2&cursor={first_page['next_cursor']}", + "/activities?page=2&size=2", headers=auth(admin_token), ) assert second_response.status_code == 200 second_page = second_response.json()["data"] + assert second_page["total"] == 3 + assert second_page["page"] == 2 assert [item["id"] for item in second_page["items"]] == [created_ids[0]] - assert second_page["next_cursor"] is None