diff --git a/docs/docs/guides/response/pagination.md b/docs/docs/guides/response/pagination.md index 1e79181fc..f9c771a23 100644 --- a/docs/docs/guides/response/pagination.md +++ b/docs/docs/guides/response/pagination.md @@ -79,7 +79,14 @@ you can also set custom page_size value individually per view: def list_users(... ``` +In addition to the `page` parameter, you can also use the `page_size` parameter to dynamically adjust the number of records displayed per page: +Example query: +``` +/api/users?page=2&page_size=20 +``` + +This allows you to temporarily override the page size setting in your request. The request will use the specified `page_size` value if provided. Otherwise, it will use either the value specified in the decorator or the value from `PAGINATION_MAX_PER_PAGE_SIZE` in settings.py if no decorator value is set. ## Accessing paginator parameters in view function diff --git a/ninja/conf.py b/ninja/conf.py index 71da0b0a9..e9f1a9b76 100644 --- a/ninja/conf.py +++ b/ninja/conf.py @@ -11,6 +11,7 @@ class Settings(BaseModel): "ninja.pagination.LimitOffsetPagination", alias="NINJA_PAGINATION_CLASS" ) PAGINATION_PER_PAGE: int = Field(100, alias="NINJA_PAGINATION_PER_PAGE") + PAGINATION_MAX_PER_PAGE_SIZE: int = Field(100, alias="NINJA_MAX_PER_PAGE_SIZE") PAGINATION_MAX_LIMIT: int = Field(inf, alias="NINJA_PAGINATION_MAX_LIMIT") # type: ignore # Throttling diff --git a/ninja/pagination.py b/ninja/pagination.py index eeee67b35..7a5f76f82 100644 --- a/ninja/pagination.py +++ b/ninja/pagination.py @@ -122,22 +122,34 @@ async def apaginate_queryset( class PageNumberPagination(AsyncPaginationBase): class Input(Schema): page: int = Field(1, ge=1) + page_size: Optional[int] = Field(None, ge=1) def __init__( - self, page_size: int = settings.PAGINATION_PER_PAGE, **kwargs: Any + self, + page_size: int = settings.PAGINATION_PER_PAGE, + max_page_size: int = settings.PAGINATION_MAX_PER_PAGE_SIZE, + **kwargs: Any, ) -> None: self.page_size = page_size + self.max_page_size = max_page_size super().__init__(**kwargs) + def _get_page_size(self, requested_page_size: Optional[int]) -> int: + if requested_page_size is None: + return self.page_size + + return min(requested_page_size, self.max_page_size) + def paginate_queryset( self, queryset: QuerySet, pagination: Input, **params: Any, ) -> Any: - offset = (pagination.page - 1) * self.page_size + page_size = self._get_page_size(pagination.page_size) + offset = (pagination.page - 1) * page_size return { - "items": queryset[offset : offset + self.page_size], + "items": queryset[offset : offset + page_size], "count": self._items_count(queryset), } # noqa: E203 @@ -147,11 +159,14 @@ async def apaginate_queryset( pagination: Input, **params: Any, ) -> Any: - offset = (pagination.page - 1) * self.page_size + page_size = self._get_page_size(pagination.page_size) + offset = (pagination.page - 1) * page_size + if isinstance(queryset, QuerySet): - items = [obj async for obj in queryset[offset : offset + self.page_size]] + items = [obj async for obj in queryset[offset : offset + page_size]] else: - items = queryset[offset : offset + self.page_size] + items = queryset[offset : offset + page_size] + return { "items": items, "count": await self._aitems_count(queryset), diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 9cc11b33a..2cb8f0e7a 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -154,6 +154,12 @@ def items_9(request): return list(range(100)) +@api.get("/items_10", response=List[int]) +@paginate(PageNumberPagination, page_size=10, max_page_size=20) +def items_10(request): + return ITEMS + + client = TestClient(api) @@ -260,7 +266,106 @@ def test_case4(): "type": "integer", }, "required": False, - } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], + "title": "Page Size", + }, + "required": False, + }, + ] + + +def test_case4_page_size(): + response = client.get("/items_4?page=2&page_size=20").json() + assert response == {"items": ITEMS[20:40], "count": 100} + + schema = api.get_openapi_schema()["paths"]["/api/items_4"]["get"] + # print(schema) + assert schema["parameters"] == [ + { + "in": "query", + "name": "page", + "schema": { + "title": "Page", + "default": 1, + "minimum": 1, + "type": "integer", + }, + "required": False, + }, + { + "in": "query", + "name": "page_size", + "schema": { + "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], + "title": "Page Size", + }, + "required": False, + }, + ] + + +def test_case4_no_page_param(): + response = client.get("/items_4?page_size=20").json() + assert response == {"items": ITEMS[0:20], "count": 100} + + schema = api.get_openapi_schema()["paths"]["/api/items_4"]["get"] + # print(schema) + assert schema["parameters"] == [ + { + "in": "query", + "name": "page", + "schema": { + "title": "Page", + "default": 1, + "minimum": 1, + "type": "integer", + }, + "required": False, + }, + { + "in": "query", + "name": "page_size", + "schema": { + "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], + "title": "Page Size", + }, + "required": False, + }, + ] + + +def test_case4_out_of_range(): + response = client.get("/items_4?page=2&page_size=100").json() + assert response == {"items": [], "count": 100} + + schema = api.get_openapi_schema()["paths"]["/api/items_4"]["get"] + # print(schema) + assert schema["parameters"] == [ + { + "in": "query", + "name": "page", + "schema": { + "title": "Page", + "default": 1, + "minimum": 1, + "type": "integer", + }, + "required": False, + }, + { + "in": "query", + "name": "page_size", + "schema": { + "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], + "title": "Page Size", + }, + "required": False, + }, ] @@ -281,14 +386,23 @@ def test_case5_no_kwargs(): "type": "integer", }, "required": False, - } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], + "title": "Page Size", + }, + "required": False, + }, ] def test_case6_pass_param_kwargs(): page = 11 response = client.get(f"/items_6?page={page}").json() - assert response == {"items": [{"page": 11}], "count": 101} + assert response == {"items": [{"page": 11, "page_size": None}], "count": 101} schema = api.get_openapi_schema()["paths"]["/api/items_6"]["get"] @@ -303,7 +417,16 @@ def test_case6_pass_param_kwargs(): "type": "integer", }, "required": False, - } + }, + { + "in": "query", + "name": "page_size", + "schema": { + "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], + "title": "Page Size", + }, + "required": False, + }, ] @@ -335,6 +458,36 @@ def test_case9(): } +def test_case10_max_page_size(): + response = client.get("/items_10?page=2&page_size=30").json() + assert response == {"items": ITEMS[20:40], "count": 100} + + schema = api.get_openapi_schema()["paths"]["/api/items_5"]["get"] + + assert schema["parameters"] == [ + { + "in": "query", + "name": "page", + "schema": { + "title": "Page", + "default": 1, + "minimum": 1, + "type": "integer", + }, + "required": False, + }, + { + "in": "query", + "name": "page_size", + "schema": { + "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], + "title": "Page Size", + }, + "required": False, + }, + ] + + @override_settings(NINJA_PAGINATION_MAX_LIMIT=1000) def test_10_max_limit_set(): # reload to apply django settings diff --git a/tests/test_pagination_async.py b/tests/test_pagination_async.py index 9a1140c67..afe1720c0 100644 --- a/tests/test_pagination_async.py +++ b/tests/test_pagination_async.py @@ -122,7 +122,7 @@ async def items_page_number(request, **kwargs): client = TestAsyncClient(api) response = await client.get("/items_page_number?page=11") - assert response.json() == {"items": [{"page": 11}], "count": 101} + assert response.json() == {"items": [{"page": 11, "page_size": None}], "count": 101} @pytest.mark.skipif(django.VERSION[:2] < (5, 0), reason="Requires Django 5.0+")