django-ninja-aio-crud 1.0.5__py3-none-any.whl → 2.0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 1.0.5
3
+ Version: 2.0.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10
@@ -17,15 +17,17 @@ Classifier: License :: OSI Approved :: MIT License
17
17
  Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
20
22
  Classifier: Programming Language :: Python :: 3 :: Only
21
23
  Classifier: Framework :: Django
22
24
  Classifier: Framework :: AsyncIO
23
25
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
24
26
  Classifier: Topic :: Internet :: WWW/HTTP
25
27
  License-File: LICENSE
26
- Requires-Dist: django-ninja >=1.3.0, <=1.4.5
28
+ Requires-Dist: django-ninja >=1.3.0, <=1.5.1
27
29
  Requires-Dist: joserfc >=1.0.0, <= 1.4.1
28
- Requires-Dist: orjson >= 3.10.7, <= 3.11.4
30
+ Requires-Dist: orjson >= 3.10.7, <= 3.11.5
29
31
  Requires-Dist: coverage ; extra == "test"
30
32
  Project-URL: Documentation, https://django-ninja-aio.com
31
33
  Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
@@ -0,0 +1,21 @@
1
+ ninja_aio/__init__.py,sha256=cH3S5kz6llYjarJhndCM9pmxriBwh3GJdi7HBMPj5v8,119
2
+ ninja_aio/api.py,sha256=SS1TYUiFkdYjfJLVy6GI90GOzvIHzPEeL-UcqWFRHkM,1684
3
+ ninja_aio/auth.py,sha256=8jaEp7oEJvUUB9EuyE2fOYk-khyAaekT3i80E7AbgOA,5101
4
+ ninja_aio/decorators.py,sha256=BHoFIiqdIVMFqSxGh-F6WeZFo1xZK4ieDw3dzKfxZIM,8147
5
+ ninja_aio/exceptions.py,sha256=1-iRbrloIyi0CR6Tcrn5YR4_LloA7PPohKIBaxXJ0-8,2596
6
+ ninja_aio/models.py,sha256=aJlo5a64O4o-fB8QESLMUJpoA5kcjRJxPBiAIMxg46k,47652
7
+ ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
8
+ ninja_aio/renders.py,sha256=VtmSliRJyZ6gjyoib8AXMVUBYF1jPNsiceCHujI_mAs,1699
9
+ ninja_aio/types.py,sha256=TJSGlA7bt4g9fvPhJ7gzH5tKbLagPmZUzfgttEOp4xs,468
10
+ ninja_aio/views.py,sha256=8vMFw-8au9O0Hpf-79jvB47PwokCzm7P8UY0DDWpN5A,16786
11
+ ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ ninja_aio/helpers/api.py,sha256=kgHxYPfbV6kQbp8Z4qMwQtwFiFIcRAenxI9MDowGtis,20181
13
+ ninja_aio/helpers/query.py,sha256=tE8RjXvSig-WB_0LRQ0LqoE4G_HMHsu0Na5QzTNIm6U,4262
14
+ ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
15
+ ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
16
+ ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
17
+ ninja_aio/schemas/helpers.py,sha256=rmE0D15lJg95Unv8PU44Hbf0VDTcErMCZZFG3D_znTo,2823
18
+ django_ninja_aio_crud-2.0.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
19
+ django_ninja_aio_crud-2.0.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
20
+ django_ninja_aio_crud-2.0.0.dist-info/METADATA,sha256=FKp28V4DMReIrIFlseCRVJy5hZezdESq4ZC0nw3DikA,8672
21
+ django_ninja_aio_crud-2.0.0.dist-info/RECORD,,
ninja_aio/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "1.0.5"
3
+ __version__ = "2.0.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
ninja_aio/api.py CHANGED
@@ -23,7 +23,6 @@ class NinjaAIO(NinjaAPI):
23
23
  docs_decorator=None,
24
24
  servers: list[dict[str, Any]] | None = None,
