django-ninja-aio-crud 0.10.2__py3-none-any.whl → 2.4.0__py3-none-any.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.
- django_ninja_aio_crud-2.4.0.dist-info/METADATA +382 -0
- django_ninja_aio_crud-2.4.0.dist-info/RECORD +29 -0
- ninja_aio/__init__.py +1 -1
- ninja_aio/api.py +24 -2
- ninja_aio/auth.py +186 -4
- ninja_aio/decorators/__init__.py +23 -0
- ninja_aio/decorators/operations.py +9 -0
- ninja_aio/decorators/views.py +219 -0
- ninja_aio/exceptions.py +36 -1
- ninja_aio/factory/__init__.py +3 -0
- ninja_aio/factory/operations.py +296 -0
- ninja_aio/helpers/__init__.py +0 -0
- ninja_aio/helpers/api.py +506 -0
- ninja_aio/helpers/query.py +108 -0
- ninja_aio/models/__init__.py +4 -0
- ninja_aio/models/serializers.py +738 -0
- ninja_aio/models/utils.py +894 -0
- ninja_aio/renders.py +26 -26
- ninja_aio/schemas/__init__.py +23 -0
- ninja_aio/{schemas.py → schemas/api.py} +0 -5
- ninja_aio/schemas/generics.py +5 -0
- ninja_aio/schemas/helpers.py +170 -0
- ninja_aio/types.py +3 -1
- ninja_aio/views/__init__.py +3 -0
- ninja_aio/views/api.py +582 -0
- ninja_aio/views/mixins.py +275 -0
- django_ninja_aio_crud-0.10.2.dist-info/METADATA +0 -526
- django_ninja_aio_crud-0.10.2.dist-info/RECORD +0 -14
- ninja_aio/models.py +0 -549
- ninja_aio/views.py +0 -522
- {django_ninja_aio_crud-0.10.2.dist-info → django_ninja_aio_crud-2.4.0.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-0.10.2.dist-info → django_ninja_aio_crud-2.4.0.dist-info}/licenses/LICENSE +0 -0
ninja_aio/helpers/api.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Coroutine
|
|
3
|
+
|
|
4
|
+
from django.http import HttpRequest
|
|
5
|
+
from ninja import Path, Query
|
|
6
|
+
from ninja.pagination import paginate
|
|
7
|
+
from ninja_aio.decorators import unique_view, decorate_view
|
|
8
|
+
from ninja_aio.models import ModelSerializer, ModelUtil
|
|
9
|
+
from ninja_aio.schemas.helpers import ObjectsQuerySchema
|
|
10
|
+
from ninja_aio.schemas import (
|
|
11
|
+
GenericMessageSchema,
|
|
12
|
+
M2MRelationSchema,
|
|
13
|
+
M2MSchemaIn,
|
|
14
|
+
M2MSchemaOut,
|
|
15
|
+
M2MAddSchemaIn,
|
|
16
|
+
M2MRemoveSchemaIn,
|
|
17
|
+
)
|
|
18
|
+
from django.db.models import QuerySet, Model
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ManyToManyAPI:
|
|
22
|
+
"""
|
|
23
|
+
ManyToManyAPI
|
|
24
|
+
-------------
|
|
25
|
+
WARNING (Internal Use Only):
|
|
26
|
+
This helper is currently intended solely for internal purposes. Its API,
|
|
27
|
+
behaviors, and response formats may change without notice. Do not rely on
|
|
28
|
+
it as a stable public interface.
|
|
29
|
+
|
|
30
|
+
Utility class that dynamically attaches asynchronous Many-To-Many (M2M) management
|
|
31
|
+
endpoints (GET / ADD / REMOVE) to a provided APIViewSet router in a Django Ninja
|
|
32
|
+
async CRUD context.
|
|
33
|
+
|
|
34
|
+
It inspects a list of M2MRelationSchema definitions and, for each relation, builds:
|
|
35
|
+
- An optional paginated GET endpoint to list related objects.
|
|
36
|
+
- An optional POST endpoint to add and/or remove related object primary keys.
|
|
37
|
+
|
|
38
|
+
Core behaviors:
|
|
39
|
+
- Dynamically generates per-relation filter schemas for query parameters.
|
|
40
|
+
- Supports custom per-relation query filtering handlers on the parent view set
|
|
41
|
+
via a `{related_name}_query_params_handler` coroutine for GET list filters.
|
|
42
|
+
- Supports custom per-relation object resolution for add/remove validation via
|
|
43
|
+
`{related_name}_query_handler(request, pk, instance)` used during POST to
|
|
44
|
+
resolve a single related object before mutation.
|
|
45
|
+
- Validates requested add/remove primary keys, producing granular success and
|
|
46
|
+
error feedback.
|
|
47
|
+
- Performs add/remove operations concurrently using asyncio.gather when both
|
|
48
|
+
types of operations are requested in the same call.
|
|
49
|
+
|
|
50
|
+
Attributes established at initialization:
|
|
51
|
+
relations: list of M2MRelationSchema defining each relation.
|
|
52
|
+
view_set: The parent APIViewSet instance from which router, pagination, model util,
|
|
53
|
+
and path schema are derived.
|
|
54
|
+
router: Ninja router used to register generated endpoints.
|
|
55
|
+
pagination_class: Pagination class used for GET related endpoints.
|
|
56
|
+
path_schema: Pydantic schema used to validate path parameters (e.g., primary key).
|
|
57
|
+
related_model_util: A ModelUtil instance cloned from the parent view set to access
|
|
58
|
+
base object retrieval helpers.
|
|
59
|
+
relations_filters_schemas: Mapping of related_name -> generated Pydantic filter schema.
|
|
60
|
+
|
|
61
|
+
Generated endpoint naming conventions:
|
|
62
|
+
GET -> get_{base_model_name}_{relation_path}
|
|
63
|
+
POST -> manage_{base_model_name}_{relation_path}
|
|
64
|
+
|
|
65
|
+
Endpoint registration details:
|
|
66
|
+
- GET: registered at `{retrieve_path}{relation_path}` with pagination; accepts Query filters.
|
|
67
|
+
- POST: registered at `{retrieve_path}{relation_path}/` (trailing slash) to manage add/remove.
|
|
68
|
+
|
|
69
|
+
All responses standardize success and error reporting for POST as:
|
|
70
|
+
{
|
|
71
|
+
"results": {"count": int, "details": [str, ...]},
|
|
72
|
+
"errors": {"count": int, "details": [str, ...]}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
Concurrency note:
|
|
76
|
+
- Add and remove operations are executed concurrently when both lists are non-empty,
|
|
77
|
+
minimizing round-trip latency for bulk mutations.
|
|
78
|
+
- Uses related_manager.aadd(...) and related_manager.aremove(...) inside asyncio.gather.
|
|
79
|
+
|
|
80
|
+
Error semantics:
|
|
81
|
+
- Missing related objects: reported individually.
|
|
82
|
+
- Invalid operation context (e.g., removing objects not currently related or adding
|
|
83
|
+
objects already related) reported per primary key.
|
|
84
|
+
- Successful operations yield a corresponding success detail string per PK.
|
|
85
|
+
|
|
86
|
+
Security / auth:
|
|
87
|
+
- Each relation may optionally override auth via its schema; otherwise falls back
|
|
88
|
+
to a default configured on the instance (self.default_auth).
|
|
89
|
+
|
|
90
|
+
Pagination:
|
|
91
|
+
- Applied only to GET related endpoints via @paginate(self.pagination_class).
|
|
92
|
+
|
|
93
|
+
Extensibility:
|
|
94
|
+
- Provide custom query param handling by defining an async method on the parent
|
|
95
|
+
view set: `<related_name>_query_params_handler(self, queryset, filters_dict)`.
|
|
96
|
+
- Provide custom per-PK resolution for POST validation by defining an async method:
|
|
97
|
+
`<related_name>_query_handler(self, request, pk, instance)` returning a queryset,
|
|
98
|
+
from which .afirst() is used to resolve the single target object.
|
|
99
|
+
- Customize relation filtering schema via each relation's `filters` definition.
|
|
100
|
+
|
|
101
|
+
-----------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
__init__(relations, view_set)
|
|
104
|
+
Initialize the M2M API helper by binding core utilities from the provided view set
|
|
105
|
+
and precomputing filter schemas per relation.
|
|
106
|
+
|
|
107
|
+
Parameters:
|
|
108
|
+
relations (list[M2MRelationSchema]): Definitions for each M2M relation to expose.
|
|
109
|
+
view_set (APIViewSet): Parent view set containing model utilities and router.
|
|
110
|
+
|
|
111
|
+
Side effects:
|
|
112
|
+
- Captures router, pagination class, path schema from view_set.
|
|
113
|
+
- Clones ModelUtil for related model operations.
|
|
114
|
+
- Pre-generates filter schemas for each relation (if filters declared).
|
|
115
|
+
|
|
116
|
+
-----------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
_generate_m2m_filters_schemas()
|
|
119
|
+
Create a mapping of related_name -> Pydantic schema used for query filtering in
|
|
120
|
+
GET related endpoints. If a relation has no filters specified, an empty schema
|
|
121
|
+
(dict) is used.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
dict[str, BaseModel]: Generated schemas keyed by related_name.
|
|
125
|
+
|
|
126
|
+
-----------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
_get_query_params_handler(related_name)
|
|
129
|
+
Retrieve an optional per-relation query handler from the parent view set for GET list filters.
|
|
130
|
+
Naming convention: `<related_name}_query_params_handler`.
|
|
131
|
+
|
|
132
|
+
Parameters:
|
|
133
|
+
related_name (str): The relation's attribute name on the base model.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Coroutine | None: Handler to transform or filter the queryset based on query params.
|
|
137
|
+
|
|
138
|
+
-----------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
_get_query_handler(related_name)
|
|
141
|
+
Retrieve an optional per-relation single-object resolution handler from the parent view set
|
|
142
|
+
used during POST add/remove validation. If present, it receives `(request, pk, instance)` and
|
|
143
|
+
should return a queryset from which `.afirst()` will resolve the target object.
|
|
144
|
+
|
|
145
|
+
Parameters:
|
|
146
|
+
related_name (str): The relation's attribute name on the base model.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Coroutine | None: Handler to resolve a single related object by pk.
|
|
150
|
+
|
|
151
|
+
-----------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
_check_m2m_objs(request, objs_pks, related_model, related_manager, related_name, instance, remove=False)
|
|
154
|
+
Validate requested primary keys for add/remove operations against the current
|
|
155
|
+
relation state. Performs existence checks and logical consistency (e.g., prevents
|
|
156
|
+
adding already-related objects or removing non-related objects). Uses `_get_query_handler`
|
|
157
|
+
when available; otherwise falls back to ModelUtil(...).get_objects(...) to resolve by pk.
|
|
158
|
+
|
|
159
|
+
Parameters:
|
|
160
|
+
request (HttpRequest): Incoming request context (passed to ModelUtil for access control).
|
|
161
|
+
objs_pks (list): List of primary keys to add or remove.
|
|
162
|
+
related_model (ModelSerializer | Model): Model class or serializer used to resolve objects.
|
|
163
|
+
related_manager (QuerySet): Related manager for the base object's M2M field.
|
|
164
|
+
related_name (str): M2M field name on the base object.
|
|
165
|
+
instance (ModelSerializer | Model): Base object instance (owner of the relation).
|
|
166
|
+
remove (bool): If True, treat operation as removal validation.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
tuple[list[str], list[str], list[Model]]:
|
|
170
|
+
errors -> List of error messages per invalid PK.
|
|
171
|
+
objs_detail -> List of success detail messages per valid PK.
|
|
172
|
+
objs -> List of resolved model instances to process.
|
|
173
|
+
|
|
174
|
+
Error cases:
|
|
175
|
+
- Object not found.
|
|
176
|
+
- Object presence mismatch (attempting wrong operation given relation membership).
|
|
177
|
+
|
|
178
|
+
-----------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
_collect_m2m(request, pks, model, related_manager, related_name, instance, remove=False)
|
|
181
|
+
Wrapper around _check_m2m_objs that short-circuits on empty PK lists.
|
|
182
|
+
|
|
183
|
+
Parameters:
|
|
184
|
+
request (HttpRequest)
|
|
185
|
+
pks (list): Primary keys proposed for mutation.
|
|
186
|
+
model (ModelSerializer | Model)
|
|
187
|
+
related_manager (QuerySet)
|
|
188
|
+
related_name (str)
|
|
189
|
+
instance (ModelSerializer | Model)
|
|
190
|
+
remove (bool): Operation type flag.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
tuple[list[str], list[str], list[Model]]: See _check_m2m_objs.
|
|
194
|
+
|
|
195
|
+
-----------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
_register_get_relation_view(...)
|
|
198
|
+
Registers the GET endpoint for listing related objects. Applies optional
|
|
199
|
+
query params handler for filtering. Uses pagination and serializes via `list_read_s`.
|
|
200
|
+
|
|
201
|
+
-----------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
_register_manage_relation_view(...)
|
|
204
|
+
Registers the POST endpoint for adding/removing related objects. Validates via
|
|
205
|
+
`_collect_m2m` and executes mutations concurrently using `aadd`/`aremove` with
|
|
206
|
+
`asyncio.gather`. Aggregates per-PK results and errors into a standardized payload.
|
|
207
|
+
|
|
208
|
+
-----------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
_build_views(relation)
|
|
211
|
+
Dynamically define and register the GET and/or POST endpoints for a single M2M
|
|
212
|
+
relation based on the relation's schema flags (get/add/remove). Builds filter
|
|
213
|
+
schemas, resolves path fragments, and binds handlers to the router with unique
|
|
214
|
+
operation IDs.
|
|
215
|
+
|
|
216
|
+
-----------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
_add_views()
|
|
219
|
+
Iterates over all declared relations and invokes _build_views to attach endpoints.
|
|
220
|
+
|
|
221
|
+
-----------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
Usage Example (conceptual):
|
|
224
|
+
api = ManyToManyAPI(relations=[...], view_set=my_view_set)
|
|
225
|
+
api._add_views() # Registers all endpoints automatically during initialization flow.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
relations: list[M2MRelationSchema],
|
|
231
|
+
view_set,
|
|
232
|
+
):
|
|
233
|
+
# Import here to avoid circular imports
|
|
234
|
+
from ninja_aio.views import APIViewSet
|
|
235
|
+
|
|
236
|
+
self.relations = relations
|
|
237
|
+
self.view_set: APIViewSet = view_set
|
|
238
|
+
self.router = self.view_set.router
|
|
239
|
+
self.pagination_class = self.view_set.pagination_class
|
|
240
|
+
self.path_schema = self.view_set.path_schema
|
|
241
|
+
self.default_auth = self.view_set.m2m_auth
|
|
242
|
+
self.related_model_util = self.view_set.model_util
|
|
243
|
+
self.relations_filters_schemas = self._generate_m2m_filters_schemas()
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def views_action_map(self):
|
|
247
|
+
return {
|
|
248
|
+
(True, True): ("Add or Remove", M2MSchemaIn),
|
|
249
|
+
(True, False): ("Add", M2MAddSchemaIn),
|
|
250
|
+
(False, True): ("Remove", M2MRemoveSchemaIn),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
def _generate_m2m_filters_schemas(self):
|
|
254
|
+
"""
|
|
255
|
+
Build per-relation filters schemas for M2M endpoints.
|
|
256
|
+
"""
|
|
257
|
+
return {
|
|
258
|
+
data.related_name: self.view_set._generate_schema(
|
|
259
|
+
{} if not data.filters else data.filters,
|
|
260
|
+
f"{self.related_model_util.model_name}{data.related_name.capitalize()}FiltersSchema",
|
|
261
|
+
)
|
|
262
|
+
for data in self.relations
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
def _get_query_params_handler(self, related_name: str) -> Coroutine | None:
|
|
266
|
+
return getattr(self.view_set, f"{related_name}_query_params_handler", None)
|
|
267
|
+
|
|
268
|
+
def _get_query_handler(self, related_name: str) -> Coroutine | None:
|
|
269
|
+
return getattr(self.view_set, f"{related_name}_query_handler", None)
|
|
270
|
+
|
|
271
|
+
async def _check_m2m_objs(
|
|
272
|
+
self,
|
|
273
|
+
request: HttpRequest,
|
|
274
|
+
objs_pks: list,
|
|
275
|
+
related_model: ModelSerializer | Model,
|
|
276
|
+
related_manager: QuerySet,
|
|
277
|
+
related_name: str,
|
|
278
|
+
instance: ModelSerializer | Model,
|
|
279
|
+
remove: bool = False,
|
|
280
|
+
):
|
|
281
|
+
"""
|
|
282
|
+
Validate requested add/remove pk list for M2M operations.
|
|
283
|
+
Returns (errors, details, objects_to_process).
|
|
284
|
+
Uses per-PK query handler if available, else falls back to ModelUtil lookup by pk.
|
|
285
|
+
"""
|
|
286
|
+
errors, objs_detail, objs = [], [], []
|
|
287
|
+
rel_objs = [rel_obj async for rel_obj in related_manager.select_related().all()]
|
|
288
|
+
rel_model_name = related_model._meta.verbose_name.capitalize()
|
|
289
|
+
for obj_pk in objs_pks:
|
|
290
|
+
if query_handler := self._get_query_handler(related_name):
|
|
291
|
+
rel_obj = await (
|
|
292
|
+
await query_handler(
|
|
293
|
+
request,
|
|
294
|
+
obj_pk,
|
|
295
|
+
instance,
|
|
296
|
+
)
|
|
297
|
+
).afirst()
|
|
298
|
+
else:
|
|
299
|
+
rel_obj = await (
|
|
300
|
+
await ModelUtil(related_model).get_objects(
|
|
301
|
+
request, query_data=ObjectsQuerySchema(filters={"pk": obj_pk})
|
|
302
|
+
)
|
|
303
|
+
).afirst()
|
|
304
|
+
if rel_obj is None:
|
|
305
|
+
errors.append(f"{rel_model_name} with pk {obj_pk} not found.")
|
|
306
|
+
continue
|
|
307
|
+
if remove ^ (rel_obj in rel_objs):
|
|
308
|
+
errors.append(
|
|
309
|
+
f"{rel_model_name} with pk {obj_pk} is {'not ' if remove else ''}in {self.related_model_util.model_name}"
|
|
310
|
+
)
|
|
311
|
+
continue
|
|
312
|
+
objs.append(rel_obj)
|
|
313
|
+
objs_detail.append(
|
|
314
|
+
f"{rel_model_name} with pk {obj_pk} successfully {'removed' if remove else 'added'}"
|
|
315
|
+
)
|
|
316
|
+
return errors, objs_detail, objs
|
|
317
|
+
|
|
318
|
+
async def _collect_m2m(
|
|
319
|
+
self,
|
|
320
|
+
request: HttpRequest,
|
|
321
|
+
pks: list,
|
|
322
|
+
reletad_model: ModelSerializer | Model,
|
|
323
|
+
related_manager: QuerySet,
|
|
324
|
+
related_name: str,
|
|
325
|
+
instance: ModelSerializer | Model,
|
|
326
|
+
remove: bool = False,
|
|
327
|
+
):
|
|
328
|
+
if not pks:
|
|
329
|
+
return ([], [], [])
|
|
330
|
+
return await self._check_m2m_objs(
|
|
331
|
+
request,
|
|
332
|
+
pks,
|
|
333
|
+
reletad_model,
|
|
334
|
+
related_manager,
|
|
335
|
+
related_name,
|
|
336
|
+
instance,
|
|
337
|
+
remove=remove,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _get_api_path(self, rel_path: str, append_slash: bool = None) -> str:
|
|
341
|
+
append_slash = append_slash if append_slash is not None else True
|
|
342
|
+
path = (
|
|
343
|
+
f"{self.view_set.path_retrieve}{rel_path}/"
|
|
344
|
+
if rel_path.startswith("/")
|
|
345
|
+
else f"{self.view_set.path_retrieve}/{rel_path}/"
|
|
346
|
+
)
|
|
347
|
+
if not append_slash:
|
|
348
|
+
path = path.rstrip("/")
|
|
349
|
+
return path
|
|
350
|
+
|
|
351
|
+
def _register_get_relation_view(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
related_name: str,
|
|
355
|
+
m2m_auth,
|
|
356
|
+
rel_util: ModelUtil,
|
|
357
|
+
rel_path: str,
|
|
358
|
+
related_schema,
|
|
359
|
+
filters_schema,
|
|
360
|
+
append_slash: bool,
|
|
361
|
+
):
|
|
362
|
+
@self.router.get(
|
|
363
|
+
self._get_api_path(rel_path, append_slash=append_slash),
|
|
364
|
+
response={
|
|
365
|
+
200: list[related_schema],
|
|
366
|
+
self.view_set.error_codes: GenericMessageSchema,
|
|
367
|
+
},
|
|
368
|
+
auth=m2m_auth,
|
|
369
|
+
summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
|
|
370
|
+
description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
|
|
371
|
+
)
|
|
372
|
+
@decorate_view(
|
|
373
|
+
unique_view(f"get_{self.related_model_util.model_name}_{rel_path}"),
|
|
374
|
+
paginate(self.pagination_class),
|
|
375
|
+
)
|
|
376
|
+
async def get_related(
|
|
377
|
+
request: HttpRequest,
|
|
378
|
+
pk: Path[self.path_schema], # type: ignore
|
|
379
|
+
filters: Query[filters_schema] = None, # type: ignore
|
|
380
|
+
):
|
|
381
|
+
obj = await self.related_model_util.get_object(
|
|
382
|
+
request, self.view_set._get_pk(pk)
|
|
383
|
+
)
|
|
384
|
+
related_manager = getattr(obj, related_name)
|
|
385
|
+
related_qs = related_manager.all()
|
|
386
|
+
|
|
387
|
+
query_handler = self._get_query_params_handler(related_name)
|
|
388
|
+
if filters is not None and query_handler:
|
|
389
|
+
if asyncio.iscoroutinefunction(query_handler):
|
|
390
|
+
related_qs = await query_handler(related_qs, filters.model_dump())
|
|
391
|
+
else:
|
|
392
|
+
related_qs = query_handler(related_qs, filters.model_dump())
|
|
393
|
+
|
|
394
|
+
return await rel_util.list_read_s(related_schema, request, related_qs)
|
|
395
|
+
|
|
396
|
+
def _resolve_action_schema(self, add: bool, remove: bool):
|
|
397
|
+
return self.views_action_map[(add, remove)]
|
|
398
|
+
|
|
399
|
+
def _register_manage_relation_view(
|
|
400
|
+
self,
|
|
401
|
+
*,
|
|
402
|
+
related_model: ModelSerializer | Model,
|
|
403
|
+
related_name: str,
|
|
404
|
+
m2m_auth,
|
|
405
|
+
rel_util: ModelUtil,
|
|
406
|
+
rel_path: str,
|
|
407
|
+
m2m_add: bool,
|
|
408
|
+
m2m_remove: bool,
|
|
409
|
+
):
|
|
410
|
+
action, schema_in = self._resolve_action_schema(m2m_add, m2m_remove)
|
|
411
|
+
plural = rel_util.model._meta.verbose_name_plural.capitalize()
|
|
412
|
+
summary = f"{action} {plural}"
|
|
413
|
+
|
|
414
|
+
@self.router.post(
|
|
415
|
+
self._get_api_path(rel_path),
|
|
416
|
+
response={
|
|
417
|
+
200: M2MSchemaOut,
|
|
418
|
+
self.view_set.error_codes: GenericMessageSchema,
|
|
419
|
+
},
|
|
420
|
+
auth=m2m_auth,
|
|
421
|
+
summary=summary,
|
|
422
|
+
description=summary,
|
|
423
|
+
)
|
|
424
|
+
@unique_view(f"manage_{self.related_model_util.model_name}_{rel_path}")
|
|
425
|
+
async def manage_related(
|
|
426
|
+
request: HttpRequest,
|
|
427
|
+
pk: Path[self.path_schema], # type: ignore
|
|
428
|
+
data: schema_in, # type: ignore
|
|
429
|
+
):
|
|
430
|
+
obj = await self.related_model_util.get_object(
|
|
431
|
+
request, self.view_set._get_pk(pk)
|
|
432
|
+
)
|
|
433
|
+
related_manager: QuerySet = getattr(obj, related_name)
|
|
434
|
+
|
|
435
|
+
add_pks = getattr(data, "add", []) if m2m_add else []
|
|
436
|
+
remove_pks = getattr(data, "remove", []) if m2m_remove else []
|
|
437
|
+
|
|
438
|
+
add_errors, add_details, add_objs = await self._collect_m2m(
|
|
439
|
+
request,
|
|
440
|
+
add_pks,
|
|
441
|
+
related_model,
|
|
442
|
+
related_manager,
|
|
443
|
+
related_name,
|
|
444
|
+
obj,
|
|
445
|
+
)
|
|
446
|
+
remove_errors, remove_details, remove_objs = await self._collect_m2m(
|
|
447
|
+
request,
|
|
448
|
+
remove_pks,
|
|
449
|
+
related_model,
|
|
450
|
+
related_manager,
|
|
451
|
+
related_name,
|
|
452
|
+
obj,
|
|
453
|
+
remove=True,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
tasks = []
|
|
457
|
+
if add_objs:
|
|
458
|
+
tasks.append(related_manager.aadd(*add_objs))
|
|
459
|
+
if remove_objs:
|
|
460
|
+
tasks.append(related_manager.aremove(*remove_objs))
|
|
461
|
+
if tasks:
|
|
462
|
+
await asyncio.gather(*tasks)
|
|
463
|
+
|
|
464
|
+
results = add_details + remove_details
|
|
465
|
+
errors = add_errors + remove_errors
|
|
466
|
+
return {
|
|
467
|
+
"results": {"count": len(results), "details": results},
|
|
468
|
+
"errors": {"count": len(errors), "details": errors},
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
def _build_views(self, relation: M2MRelationSchema):
|
|
472
|
+
model = relation.model
|
|
473
|
+
related_name = relation.related_name
|
|
474
|
+
m2m_auth = relation.auth or self.default_auth
|
|
475
|
+
rel_util = ModelUtil(model)
|
|
476
|
+
rel_path = relation.path or rel_util.verbose_name_path_resolver()
|
|
477
|
+
related_schema = relation.related_schema
|
|
478
|
+
m2m_add, m2m_remove, m2m_get = relation.add, relation.remove, relation.get
|
|
479
|
+
filters_schema = self.relations_filters_schemas.get(related_name)
|
|
480
|
+
append_slash = relation.append_slash
|
|
481
|
+
|
|
482
|
+
if m2m_get:
|
|
483
|
+
self._register_get_relation_view(
|
|
484
|
+
related_name=related_name,
|
|
485
|
+
m2m_auth=m2m_auth,
|
|
486
|
+
rel_util=rel_util,
|
|
487
|
+
rel_path=rel_path,
|
|
488
|
+
related_schema=related_schema,
|
|
489
|
+
filters_schema=filters_schema,
|
|
490
|
+
append_slash=append_slash,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if m2m_add or m2m_remove:
|
|
494
|
+
self._register_manage_relation_view(
|
|
495
|
+
related_model=model,
|
|
496
|
+
related_name=related_name,
|
|
497
|
+
m2m_auth=m2m_auth,
|
|
498
|
+
rel_util=rel_util,
|
|
499
|
+
rel_path=rel_path,
|
|
500
|
+
m2m_add=m2m_add,
|
|
501
|
+
m2m_remove=m2m_remove,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
def _add_views(self):
|
|
505
|
+
for relation in self.relations:
|
|
506
|
+
self._build_views(relation)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from ninja_aio.types import ModelSerializerMeta
|
|
2
|
+
from ninja_aio.schemas.helpers import (
|
|
3
|
+
ModelQuerySetSchema,
|
|
4
|
+
QueryUtilBaseScopesSchema,
|
|
5
|
+
ModelQuerySetExtraSchema,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScopeNamespace:
|
|
10
|
+
def __init__(self, **scopes):
|
|
11
|
+
"""Create a simple namespace where each provided scope becomes an attribute."""
|
|
12
|
+
for key, value in scopes.items():
|
|
13
|
+
setattr(self, key, value)
|
|
14
|
+
|
|
15
|
+
def __iter__(self):
|
|
16
|
+
"""Iterate over the stored scope values."""
|
|
17
|
+
return iter(self.__dict__.values())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QueryUtil:
|
|
21
|
+
"""
|
|
22
|
+
Helper class to manage queryset optimizations based on predefined scopes.
|
|
23
|
+
Attributes:
|
|
24
|
+
model (ModelSerializerMeta): The model serializer meta to which this utility is attached.
|
|
25
|
+
SCOPES (ScopeNamespace): An enumeration-like object containing available scopes.
|
|
26
|
+
read_config (ModelQuerySetSchema): Configuration for the 'read' scope.
|
|
27
|
+
queryset_request_config (ModelQuerySetSchema): Configuration for the 'queryset_request' scope
|
|
28
|
+
extra_configs (dict): Additional configurations for custom scopes.
|
|
29
|
+
Methods:
|
|
30
|
+
apply_queryset_optimizations(queryset, scope): Applies select_related and prefetch_related
|
|
31
|
+
optimizations to the given queryset based on the specified scope.
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
query_util = QueryUtil(MyModelSerializer) or MyModel.query_util
|
|
35
|
+
qs = MyModel.objects.all()
|
|
36
|
+
optimized_qs = query_util.apply_queryset_optimizations(qs, query_util.SCOPES.READ)
|
|
37
|
+
|
|
38
|
+
# Applying optimizations for a custom scope
|
|
39
|
+
class MyModelSerializer(ModelSerializer):
|
|
40
|
+
class QuerySet:
|
|
41
|
+
extras = [
|
|
42
|
+
ModelQuerySetExtraSchema(
|
|
43
|
+
scope="custom_scope",
|
|
44
|
+
select_related=["custom_fk_field"],
|
|
45
|
+
prefetch_related=["custom_m2m_field"],
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
query_util = MyModelSerializer.query_util
|
|
49
|
+
qs = MyModelSerializer.objects.all()
|
|
50
|
+
optimized_qs_custom = query_util.apply_queryset_optimizations(qs, "custom_scope")
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
SCOPES: QueryUtilBaseScopesSchema
|
|
54
|
+
|
|
55
|
+
def __init__(self, model: ModelSerializerMeta):
|
|
56
|
+
"""Initialize QueryUtil, resolving base and extra scope configurations for a model."""
|
|
57
|
+
self.model = model
|
|
58
|
+
self._configuration = getattr(self.model, "QuerySet", None)
|
|
59
|
+
self._extra_configuration: list[ModelQuerySetExtraSchema] = getattr(
|
|
60
|
+
self._configuration, "extras", []
|
|
61
|
+
)
|
|
62
|
+
self._BASE_SCOPES = QueryUtilBaseScopesSchema().model_dump()
|
|
63
|
+
self.SCOPES = ScopeNamespace(
|
|
64
|
+
**self._BASE_SCOPES,
|
|
65
|
+
**{extra.scope: extra.scope for extra in self._extra_configuration},
|
|
66
|
+
)
|
|
67
|
+
self.extra_configs = {extra.scope: extra for extra in self._extra_configuration}
|
|
68
|
+
self._configs = {
|
|
69
|
+
**{scope: self._get_config(scope) for scope in self._BASE_SCOPES.values()},
|
|
70
|
+
**self.extra_configs,
|
|
71
|
+
}
|
|
72
|
+
self.read_config: ModelQuerySetSchema = self._configs.get(
|
|
73
|
+
self.SCOPES.READ, ModelQuerySetSchema()
|
|
74
|
+
)
|
|
75
|
+
self.queryset_request_config: ModelQuerySetSchema = self._configs.get(
|
|
76
|
+
self.SCOPES.QUERYSET_REQUEST, ModelQuerySetSchema()
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _get_config(self, conf_name: str) -> ModelQuerySetSchema:
|
|
80
|
+
"""Helper method to retrieve configuration attributes."""
|
|
81
|
+
return getattr(self._configuration, conf_name, ModelQuerySetSchema())
|
|
82
|
+
|
|
83
|
+
def apply_queryset_optimizations(self, queryset, scope: str):
|
|
84
|
+
"""
|
|
85
|
+
Apply select_related and prefetch_related optimizations to the queryset
|
|
86
|
+
according to the specified scope.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
queryset (QuerySet): The Django queryset to optimize.
|
|
90
|
+
scope (str): The scope to apply. Must be in self.SCOPES.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
QuerySet: The optimized queryset.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ValueError: If the given scope is not supported.
|
|
97
|
+
"""
|
|
98
|
+
if scope not in self._configs:
|
|
99
|
+
valid_scopes = list(self._configs.keys())
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Invalid scope '{scope}' for QueryUtil. Supported scopes: {valid_scopes}"
|
|
102
|
+
)
|
|
103
|
+
config = self._configs.get(scope, ModelQuerySetSchema())
|
|
104
|
+
if config.select_related:
|
|
105
|
+
queryset = queryset.select_related(*config.select_related)
|
|
106
|
+
if config.prefetch_related:
|
|
107
|
+
queryset = queryset.prefetch_related(*config.prefetch_related)
|
|
108
|
+
return queryset
|