django-bolt 0.1.0__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of django-bolt might be problematic. Click here for more details.
- django_bolt/__init__.py +147 -0
- django_bolt/_core.abi3.so +0 -0
- django_bolt/admin/__init__.py +25 -0
- django_bolt/admin/admin_detection.py +179 -0
- django_bolt/admin/asgi_bridge.py +267 -0
- django_bolt/admin/routes.py +91 -0
- django_bolt/admin/static.py +155 -0
- django_bolt/admin/static_routes.py +111 -0
- django_bolt/api.py +1011 -0
- django_bolt/apps.py +7 -0
- django_bolt/async_collector.py +228 -0
- django_bolt/auth/README.md +464 -0
- django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
- django_bolt/auth/__init__.py +84 -0
- django_bolt/auth/backends.py +236 -0
- django_bolt/auth/guards.py +224 -0
- django_bolt/auth/jwt_utils.py +212 -0
- django_bolt/auth/revocation.py +286 -0
- django_bolt/auth/token.py +335 -0
- django_bolt/binding.py +363 -0
- django_bolt/bootstrap.py +77 -0
- django_bolt/cli.py +133 -0
- django_bolt/compression.py +104 -0
- django_bolt/decorators.py +159 -0
- django_bolt/dependencies.py +128 -0
- django_bolt/error_handlers.py +305 -0
- django_bolt/exceptions.py +294 -0
- django_bolt/health.py +129 -0
- django_bolt/logging/__init__.py +6 -0
- django_bolt/logging/config.py +357 -0
- django_bolt/logging/middleware.py +296 -0
- django_bolt/management/__init__.py +1 -0
- django_bolt/management/commands/__init__.py +0 -0
- django_bolt/management/commands/runbolt.py +427 -0
- django_bolt/middleware/__init__.py +32 -0
- django_bolt/middleware/compiler.py +131 -0
- django_bolt/middleware/middleware.py +247 -0
- django_bolt/openapi/__init__.py +23 -0
- django_bolt/openapi/config.py +196 -0
- django_bolt/openapi/plugins.py +439 -0
- django_bolt/openapi/routes.py +152 -0
- django_bolt/openapi/schema_generator.py +581 -0
- django_bolt/openapi/spec/__init__.py +68 -0
- django_bolt/openapi/spec/base.py +74 -0
- django_bolt/openapi/spec/callback.py +24 -0
- django_bolt/openapi/spec/components.py +72 -0
- django_bolt/openapi/spec/contact.py +21 -0
- django_bolt/openapi/spec/discriminator.py +25 -0
- django_bolt/openapi/spec/encoding.py +67 -0
- django_bolt/openapi/spec/enums.py +41 -0
- django_bolt/openapi/spec/example.py +36 -0
- django_bolt/openapi/spec/external_documentation.py +21 -0
- django_bolt/openapi/spec/header.py +132 -0
- django_bolt/openapi/spec/info.py +50 -0
- django_bolt/openapi/spec/license.py +28 -0
- django_bolt/openapi/spec/link.py +66 -0
- django_bolt/openapi/spec/media_type.py +51 -0
- django_bolt/openapi/spec/oauth_flow.py +36 -0
- django_bolt/openapi/spec/oauth_flows.py +28 -0
- django_bolt/openapi/spec/open_api.py +87 -0
- django_bolt/openapi/spec/operation.py +105 -0
- django_bolt/openapi/spec/parameter.py +147 -0
- django_bolt/openapi/spec/path_item.py +78 -0
- django_bolt/openapi/spec/paths.py +27 -0
- django_bolt/openapi/spec/reference.py +38 -0
- django_bolt/openapi/spec/request_body.py +38 -0
- django_bolt/openapi/spec/response.py +48 -0
- django_bolt/openapi/spec/responses.py +44 -0
- django_bolt/openapi/spec/schema.py +678 -0
- django_bolt/openapi/spec/security_requirement.py +28 -0
- django_bolt/openapi/spec/security_scheme.py +69 -0
- django_bolt/openapi/spec/server.py +34 -0
- django_bolt/openapi/spec/server_variable.py +32 -0
- django_bolt/openapi/spec/tag.py +32 -0
- django_bolt/openapi/spec/xml.py +44 -0
- django_bolt/pagination.py +669 -0
- django_bolt/param_functions.py +49 -0
- django_bolt/params.py +337 -0
- django_bolt/request_parsing.py +128 -0
- django_bolt/responses.py +214 -0
- django_bolt/router.py +48 -0
- django_bolt/serialization.py +193 -0
- django_bolt/status_codes.py +321 -0
- django_bolt/testing/__init__.py +10 -0
- django_bolt/testing/client.py +274 -0
- django_bolt/testing/helpers.py +93 -0
- django_bolt/tests/__init__.py +0 -0
- django_bolt/tests/admin_tests/__init__.py +1 -0
- django_bolt/tests/admin_tests/conftest.py +6 -0
- django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
- django_bolt/tests/admin_tests/urls.py +9 -0
- django_bolt/tests/cbv/__init__.py +0 -0
- django_bolt/tests/cbv/test_class_views.py +570 -0
- django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
- django_bolt/tests/cbv/test_class_views_features.py +1173 -0
- django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
- django_bolt/tests/conftest.py +165 -0
- django_bolt/tests/test_action_decorator.py +399 -0
- django_bolt/tests/test_auth_secret_key.py +83 -0
- django_bolt/tests/test_decorator_syntax.py +159 -0
- django_bolt/tests/test_error_handling.py +481 -0
- django_bolt/tests/test_file_response.py +192 -0
- django_bolt/tests/test_global_cors.py +172 -0
- django_bolt/tests/test_guards_auth.py +441 -0
- django_bolt/tests/test_guards_integration.py +303 -0
- django_bolt/tests/test_health.py +283 -0
- django_bolt/tests/test_integration_validation.py +400 -0
- django_bolt/tests/test_json_validation.py +536 -0
- django_bolt/tests/test_jwt_auth.py +327 -0
- django_bolt/tests/test_jwt_token.py +458 -0
- django_bolt/tests/test_logging.py +837 -0
- django_bolt/tests/test_logging_merge.py +419 -0
- django_bolt/tests/test_middleware.py +492 -0
- django_bolt/tests/test_middleware_server.py +230 -0
- django_bolt/tests/test_model_viewset.py +323 -0
- django_bolt/tests/test_models.py +24 -0
- django_bolt/tests/test_pagination.py +1258 -0
- django_bolt/tests/test_parameter_validation.py +178 -0
- django_bolt/tests/test_syntax.py +626 -0
- django_bolt/tests/test_testing_utilities.py +163 -0
- django_bolt/tests/test_testing_utilities_simple.py +123 -0
- django_bolt/tests/test_viewset_unified.py +346 -0
- django_bolt/typing.py +273 -0
- django_bolt/views.py +1110 -0
- django_bolt-0.1.0.dist-info/METADATA +629 -0
- django_bolt-0.1.0.dist-info/RECORD +128 -0
- django_bolt-0.1.0.dist-info/WHEEL +4 -0
- django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pagination utilities for Django-Bolt.
|
|
3
|
+
|
|
4
|
+
Provides Django Paginator-based pagination that works with both functional
|
|
5
|
+
and class-based views, leveraging Django's built-in pagination while integrating
|
|
6
|
+
with Bolt's parameter extraction and serialization systems.
|
|
7
|
+
|
|
8
|
+
Example (Functional View):
|
|
9
|
+
@api.get("/users")
|
|
10
|
+
@paginate(PageNumberPagination)
|
|
11
|
+
async def list_users():
|
|
12
|
+
return User.objects.all()
|
|
13
|
+
|
|
14
|
+
Example (Class-Based View):
|
|
15
|
+
@api.viewset("/articles")
|
|
16
|
+
class ArticleViewSet(ModelViewSet):
|
|
17
|
+
queryset = Article.objects.all()
|
|
18
|
+
serializer_class = ArticleSchema
|
|
19
|
+
pagination_class = PageNumberPagination
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import inspect
|
|
25
|
+
import msgspec
|
|
26
|
+
from abc import ABC, abstractmethod
|
|
27
|
+
from typing import Any, Callable, Dict, List, Optional, TypeVar, Generic, get_origin, get_args
|
|
28
|
+
from functools import wraps
|
|
29
|
+
from django.core.paginator import Paginator, Page, EmptyPage, PageNotAnInteger
|
|
30
|
+
from asgiref.sync import sync_to_async
|
|
31
|
+
|
|
32
|
+
from .params import Query
|
|
33
|
+
from .typing import is_optional
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"PaginationBase",
|
|
37
|
+
"PageNumberPagination",
|
|
38
|
+
"LimitOffsetPagination",
|
|
39
|
+
"CursorPagination",
|
|
40
|
+
"PaginatedResponse",
|
|
41
|
+
"paginate",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
T = TypeVar("T")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PaginatedResponse(msgspec.Struct, Generic[T]):
|
|
48
|
+
"""
|
|
49
|
+
Standard paginated response structure.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
items: List of paginated items
|
|
53
|
+
total: Total number of items across all pages
|
|
54
|
+
page: Current page number (for PageNumber pagination)
|
|
55
|
+
page_size: Number of items per page
|
|
56
|
+
total_pages: Total number of pages
|
|
57
|
+
has_next: Whether there is a next page
|
|
58
|
+
has_previous: Whether there is a previous page
|
|
59
|
+
next_page: Next page number (None if no next page)
|
|
60
|
+
previous_page: Previous page number (None if no previous page)
|
|
61
|
+
"""
|
|
62
|
+
items: List[T]
|
|
63
|
+
total: int
|
|
64
|
+
page: Optional[int] = None
|
|
65
|
+
page_size: Optional[int] = None
|
|
66
|
+
total_pages: Optional[int] = None
|
|
67
|
+
has_next: bool = False
|
|
68
|
+
has_previous: bool = False
|
|
69
|
+
next_page: Optional[int] = None
|
|
70
|
+
previous_page: Optional[int] = None
|
|
71
|
+
|
|
72
|
+
# For LimitOffset pagination
|
|
73
|
+
limit: Optional[int] = None
|
|
74
|
+
offset: Optional[int] = None
|
|
75
|
+
|
|
76
|
+
# For Cursor pagination
|
|
77
|
+
next_cursor: Optional[str] = None
|
|
78
|
+
previous_cursor: Optional[str] = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PaginationBase(ABC):
|
|
82
|
+
"""
|
|
83
|
+
Base class for all pagination schemes.
|
|
84
|
+
|
|
85
|
+
Subclasses must implement:
|
|
86
|
+
- get_page_params(): Extract pagination params from request
|
|
87
|
+
- paginate_queryset(): Apply pagination to queryset
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# Default page size
|
|
91
|
+
page_size: int = 100
|
|
92
|
+
|
|
93
|
+
# Maximum allowed page size (prevents abuse)
|
|
94
|
+
max_page_size: int = 1000
|
|
95
|
+
|
|
96
|
+
# Name of the page size query parameter
|
|
97
|
+
page_size_query_param: Optional[str] = None
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
async def get_page_params(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
101
|
+
"""
|
|
102
|
+
Extract pagination parameters from request.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
request: Request dictionary
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Dictionary of pagination parameters
|
|
109
|
+
"""
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
async def paginate_queryset(
|
|
114
|
+
self,
|
|
115
|
+
queryset: Any,
|
|
116
|
+
request: Dict[str, Any],
|
|
117
|
+
**params: Any
|
|
118
|
+
) -> PaginatedResponse:
|
|
119
|
+
"""
|
|
120
|
+
Apply pagination to a queryset and return paginated response.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
queryset: Django QuerySet to paginate
|
|
124
|
+
request: Request dictionary
|
|
125
|
+
**params: Additional parameters
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
PaginatedResponse with items and metadata
|
|
129
|
+
"""
|
|
130
|
+
raise NotImplementedError
|
|
131
|
+
|
|
132
|
+
async def _get_queryset_count(self, queryset: Any) -> int:
|
|
133
|
+
"""
|
|
134
|
+
Get total count of queryset items.
|
|
135
|
+
|
|
136
|
+
Handles both sync and async querysets.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
queryset: Django QuerySet
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Total count
|
|
143
|
+
"""
|
|
144
|
+
# Check if queryset has acount (async count)
|
|
145
|
+
if hasattr(queryset, 'acount'):
|
|
146
|
+
return await queryset.acount()
|
|
147
|
+
# Fallback to sync count wrapped in sync_to_async
|
|
148
|
+
elif hasattr(queryset, 'count'):
|
|
149
|
+
return await sync_to_async(queryset.count)()
|
|
150
|
+
# For lists or other iterables
|
|
151
|
+
else:
|
|
152
|
+
return len(queryset)
|
|
153
|
+
|
|
154
|
+
async def _evaluate_queryset_slice(self, queryset: Any) -> List[Any]:
|
|
155
|
+
"""
|
|
156
|
+
Evaluate a queryset slice to a list.
|
|
157
|
+
|
|
158
|
+
Handles both sync and async querysets.
|
|
159
|
+
Converts Django model instances to dicts for serialization.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
queryset: Django QuerySet or iterable
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
List of items (Django models converted to dicts)
|
|
166
|
+
"""
|
|
167
|
+
items = []
|
|
168
|
+
|
|
169
|
+
# Check if it's an async iterable (has __aiter__)
|
|
170
|
+
if hasattr(queryset, '__aiter__'):
|
|
171
|
+
async for item in queryset:
|
|
172
|
+
items.append(self._model_to_dict(item))
|
|
173
|
+
return items
|
|
174
|
+
# Check if it's a Django QuerySet with async support
|
|
175
|
+
elif hasattr(queryset, '_iterable_class') and hasattr(queryset, 'model'):
|
|
176
|
+
# It's a QuerySet - check if we can iterate async
|
|
177
|
+
try:
|
|
178
|
+
async for item in queryset:
|
|
179
|
+
items.append(self._model_to_dict(item))
|
|
180
|
+
return items
|
|
181
|
+
except TypeError:
|
|
182
|
+
# Not async iterable, use sync_to_async
|
|
183
|
+
raw_items = await sync_to_async(list)(queryset)
|
|
184
|
+
return [self._model_to_dict(item) for item in raw_items]
|
|
185
|
+
# Regular iterable or list
|
|
186
|
+
else:
|
|
187
|
+
result = list(queryset)
|
|
188
|
+
return [self._model_to_dict(item) for item in result]
|
|
189
|
+
|
|
190
|
+
def _model_to_dict(self, item: Any) -> Any:
|
|
191
|
+
"""
|
|
192
|
+
Convert Django model instance to dict for serialization.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
item: Django model instance or any object
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Dict if item is a Django model, otherwise returns item unchanged
|
|
199
|
+
"""
|
|
200
|
+
# Check if it's a Django model instance
|
|
201
|
+
if hasattr(item, '_meta') and hasattr(item, '_state'):
|
|
202
|
+
# It's a Django model - convert to dict
|
|
203
|
+
# Get all field values - use __dict__ which is safe in async context
|
|
204
|
+
data = {}
|
|
205
|
+
# Use model's __dict__ to avoid accessing _meta in async context
|
|
206
|
+
for key, value in item.__dict__.items():
|
|
207
|
+
# Skip private attributes and Django internal state
|
|
208
|
+
if not key.startswith('_'):
|
|
209
|
+
data[key] = value
|
|
210
|
+
return data
|
|
211
|
+
# Not a model, return as-is
|
|
212
|
+
return item
|
|
213
|
+
|
|
214
|
+
def _get_page_size(self, request: Dict[str, Any]) -> int:
|
|
215
|
+
"""
|
|
216
|
+
Get page size from request, with validation.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
request: Request dictionary
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Validated page size
|
|
223
|
+
"""
|
|
224
|
+
if self.page_size_query_param:
|
|
225
|
+
query = request.get('query', {})
|
|
226
|
+
page_size_str = query.get(self.page_size_query_param)
|
|
227
|
+
if page_size_str:
|
|
228
|
+
try:
|
|
229
|
+
page_size = int(page_size_str)
|
|
230
|
+
# Enforce max_page_size limit
|
|
231
|
+
if page_size > self.max_page_size:
|
|
232
|
+
return self.max_page_size
|
|
233
|
+
if page_size < 1:
|
|
234
|
+
return self.page_size
|
|
235
|
+
return page_size
|
|
236
|
+
except (ValueError, TypeError):
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
return self.page_size
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class PageNumberPagination(PaginationBase):
|
|
243
|
+
"""
|
|
244
|
+
Page number-based pagination.
|
|
245
|
+
|
|
246
|
+
Query parameters:
|
|
247
|
+
- page: Page number (default: 1)
|
|
248
|
+
- page_size: Items per page (optional, default: 100)
|
|
249
|
+
|
|
250
|
+
Example:
|
|
251
|
+
/api/users?page=2&page_size=20
|
|
252
|
+
|
|
253
|
+
Attributes:
|
|
254
|
+
page_size: Default number of items per page (default: 100)
|
|
255
|
+
max_page_size: Maximum allowed page size (default: 1000)
|
|
256
|
+
page_size_query_param: Query param name for page size (default: "page_size")
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
page_size: int = 100
|
|
260
|
+
max_page_size: int = 1000
|
|
261
|
+
page_size_query_param: str = "page_size"
|
|
262
|
+
|
|
263
|
+
async def get_page_params(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
264
|
+
"""Extract page number and page_size from request."""
|
|
265
|
+
query = request.get('query', {})
|
|
266
|
+
|
|
267
|
+
# Get page number (default to 1)
|
|
268
|
+
page_str = query.get('page', '1')
|
|
269
|
+
try:
|
|
270
|
+
page = int(page_str)
|
|
271
|
+
if page < 1:
|
|
272
|
+
page = 1
|
|
273
|
+
except (ValueError, TypeError):
|
|
274
|
+
page = 1
|
|
275
|
+
|
|
276
|
+
# Get page size
|
|
277
|
+
page_size = self._get_page_size(request)
|
|
278
|
+
|
|
279
|
+
return {'page': page, 'page_size': page_size}
|
|
280
|
+
|
|
281
|
+
async def paginate_queryset(
|
|
282
|
+
self,
|
|
283
|
+
queryset: Any,
|
|
284
|
+
request: Dict[str, Any],
|
|
285
|
+
**params: Any
|
|
286
|
+
) -> PaginatedResponse:
|
|
287
|
+
"""
|
|
288
|
+
Paginate queryset using page numbers.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
queryset: Django QuerySet to paginate
|
|
292
|
+
request: Request dictionary
|
|
293
|
+
**params: Additional parameters (unused)
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
PaginatedResponse with pagination metadata
|
|
297
|
+
"""
|
|
298
|
+
page_params = await self.get_page_params(request)
|
|
299
|
+
page_number = page_params['page']
|
|
300
|
+
page_size = page_params['page_size']
|
|
301
|
+
|
|
302
|
+
# Get total count
|
|
303
|
+
total = await self._get_queryset_count(queryset)
|
|
304
|
+
|
|
305
|
+
# Calculate total pages
|
|
306
|
+
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
|
307
|
+
|
|
308
|
+
# Validate page number
|
|
309
|
+
if page_number > total_pages and total_pages > 0:
|
|
310
|
+
page_number = total_pages
|
|
311
|
+
|
|
312
|
+
# Calculate offset
|
|
313
|
+
offset = (page_number - 1) * page_size
|
|
314
|
+
|
|
315
|
+
# Slice queryset
|
|
316
|
+
items = await self._evaluate_queryset_slice(queryset[offset:offset + page_size])
|
|
317
|
+
|
|
318
|
+
# Build response
|
|
319
|
+
return PaginatedResponse(
|
|
320
|
+
items=items,
|
|
321
|
+
total=total,
|
|
322
|
+
page=page_number,
|
|
323
|
+
page_size=page_size,
|
|
324
|
+
total_pages=total_pages,
|
|
325
|
+
has_next=page_number < total_pages,
|
|
326
|
+
has_previous=page_number > 1,
|
|
327
|
+
next_page=page_number + 1 if page_number < total_pages else None,
|
|
328
|
+
previous_page=page_number - 1 if page_number > 1 else None,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class LimitOffsetPagination(PaginationBase):
|
|
333
|
+
"""
|
|
334
|
+
Limit-offset based pagination.
|
|
335
|
+
|
|
336
|
+
Query parameters:
|
|
337
|
+
- limit: Number of items to return (default: 100)
|
|
338
|
+
- offset: Starting position (default: 0)
|
|
339
|
+
|
|
340
|
+
Example:
|
|
341
|
+
/api/users?limit=20&offset=40
|
|
342
|
+
|
|
343
|
+
Attributes:
|
|
344
|
+
page_size: Default limit when not specified (default: 100)
|
|
345
|
+
max_page_size: Maximum allowed limit (default: 1000)
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
page_size: int = 100
|
|
349
|
+
max_page_size: int = 1000
|
|
350
|
+
|
|
351
|
+
async def get_page_params(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
352
|
+
"""Extract limit and offset from request."""
|
|
353
|
+
query = request.get('query', {})
|
|
354
|
+
|
|
355
|
+
# Get limit (default to page_size)
|
|
356
|
+
limit_str = query.get('limit', None)
|
|
357
|
+
if limit_str is None:
|
|
358
|
+
# No limit specified, use default
|
|
359
|
+
limit = self.page_size
|
|
360
|
+
else:
|
|
361
|
+
try:
|
|
362
|
+
limit = int(limit_str)
|
|
363
|
+
if limit < 1:
|
|
364
|
+
limit = self.page_size
|
|
365
|
+
if limit > self.max_page_size:
|
|
366
|
+
limit = self.max_page_size
|
|
367
|
+
except (ValueError, TypeError):
|
|
368
|
+
limit = self.page_size
|
|
369
|
+
|
|
370
|
+
# Get offset (default to 0)
|
|
371
|
+
offset_str = query.get('offset', '0')
|
|
372
|
+
try:
|
|
373
|
+
offset = int(offset_str)
|
|
374
|
+
if offset < 0:
|
|
375
|
+
offset = 0
|
|
376
|
+
except (ValueError, TypeError):
|
|
377
|
+
offset = 0
|
|
378
|
+
|
|
379
|
+
return {'limit': limit, 'offset': offset}
|
|
380
|
+
|
|
381
|
+
async def paginate_queryset(
|
|
382
|
+
self,
|
|
383
|
+
queryset: Any,
|
|
384
|
+
request: Dict[str, Any],
|
|
385
|
+
**params: Any
|
|
386
|
+
) -> PaginatedResponse:
|
|
387
|
+
"""
|
|
388
|
+
Paginate queryset using limit/offset.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
queryset: Django QuerySet to paginate
|
|
392
|
+
request: Request dictionary
|
|
393
|
+
**params: Additional parameters (unused)
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
PaginatedResponse with pagination metadata
|
|
397
|
+
"""
|
|
398
|
+
page_params = await self.get_page_params(request)
|
|
399
|
+
limit = page_params['limit']
|
|
400
|
+
offset = page_params['offset']
|
|
401
|
+
|
|
402
|
+
# Get total count
|
|
403
|
+
total = await self._get_queryset_count(queryset)
|
|
404
|
+
|
|
405
|
+
# Slice queryset
|
|
406
|
+
items = await self._evaluate_queryset_slice(queryset[offset:offset + limit])
|
|
407
|
+
|
|
408
|
+
# Calculate page info
|
|
409
|
+
has_next = (offset + limit) < total
|
|
410
|
+
has_previous = offset > 0
|
|
411
|
+
|
|
412
|
+
return PaginatedResponse(
|
|
413
|
+
items=items,
|
|
414
|
+
total=total,
|
|
415
|
+
limit=limit,
|
|
416
|
+
offset=offset,
|
|
417
|
+
has_next=has_next,
|
|
418
|
+
has_previous=has_previous,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class CursorPagination(PaginationBase):
|
|
423
|
+
"""
|
|
424
|
+
Cursor-based pagination for large datasets.
|
|
425
|
+
|
|
426
|
+
More efficient than offset-based pagination for large datasets
|
|
427
|
+
as it doesn't require counting all records or scanning through skipped records.
|
|
428
|
+
|
|
429
|
+
Query parameters:
|
|
430
|
+
- cursor: Opaque cursor string (optional)
|
|
431
|
+
- page_size: Items per page (optional)
|
|
432
|
+
|
|
433
|
+
Example:
|
|
434
|
+
/api/users?cursor=eyJpZCI6MTAwfQ==&page_size=20
|
|
435
|
+
|
|
436
|
+
Attributes:
|
|
437
|
+
page_size: Default number of items per page (default: 100)
|
|
438
|
+
max_page_size: Maximum allowed page size (default: 1000)
|
|
439
|
+
page_size_query_param: Query param name for page size (default: "page_size")
|
|
440
|
+
ordering: Field to order by (default: "-id" for descending ID)
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
page_size: int = 100
|
|
444
|
+
max_page_size: int = 1000
|
|
445
|
+
page_size_query_param: str = "page_size"
|
|
446
|
+
ordering: str = "-id" # Default ordering field
|
|
447
|
+
|
|
448
|
+
def _encode_cursor(self, value: Any) -> str:
|
|
449
|
+
"""
|
|
450
|
+
Encode cursor value to base64 string.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
value: Cursor value (typically an ID)
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Base64-encoded cursor string
|
|
457
|
+
"""
|
|
458
|
+
cursor_data = msgspec.json.encode({"v": value})
|
|
459
|
+
return base64.b64encode(cursor_data).decode('utf-8')
|
|
460
|
+
|
|
461
|
+
def _decode_cursor(self, cursor: str) -> Any:
|
|
462
|
+
"""
|
|
463
|
+
Decode cursor string to value.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
cursor: Base64-encoded cursor string
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Decoded cursor value
|
|
470
|
+
"""
|
|
471
|
+
try:
|
|
472
|
+
cursor_data = base64.b64decode(cursor.encode('utf-8'))
|
|
473
|
+
data = msgspec.json.decode(cursor_data)
|
|
474
|
+
return data.get("v")
|
|
475
|
+
except Exception:
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
async def get_page_params(self, request: Dict[str, Any]) -> Dict[str, Any]:
|
|
479
|
+
"""Extract cursor and page_size from request."""
|
|
480
|
+
query = request.get('query', {})
|
|
481
|
+
|
|
482
|
+
# Get cursor (optional)
|
|
483
|
+
cursor_str = query.get('cursor')
|
|
484
|
+
cursor_value = self._decode_cursor(cursor_str) if cursor_str else None
|
|
485
|
+
|
|
486
|
+
# Get page size
|
|
487
|
+
page_size = self._get_page_size(request)
|
|
488
|
+
|
|
489
|
+
return {'cursor': cursor_value, 'page_size': page_size}
|
|
490
|
+
|
|
491
|
+
async def paginate_queryset(
|
|
492
|
+
self,
|
|
493
|
+
queryset: Any,
|
|
494
|
+
request: Dict[str, Any],
|
|
495
|
+
**params: Any
|
|
496
|
+
) -> PaginatedResponse:
|
|
497
|
+
"""
|
|
498
|
+
Paginate queryset using cursor-based pagination.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
queryset: Django QuerySet to paginate
|
|
502
|
+
request: Request dictionary
|
|
503
|
+
**params: Additional parameters (unused)
|
|
504
|
+
|
|
505
|
+
Returns:
|
|
506
|
+
PaginatedResponse with cursor metadata
|
|
507
|
+
"""
|
|
508
|
+
page_params = await self.get_page_params(request)
|
|
509
|
+
cursor_value = page_params['cursor']
|
|
510
|
+
page_size = page_params['page_size']
|
|
511
|
+
|
|
512
|
+
# Apply ordering
|
|
513
|
+
ordering = self.ordering
|
|
514
|
+
is_descending = ordering.startswith('-')
|
|
515
|
+
ordering_field = ordering.lstrip('-')
|
|
516
|
+
|
|
517
|
+
# Apply ordering to queryset
|
|
518
|
+
ordered_qs = queryset.order_by(ordering)
|
|
519
|
+
|
|
520
|
+
# Apply cursor filter if present
|
|
521
|
+
if cursor_value is not None:
|
|
522
|
+
if is_descending:
|
|
523
|
+
# For descending order, we want items less than cursor
|
|
524
|
+
filter_kwargs = {f"{ordering_field}__lt": cursor_value}
|
|
525
|
+
else:
|
|
526
|
+
# For ascending order, we want items greater than cursor
|
|
527
|
+
filter_kwargs = {f"{ordering_field}__gt": cursor_value}
|
|
528
|
+
|
|
529
|
+
ordered_qs = ordered_qs.filter(**filter_kwargs)
|
|
530
|
+
|
|
531
|
+
# Fetch page_size + 1 items to determine if there's a next page
|
|
532
|
+
items = await self._evaluate_queryset_slice(ordered_qs[:page_size + 1])
|
|
533
|
+
|
|
534
|
+
# Check if there are more items
|
|
535
|
+
has_next = len(items) > page_size
|
|
536
|
+
if has_next:
|
|
537
|
+
items = items[:page_size] # Trim to page_size
|
|
538
|
+
|
|
539
|
+
# Generate next cursor from last item
|
|
540
|
+
next_cursor = None
|
|
541
|
+
if has_next and items:
|
|
542
|
+
last_item = items[-1]
|
|
543
|
+
# Items are now dicts (converted from models), so use dict access
|
|
544
|
+
if isinstance(last_item, dict):
|
|
545
|
+
last_value = last_item.get(ordering_field)
|
|
546
|
+
else:
|
|
547
|
+
last_value = getattr(last_item, ordering_field, None)
|
|
548
|
+
|
|
549
|
+
if last_value is not None:
|
|
550
|
+
next_cursor = self._encode_cursor(last_value)
|
|
551
|
+
|
|
552
|
+
return PaginatedResponse(
|
|
553
|
+
items=items,
|
|
554
|
+
total=0, # Cursor pagination doesn't provide total count for efficiency
|
|
555
|
+
page_size=page_size,
|
|
556
|
+
has_next=has_next,
|
|
557
|
+
has_previous=cursor_value is not None,
|
|
558
|
+
next_cursor=next_cursor,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def paginate(pagination_class: type[PaginationBase] = PageNumberPagination):
|
|
563
|
+
"""
|
|
564
|
+
Decorator to apply pagination to a route handler.
|
|
565
|
+
|
|
566
|
+
The decorated handler should return a Django QuerySet or list.
|
|
567
|
+
The decorator will automatically apply pagination and return a PaginatedResponse.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
pagination_class: Pagination class to use (default: PageNumberPagination)
|
|
571
|
+
|
|
572
|
+
Example:
|
|
573
|
+
@api.get("/users")
|
|
574
|
+
@paginate(PageNumberPagination)
|
|
575
|
+
async def list_users():
|
|
576
|
+
return User.objects.all()
|
|
577
|
+
|
|
578
|
+
@api.get("/articles")
|
|
579
|
+
@paginate(LimitOffsetPagination)
|
|
580
|
+
async def list_articles(status: str = "published"):
|
|
581
|
+
return Article.objects.filter(status=status)
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
Decorated handler function
|
|
585
|
+
"""
|
|
586
|
+
def decorator(handler: Callable) -> Callable:
|
|
587
|
+
# Create pagination instance
|
|
588
|
+
paginator = pagination_class()
|
|
589
|
+
|
|
590
|
+
# Store original handler for introspection
|
|
591
|
+
original_handler = handler
|
|
592
|
+
|
|
593
|
+
@wraps(handler)
|
|
594
|
+
async def wrapper(*args, **kwargs):
|
|
595
|
+
# Extract request from args/kwargs
|
|
596
|
+
# Request can be in:
|
|
597
|
+
# 1. kwargs['request'] - most common
|
|
598
|
+
# 2. args[0] - for single-param handlers
|
|
599
|
+
# 3. args[1] - for ViewSet methods (args[0] is self)
|
|
600
|
+
request = None
|
|
601
|
+
|
|
602
|
+
# Try kwargs first (most reliable)
|
|
603
|
+
if 'request' in kwargs:
|
|
604
|
+
request = kwargs['request']
|
|
605
|
+
# Check if this is a method with self as first arg
|
|
606
|
+
elif len(args) >= 2:
|
|
607
|
+
# Could be (self, request, ...) or (request, other_params...)
|
|
608
|
+
# If args[0] looks like a view instance, args[1] is request
|
|
609
|
+
first_arg = args[0]
|
|
610
|
+
if (hasattr(first_arg, '__class__') and
|
|
611
|
+
hasattr(first_arg.__class__, '__mro__') and
|
|
612
|
+
any('View' in cls.__name__ for cls in first_arg.__class__.__mro__)):
|
|
613
|
+
request = args[1]
|
|
614
|
+
else:
|
|
615
|
+
# args[0] is request, args[1] is another parameter
|
|
616
|
+
request = args[0]
|
|
617
|
+
elif len(args) == 1:
|
|
618
|
+
# Single arg - should be request
|
|
619
|
+
request = args[0]
|
|
620
|
+
|
|
621
|
+
if request is None:
|
|
622
|
+
raise ValueError(
|
|
623
|
+
f"Pagination decorator on {handler.__name__} could not find request. "
|
|
624
|
+
f"Args: {[type(a).__name__ for a in args]}, kwargs: {list(kwargs.keys())}"
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# Convert PyRequest to dict if needed for pagination
|
|
628
|
+
# PyRequest objects from Rust layer behave like dicts
|
|
629
|
+
if not isinstance(request, dict) and hasattr(request, '__getitem__'):
|
|
630
|
+
# It's a PyRequest object - convert to dict for pagination methods
|
|
631
|
+
request_dict = {
|
|
632
|
+
'method': request.get('method', 'GET'),
|
|
633
|
+
'query': request.get('query', {}),
|
|
634
|
+
'params': request.get('params', {}),
|
|
635
|
+
'headers': request.get('headers', {}),
|
|
636
|
+
'cookies': request.get('cookies', {}),
|
|
637
|
+
}
|
|
638
|
+
elif isinstance(request, dict):
|
|
639
|
+
request_dict = request
|
|
640
|
+
else:
|
|
641
|
+
raise ValueError(
|
|
642
|
+
f"Unexpected request type: {type(request)}. "
|
|
643
|
+
f"Args: {[type(a).__name__ for a in args]}, kwargs: {list(kwargs.keys())}"
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# Call original handler to get queryset
|
|
647
|
+
# Pass all args along (including self for methods)
|
|
648
|
+
queryset = await handler(*args, **kwargs)
|
|
649
|
+
|
|
650
|
+
# Apply pagination using dict version
|
|
651
|
+
paginated = await paginator.paginate_queryset(
|
|
652
|
+
queryset, request_dict
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
return paginated
|
|
656
|
+
|
|
657
|
+
# Preserve signature so framework knows to pass request
|
|
658
|
+
wrapper.__signature__ = inspect.signature(original_handler)
|
|
659
|
+
wrapper.__name__ = original_handler.__name__
|
|
660
|
+
wrapper.__doc__ = original_handler.__doc__
|
|
661
|
+
wrapper.__module__ = original_handler.__module__
|
|
662
|
+
|
|
663
|
+
# Mark that this handler returns PaginatedResponse for serialization
|
|
664
|
+
wrapper.__paginated__ = True
|
|
665
|
+
wrapper.__pagination_class__ = pagination_class
|
|
666
|
+
|
|
667
|
+
return wrapper
|
|
668
|
+
|
|
669
|
+
return decorator
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from .params import (
|
|
4
|
+
Query as _Query,
|
|
5
|
+
Path as _Path,
|
|
6
|
+
Body as _Body,
|
|
7
|
+
Header as _Header,
|
|
8
|
+
Cookie as _Cookie,
|
|
9
|
+
Depends as _Depends,
|
|
10
|
+
Form as _Form,
|
|
11
|
+
File as _File,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def Query(*args: Any, **kwargs: Any) -> Any: # noqa: N802
|
|
16
|
+
return _Query(*args, **kwargs)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def Path(*args: Any, **kwargs: Any) -> Any: # noqa: N802
|
|
20
|
+
return _Path(*args, **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def Body(*args: Any, **kwargs: Any) -> Any: # noqa: N802
|
|
24
|
+
return _Body(*args, **kwargs)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def Header(*args: Any, **kwargs: Any) -> Any: # noqa: N802
|
|
28
|
+
return _Header(*args, **kwargs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def Cookie(*args: Any, **kwargs: Any) -> Any: # noqa: N802
|
|
32
|
+
return _Cookie(*args, **kwargs)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def Depends(*args: Any, **kwargs: Any) -> Any: # noqa: N802
|
|
36
|
+
return _Depends(*args, **kwargs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def Form(*args: Any, **kwargs: Any) -> Any: # noqa: N802
|
|
40
|
+
return _Form(*args, **kwargs)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def File(*args: Any, **kwargs: Any) -> Any: # noqa: N802
|
|
44
|
+
return _File(*args, **kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["Query", "Path", "Body", "Header", "Cookie", "Depends", "Form", "File"]
|
|
48
|
+
|
|
49
|
+
|