25
25
  urls_namespace: str | None = None,
26
- csrf: bool = False,
27
26
  auth: Sequence[Any] | NOT_SET_TYPE = NOT_SET,
28
27
  throttle: BaseThrottle | list[BaseThrottle] | NOT_SET_TYPE = NOT_SET,
29
28
  default_router: Router | None = None,
@@ -39,7 +38,6 @@ class NinjaAIO(NinjaAPI):
39
38
  docs_decorator=docs_decorator,
40
39
  servers=servers,
41
40
  urls_namespace=urls_namespace,
42
- csrf=csrf,
43
41
  auth=auth,
44
42
  throttle=throttle,
45
43
  default_router=default_router,
ninja_aio/auth.py CHANGED
@@ -2,8 +2,6 @@ from joserfc import jwt, jwk, errors
2
2
  from django.http.request import HttpRequest
3
3
  from ninja.security.http import HttpBearer
4
4
 
5
- from .exceptions import AuthError
6
-
7
5
 
8
6
  class AsyncJwtBearer(HttpBearer):
9
7
  """
@@ -71,6 +69,7 @@ class AsyncJwtBearer(HttpBearer):
71
69
  Return Semantics:
72
70
  - authenticate -> user object (success) | False (failure)
73
71
  """
72
+
74
73
  jwt_public: jwk.RSAKey
75
74
  claims: dict[str, dict]
76
75
  algorithms: list[str] = ["RS256"]
@@ -96,13 +95,13 @@ class AsyncJwtBearer(HttpBearer):
96
95
  """
97
96
  try:
98
97
  self.dcd = jwt.decode(token, self.jwt_public, algorithms=self.algorithms)
99
- except ValueError as exc:
98
+ except ValueError:
100
99
  # raise AuthError(", ".join(exc.args), 401)
101
100
  return False
102
101
 
103
102
  try:
104
103
  self.validate_claims(self.dcd.claims)
105
- except errors.JoseError as exc:
104
+ except errors.JoseError:
106
105
  return False
107
106
 
108
107
  return await self.auth_handler(request)
ninja_aio/decorators.py CHANGED
@@ -46,10 +46,12 @@ def aatomic(func):
46
46
  your async ORM / database backend.
47
47
  - Only use on async functions.
48
48
  """
49
+
49
50
  @wraps(func)
50
51
  async def wrapper(*args, **kwargs):
51
52
  async with AsyncAtomicContextManager():
52
53
  return await func(*args, **kwargs)
54
+
53
55
  return wrapper
54
56
 
55
57
 
@@ -117,6 +119,7 @@ def unique_view(self: object | str, plural: bool = False):
117
119
  - Ensure that the modified name does not conflict with other functions after decoration.
118
120
  - Use cautiously when decorators relying on original __name__ appear earlier in the chain.
