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
django_bolt/views.py
ADDED
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Class-based views for Django-Bolt.
|
|
3
|
+
|
|
4
|
+
Provides Django-style class-based views that integrate seamlessly with
|
|
5
|
+
Bolt's routing, dependency injection, guards, and authentication.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
api = BoltAPI()
|
|
9
|
+
|
|
10
|
+
@api.view("/hello")
|
|
11
|
+
class HelloView(APIView):
|
|
12
|
+
guards = [IsAuthenticated()]
|
|
13
|
+
|
|
14
|
+
async def get(self, request, current_user=Depends(get_current_user)) -> dict:
|
|
15
|
+
return {"user": current_user.id}
|
|
16
|
+
"""
|
|
17
|
+
import inspect
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Type
|
|
19
|
+
from .exceptions import HTTPException
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIView:
|
|
23
|
+
"""
|
|
24
|
+
Base class for class-based views in Django-Bolt.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
http_method_names: List of supported HTTP methods (lowercase)
|
|
28
|
+
guards: List of guard/permission classes to apply to all methods
|
|
29
|
+
auth: List of authentication backends to apply to all methods
|
|
30
|
+
status_code: Default status code for responses (can be overridden per-method)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
http_method_names = ["get", "post", "put", "patch", "delete", "head", "options"]
|
|
34
|
+
|
|
35
|
+
# Class-level defaults (can be overridden by subclasses)
|
|
36
|
+
guards: Optional[List[Any]] = None
|
|
37
|
+
auth: Optional[List[Any]] = None
|
|
38
|
+
status_code: Optional[int] = None
|
|
39
|
+
|
|
40
|
+
def __init__(self, **kwargs):
|
|
41
|
+
"""
|
|
42
|
+
Initialize the view instance.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
**kwargs: Additional instance attributes
|
|
46
|
+
"""
|
|
47
|
+
for key, value in kwargs.items():
|
|
48
|
+
setattr(self, key, value)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def as_view(cls, method: str, action: Optional[str] = None) -> Callable:
|
|
52
|
+
"""
|
|
53
|
+
Create a handler callable for a specific HTTP method.
|
|
54
|
+
|
|
55
|
+
This method:
|
|
56
|
+
1. Validates that the HTTP method is supported
|
|
57
|
+
2. Creates a wrapper that instantiates the view and calls the method handler
|
|
58
|
+
3. Preserves the method signature for parameter extraction and dependency injection
|
|
59
|
+
4. Attaches class-level metadata (guards, auth) for middleware compilation
|
|
60
|
+
5. Maps DRF-style action names (list, retrieve, etc.) to HTTP methods
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
method: HTTP method name (lowercase, e.g., "get", "post")
|
|
64
|
+
action: Optional action name for DRF-style methods (e.g., "list", "retrieve")
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Async handler function compatible with BoltAPI routing
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If method is not supported by this view
|
|
71
|
+
"""
|
|
72
|
+
method_lower = method.lower()
|
|
73
|
+
|
|
74
|
+
if method_lower not in cls.http_method_names:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Method '{method}' not allowed. "
|
|
77
|
+
f"Allowed methods: {cls.http_method_names}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# DRF-style action mapping: try action name first, then HTTP method
|
|
81
|
+
# Actions: list, retrieve, create, update, partial_update, destroy
|
|
82
|
+
method_handler = None
|
|
83
|
+
action_name = None
|
|
84
|
+
|
|
85
|
+
if action:
|
|
86
|
+
# Try the action name first (e.g., "list", "retrieve")
|
|
87
|
+
method_handler = getattr(cls, action, None)
|
|
88
|
+
action_name = action
|
|
89
|
+
|
|
90
|
+
# Fall back to HTTP method name
|
|
91
|
+
if method_handler is None:
|
|
92
|
+
method_handler = getattr(cls, method_lower, None)
|
|
93
|
+
action_name = method_lower
|
|
94
|
+
|
|
95
|
+
if method_handler is None:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"View class {cls.__name__} does not implement method '{action or method_lower}'"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Validate that handler is async
|
|
101
|
+
if not inspect.iscoroutinefunction(method_handler):
|
|
102
|
+
raise TypeError(
|
|
103
|
+
f"Handler {cls.__name__}.{action_name} must be async. "
|
|
104
|
+
f"Use 'async def' instead of 'def'"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Create wrapper that preserves signature for parameter extraction
|
|
108
|
+
# The wrapper's signature matches the method handler (excluding 'self')
|
|
109
|
+
sig = inspect.signature(method_handler)
|
|
110
|
+
params = list(sig.parameters.values())[1:] # Skip 'self' parameter
|
|
111
|
+
|
|
112
|
+
# Build new signature without 'self'
|
|
113
|
+
new_sig = sig.replace(parameters=params)
|
|
114
|
+
|
|
115
|
+
# Create single view instance at registration time (not per-request)
|
|
116
|
+
# This eliminates the per-request instantiation overhead (~40% faster)
|
|
117
|
+
view_instance = cls()
|
|
118
|
+
|
|
119
|
+
# Set action name once at registration time
|
|
120
|
+
if hasattr(view_instance, 'action'):
|
|
121
|
+
view_instance.action = action_name
|
|
122
|
+
|
|
123
|
+
# Bind the method once to eliminate lookup overhead
|
|
124
|
+
bound_method = method_handler.__get__(view_instance, cls)
|
|
125
|
+
|
|
126
|
+
# Create pure functional handler that calls bound method directly
|
|
127
|
+
async def view_handler(*args, **kwargs):
|
|
128
|
+
"""Auto-generated view handler that calls bound method directly."""
|
|
129
|
+
# Inject request object into view instance for pagination/filtering
|
|
130
|
+
# Request is typically the first positional arg or named 'request'
|
|
131
|
+
if args and isinstance(args[0], dict) and 'method' in args[0]:
|
|
132
|
+
view_instance.request = args[0]
|
|
133
|
+
elif 'request' in kwargs:
|
|
134
|
+
view_instance.request = kwargs['request']
|
|
135
|
+
|
|
136
|
+
return await bound_method(*args, **kwargs)
|
|
137
|
+
|
|
138
|
+
# Attach the signature (for parameter extraction)
|
|
139
|
+
view_handler.__signature__ = new_sig
|
|
140
|
+
view_handler.__annotations__ = {
|
|
141
|
+
k: v for k, v in method_handler.__annotations__.items() if k != "self"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Preserve docstring and name
|
|
145
|
+
view_handler.__name__ = f"{cls.__name__}.{action_name}"
|
|
146
|
+
view_handler.__doc__ = method_handler.__doc__
|
|
147
|
+
view_handler.__module__ = cls.__module__
|
|
148
|
+
|
|
149
|
+
# Attach class-level metadata for middleware compilation
|
|
150
|
+
# These will be picked up by BoltAPI._route_decorator
|
|
151
|
+
if cls.guards is not None:
|
|
152
|
+
view_handler.__bolt_guards__ = cls.guards
|
|
153
|
+
if cls.auth is not None:
|
|
154
|
+
view_handler.__bolt_auth__ = cls.auth
|
|
155
|
+
if cls.status_code is not None:
|
|
156
|
+
view_handler.__bolt_status_code__ = cls.status_code
|
|
157
|
+
|
|
158
|
+
return view_handler
|
|
159
|
+
|
|
160
|
+
def initialize(self, request: Dict[str, Any]) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Hook called before the method handler is invoked.
|
|
163
|
+
|
|
164
|
+
Override this to perform per-request initialization.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
request: The request dictionary
|
|
168
|
+
"""
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def get_allowed_methods(cls) -> Set[str]:
|
|
173
|
+
"""
|
|
174
|
+
Get the set of HTTP methods that this view implements.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Set of uppercase HTTP method names (e.g., {"GET", "POST"})
|
|
178
|
+
"""
|
|
179
|
+
allowed = set()
|
|
180
|
+
for method in cls.http_method_names:
|
|
181
|
+
if hasattr(cls, method) and callable(getattr(cls, method)):
|
|
182
|
+
allowed.add(method.upper())
|
|
183
|
+
return allowed
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ViewSet(APIView):
|
|
187
|
+
"""
|
|
188
|
+
ViewSet for CRUD operations on resources.
|
|
189
|
+
|
|
190
|
+
Provides a higher-level abstraction for common REST patterns.
|
|
191
|
+
Subclasses can implement standard methods: list, retrieve, create, update, partial_update, destroy.
|
|
192
|
+
|
|
193
|
+
Example:
|
|
194
|
+
@api.viewset("/users")
|
|
195
|
+
class UserViewSet(ViewSet):
|
|
196
|
+
queryset = User.objects.all()
|
|
197
|
+
serializer_class = UserSchema
|
|
198
|
+
list_serializer_class = UserMiniSchema # Optional: different serializer for lists
|
|
199
|
+
pagination_class = PageNumberPagination # Optional: enable pagination
|
|
200
|
+
|
|
201
|
+
async def list(self, request):
|
|
202
|
+
users = await self.get_queryset()
|
|
203
|
+
return [UserSchema.from_model(u) async for u in users]
|
|
204
|
+
|
|
205
|
+
async def retrieve(self, request, pk: int):
|
|
206
|
+
user = await self.get_object(pk)
|
|
207
|
+
return UserSchema.from_model(user)
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
# ViewSet configuration
|
|
211
|
+
queryset: Optional[Any] = None
|
|
212
|
+
serializer_class: Optional[Type] = None
|
|
213
|
+
list_serializer_class: Optional[Type] = None # Optional: override serializer for list operations
|
|
214
|
+
lookup_field: str = 'pk' # Field to use for object lookup (default: 'pk')
|
|
215
|
+
pagination_class: Optional[Any] = None # Optional: pagination class to use
|
|
216
|
+
|
|
217
|
+
# Action name for current request (set automatically)
|
|
218
|
+
action: Optional[str] = None
|
|
219
|
+
|
|
220
|
+
# Request object (set automatically during dispatch)
|
|
221
|
+
request: Optional[Dict[str, Any]] = None
|
|
222
|
+
|
|
223
|
+
def __init_subclass__(cls, **kwargs):
|
|
224
|
+
"""
|
|
225
|
+
Hook called when a subclass is created.
|
|
226
|
+
|
|
227
|
+
Converts class-level queryset to instance-level _base_queryset
|
|
228
|
+
to enable proper cloning on each access (Litestar pattern).
|
|
229
|
+
"""
|
|
230
|
+
super().__init_subclass__(**kwargs)
|
|
231
|
+
|
|
232
|
+
# If subclass defines queryset as class attribute, store it separately
|
|
233
|
+
if 'queryset' in cls.__dict__ and cls.__dict__['queryset'] is not None:
|
|
234
|
+
# Store the base queryset for cloning
|
|
235
|
+
cls._base_queryset = cls.__dict__['queryset']
|
|
236
|
+
# Remove the class attribute so property works
|
|
237
|
+
delattr(cls, 'queryset')
|
|
238
|
+
|
|
239
|
+
def _get_base_queryset(self):
|
|
240
|
+
"""
|
|
241
|
+
Get the base queryset defined on the class.
|
|
242
|
+
|
|
243
|
+
Returns None if no queryset is defined.
|
|
244
|
+
"""
|
|
245
|
+
# Check instance attribute first (for dynamic assignment)
|
|
246
|
+
if hasattr(self, '_instance_queryset'):
|
|
247
|
+
return self._instance_queryset
|
|
248
|
+
|
|
249
|
+
# Check class attribute (set via __init_subclass__)
|
|
250
|
+
if hasattr(self.__class__, '_base_queryset'):
|
|
251
|
+
return self.__class__._base_queryset
|
|
252
|
+
|
|
253
|
+
# Check if there's a class attribute 'queryset' (shouldn't happen after __init_subclass__)
|
|
254
|
+
return getattr(self.__class__, 'queryset', None)
|
|
255
|
+
|
|
256
|
+
def _clone_queryset(self, queryset):
|
|
257
|
+
"""
|
|
258
|
+
Clone a queryset to ensure isolation between requests.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
queryset: The queryset to clone
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Fresh QuerySet clone
|
|
265
|
+
"""
|
|
266
|
+
if queryset is None:
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
# Always return a fresh clone to prevent state leakage
|
|
270
|
+
# Django QuerySets are lazy, so .all() creates a new QuerySet instance
|
|
271
|
+
if hasattr(queryset, '_clone'):
|
|
272
|
+
# Use Django's internal _clone() for true deep copy
|
|
273
|
+
return queryset._clone()
|
|
274
|
+
elif hasattr(queryset, 'all'):
|
|
275
|
+
# Fallback to .all() which also creates a new QuerySet
|
|
276
|
+
return queryset.all()
|
|
277
|
+
|
|
278
|
+
# Not a QuerySet, return as-is
|
|
279
|
+
return queryset
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def queryset(self):
|
|
283
|
+
"""
|
|
284
|
+
Property that returns a fresh queryset clone on each access.
|
|
285
|
+
|
|
286
|
+
This ensures queryset isolation between requests while maintaining
|
|
287
|
+
single-instance performance (Litestar pattern).
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Fresh QuerySet clone or None if not set
|
|
291
|
+
"""
|
|
292
|
+
base_qs = self._get_base_queryset()
|
|
293
|
+
return self._clone_queryset(base_qs)
|
|
294
|
+
|
|
295
|
+
@queryset.setter
|
|
296
|
+
def queryset(self, value):
|
|
297
|
+
"""
|
|
298
|
+
Setter for queryset attribute.
|
|
299
|
+
|
|
300
|
+
Stores the base queryset that will be cloned on each access.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
value: Base queryset to store
|
|
304
|
+
"""
|
|
305
|
+
self._instance_queryset = value
|
|
306
|
+
|
|
307
|
+
async def get_queryset(self):
|
|
308
|
+
"""
|
|
309
|
+
Get the queryset for this viewset.
|
|
310
|
+
|
|
311
|
+
This method returns a fresh queryset clone on each call, ensuring
|
|
312
|
+
no state leakage between requests (following Litestar's pattern).
|
|
313
|
+
Override to customize queryset filtering.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Django QuerySet
|
|
317
|
+
"""
|
|
318
|
+
base_qs = self._get_base_queryset()
|
|
319
|
+
|
|
320
|
+
if base_qs is None:
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"'{self.__class__.__name__}' should either include a `queryset` attribute, "
|
|
323
|
+
f"or override the `get_queryset()` method."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Return a fresh clone
|
|
327
|
+
return self._clone_queryset(base_qs)
|
|
328
|
+
|
|
329
|
+
async def filter_queryset(self, queryset):
|
|
330
|
+
"""
|
|
331
|
+
Given a queryset, filter it with whichever filter backends are enabled.
|
|
332
|
+
|
|
333
|
+
This method provides a hook for filtering, searching, and ordering.
|
|
334
|
+
Override this method to implement custom filtering logic.
|
|
335
|
+
|
|
336
|
+
Note: Pagination is handled separately via paginate_queryset().
|
|
337
|
+
|
|
338
|
+
Example:
|
|
339
|
+
async def filter_queryset(self, queryset):
|
|
340
|
+
# Apply filters from query params
|
|
341
|
+
status = self.request.get('query', {}).get('status')
|
|
342
|
+
if status:
|
|
343
|
+
queryset = queryset.filter(status=status)
|
|
344
|
+
|
|
345
|
+
# Apply ordering
|
|
346
|
+
ordering = self.request.get('query', {}).get('ordering')
|
|
347
|
+
if ordering:
|
|
348
|
+
queryset = queryset.order_by(ordering)
|
|
349
|
+
|
|
350
|
+
# Apply search
|
|
351
|
+
search = self.request.get('query', {}).get('search')
|
|
352
|
+
if search:
|
|
353
|
+
queryset = queryset.filter(name__icontains=search)
|
|
354
|
+
|
|
355
|
+
return queryset
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
queryset: The base queryset to filter
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Filtered queryset (still lazy, not evaluated)
|
|
362
|
+
"""
|
|
363
|
+
# Default implementation: return queryset unchanged
|
|
364
|
+
# Subclasses should override this method to add filtering logic
|
|
365
|
+
return queryset
|
|
366
|
+
|
|
367
|
+
async def paginate_queryset(self, queryset):
|
|
368
|
+
"""
|
|
369
|
+
Paginate a queryset if pagination is enabled.
|
|
370
|
+
|
|
371
|
+
This method checks if self.pagination_class is set and applies
|
|
372
|
+
pagination if available. If no pagination is configured, returns
|
|
373
|
+
the queryset unchanged.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
queryset: The queryset to paginate
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
PaginatedResponse if pagination enabled, otherwise queryset
|
|
380
|
+
"""
|
|
381
|
+
if self.pagination_class is None:
|
|
382
|
+
return queryset
|
|
383
|
+
|
|
384
|
+
if self.request is None:
|
|
385
|
+
raise ValueError(
|
|
386
|
+
f"Cannot paginate in {self.__class__.__name__}: request object not available. "
|
|
387
|
+
f"Ensure request parameter is passed to the handler."
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# Create paginator instance
|
|
391
|
+
paginator = self.pagination_class()
|
|
392
|
+
|
|
393
|
+
# Apply pagination
|
|
394
|
+
return await paginator.paginate_queryset(queryset, self.request)
|
|
395
|
+
|
|
396
|
+
def get_pagination_class(self):
|
|
397
|
+
"""
|
|
398
|
+
Get the pagination class for this viewset.
|
|
399
|
+
|
|
400
|
+
Override this method to dynamically select pagination class
|
|
401
|
+
based on action or other criteria.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Pagination class or None
|
|
405
|
+
"""
|
|
406
|
+
return self.pagination_class
|
|
407
|
+
|
|
408
|
+
async def get_object(self, pk: Any = None, **lookup_kwargs):
|
|
409
|
+
"""
|
|
410
|
+
Get a single object by lookup field.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
pk: Primary key value (if using default lookup_field)
|
|
414
|
+
**lookup_kwargs: Additional lookup parameters
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Model instance
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
HTTPException: If object not found (404)
|
|
421
|
+
"""
|
|
422
|
+
queryset = await self.get_queryset()
|
|
423
|
+
|
|
424
|
+
# Build lookup kwargs
|
|
425
|
+
if pk is not None and not lookup_kwargs:
|
|
426
|
+
lookup_kwargs = {self.lookup_field: pk}
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
# Use aget for async retrieval
|
|
430
|
+
obj = await queryset.aget(**lookup_kwargs)
|
|
431
|
+
return obj
|
|
432
|
+
except Exception as e:
|
|
433
|
+
# Django raises DoesNotExist, but we convert to HTTPException
|
|
434
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
435
|
+
|
|
436
|
+
def get_serializer_class(self, action: Optional[str] = None):
|
|
437
|
+
"""
|
|
438
|
+
Get the serializer class for this viewset.
|
|
439
|
+
|
|
440
|
+
Override to customize serializer selection based on action.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
action: The action being performed ('list', 'retrieve', 'create', etc.)
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Serializer class
|
|
447
|
+
"""
|
|
448
|
+
# Use instance action if not provided
|
|
449
|
+
if action is None:
|
|
450
|
+
action = self.action
|
|
451
|
+
|
|
452
|
+
# Use list_serializer_class for list actions if defined
|
|
453
|
+
if action == "list" and self.list_serializer_class is not None:
|
|
454
|
+
return self.list_serializer_class
|
|
455
|
+
|
|
456
|
+
if self.serializer_class is None:
|
|
457
|
+
raise ValueError(
|
|
458
|
+
f"'{self.__class__.__name__}' should either include a `serializer_class` attribute, "
|
|
459
|
+
f"or override the `get_serializer_class()` method."
|
|
460
|
+
)
|
|
461
|
+
return self.serializer_class
|
|
462
|
+
|
|
463
|
+
def get_serializer(self, instance=None, data=None, many=False):
|
|
464
|
+
"""
|
|
465
|
+
Get a serializer instance.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
instance: Model instance to serialize (for reading)
|
|
469
|
+
data: Data to validate/deserialize (for writing)
|
|
470
|
+
many: Whether serializing multiple instances
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Serializer instance or list of serialized data
|
|
474
|
+
"""
|
|
475
|
+
serializer_class = self.get_serializer_class()
|
|
476
|
+
|
|
477
|
+
# If it's a msgspec.Struct, handle conversion
|
|
478
|
+
if hasattr(serializer_class, "__struct_fields__"):
|
|
479
|
+
if instance is not None:
|
|
480
|
+
if many:
|
|
481
|
+
# Serialize multiple instances
|
|
482
|
+
if hasattr(serializer_class, "from_model"):
|
|
483
|
+
return [serializer_class.from_model(obj) for obj in instance]
|
|
484
|
+
else:
|
|
485
|
+
# Manual mapping
|
|
486
|
+
import msgspec
|
|
487
|
+
fields = getattr(serializer_class, "__annotations__", {})
|
|
488
|
+
return [
|
|
489
|
+
msgspec.convert({name: getattr(obj, name, None) for name in fields.keys()}, serializer_class)
|
|
490
|
+
for obj in instance
|
|
491
|
+
]
|
|
492
|
+
else:
|
|
493
|
+
# Serialize single instance
|
|
494
|
+
if hasattr(serializer_class, "from_model"):
|
|
495
|
+
return serializer_class.from_model(instance)
|
|
496
|
+
else:
|
|
497
|
+
import msgspec
|
|
498
|
+
fields = getattr(serializer_class, "__annotations__", {})
|
|
499
|
+
mapped = {name: getattr(instance, name, None) for name in fields.keys()}
|
|
500
|
+
return msgspec.convert(mapped, serializer_class)
|
|
501
|
+
elif data is not None:
|
|
502
|
+
# Data is already validated by msgspec at parameter binding
|
|
503
|
+
return data
|
|
504
|
+
|
|
505
|
+
return serializer_class
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# Mixins for common CRUD operations
|
|
509
|
+
|
|
510
|
+
class ListMixin:
|
|
511
|
+
"""
|
|
512
|
+
Mixin that provides a list() method for GET requests on collections.
|
|
513
|
+
|
|
514
|
+
Automatically implements:
|
|
515
|
+
async def get(self, request) -> list
|
|
516
|
+
|
|
517
|
+
Requires:
|
|
518
|
+
- queryset attribute
|
|
519
|
+
- serializer_class attribute (optional, returns raw queryset if not provided)
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
async def get(self, request):
|
|
523
|
+
"""
|
|
524
|
+
List all objects in the queryset.
|
|
525
|
+
|
|
526
|
+
Note: This evaluates the entire queryset. For large datasets,
|
|
527
|
+
consider implementing pagination or filtering via filter_queryset().
|
|
528
|
+
"""
|
|
529
|
+
queryset = await self.get_queryset()
|
|
530
|
+
|
|
531
|
+
# Optional: Apply filtering if filter_queryset is available
|
|
532
|
+
if hasattr(self, 'filter_queryset'):
|
|
533
|
+
queryset = await self.filter_queryset(queryset)
|
|
534
|
+
|
|
535
|
+
# Convert queryset to list (evaluates database query here)
|
|
536
|
+
results = []
|
|
537
|
+
async for obj in queryset:
|
|
538
|
+
# If serializer_class is defined, use it
|
|
539
|
+
if hasattr(self, "serializer_class") and self.serializer_class:
|
|
540
|
+
# Get serializer class (use method if available, otherwise direct attribute)
|
|
541
|
+
if hasattr(self, "get_serializer_class"):
|
|
542
|
+
serializer_class = self.get_serializer_class()
|
|
543
|
+
else:
|
|
544
|
+
serializer_class = self.serializer_class
|
|
545
|
+
|
|
546
|
+
if hasattr(serializer_class, "from_model"):
|
|
547
|
+
results.append(serializer_class.from_model(obj))
|
|
548
|
+
else:
|
|
549
|
+
# Assume it's a msgspec.Struct, use convert
|
|
550
|
+
import msgspec
|
|
551
|
+
fields = getattr(serializer_class, "__annotations__", {})
|
|
552
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
553
|
+
results.append(msgspec.convert(mapped, serializer_class))
|
|
554
|
+
else:
|
|
555
|
+
results.append(obj)
|
|
556
|
+
|
|
557
|
+
return results
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
class RetrieveMixin:
|
|
561
|
+
"""
|
|
562
|
+
Mixin that provides a retrieve() method for GET requests on single objects.
|
|
563
|
+
|
|
564
|
+
Automatically implements:
|
|
565
|
+
async def get(self, request, pk: int) -> object
|
|
566
|
+
|
|
567
|
+
Requires:
|
|
568
|
+
- queryset attribute
|
|
569
|
+
- get_object(pk) method
|
|
570
|
+
- serializer_class attribute (optional, returns raw object if not provided)
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
async def get(self, request, pk: int):
|
|
574
|
+
"""Retrieve a single object by primary key."""
|
|
575
|
+
obj = await self.get_object(pk)
|
|
576
|
+
|
|
577
|
+
# If serializer_class is defined, use it
|
|
578
|
+
if hasattr(self, "serializer_class") and self.serializer_class:
|
|
579
|
+
# Get serializer class (use method if available, otherwise direct attribute)
|
|
580
|
+
if hasattr(self, "get_serializer_class"):
|
|
581
|
+
serializer_class = self.get_serializer_class()
|
|
582
|
+
else:
|
|
583
|
+
serializer_class = self.serializer_class
|
|
584
|
+
|
|
585
|
+
if hasattr(serializer_class, "from_model"):
|
|
586
|
+
return serializer_class.from_model(obj)
|
|
587
|
+
else:
|
|
588
|
+
# Assume it's a msgspec.Struct, use convert
|
|
589
|
+
import msgspec
|
|
590
|
+
fields = getattr(serializer_class, "__annotations__", {})
|
|
591
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
592
|
+
return msgspec.convert(mapped, serializer_class)
|
|
593
|
+
|
|
594
|
+
return obj
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class CreateMixin:
|
|
598
|
+
"""
|
|
599
|
+
Mixin that provides a create() method for POST requests.
|
|
600
|
+
|
|
601
|
+
Automatically implements:
|
|
602
|
+
async def post(self, request, data: SerializerClass) -> object
|
|
603
|
+
|
|
604
|
+
Requires:
|
|
605
|
+
- queryset attribute (to determine model)
|
|
606
|
+
- serializer_class attribute (for input validation)
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
async def post(self, request, data):
|
|
610
|
+
"""Create a new object."""
|
|
611
|
+
# Get the model class without evaluating queryset
|
|
612
|
+
base_qs = self._get_base_queryset()
|
|
613
|
+
if base_qs is None:
|
|
614
|
+
raise ValueError(
|
|
615
|
+
f"'{self.__class__.__name__}' should include a `queryset` attribute."
|
|
616
|
+
)
|
|
617
|
+
model = base_qs.model
|
|
618
|
+
|
|
619
|
+
# Extract data from msgspec.Struct to dict
|
|
620
|
+
if hasattr(data, "__struct_fields__"):
|
|
621
|
+
# It's a msgspec.Struct
|
|
622
|
+
fields = data.__struct_fields__
|
|
623
|
+
data_dict = {field: getattr(data, field) for field in fields}
|
|
624
|
+
elif isinstance(data, dict):
|
|
625
|
+
data_dict = data
|
|
626
|
+
else:
|
|
627
|
+
raise ValueError(f"Cannot extract data from {type(data)}")
|
|
628
|
+
|
|
629
|
+
# Create object using async ORM
|
|
630
|
+
obj = await model.objects.acreate(**data_dict)
|
|
631
|
+
|
|
632
|
+
# Serialize response
|
|
633
|
+
if hasattr(self, "serializer_class") and self.serializer_class:
|
|
634
|
+
# Get serializer class (use method if available, otherwise direct attribute)
|
|
635
|
+
if hasattr(self, "get_serializer_class"):
|
|
636
|
+
serializer_class = self.get_serializer_class()
|
|
637
|
+
else:
|
|
638
|
+
serializer_class = self.serializer_class
|
|
639
|
+
|
|
640
|
+
if hasattr(serializer_class, "from_model"):
|
|
641
|
+
return serializer_class.from_model(obj)
|
|
642
|
+
else:
|
|
643
|
+
import msgspec
|
|
644
|
+
fields = getattr(serializer_class, "__annotations__", {})
|
|
645
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
646
|
+
return msgspec.convert(mapped, serializer_class)
|
|
647
|
+
|
|
648
|
+
return obj
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
class UpdateMixin:
|
|
652
|
+
"""
|
|
653
|
+
Mixin that provides an update() method for PUT requests.
|
|
654
|
+
|
|
655
|
+
Automatically implements:
|
|
656
|
+
async def put(self, request, pk: int, data: SerializerClass) -> object
|
|
657
|
+
|
|
658
|
+
Requires:
|
|
659
|
+
- queryset attribute
|
|
660
|
+
- get_object(pk) method
|
|
661
|
+
- serializer_class attribute
|
|
662
|
+
"""
|
|
663
|
+
|
|
664
|
+
async def put(self, request, pk: int, data):
|
|
665
|
+
"""Update an object (full update)."""
|
|
666
|
+
obj = await self.get_object(pk)
|
|
667
|
+
|
|
668
|
+
# Extract data from msgspec.Struct to dict
|
|
669
|
+
if hasattr(data, "__struct_fields__"):
|
|
670
|
+
fields = data.__struct_fields__
|
|
671
|
+
data_dict = {field: getattr(data, field) for field in fields}
|
|
672
|
+
elif isinstance(data, dict):
|
|
673
|
+
data_dict = data
|
|
674
|
+
else:
|
|
675
|
+
raise ValueError(f"Cannot extract data from {type(data)}")
|
|
676
|
+
|
|
677
|
+
# Update object fields
|
|
678
|
+
for key, value in data_dict.items():
|
|
679
|
+
setattr(obj, key, value)
|
|
680
|
+
|
|
681
|
+
# Save using async ORM
|
|
682
|
+
await obj.asave()
|
|
683
|
+
|
|
684
|
+
# Serialize response
|
|
685
|
+
if hasattr(self, "serializer_class") and self.serializer_class:
|
|
686
|
+
# Get serializer class (use method if available, otherwise direct attribute)
|
|
687
|
+
if hasattr(self, "get_serializer_class"):
|
|
688
|
+
serializer_class = self.get_serializer_class()
|
|
689
|
+
else:
|
|
690
|
+
serializer_class = self.serializer_class
|
|
691
|
+
|
|
692
|
+
if hasattr(serializer_class, "from_model"):
|
|
693
|
+
return serializer_class.from_model(obj)
|
|
694
|
+
else:
|
|
695
|
+
import msgspec
|
|
696
|
+
fields = getattr(serializer_class, "__annotations__", {})
|
|
697
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
698
|
+
return msgspec.convert(mapped, serializer_class)
|
|
699
|
+
|
|
700
|
+
return obj
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
class PartialUpdateMixin:
|
|
704
|
+
"""
|
|
705
|
+
Mixin that provides a partial_update() method for PATCH requests.
|
|
706
|
+
|
|
707
|
+
Automatically implements:
|
|
708
|
+
async def patch(self, request, pk: int, data: SerializerClass) -> object
|
|
709
|
+
|
|
710
|
+
Requires:
|
|
711
|
+
- queryset attribute
|
|
712
|
+
- get_object(pk) method
|
|
713
|
+
- serializer_class attribute
|
|
714
|
+
"""
|
|
715
|
+
|
|
716
|
+
async def patch(self, request, pk: int, data):
|
|
717
|
+
"""Update an object (partial update)."""
|
|
718
|
+
obj = await self.get_object(pk)
|
|
719
|
+
|
|
720
|
+
# Extract data from msgspec.Struct to dict
|
|
721
|
+
if hasattr(data, "__struct_fields__"):
|
|
722
|
+
fields = data.__struct_fields__
|
|
723
|
+
data_dict = {field: getattr(data, field) for field in fields}
|
|
724
|
+
elif isinstance(data, dict):
|
|
725
|
+
data_dict = data
|
|
726
|
+
else:
|
|
727
|
+
raise ValueError(f"Cannot extract data from {type(data)}")
|
|
728
|
+
|
|
729
|
+
# Update only provided fields
|
|
730
|
+
for key, value in data_dict.items():
|
|
731
|
+
if value is not None: # Skip None values in PATCH
|
|
732
|
+
setattr(obj, key, value)
|
|
733
|
+
|
|
734
|
+
# Save using async ORM
|
|
735
|
+
await obj.asave()
|
|
736
|
+
|
|
737
|
+
# Serialize response
|
|
738
|
+
if hasattr(self, "serializer_class") and self.serializer_class:
|
|
739
|
+
# Get serializer class (use method if available, otherwise direct attribute)
|
|
740
|
+
if hasattr(self, "get_serializer_class"):
|
|
741
|
+
serializer_class = self.get_serializer_class()
|
|
742
|
+
else:
|
|
743
|
+
serializer_class = self.serializer_class
|
|
744
|
+
|
|
745
|
+
if hasattr(serializer_class, "from_model"):
|
|
746
|
+
return serializer_class.from_model(obj)
|
|
747
|
+
else:
|
|
748
|
+
import msgspec
|
|
749
|
+
fields = getattr(serializer_class, "__annotations__", {})
|
|
750
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
751
|
+
return msgspec.convert(mapped, serializer_class)
|
|
752
|
+
|
|
753
|
+
return obj
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
class DestroyMixin:
|
|
757
|
+
"""
|
|
758
|
+
Mixin that provides a destroy() method for DELETE requests.
|
|
759
|
+
|
|
760
|
+
Automatically implements:
|
|
761
|
+
async def delete(self, request, pk: int) -> dict
|
|
762
|
+
|
|
763
|
+
Requires:
|
|
764
|
+
- queryset attribute
|
|
765
|
+
- get_object(pk) method
|
|
766
|
+
"""
|
|
767
|
+
|
|
768
|
+
async def delete(self, request, pk: int):
|
|
769
|
+
"""Delete an object."""
|
|
770
|
+
obj = await self.get_object(pk)
|
|
771
|
+
|
|
772
|
+
# Delete using async ORM
|
|
773
|
+
await obj.adelete()
|
|
774
|
+
|
|
775
|
+
# Return success response
|
|
776
|
+
return {"detail": "Object deleted successfully"}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# Convenience ViewSet classes (like Django REST Framework)
|
|
780
|
+
|
|
781
|
+
class ReadOnlyModelViewSet(ViewSet):
|
|
782
|
+
"""
|
|
783
|
+
A viewset base class for read-only operations.
|
|
784
|
+
|
|
785
|
+
Provides `get_queryset()`, `get_object()`, and `get_serializer_class()` methods.
|
|
786
|
+
You implement the HTTP method handlers with proper type annotations.
|
|
787
|
+
|
|
788
|
+
Example:
|
|
789
|
+
@api.view("/articles")
|
|
790
|
+
class ArticleListViewSet(ReadOnlyModelViewSet):
|
|
791
|
+
queryset = Article.objects.all()
|
|
792
|
+
serializer_class = ArticleSchema
|
|
793
|
+
|
|
794
|
+
async def get(self, request):
|
|
795
|
+
\"\"\"List all articles.\"\"\"
|
|
796
|
+
articles = []
|
|
797
|
+
async for article in await self.get_queryset():
|
|
798
|
+
articles.append(ArticleSchema.from_model(article))
|
|
799
|
+
return articles
|
|
800
|
+
|
|
801
|
+
@api.view("/articles/{pk}")
|
|
802
|
+
class ArticleDetailViewSet(ReadOnlyModelViewSet):
|
|
803
|
+
queryset = Article.objects.all()
|
|
804
|
+
serializer_class = ArticleSchema
|
|
805
|
+
|
|
806
|
+
async def get(self, request, pk: int):
|
|
807
|
+
\"\"\"Retrieve a single article.\"\"\"
|
|
808
|
+
article = await self.get_object(pk)
|
|
809
|
+
return ArticleSchema.from_model(article)
|
|
810
|
+
"""
|
|
811
|
+
pass
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
class ModelViewSet(ViewSet):
|
|
815
|
+
"""
|
|
816
|
+
A viewset base class that provides helpers for full CRUD operations.
|
|
817
|
+
|
|
818
|
+
Similar to Django REST Framework's ModelViewSet, but adapted for Django-Bolt's
|
|
819
|
+
type-based parameter binding. You set `queryset` and `serializer_class`, then
|
|
820
|
+
implement DRF-style action methods (list, retrieve, create, update, etc.).
|
|
821
|
+
|
|
822
|
+
Example:
|
|
823
|
+
from django_bolt import BoltAPI, ModelViewSet
|
|
824
|
+
from myapp.models import Article
|
|
825
|
+
import msgspec
|
|
826
|
+
|
|
827
|
+
api = BoltAPI()
|
|
828
|
+
|
|
829
|
+
class ArticleSchema(msgspec.Struct):
|
|
830
|
+
id: int
|
|
831
|
+
title: str
|
|
832
|
+
content: str
|
|
833
|
+
|
|
834
|
+
@classmethod
|
|
835
|
+
def from_model(cls, obj):
|
|
836
|
+
return cls(id=obj.id, title=obj.title, content=obj.content)
|
|
837
|
+
|
|
838
|
+
class ArticleCreateSchema(msgspec.Struct):
|
|
839
|
+
title: str
|
|
840
|
+
content: str
|
|
841
|
+
|
|
842
|
+
@api.viewset("/articles")
|
|
843
|
+
class ArticleViewSet(ModelViewSet):
|
|
844
|
+
queryset = Article.objects.all()
|
|
845
|
+
serializer_class = ArticleSchema
|
|
846
|
+
|
|
847
|
+
async def list(self, request):
|
|
848
|
+
\"\"\"GET /articles - List all articles.\"\"\"
|
|
849
|
+
articles = []
|
|
850
|
+
async for article in await self.get_queryset():
|
|
851
|
+
articles.append(ArticleSchema.from_model(article))
|
|
852
|
+
return articles
|
|
853
|
+
|
|
854
|
+
async def retrieve(self, request, pk: int):
|
|
855
|
+
\"\"\"GET /articles/{pk} - Retrieve a single article.\"\"\"
|
|
856
|
+
article = await self.get_object(pk=pk)
|
|
857
|
+
return ArticleSchema.from_model(article)
|
|
858
|
+
|
|
859
|
+
async def create(self, request, data: ArticleCreateSchema):
|
|
860
|
+
\"\"\"POST /articles - Create a new article.\"\"\"
|
|
861
|
+
article = await Article.objects.acreate(
|
|
862
|
+
title=data.title,
|
|
863
|
+
content=data.content
|
|
864
|
+
)
|
|
865
|
+
return ArticleSchema.from_model(article)
|
|
866
|
+
|
|
867
|
+
async def update(self, request, pk: int, data: ArticleCreateSchema):
|
|
868
|
+
\"\"\"PUT /articles/{pk} - Update an article.\"\"\"
|
|
869
|
+
article = await self.get_object(pk=pk)
|
|
870
|
+
article.title = data.title
|
|
871
|
+
article.content = data.content
|
|
872
|
+
await article.asave()
|
|
873
|
+
return ArticleSchema.from_model(article)
|
|
874
|
+
|
|
875
|
+
async def partial_update(self, request, pk: int, data: ArticleCreateSchema):
|
|
876
|
+
\"\"\"PATCH /articles/{pk} - Partially update an article.\"\"\"
|
|
877
|
+
article = await self.get_object(pk=pk)
|
|
878
|
+
if data.title:
|
|
879
|
+
article.title = data.title
|
|
880
|
+
if data.content:
|
|
881
|
+
article.content = data.content
|
|
882
|
+
await article.asave()
|
|
883
|
+
return ArticleSchema.from_model(article)
|
|
884
|
+
|
|
885
|
+
async def destroy(self, request, pk: int):
|
|
886
|
+
\"\"\"DELETE /articles/{pk} - Delete an article.\"\"\"
|
|
887
|
+
article = await self.get_object(pk=pk)
|
|
888
|
+
await article.adelete()
|
|
889
|
+
return {"deleted": True}
|
|
890
|
+
|
|
891
|
+
This provides full CRUD operations with Django ORM integration, just like DRF.
|
|
892
|
+
The difference is that Django-Bolt requires explicit type annotations for
|
|
893
|
+
parameter binding and validation. Routes are automatically generated based on
|
|
894
|
+
implemented action methods.
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
# Optional: separate serializer for create/update operations
|
|
898
|
+
create_serializer_class: Optional[Type] = None
|
|
899
|
+
update_serializer_class: Optional[Type] = None
|
|
900
|
+
|
|
901
|
+
async def list(self, request):
|
|
902
|
+
"""
|
|
903
|
+
List all objects in the queryset.
|
|
904
|
+
|
|
905
|
+
Uses list_serializer_class if defined, otherwise serializer_class.
|
|
906
|
+
Applies filter_queryset() for filtering, searching, ordering.
|
|
907
|
+
"""
|
|
908
|
+
qs = await self.get_queryset()
|
|
909
|
+
qs = await self.filter_queryset(qs) # Apply filtering (still lazy)
|
|
910
|
+
serializer_class = self.get_serializer_class(action='list')
|
|
911
|
+
|
|
912
|
+
# Queryset is evaluated here during iteration
|
|
913
|
+
results = []
|
|
914
|
+
async for obj in qs:
|
|
915
|
+
if hasattr(serializer_class, 'from_model'):
|
|
916
|
+
results.append(serializer_class.from_model(obj))
|
|
917
|
+
else:
|
|
918
|
+
# Fallback: manual conversion
|
|
919
|
+
import msgspec
|
|
920
|
+
fields = getattr(serializer_class, '__annotations__', {})
|
|
921
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
922
|
+
results.append(msgspec.convert(mapped, serializer_class))
|
|
923
|
+
|
|
924
|
+
return results
|
|
925
|
+
|
|
926
|
+
async def retrieve(self, request, **kwargs):
|
|
927
|
+
"""
|
|
928
|
+
Retrieve a single object by lookup field.
|
|
929
|
+
|
|
930
|
+
The lookup field value is passed as a keyword argument (e.g., pk=1, id=1).
|
|
931
|
+
"""
|
|
932
|
+
# Extract lookup value from kwargs
|
|
933
|
+
lookup_value = kwargs.get(self.lookup_field)
|
|
934
|
+
if lookup_value is None:
|
|
935
|
+
raise HTTPException(status_code=400, detail=f"Missing lookup field: {self.lookup_field}")
|
|
936
|
+
|
|
937
|
+
obj = await self.get_object(**{self.lookup_field: lookup_value})
|
|
938
|
+
serializer_class = self.get_serializer_class(action='retrieve')
|
|
939
|
+
|
|
940
|
+
if hasattr(serializer_class, 'from_model'):
|
|
941
|
+
return serializer_class.from_model(obj)
|
|
942
|
+
else:
|
|
943
|
+
import msgspec
|
|
944
|
+
fields = getattr(serializer_class, '__annotations__', {})
|
|
945
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
946
|
+
return msgspec.convert(mapped, serializer_class)
|
|
947
|
+
|
|
948
|
+
async def create(self, request, data):
|
|
949
|
+
"""
|
|
950
|
+
Create a new object.
|
|
951
|
+
|
|
952
|
+
The `data` parameter should be a msgspec.Struct with the fields to create.
|
|
953
|
+
Uses create_serializer_class if defined, otherwise serializer_class.
|
|
954
|
+
"""
|
|
955
|
+
# Get the model class without evaluating queryset
|
|
956
|
+
base_qs = self._get_base_queryset()
|
|
957
|
+
if base_qs is None:
|
|
958
|
+
raise ValueError(
|
|
959
|
+
f"'{self.__class__.__name__}' should include a `queryset` attribute."
|
|
960
|
+
)
|
|
961
|
+
model = base_qs.model
|
|
962
|
+
|
|
963
|
+
# Extract data from msgspec.Struct
|
|
964
|
+
if hasattr(data, '__struct_fields__'):
|
|
965
|
+
fields = data.__struct_fields__
|
|
966
|
+
data_dict = {field: getattr(data, field) for field in fields}
|
|
967
|
+
elif isinstance(data, dict):
|
|
968
|
+
data_dict = data
|
|
969
|
+
else:
|
|
970
|
+
raise ValueError(f"Cannot extract data from {type(data)}")
|
|
971
|
+
|
|
972
|
+
# Create object
|
|
973
|
+
obj = await model.objects.acreate(**data_dict)
|
|
974
|
+
|
|
975
|
+
# Serialize response
|
|
976
|
+
serializer_class = self.get_serializer_class(action='create')
|
|
977
|
+
if hasattr(serializer_class, 'from_model'):
|
|
978
|
+
return serializer_class.from_model(obj)
|
|
979
|
+
else:
|
|
980
|
+
import msgspec
|
|
981
|
+
fields = getattr(serializer_class, '__annotations__', {})
|
|
982
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
983
|
+
return msgspec.convert(mapped, serializer_class)
|
|
984
|
+
|
|
985
|
+
async def update(self, request, data, **kwargs):
|
|
986
|
+
"""
|
|
987
|
+
Update an object (full update).
|
|
988
|
+
|
|
989
|
+
The lookup field value is passed as a keyword argument.
|
|
990
|
+
Uses update_serializer_class if defined, otherwise create_serializer_class or serializer_class.
|
|
991
|
+
"""
|
|
992
|
+
lookup_value = kwargs.get(self.lookup_field)
|
|
993
|
+
if lookup_value is None:
|
|
994
|
+
raise HTTPException(status_code=400, detail=f"Missing lookup field: {self.lookup_field}")
|
|
995
|
+
|
|
996
|
+
obj = await self.get_object(**{self.lookup_field: lookup_value})
|
|
997
|
+
|
|
998
|
+
# Extract data
|
|
999
|
+
if hasattr(data, '__struct_fields__'):
|
|
1000
|
+
fields = data.__struct_fields__
|
|
1001
|
+
data_dict = {field: getattr(data, field) for field in fields}
|
|
1002
|
+
elif isinstance(data, dict):
|
|
1003
|
+
data_dict = data
|
|
1004
|
+
else:
|
|
1005
|
+
raise ValueError(f"Cannot extract data from {type(data)}")
|
|
1006
|
+
|
|
1007
|
+
# Update all fields
|
|
1008
|
+
for key, value in data_dict.items():
|
|
1009
|
+
setattr(obj, key, value)
|
|
1010
|
+
|
|
1011
|
+
await obj.asave()
|
|
1012
|
+
|
|
1013
|
+
# Serialize response
|
|
1014
|
+
serializer_class = self.get_serializer_class(action='update')
|
|
1015
|
+
if hasattr(serializer_class, 'from_model'):
|
|
1016
|
+
return serializer_class.from_model(obj)
|
|
1017
|
+
else:
|
|
1018
|
+
import msgspec
|
|
1019
|
+
fields = getattr(serializer_class, '__annotations__', {})
|
|
1020
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
1021
|
+
return msgspec.convert(mapped, serializer_class)
|
|
1022
|
+
|
|
1023
|
+
async def partial_update(self, request, data, **kwargs):
|
|
1024
|
+
"""
|
|
1025
|
+
Partially update an object.
|
|
1026
|
+
|
|
1027
|
+
Only updates fields that are not None in the data.
|
|
1028
|
+
"""
|
|
1029
|
+
lookup_value = kwargs.get(self.lookup_field)
|
|
1030
|
+
if lookup_value is None:
|
|
1031
|
+
raise HTTPException(status_code=400, detail=f"Missing lookup field: {self.lookup_field}")
|
|
1032
|
+
|
|
1033
|
+
obj = await self.get_object(**{self.lookup_field: lookup_value})
|
|
1034
|
+
|
|
1035
|
+
# Extract data
|
|
1036
|
+
if hasattr(data, '__struct_fields__'):
|
|
1037
|
+
fields = data.__struct_fields__
|
|
1038
|
+
data_dict = {field: getattr(data, field) for field in fields}
|
|
1039
|
+
elif isinstance(data, dict):
|
|
1040
|
+
data_dict = data
|
|
1041
|
+
else:
|
|
1042
|
+
raise ValueError(f"Cannot extract data from {type(data)}")
|
|
1043
|
+
|
|
1044
|
+
# Update only non-None fields
|
|
1045
|
+
for key, value in data_dict.items():
|
|
1046
|
+
if value is not None:
|
|
1047
|
+
setattr(obj, key, value)
|
|
1048
|
+
|
|
1049
|
+
await obj.asave()
|
|
1050
|
+
|
|
1051
|
+
# Serialize response
|
|
1052
|
+
serializer_class = self.get_serializer_class(action='partial_update')
|
|
1053
|
+
if hasattr(serializer_class, 'from_model'):
|
|
1054
|
+
return serializer_class.from_model(obj)
|
|
1055
|
+
else:
|
|
1056
|
+
import msgspec
|
|
1057
|
+
fields = getattr(serializer_class, '__annotations__', {})
|
|
1058
|
+
mapped = {name: getattr(obj, name, None) for name in fields.keys()}
|
|
1059
|
+
return msgspec.convert(mapped, serializer_class)
|
|
1060
|
+
|
|
1061
|
+
async def destroy(self, request, **kwargs):
|
|
1062
|
+
"""
|
|
1063
|
+
Delete an object.
|
|
1064
|
+
"""
|
|
1065
|
+
lookup_value = kwargs.get(self.lookup_field)
|
|
1066
|
+
if lookup_value is None:
|
|
1067
|
+
raise HTTPException(status_code=400, detail=f"Missing lookup field: {self.lookup_field}")
|
|
1068
|
+
|
|
1069
|
+
obj = await self.get_object(**{self.lookup_field: lookup_value})
|
|
1070
|
+
await obj.adelete()
|
|
1071
|
+
|
|
1072
|
+
return {"deleted": True}
|
|
1073
|
+
|
|
1074
|
+
def get_serializer_class(self, action: Optional[str] = None):
|
|
1075
|
+
"""
|
|
1076
|
+
Get the serializer class for this viewset.
|
|
1077
|
+
|
|
1078
|
+
Supports action-specific serializer classes:
|
|
1079
|
+
- list: list_serializer_class or serializer_class
|
|
1080
|
+
- create: create_serializer_class or serializer_class
|
|
1081
|
+
- update/partial_update: update_serializer_class or create_serializer_class or serializer_class
|
|
1082
|
+
- retrieve/destroy: serializer_class
|
|
1083
|
+
|
|
1084
|
+
Args:
|
|
1085
|
+
action: The action being performed ('list', 'retrieve', 'create', etc.)
|
|
1086
|
+
|
|
1087
|
+
Returns:
|
|
1088
|
+
Serializer class
|
|
1089
|
+
"""
|
|
1090
|
+
if action is None:
|
|
1091
|
+
action = self.action
|
|
1092
|
+
|
|
1093
|
+
# Action-specific serializer classes
|
|
1094
|
+
if action == 'list' and self.list_serializer_class is not None:
|
|
1095
|
+
return self.list_serializer_class
|
|
1096
|
+
elif action == 'create' and self.create_serializer_class is not None:
|
|
1097
|
+
return self.create_serializer_class
|
|
1098
|
+
elif action in ('update', 'partial_update'):
|
|
1099
|
+
if self.update_serializer_class is not None:
|
|
1100
|
+
return self.update_serializer_class
|
|
1101
|
+
elif self.create_serializer_class is not None:
|
|
1102
|
+
return self.create_serializer_class
|
|
1103
|
+
|
|
1104
|
+
if self.serializer_class is None:
|
|
1105
|
+
raise ValueError(
|
|
1106
|
+
f"'{self.__class__.__name__}' should either include a `serializer_class` attribute, "
|
|
1107
|
+
f"or override the `get_serializer_class()` method."
|
|
1108
|
+
)
|
|
1109
|
+
return self.serializer_class
|
|
1110
|
+
|