django-ninja-aio-crud 1.0.1__tar.gz → 1.0.2__tar.gz

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.1
3
+ Version: 1.0.2
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10
@@ -34,6 +34,7 @@ Provides-Extra: test
34
34
  # 🥷 django-ninja-aio-crud
35
35
 
36
36
  ![Tests](https://github.com/caspel26/django-ninja-aio-crud/actions/workflows/coverage.yml/badge.svg)
37
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=caspel26_django-ninja-aio-crud&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=caspel26_django-ninja-aio-crud)
37
38
  [![codecov](https://codecov.io/gh/caspel26/django-ninja-aio-crud/graph/badge.svg?token=DZ5WDT3S20)](https://codecov.io/gh/caspel26/django-ninja-aio-crud/)
38
39
  [![PyPI - Version](https://img.shields.io/pypi/v/django-ninja-aio-crud?color=g&logo=pypi&logoColor=white)](https://pypi.org/project/django-ninja-aio-crud/)
39
40
  [![PyPI - License](https://img.shields.io/pypi/l/django-ninja-aio-crud)](LICENSE)
@@ -334,3 +335,4 @@ MIT License. See [LICENSE](LICENSE).
334
335
  | Issues | https://github.com/caspel26/django-ninja-aio-crud/issues |
335
336
 
336
337
  ---
338
+
@@ -1,6 +1,7 @@
1
1
  # 🥷 django-ninja-aio-crud
2
2
 
3
3
  ![Tests](https://github.com/caspel26/django-ninja-aio-crud/actions/workflows/coverage.yml/badge.svg)
4
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=caspel26_django-ninja-aio-crud&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=caspel26_django-ninja-aio-crud)
4
5
  [![codecov](https://codecov.io/gh/caspel26/django-ninja-aio-crud/graph/badge.svg?token=DZ5WDT3S20)](https://codecov.io/gh/caspel26/django-ninja-aio-crud/)
5
6
  [![PyPI - Version](https://img.shields.io/pypi/v/django-ninja-aio-crud?color=g&logo=pypi&logoColor=white)](https://pypi.org/project/django-ninja-aio-crud/)
6
7
  [![PyPI - License](https://img.shields.io/pypi/l/django-ninja-aio-crud)](LICENSE)
@@ -300,4 +301,4 @@ MIT License. See [LICENSE](LICENSE).
300
301
  | Docs | https://django-ninja-aio.com
301
302
  | Issues | https://github.com/caspel26/django-ninja-aio-crud/issues |
302
303
 
303
- ---
304
+ ---
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "1.0.1"
3
+ __version__ = "1.0.2"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -42,7 +42,7 @@ class NotFoundError(BaseException):
42
42
 
43
43
  def __init__(self, model: Model, details=None):
44
44
  super().__init__(
45
- error={model._meta.verbose_name: self.error},
45
+ error={model._meta.verbose_name.replace(" ", "_"): self.error},
46
46
  status_code=self.status_code,
47
47
  details=details,
48
48
  )
@@ -0,0 +1,3 @@
1
+ from .api import ManyToManyAPI
2
+
3
+ __all__ = ["ManyToManyAPI"]
@@ -0,0 +1,432 @@
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
8
+ from ninja_aio.models import ModelSerializer, ModelUtil
9
+ from ninja_aio.schemas import (
10
+ GenericMessageSchema,
11
+ M2MRelationSchema,
12
+ M2MSchemaIn,
13
+ M2MSchemaOut,
14
+ M2MAddSchemaIn,
15
+ M2MRemoveSchemaIn,
16
+ )
17
+ from django.db.models import QuerySet, Model
18
+
19
+
20
+ class ManyToManyAPI:
21
+ """
22
+ ManyToManyAPI
23
+ -------------
24
+ WARNING (Internal Use Only):
25
+ This helper is currently intended solely for internal purposes. Its API,
26
+ behaviors, and response formats may change without notice. Do not rely on
27
+ it as a stable public interface.
28
+
29
+ Utility class that dynamically attaches asynchronous Many-To-Many (M2M) management
30
+ endpoints (GET / ADD / REMOVE) to a provided APIViewSet router in a Django Ninja
31
+ async CRUD context.
32
+
33
+ It inspects a list of M2MRelationSchema definitions and, for each relation, builds:
34
+ - An optional paginated GET endpoint to list related objects.
35
+ - An optional POST endpoint to add and/or remove related object primary keys.
36
+
37
+ Core behaviors:
38
+ - Dynamically generates per-relation filter schemas for query parameters.
39
+ - Supports custom per-relation query filtering handlers on the parent view set
40
+ via a `{related_name}_query_params_handler` coroutine.
41
+ - Validates requested add/remove primary keys, producing granular success and
42
+ error feedback.
43
+ - Performs add/remove operations concurrently using asyncio.gather when both
44
+ types of operations are requested in the same call.
45
+
46
+ Attributes established at initialization:
47
+ relations: list of M2MRelationSchema defining each relation.
48
+ view_set: The parent APIViewSet instance from which router, pagination, model util,
49
+ and path schema are derived.
50
+ router: Ninja router used to register generated endpoints.
51
+ pagination_class: Pagination class used for GET related endpoints.
52
+ path_schema: Pydantic schema used to validate path parameters (e.g., primary key).
53
+ related_model_util: A ModelUtil instance cloned from the parent view set to access
54
+ base object retrieval helpers.
55
+ relations_filters_schemas: Mapping of related_name -> generated Pydantic filter schema.
56
+
57
+ Generated endpoint naming conventions:
58
+ GET -> get_{base_model_name}_{relation_path}
59
+ POST -> manage_{base_model_name}_{relation_path}
60
+
61
+ All responses standardize success and error reporting for POST as:
62
+ {
63
+ "results": {"count": int, "details": [str, ...]},
64
+ "errors": {"count": int, "details": [str, ...]}
65
+ }
66
+
67
+ Concurrency note:
68
+ Add and remove operations are executed concurrently when both lists are non-empty,
69
+ minimizing round-trip latency for bulk mutations.
70
+
71
+ Error semantics:
72
+ - Missing related objects: reported individually.
73
+ - Invalid operation context (e.g., removing objects not currently related or adding
74
+ objects already related) reported per primary key.
75
+ - Successful operations yield a corresponding success detail string per PK.
76
+
77
+ Security / auth:
78
+ - Each relation may optionally override auth via its schema; otherwise falls back
79
+ to a default configured on the instance (self.default_auth).
80
+
81
+ Pagination:
82
+ - Applied only to GET related endpoints via @paginate(self.pagination_class).
83
+
84
+ Extensibility:
85
+ - 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)`.
87
+ - Customize relation filtering schema via each relation's `filters` definition.
88
+
89
+ -----------------------------------------------------------------------
90
+
91
+ __init__(relations, view_set)
92
+ Initialize the M2M API helper by binding core utilities from the provided view set
93
+ and precomputing filter schemas per relation.
94
+
95
+ Parameters:
96
+ relations (list[M2MRelationSchema]): Definitions for each M2M relation to expose.
97
+ view_set (APIViewSet): Parent view set containing model utilities and router.
98
+
99
+ Side effects:
100
+ - Captures router, pagination class, path schema from view_set.
101
+ - Clones ModelUtil for related model operations.
102
+ - Pre-generates filter schemas for each relation (if filters declared).
103
+
104
+ -----------------------------------------------------------------------
105
+
106
+ _generate_m2m_filters_schemas()
107
+ Create a mapping of related_name -> Pydantic schema used for query filtering in
108
+ GET related endpoints. If a relation has no filters specified, an empty schema
109
+ (dict) is used.
110
+
111
+ Returns:
112
+ dict[str, BaseModel]: Generated schemas keyed by related_name.
113
+
114
+ -----------------------------------------------------------------------
115
+
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`.
119
+
120
+ Parameters:
121
+ related_name (str): The relation's attribute name on the base model.
122
+
123
+ Returns:
124
+ Coroutine | None: Handler to transform or filter the queryset based on query params.
125
+
126
+ -----------------------------------------------------------------------
127
+
128
+ _check_m2m_objs(request, objs_pks, model, related_manager, remove=False)
129
+ Validate requested primary keys for add/remove operations against the current
130
+ relation state. Performs existence checks and logical consistency (e.g., prevents
131
+ adding already-related objects or removing non-related objects).
132
+
133
+ Parameters:
134
+ request (HttpRequest): Incoming request context (passed to ModelUtil for access control).
135
+ objs_pks (list): List of primary keys to add or remove.
136
+ model (ModelSerializer | Model): Model class or serializer used to resolve objects.
137
+ related_manager (QuerySet): Related manager for the base object's M2M field.
138
+ remove (bool): If True, treat operation as removal validation.
139
+
140
+ Returns:
141
+ tuple[list[str], list[str], list[Model]]:
142
+ errors -> List of error messages per invalid PK.
143
+ objs_detail -> List of success detail messages per valid PK.
144
+ objs -> List of resolved model instances to process.
145
+
146
+ Error cases:
147
+ - Object not found.
148
+ - Object presence mismatch (attempting wrong operation given relation membership).
149
+
150
+ -----------------------------------------------------------------------
151
+
152
+ _collect_m2m(request, pks, model, related_manager, remove=False)
153
+ Wrapper around _check_m2m_objs that short-circuits on empty PK lists.
154
+
155
+ Parameters:
156
+ request (HttpRequest)
157
+ pks (list): Primary keys proposed for mutation.
158
+ model (ModelSerializer | Model)
159
+ related_manager (QuerySet)
160
+ remove (bool): Operation type flag.
161
+
162
+ Returns:
163
+ tuple[list[str], list[str], list[Model]]: See _check_m2m_objs.
164
+
165
+ -----------------------------------------------------------------------
166
+
167
+ _build_views(relation)
168
+ Dynamically define and register the GET and/or POST endpoints for a single M2M
169
+ relation based on the relation's schema flags (get/add/remove). Builds filter
170
+ schemas, resolves path fragments, and binds handlers to the router with unique
171
+ operation IDs.
172
+
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
+ -----------------------------------------------------------------------
192
+
193
+ _add_views()
194
+ Iterates over all declared relations and invokes _build_views to attach endpoints.
195
+
196
+ Side effects:
197
+ - Populates router with all required M2M endpoints.
198
+
199
+ -----------------------------------------------------------------------
200
+
201
+ Usage Example (conceptual):
202
+ api = ManyToManyAPI(relations=[...], view_set=my_view_set)
203
+ api._add_views() # Registers all endpoints automatically during initialization flow.
204
+ """
205
+
206
+ def __init__(
207
+ self,
208
+ relations: list[M2MRelationSchema],
209
+ view_set,
210
+ ):
211
+ # Import here to avoid circular imports
212
+ from ninja_aio.views import APIViewSet
213
+
214
+ self.relations = relations
215
+ self.view_set: APIViewSet = view_set
216
+ self.router = self.view_set.router
217
+ self.pagination_class = self.view_set.pagination_class
218
+ self.path_schema = self.view_set.path_schema
219
+ self.default_auth = self.view_set.m2m_auth
220
+ self.related_model_util = self.view_set.model_util
221
+ self.relations_filters_schemas = self._generate_m2m_filters_schemas()
222
+
223
+ @property
224
+ def views_action_map(self):
225
+ return {
226
+ (True, True): ("Add or Remove", M2MSchemaIn),
227
+ (True, False): ("Add", M2MAddSchemaIn),
228
+ (False, True): ("Remove", M2MRemoveSchemaIn),
229
+ }
230
+
231
+ def _generate_m2m_filters_schemas(self):
232
+ """
233
+ Build per-relation filters schemas for M2M endpoints.
234
+ """
235
+ return {
236
+ data.related_name: self.view_set._generate_schema(
237
+ {} if not data.filters else data.filters,
238
+ f"{self.related_model_util.model_name}{data.related_name.capitalize()}FiltersSchema",
239
+ )
240
+ for data in self.relations
241
+ }
242
+
243
+ def _get_query_handler(self, related_name: str) -> Coroutine | None:
244
+ return getattr(self.view_set, f"{related_name}_query_params_handler", None)
245
+
246
+ async def _check_m2m_objs(
247
+ self,
248
+ request: HttpRequest,
249
+ objs_pks: list,
250
+ model: ModelSerializer | Model,
251
+ related_manager: QuerySet,
252
+ remove: bool = False,
253
+ ):
254
+ """
255
+ Validate requested add/remove pk list for M2M operations.
256
+ Returns (errors, details, objects_to_process).
257
+ """
258
+ errors, objs_detail, objs = [], [], []
259
+ rel_objs = [rel_obj async for rel_obj in related_manager.select_related().all()]
260
+ rel_model_name = model._meta.verbose_name.capitalize()
261
+ for obj_pk in objs_pks:
262
+ rel_obj = await (
263
+ await ModelUtil(model).get_object(request, filters={"pk": obj_pk})
264
+ ).afirst()
265
+ if rel_obj is None:
266
+ errors.append(f"{rel_model_name} with pk {obj_pk} not found.")
267
+ continue
268
+ if remove ^ (rel_obj in rel_objs):
269
+ errors.append(
270
+ f"{rel_model_name} with id {obj_pk} is {'not ' if remove else ''}in {self.related_model_util.model_name}"
271
+ )
272
+ continue
273
+ objs.append(rel_obj)
274
+ objs_detail.append(
275
+ f"{rel_model_name} with id {obj_pk} successfully {'removed' if remove else 'added'}"
276
+ )
277
+ return errors, objs_detail, objs
278
+
279
+ async def _collect_m2m(
280
+ self,
281
+ request: HttpRequest,
282
+ pks: list,
283
+ model: ModelSerializer | Model,
284
+ related_manager: QuerySet,
285
+ remove=False,
286
+ ):
287
+ if not pks:
288
+ return ([], [], [])
289
+ return await self._check_m2m_objs(
290
+ request, pks, model, related_manager, remove=remove
291
+ )
292
+
293
+ def _register_get_relation_view(
294
+ self,
295
+ *,
296
+ related_name: str,
297
+ m2m_auth,
298
+ rel_util: ModelUtil,
299
+ rel_path: str,
300
+ related_schema,
301
+ filters_schema,
302
+ ):
303
+ @self.router.get(
304
+ f"{self.view_set.path_retrieve}{rel_path}",
305
+ response={
306
+ 200: list[related_schema],
307
+ self.view_set.error_codes: GenericMessageSchema,
308
+ },
309
+ auth=m2m_auth,
310
+ summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
311
+ description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
312
+ )
313
+ @unique_view(f"get_{self.related_model_util.model_name}_{rel_path}")
314
+ @paginate(self.pagination_class)
315
+ async def get_related(
316
+ request: HttpRequest,
317
+ pk: Path[self.path_schema], # type: ignore
318
+ filters: Query[filters_schema] = None, # type: ignore
319
+ ):
320
+ obj = await self.related_model_util.get_object(
321
+ request, self.view_set._get_pk(pk)
322
+ )
323
+ related_manager = getattr(obj, related_name)
324
+ related_qs = related_manager.all()
325
+
326
+ query_handler = self._get_query_handler(related_name)
327
+ if filters is not None and query_handler:
328
+ related_qs = await query_handler(related_qs, filters.model_dump())
329
+
330
+ return [
331
+ await rel_util.read_s(request, rel_obj, related_schema)
332
+ async for rel_obj in related_qs
333
+ ]
334
+
335
+ def _resolve_action_schema(self, add: bool, remove: bool):
336
+ return self.views_action_map[(add, remove)]
337
+
338
+ def _register_manage_relation_view(
339
+ self,
340
+ *,
341
+ model,
342
+ related_name: str,
343
+ m2m_auth,
344
+ rel_util: ModelUtil,
345
+ rel_path: str,
346
+ m2m_add: bool,
347
+ m2m_remove: bool,
348
+ ):
349
+ action, schema_in = self._resolve_action_schema(m2m_add, m2m_remove)
350
+ plural = rel_util.model._meta.verbose_name_plural.capitalize()
351
+ summary = f"{action} {plural}"
352
+
353
+ @self.router.post(
354
+ f"{self.view_set.path_retrieve}{rel_path}/",
355
+ response={
356
+ 200: M2MSchemaOut,
357
+ self.view_set.error_codes: GenericMessageSchema,
358
+ },
359
+ auth=m2m_auth,
360
+ summary=summary,
361
+ description=summary,
362
+ )
363
+ @unique_view(f"manage_{self.related_model_util.model_name}_{rel_path}")
364
+ async def manage_related(
365
+ request: HttpRequest,
366
+ pk: Path[self.path_schema], # type: ignore
367
+ data: schema_in, # type: ignore
368
+ ):
369
+ obj = await self.related_model_util.get_object(
370
+ request, self.view_set._get_pk(pk)
371
+ )
372
+ related_manager: QuerySet = getattr(obj, related_name)
373
+
374
+ add_pks = getattr(data, "add", []) if m2m_add else []
375
+ remove_pks = getattr(data, "remove", []) if m2m_remove else []
376
+
377
+ add_errors, add_details, add_objs = await self._collect_m2m(
378
+ request, add_pks, model, related_manager
379
+ )
380
+ remove_errors, remove_details, remove_objs = await self._collect_m2m(
381
+ request, remove_pks, model, related_manager, remove=True
382
+ )
383
+
384
+ tasks = []
385
+ if add_objs:
386
+ tasks.append(related_manager.aadd(*add_objs))
387
+ if remove_objs:
388
+ tasks.append(related_manager.aremove(*remove_objs))
389
+ if tasks:
390
+ await asyncio.gather(*tasks)
391
+
392
+ results = add_details + remove_details
393
+ errors = add_errors + remove_errors
394
+ return {
395
+ "results": {"count": len(results), "details": results},
396
+ "errors": {"count": len(errors), "details": errors},
397
+ }
398
+
399
+ def _build_views(self, relation: M2MRelationSchema):
400
+ model = relation.model
401
+ related_name = relation.related_name
402
+ m2m_auth = relation.auth or self.default_auth
403
+ rel_util = ModelUtil(model)
404
+ rel_path = relation.path or rel_util.verbose_name_path_resolver()
405
+ related_schema = relation.related_schema
406
+ m2m_add, m2m_remove, m2m_get = relation.add, relation.remove, relation.get
407
+ filters_schema = self.relations_filters_schemas.get(related_name)
408
+
409
+ if m2m_get:
410
+ self._register_get_relation_view(
411
+ related_name=related_name,
412
+ m2m_auth=m2m_auth,
413
+ rel_util=rel_util,
414
+ rel_path=rel_path,
415
+ related_schema=related_schema,
416
+ filters_schema=filters_schema,
417
+ )
418
+
419
+ if m2m_add or m2m_remove:
420
+ self._register_manage_relation_view(
421
+ model=model,
422
+ related_name=related_name,
423
+ m2m_auth=m2m_auth,
424
+ rel_util=rel_util,
425
+ rel_path=rel_path,
426
+ m2m_add=m2m_add,
427
+ m2m_remove=m2m_remove,
428
+ )
429
+
430
+ def _add_views(self):
431
+ for relation in self.relations:
432
+ self._build_views(relation)