119
121
  """
122
+
120
123
  def decorator(func):
121
124
  # Allow usage as unique_view(self_instance) or unique_view("model_name")
122
125
  if isinstance(self, str):
@@ -140,3 +143,76 @@ def unique_view(self: object | str, plural: bool = False):
140
143
  return func # Return original function (no wrapper)
141
144
 
142
145
  return decorator
146
+
147
+
148
+ def decorate_view(*decorators):
149
+ """
150
+ Compose and apply multiple decorators to a view (sync or async) without adding an extra wrapper.
151
+
152
+ This utility was introduced to support class-based patterns where Django Ninja’s
153
+ built-in `decorate_view` does not fit well. For APIs implemented with vanilla
154
+ Django Ninja (function-based style), you should continue using Django Ninja’s
155
+ native `decorate_view`.
156
+
157
+ Behavior:
158
+ - Applies decorators in the same order as Python’s stacking syntax:
159
+ @d1
160
+ @d2
161
+ is equivalent to: view = d1(d2(view))
162
+ - Supports both synchronous and asynchronous views.
163
+ - Ignores None values, enabling conditional decoration.
164
+ - Does not introduce an additional wrapper; composition depends on each
165
+ decorator for signature/metadata preservation (e.g., using functools.wraps).
166
+
167
+ *decorators: Decorator callables to apply to the target view. Any None values
168
+ are skipped.
169
+
170
+ Callable: A decorator that applies the provided decorators in Python stacking order.
171
+
172
+ Method usage in class-based patterns:
173
+
174
+ Args:
175
+ *decorators: Decorator callables to apply to the target view. Any None
176
+ values are skipped.
177
+
178
+ Returns:
179
+ A decorator that applies the provided decorators in Python stacking order.
180
+
181
+ Examples:
182
+ Basic usage:
183
+ class MyAPIViewSet(APIViewSet):
184
+ api = api
185
+ model = MyModel
186
+
187
+ def views(self):
188
+ @self.router.get('some-endpoint/')
189
+ @decorate_view(authenticate, log_request)
190
+ async def some_view(request):
191
+ ...
192
+
193
+ Conditional decoration (skips None):
194
+ class MyAPIViewSet(APIViewSet):
195
+ api = api
196
+ model = MyModel
197
+ cache_dec = cache_page(60) if settings.ENABLE_CACHE else None
198
+ def views(self):
199
+ @self.router.get('data/')
200
+ @decorate_view(self.cache_dec, authenticate)
201
+ async def data_view(request):
202
+ ...
203
+
204
+ Notes:
205
+ - Each decorator is applied in the order provided, with the first decorator
206
+ wrapping the result of the second, and so on.
207
+ - Ensure that each decorator is compatible with the view’s sync/async nature.
208
+ """
209
+
210
+ def _decorator(view):
211
+ wrapped = view
212
+ for dec in reversed(decorators):
213
+ if dec is None:
214
+ continue
215
+ wrapped = dec(wrapped)
216
+ return wrapped
217
+
218
+ return _decorator
@@ -1,3 +0,0 @@
1
- from .api import ManyToManyAPI
2
-
3
- __all__ = ["ManyToManyAPI"]
ninja_aio/helpers/api.py CHANGED
@@ -6,6 +6,7 @@ from ninja import Path, Query
6
6
  from ninja.pagination import paginate
7
7
  from ninja_aio.decorators import unique_view
8
8
  from ninja_aio.models import ModelSerializer, ModelUtil
9
+ from ninja_aio.schemas.helpers import ObjectsQuerySchema
9
10
  from ninja_aio.schemas import (
10
11
  GenericMessageSchema,
11
12
  M2MRelationSchema,
@@ -37,27 +38,34 @@ class ManyToManyAPI:
37
38
  Core behaviors:
38
39
  - Dynamically generates per-relation filter schemas for query parameters.
39
40
  - Supports custom per-relation query filtering handlers on the parent view set
40
- via a `{related_name}_query_params_handler` coroutine.
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.
41
45
  - Validates requested add/remove primary keys, producing granular success and
42
- error feedback.
46
+ error feedback.
43
47
  - Performs add/remove operations concurrently using asyncio.gather when both
44
- types of operations are requested in the same call.
48
+ types of operations are requested in the same call.
45
49
 
46
50
  Attributes established at initialization:
47
51
  relations: list of M2MRelationSchema defining each relation.
48
52
  view_set: The parent APIViewSet instance from which router, pagination, model util,
49
- and path schema are derived.
53
+ and path schema are derived.
50
54
  router: Ninja router used to register generated endpoints.
51
55
  pagination_class: Pagination class used for GET related endpoints.
52
56
  path_schema: Pydantic schema used to validate path parameters (e.g., primary key).
53
57
  related_model_util: A ModelUtil instance cloned from the parent view set to access
54
- base object retrieval helpers.
58
+ base object retrieval helpers.
55
59
  relations_filters_schemas: Mapping of related_name -> generated Pydantic filter schema.
56
60
 
57
61
  Generated endpoint naming conventions:
58
62
  GET -> get_{base_model_name}_{relation_path}
59
63
  POST -> manage_{base_model_name}_{relation_path}
60
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
+
61
69
  All responses standardize success and error reporting for POST as:
62
70
  {
63
71
  "results": {"count": int, "details": [str, ...]},
@@ -65,25 +73,29 @@ class ManyToManyAPI:
65
73
  }
66
74
 
67
75
  Concurrency note:
68
- Add and remove operations are executed concurrently when both lists are non-empty,
69
- minimizing round-trip latency for bulk mutations.
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.
70
79
 
71
80
  Error semantics:
72
81
  - Missing related objects: reported individually.
73
82
  - Invalid operation context (e.g., removing objects not currently related or adding
74
- objects already related) reported per primary key.
83
+ objects already related) reported per primary key.
75
84
  - Successful operations yield a corresponding success detail string per PK.
76
85
 
77
86
  Security / auth:
78
87
  - Each relation may optionally override auth via its schema; otherwise falls back
79
- to a default configured on the instance (self.default_auth).
88
+ to a default configured on the instance (self.default_auth).
80
89
 
81
90
  Pagination:
82
91
  - Applied only to GET related endpoints via @paginate(self.pagination_class).
83
92
 
84
93
  Extensibility:
85
94
  - Provide custom query param handling by defining an async method on the parent
86
- view set: `<related_name>_query_params_handler(self, queryset, filters_dict)`.
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.
87
99
  - Customize relation filtering schema via each relation's `filters` definition.
88
100
 
89
101
  -----------------------------------------------------------------------
@@ -113,9 +125,9 @@ class ManyToManyAPI:
113
125
 
114
126
  -----------------------------------------------------------------------
115
127
 
116
- _get_query_handler(related_name)
117
- Retrieve an optional per-relation query handler coroutine from the parent view set.
118
- Naming convention: `<related_name>_query_params_handler`.
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`.
119
131
 
120
132
  Parameters:
121
133
  related_name (str): The relation's attribute name on the base model.
@@ -125,16 +137,32 @@ class ManyToManyAPI:
125
137
 
126
138
  -----------------------------------------------------------------------
127
139
 
128
- _check_m2m_objs(request, objs_pks, model, related_manager, remove=False)
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)
129
154
  Validate requested primary keys for add/remove operations against the current
130
155
  relation state. Performs existence checks and logical consistency (e.g., prevents
131
- adding already-related objects or removing non-related objects).
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.
132
158
 
133
159
  Parameters:
134
160
  request (HttpRequest): Incoming request context (passed to ModelUtil for access control).
135
161
  objs_pks (list): List of primary keys to add or remove.
136
- model (ModelSerializer | Model): Model class or serializer used to resolve objects.
162
+ related_model (ModelSerializer | Model): Model class or serializer used to resolve objects.
137
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).
138
166
  remove (bool): If True, treat operation as removal validation.
139
167
 
140
168
  Returns:
@@ -149,7 +177,7 @@ class ManyToManyAPI:
149
177
 
150
178
  -----------------------------------------------------------------------
151
179
 
152
- _collect_m2m(request, pks, model, related_manager, remove=False)
180
+ _collect_m2m(request, pks, model, related_manager, related_name, instance, remove=False)
153
181
  Wrapper around _check_m2m_objs that short-circuits on empty PK lists.
154
182
 
155
183
  Parameters:
@@ -157,6 +185,8 @@ class ManyToManyAPI:
157
185
  pks (list): Primary keys proposed for mutation.
158
186
  model (ModelSerializer | Model)
159
187
  related_manager (QuerySet)
188
+ related_name (str)
189
+ instance (ModelSerializer | Model)
160
190
  remove (bool): Operation type flag.
161
191
 
162
192
  Returns:
@@ -164,38 +194,30 @@ class ManyToManyAPI:
164
194
 
165
195
  -----------------------------------------------------------------------
166
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
+
167
210
  _build_views(relation)
168
211
  Dynamically define and register the GET and/or POST endpoints for a single M2M
169
212
  relation based on the relation's schema flags (get/add/remove). Builds filter
170
213
  schemas, resolves path fragments, and binds handlers to the router with unique
171
214
  operation IDs.
172
215
 
173
- Parameters:
174
- relation (M2MRelationSchema): Declarative specification for one M2M relation.
175
-
176
- Side effects:
177
- - Registers endpoints on self.router.
178
- - Creates closures (get_related / manage_related) capturing relation context.
179
-
180
- GET endpoint behavior:
181
- - Retrieves base object via related_model_util.
182
- - Fetches all related objects; applies optional query handler and filters.
183
- - Serializes each related object with rel_util.read_s.
184
-
185
- POST endpoint behavior:
186
- - Parses add/remove PK lists.
187
- - Validates objects via _collect_m2m.
188
- - Performs asynchronous add/remove operations using aadd / aremove.
189
- - Aggregates results and errors into standardized response payload.
190
-
191
216
  -----------------------------------------------------------------------
192
217
 
193
218
  _add_views()
194
219
  Iterates over all declared relations and invokes _build_views to attach endpoints.
195
220
 
196
- Side effects:
197
- - Populates router with all required M2M endpoints.
198
-
199
221
  -----------------------------------------------------------------------
200
222
 
201
223
  Usage Example (conceptual):
@@ -240,39 +262,56 @@ class ManyToManyAPI:
240
262
  for data in self.relations
241
263
  }
242
264
 
243
- def _get_query_handler(self, related_name: str) -> Coroutine | None:
265
+ def _get_query_params_handler(self, related_name: str) -> Coroutine | None:
244
266
  return getattr(self.view_set, f"{related_name}_query_params_handler", None)
245
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
+
246
271
  async def _check_m2m_objs(
247
272
  self,
248
273
  request: HttpRequest,
249
274
  objs_pks: list,
250
- model: ModelSerializer | Model,
275
+ related_model: ModelSerializer | Model,
251
276
  related_manager: QuerySet,
277
+ related_name: str,
278
+ instance: ModelSerializer | Model,
252
279
  remove: bool = False,
253
280
  ):
254
281
  """
255
282
  Validate requested add/remove pk list for M2M operations.
256
283
  Returns (errors, details, objects_to_process).
284
+ Uses per-PK query handler if available, else falls back to ModelUtil lookup by pk.
257
285
  """
258
286
  errors, objs_detail, objs = [], [], []
259
287
  rel_objs = [rel_obj async for rel_obj in related_manager.select_related().all()]
260
- rel_model_name = model._meta.verbose_name.capitalize()
288
+ rel_model_name = related_model._meta.verbose_name.capitalize()
261
289
  for obj_pk in objs_pks:
262
- rel_obj = await (
263
- await ModelUtil(model).get_object(request, filters={"pk": obj_pk})
264
- ).afirst()
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()
265
304
  if rel_obj is None:
266
305
  errors.append(f"{rel_model_name} with pk {obj_pk} not found.")
267
306
  continue
268
307
  if remove ^ (rel_obj in rel_objs):
269
308
  errors.append(
270
- f"{rel_model_name} with id {obj_pk} is {'not ' if remove else ''}in {self.related_model_util.model_name}"
309
+ f"{rel_model_name} with pk {obj_pk} is {'not ' if remove else ''}in {self.related_model_util.model_name}"
271
310
  )
272
311
  continue
273
312
  objs.append(rel_obj)
274
313
  objs_detail.append(
275
- f"{rel_model_name} with id {obj_pk} successfully {'removed' if remove else 'added'}"
314
+ f"{rel_model_name} with pk {obj_pk} successfully {'removed' if remove else 'added'}"
276
315
  )
277
316
  return errors, objs_detail, objs
278
317
 
@@ -280,14 +319,22 @@ class ManyToManyAPI:
280
319
  self,
281
320
  request: HttpRequest,
282
321
  pks: list,
283
- model: ModelSerializer | Model,
322
+ reletad_model: ModelSerializer | Model,
284
323
  related_manager: QuerySet,
285
- remove=False,
324
+ related_name: str,
325
+ instance: ModelSerializer | Model,
326
+ remove: bool = False,
286
327
  ):
287
328
  if not pks:
288
329
  return ([], [], [])
289
330
  return await self._check_m2m_objs(
290
- request, pks, model, related_manager, remove=remove
331
+ request,
332
+ pks,
333
+ reletad_model,
334
+ related_manager,
335
+ related_name,
336
+ instance,
337
+ remove=remove,
291
338
  )
292
339
 
293
340
  def _register_get_relation_view(
@@ -323,14 +370,14 @@ class ManyToManyAPI:
323
370
  related_manager = getattr(obj, related_name)
324
371
  related_qs = related_manager.all()
325
372
 
326
- query_handler = self._get_query_handler(related_name)
373
+ query_handler = self._get_query_params_handler(related_name)
327
374
  if filters is not None and query_handler:
328
- related_qs = await query_handler(related_qs, filters.model_dump())
375
+ if asyncio.iscoroutinefunction(query_handler):
376
+ related_qs = await query_handler(related_qs, filters.model_dump())
377
+ else:
378
+ related_qs = query_handler(related_qs, filters.model_dump())
329
379
 
330
- return [
331
- await rel_util.read_s(request, rel_obj, related_schema)
332
- async for rel_obj in related_qs
333
- ]
380
+ return await rel_util.list_read_s(related_schema, request, related_qs)
334
381
 
335
382
  def _resolve_action_schema(self, add: bool, remove: bool):
336
383
  return self.views_action_map[(add, remove)]
@@ -338,7 +385,7 @@ class ManyToManyAPI:
338
385
  def _register_manage_relation_view(
339
386
  self,
340
387
  *,
341
- model,
388
+ related_model: ModelSerializer | Model,
342
389
  related_name: str,
343
390
  m2m_auth,
344
391
  rel_util: ModelUtil,
@@ -375,10 +422,21 @@ class ManyToManyAPI:
375
422
  remove_pks = getattr(data, "remove", []) if m2m_remove else []
376
423
 
377
424
  add_errors, add_details, add_objs = await self._collect_m2m(
378
- request, add_pks, model, related_manager
425
+ request,
426
+ add_pks,
427
+ related_model,
428
+ related_manager,
429
+ related_name,
430
+ obj,
379
431
  )
380
432
  remove_errors, remove_details, remove_objs = await self._collect_m2m(
381
- request, remove_pks, model, related_manager, remove=True
433
+ request,
434
+ remove_pks,
435
+ related_model,
436
+ related_manager,
437
+ related_name,
438
+ obj,
439
+ remove=True,
382
440
  )
383
441
 
384
442
  tasks = []
@@ -418,7 +476,7 @@ class ManyToManyAPI:
418
476
 
419
477
  if m2m_add or m2m_remove:
420
478
  self._register_manage_relation_view(
421
- model=model,
479
+ related_model=model,
422
480
  related_name=related_name,
423
481
  m2m_auth=m2m_auth,
424
482
  rel_util=rel_util,
@@ -0,0 +1,103 @@
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
+ for key, value in scopes.items():
12
+ setattr(self, key, value)
13
+
14
+ def __iter__(self):
15
+ return iter(self.__dict__.values())
16
+
17
+
18
+ class QueryUtil:
19
+ """
20
+ Helper class to manage queryset optimizations based on predefined scopes.
21
+ Attributes:
22
+ model (ModelSerializerMeta): The model serializer meta to which this utility is attached.
23
+ SCOPES (ScopeNamespace): An enumeration-like object containing available scopes.
24
+ read_config (ModelQuerySetSchema): Configuration for the 'read' scope.
25
+ queryset_request_config (ModelQuerySetSchema): Configuration for the 'queryset_request' scope
26
+ extra_configs (dict): Additional configurations for custom scopes.
27
+ Methods:
28
+ apply_queryset_optimizations(queryset, scope): Applies select_related and prefetch_related
29
+ optimizations to the given queryset based on the specified scope.
30
+
31
+ Example:
32
+ query_util = QueryUtil(MyModelSerializer) or MyModel.query_util
33
+ qs = MyModel.objects.all()
34
+ optimized_qs = query_util.apply_queryset_optimizations(qs, query_util.SCOPES.READ)
35
+
36
+ # Applying optimizations for a custom scope
37
+ class MyModelSerializer(ModelSerializer):
38
+ class QuerySet:
39
+ extras = [
40
+ ModelQuerySetExtraSchema(
41
+ scope="custom_scope",
42
+ select_related=["custom_fk_field"],
43
+ prefetch_related=["custom_m2m_field"],
44
+ )
45
+ ]
46
+ query_util = MyModelSerializer.query_util
47
+ qs = MyModelSerializer.objects.all()
48
+ optimized_qs_custom = query_util.apply_queryset_optimizations(qs, "custom_scope")
49
+ """
50
+
51
+ SCOPES: QueryUtilBaseScopesSchema
52
+
53
+ def __init__(self, model: ModelSerializerMeta):
54
+ self.model = model
55
+ self._configuration = getattr(self.model, "QuerySet", None)
56
+ self._extra_configuration: list[ModelQuerySetExtraSchema] = getattr(
57
+ self._configuration, "extras", []
58
+ )
59
+ self._BASE_SCOPES = QueryUtilBaseScopesSchema().model_dump()
60
+ self.SCOPES = ScopeNamespace(
61
+ **self._BASE_SCOPES,
62
+ **{extra.scope: extra.scope for extra in self._extra_configuration},
63
+ )
64
+ self.extra_configs = {extra.scope: extra for extra in self._extra_configuration}
65
+ self._configs = {
66
+ **{scope: self._get_config(scope) for scope in self._BASE_SCOPES.values()},
67
+ **self.extra_configs,
68
+ }
69
+ self.read_config: ModelQuerySetSchema = self._configs.get(self.SCOPES.READ, ModelQuerySetSchema())
70
+ self.queryset_request_config: ModelQuerySetSchema = self._configs.get(
71
+ self.SCOPES.QUERYSET_REQUEST, ModelQuerySetSchema()
72
+ )
73
+
74
+ def _get_config(self, conf_name: str) -> ModelQuerySetSchema:
75
+ """Helper method to retrieve configuration attributes."""
76
+ return getattr(self._configuration, conf_name, ModelQuerySetSchema())
77
+
78
+ def apply_queryset_optimizations(self, queryset, scope: str):
79
+ """
80
+ Apply select_related and prefetch_related optimizations to the queryset
81
+ according to the specified scope.
82
+
83
+ Args:
84
+ queryset (QuerySet): The Django queryset to optimize.
85
+ scope (str): The scope to apply. Must be in self.SCOPES.
86
+
87
+ Returns:
88
+ QuerySet: The optimized queryset.
89
+
90
+ Raises:
91
+ ValueError: If the given scope is not supported.
92
+ """
93
+ if scope not in self._configs:
94
+ valid_scopes = list(self._configs.keys())
95
+ raise ValueError(
96
+ f"Invalid scope '{scope}' for QueryUtil. Supported scopes: {valid_scopes}"
97
+ )
98
+ config = self._configs.get(scope, ModelQuerySetSchema())
99
+ if config.select_related:
100
+ queryset = queryset.select_related(*config.select_related)
101
+ if config.prefetch_related:
102
+ queryset = queryset.prefetch_related(*config.prefetch_related)
103
+ return queryset