django-ninja-aio-crud 1.0.0__py3-none-any.whl → 1.0.1__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.
@@ -0,0 +1,336 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-ninja-aio-crud
3
+ Version: 1.0.1
4
+ Summary: Django Ninja AIO CRUD - Rest Framework
5
+ Author: Giuseppe Casillo
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Topic :: Internet
10
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
11
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Classifier: Topic :: Software Development
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Framework :: Django
22
+ Classifier: Framework :: AsyncIO
23
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ License-File: LICENSE
26
+ Requires-Dist: django-ninja >=1.3.0
27
+ Requires-Dist: joserfc >=1.0.0
28
+ Requires-Dist: orjson >= 3.10.7
29
+ Requires-Dist: coverage ; extra == "test"
30
+ Project-URL: Documentation, https://django-ninja-aio.com
31
+ Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
32
+ Provides-Extra: test
33
+
34
+ # 🥷 django-ninja-aio-crud
35
+
36
+ ![Tests](https://github.com/caspel26/django-ninja-aio-crud/actions/workflows/coverage.yml/badge.svg)
37
+ [![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
+ [![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
+ [![PyPI - License](https://img.shields.io/pypi/l/django-ninja-aio-crud)](LICENSE)
40
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
41
+
42
+ > Lightweight async CRUD layer on top of **[Django Ninja](https://django-ninja.dev/)** with automatic schema generation, filtering, pagination, auth & Many‑to‑Many management.
43
+
44
+ ---
45
+
46
+ ## ✨ Features
47
+
48
+ - Async CRUD ViewSets (create, list, retrieve, update, delete)
49
+ - Automatic Pydantic schemas from `ModelSerializer` (read/create/update)
50
+ - Dynamic query params (runtime schema via `pydantic.create_model`)
51
+ - Per-method authentication (`auth`, `get_auth`, `post_auth`, etc.)
52
+ - Async pagination (customizable)
53
+ - M2M relation endpoints via `M2MRelationSchema` (add/remove/get + filters)
54
+ - Reverse relation serialization
55
+ - Hook methods (`query_params_handler`, `<related>_query_params_handler`, `custom_actions`, lifecycle hooks)
56
+ - ORJSON renderer through `NinjaAIO`
57
+ - Clean, minimal integration
58
+
59
+ ---
60
+
61
+ ## 📦 Installation
62
+
63
+ ```bash
64
+ pip install django-ninja-aio-crud
65
+ ```
66
+
67
+ Add to your project’s dependencies and ensure Django Ninja is installed.
68
+
69
+ ---
70
+
71
+ ## 🚀 Quick Start
72
+
73
+ models.py
74
+ ```python
75
+ from django.db import models
76
+ from ninja_aio.models import ModelSerializer
77
+
78
+ class Book(ModelSerializer):
79
+ title = models.CharField(max_length=120)
80
+ published = models.BooleanField(default=True)
81
+
82
+ class ReadSerializer:
83
+ fields = ["id", "title", "published"]
84
+
85
+ class CreateSerializer:
86
+ fields = ["title", "published"]
87
+
88
+ class UpdateSerializer:
89
+ optionals = [("title", str), ("published", bool)]
90
+ ```
91
+
92
+ views.py
93
+ ```python
94
+ from ninja_aio import NinjaAIO
95
+ from ninja_aio.views import APIViewSet
96
+ from .models import Book
97
+
98
+ api = NinjaAIO()
99
+
100
+ class BookViewSet(APIViewSet):
101
+ model = Book
102
+ api = api
103
+
104
+ BookViewSet().add_views_to_route()
105
+ ```
106
+
107
+ Visit `/docs` → CRUD endpoints ready.
108
+
109
+ ---
110
+
111
+ ## 🔄 Query Filtering
112
+
113
+ ```python
114
+ class BookViewSet(APIViewSet):
115
+ model = Book
116
+ api = api
117
+ query_params = {"published": (bool, None), "title": (str, None)}
118
+
119
+ async def query_params_handler(self, queryset, filters):
120
+ if filters.get("published") is not None:
121
+ queryset = queryset.filter(published=filters["published"])
122
+ if filters.get("title"):
123
+ queryset = queryset.filter(title__icontains=filters["title"])
124
+ return queryset
125
+ ```
126
+
127
+ Request:
128
+ ```
129
+ GET /book/?published=true&title=python
130
+ ```
131
+
132
+ ---
133
+
134
+ ## 🤝 Many-to-Many Example (with filters)
135
+
136
+ ```python
137
+ from ninja_aio.schemas import M2MRelationSchema
138
+
139
+ class Tag(ModelSerializer):
140
+ name = models.CharField(max_length=50)
141
+ class ReadSerializer:
142
+ fields = ["id", "name"]
143
+
144
+ class Article(ModelSerializer):
145
+ title = models.CharField(max_length=120)
146
+ tags = models.ManyToManyField(Tag, related_name="articles")
147
+
148
+ class ReadSerializer:
149
+ fields = ["id", "title", "tags"]
150
+
151
+ class ArticleViewSet(APIViewSet):
152
+ model = Article
153
+ api = api
154
+ m2m_relations = [
155
+ M2MRelationSchema(
156
+ model=Tag,
157
+ related_name="tags",
158
+ filters={"name": (str, "")}
159
+ )
160
+ ]
161
+
162
+ async def tags_query_params_handler(self, queryset, filters):
163
+ n = filters.get("name")
164
+ if n:
165
+ queryset = queryset.filter(name__icontains=n)
166
+ return queryset
167
+
168
+ ArticleViewSet().add_views_to_route()
169
+ ```
170
+
171
+ Endpoints:
172
+ - `GET /article/{pk}/tag?name=dev`
173
+ - `POST /article/{pk}/tag/` body: `{"add":[1,2],"remove":[3]}`
174
+
175
+ ---
176
+
177
+ ## 🔐 Authentication (JWT example)
178
+
179
+ ```python
180
+ from ninja_aio.auth import AsyncJwtBearer
181
+ from joserfc import jwk
182
+ from .models import Book
183
+
184
+ PUBLIC_KEY = "-----BEGIN PUBLIC KEY----- ..."
185
+
186
+ class JWTAuth(AsyncJwtBearer):
187
+ jwt_public = jwk.RSAKey.import_key(PUBLIC_KEY)
188
+ jwt_alg = "RS256"
189
+ claims = {"sub": {"essential": True}}
190
+
191
+ async def auth_handler(self, request):
192
+ book_id = self.dcd.claims.get("sub")
193
+ return await Book.objects.aget(id=book_id)
194
+
195
+ class SecureBookViewSet(APIViewSet):
196
+ model = Book
197
+ api = api
198
+ auth = [JWTAuth()]
199
+ get_auth = None # list/retrieve public
200
+ ```
201
+
202
+ ---
203
+
204
+ ## 📑 Lifecycle Hooks (ModelSerializer)
205
+
206
+ Available on every save/delete:
207
+ - `on_create_before_save`
208
+ - `on_create_after_save`
209
+ - `before_save`
210
+ - `after_save`
211
+ - `on_delete`
212
+ - `custom_actions(payload)` (create/update custom field logic)
213
+ - `post_create()` (after create commit)
214
+
215
+ ---
216
+
217
+ ## 🧩 Adding Custom Endpoints
218
+
219
+ ```python
220
+ class BookViewSet(APIViewSet):
221
+ model = Book
222
+ api = api
223
+
224
+ def views(self):
225
+ @self.router.get("/stats/")
226
+ async def stats(request):
227
+ total = await Book.objects.acount()
228
+ return {"total": total}
229
+ ```
230
+
231
+ ---
232
+
233
+ ## 📄 Pagination
234
+
235
+ Default: `PageNumberPagination`. Override:
236
+
237
+ ```python
238
+ from ninja.pagination import PageNumberPagination
239
+
240
+ class LargePagination(PageNumberPagination):
241
+ page_size = 50
242
+ max_page_size = 200
243
+
244
+ class BookViewSet(APIViewSet):
245
+ model = Book
246
+ api = api
247
+ pagination_class = LargePagination
248
+ ```
249
+
250
+ ---
251
+
252
+ ## 🛠 Project Structure & Docs
253
+
254
+ Documentation (MkDocs + Material):
255
+ ```
256
+ docs/
257
+ getting_started/
258
+ tutorial/
259
+ api/
260
+ views/
261
+ models/
262
+ authentication.md
263
+ pagination.md
264
+ ```
265
+
266
+ Browse full reference:
267
+ - APIViewSet: `docs/api/views/api_view_set.md`
268
+ - APIView: `docs/api/views/api_view.md`
269
+ - ModelSerializer: `docs/api/models/model_serializer.md`
270
+ - Authentication: `docs/api/authentication.md`
271
+ - Pagination: `docs/api/pagination.md`
272
+
273
+ ---
274
+
275
+ ## 🧪 Tests
276
+
277
+ Use Django test runner + async ORM patterns. Example async pattern:
278
+ ```python
279
+ obj = await Book.objects.acreate(title="T1", published=True)
280
+ count = await Book.objects.acount()
281
+ ```
282
+
283
+ ---
284
+
285
+ ## 🚫 Disable Operations
286
+
287
+ ```python
288
+ class ReadOnlyBookViewSet(APIViewSet):
289
+ model = Book
290
+ api = api
291
+ disable = ["update", "delete"]
292
+ ```
293
+
294
+ ---
295
+
296
+ ## 📌 Performance Tips
297
+
298
+ - Use `queryset_request` classmethod to prefetch
299
+ - Index frequently filtered fields
300
+ - Keep pagination enabled
301
+ - Limit slices (`queryset = queryset[:1000]`) for heavy searches
302
+
303
+ ---
304
+
305
+ ## 🤲 Contributing
306
+
307
+ 1. Fork
308
+ 2. Create branch
309
+ 3. Add tests
310
+ 4. Run lint (`ruff check .`)
311
+ 5. Open PR
312
+
313
+ ---
314
+
315
+ ## ⭐ Support
316
+
317
+ Star the repo or donate:
318
+ - [Buy me a coffee](https://buymeacoffee.com/caspel26)
319
+
320
+ ---
321
+
322
+ ## 📜 License
323
+
324
+ MIT License. See [LICENSE](LICENSE).
325
+
326
+ ---
327
+
328
+ ## 🔗 Quick Links
329
+
330
+ | Item | Link |
331
+ |------|------|
332
+ | PyPI | https://pypi.org/project/django-ninja-aio-crud/ |
333
+ | Docs | https://django-ninja-aio.com
334
+ | Issues | https://github.com/caspel26/django-ninja-aio-crud/issues |
335
+
336
+ ---
@@ -1,4 +1,4 @@
1
- ninja_aio/__init__.py,sha256=0C1TJ30_2xcqtKhHchk1gb1IzGrxY2u1ufIO-lIRgRM,119
1
+ ninja_aio/__init__.py,sha256=hC9T2fsBJwd5QxeaoghQNrIN1i5mJfBAnK_Ky4lD7zs,119
2
2
  ninja_aio/api.py,sha256=Fe6l3YCy7MW5TY4-Lbl80CFuK2NT2Y7tHfmqPk6Mqak,1735
3
3
  ninja_aio/auth.py,sha256=zUwruKcz7MXuOnWp5k1CCSwEc8s2Lyqqk7Qm9kPbJ3o,5149
4
4
  ninja_aio/decorators.py,sha256=LsvHbMxmw_So8NV0ey5NRRvSbfYkOZLeLQ4Fix7rQAY,5519
@@ -8,8 +8,8 @@ ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
8
8
  ninja_aio/renders.py,sha256=0eYklRKd59aV4cZDom5vLZyA99Ob17OwkpMybsRXvyg,1970
9
9
  ninja_aio/schemas.py,sha256=iv3VHCMlzez6Qs70zITYIwEz0EFOOaMPDVGRcTZCygA,1875
10
10
  ninja_aio/types.py,sha256=TJSGlA7bt4g9fvPhJ7gzH5tKbLagPmZUzfgttEOp4xs,468
11
- ninja_aio/views.py,sha256=7sv8tpRp8CJC2sxQr-jmbjyjcJe7DabYPf8F7nieZ4Y,23518
12
- django_ninja_aio_crud-1.0.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
13
- django_ninja_aio_crud-1.0.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
14
- django_ninja_aio_crud-1.0.0.dist-info/METADATA,sha256=aV0vq6ijySGbggzrwNJ1mCC7HG_6fo1jeF9Ybb_j9Mk,14216
15
- django_ninja_aio_crud-1.0.0.dist-info/RECORD,,
11
+ ninja_aio/views.py,sha256=EGOqybYR0Y0k7DVlMUwaruyEwF1LTJcM1EeFBU69EL0,23113
12
+ django_ninja_aio_crud-1.0.1.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
13
+ django_ninja_aio_crud-1.0.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
14
+ django_ninja_aio_crud-1.0.1.dist-info/METADATA,sha256=jIF-L1BHlBtCp8n7mTH1IK388J2FP_73567_ppirNUQ,8331
15
+ django_ninja_aio_crud-1.0.1.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.0"
3
+ __version__ = "1.0.1"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
ninja_aio/views.py CHANGED
@@ -294,6 +294,7 @@ class APIViewSet:
294
294
  """
295
295
  Register create endpoint.
296
296
  """
297
+
297
298
  @self.router.post(
298
299
  self.path,
299
300
  auth=self.post_view_auth(),
@@ -311,6 +312,7 @@ class APIViewSet:
311
312
  """
312
313
  Register list endpoint with pagination and optional filters.
313
314
  """
315
+
314
316
  @self.router.get(
315
317
  self.get_path,
316
318
  auth=self.get_view_auth(),
@@ -347,6 +349,7 @@ class APIViewSet:
347
349
  """
348
350
  Register retrieve endpoint.
349
351
  """
352
+
350
353
  @self.router.get(
351
354
  self.get_path_retrieve,
352
355
  auth=self.get_view_auth(),
@@ -365,6 +368,7 @@ class APIViewSet:
365
368
  """
366
369
  Register update endpoint.
367
370
  """
371
+
368
372
  @self.router.patch(
369
373
  self.path_retrieve,
370
374
  auth=self.patch_view_auth(),
@@ -388,6 +392,7 @@ class APIViewSet:
388
392
  """
389
393
  Register delete endpoint.
390
394
  """
395
+
391
396
  @self.router.delete(
392
397
  self.path_retrieve,
393
398
  auth=self.delete_view_auth(),
@@ -440,140 +445,134 @@ class APIViewSet:
440
445
  )
441
446
  return errors, objs_detail, objs
442
447
 
443
- def _m2m_views(self):
448
+ def _m2m_views(self, m2m_relation: M2MRelationSchema):
444
449
  """
445
450
  Register M2M get/manage endpoints for each relation in m2m_relations.
446
451
  Supports optional per-relation filters and custom query handler:
447
452
  <related_name>_query_params_handler.
448
453
  """
449
- for m2m_data in self.m2m_relations:
450
- model = m2m_data.model
451
- related_name = m2m_data.related_name
452
- m2m_auth = m2m_data.auth or self.m2m_auth
453
- rel_util = ModelUtil(model)
454
- rel_path = (
455
- rel_util.verbose_name_path_resolver()
456
- if not m2m_data.path
457
- else m2m_data.path
454
+ model = m2m_relation.model
455
+ related_name = m2m_relation.related_name
456
+ m2m_auth = m2m_relation.auth or self.m2m_auth
457
+ rel_util = ModelUtil(model)
458
+ rel_path = (
459
+ rel_util.verbose_name_path_resolver()
460
+ if not m2m_relation.path
461
+ else m2m_relation.path
462
+ )
463
+ m2m_add = m2m_relation.add
464
+ m2m_remove = m2m_relation.remove
465
+ m2m_get = m2m_relation.get
466
+ filters_schema = self.m2m_filters_schemas.get(related_name)
467
+ if m2m_get:
468
+
469
+ @self.router.get(
470
+ f"{self.path_retrieve}{rel_path}",
471
+ response={
472
+ 200: List[model.generate_related_s(),],
473
+ self.error_codes: GenericMessageSchema,
474
+ },
475
+ auth=m2m_auth,
476
+ summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
477
+ description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
458
478
  )
459
- m2m_add = m2m_data.add
460
- m2m_remove = m2m_data.remove
461
- m2m_get = m2m_data.get
462
- filters_schema = self.m2m_filters_schemas.get(related_name)
463
- if m2m_get:
464
-
465
- @self.router.get(
466
- f"{self.path_retrieve}{rel_path}",
467
- response={
468
- 200: List[model.generate_related_s(),],
469
- self.error_codes: GenericMessageSchema,
470
- },
471
- auth=m2m_auth,
472
- summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
473
- description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
474
- )
475
- @unique_view(f"get_{self.model_util.model_name}_{rel_path}")
476
- @paginate(self.pagination_class)
477
- async def get_related(
478
- request: HttpRequest,
479
- pk: Path[self.path_schema], # type: ignore
480
- filters: Query[filters_schema] = None # type: ignore
481
- ):
482
- obj = await self.model_util.get_object(request, self._get_pk(pk))
483
- related_manager = getattr(obj, related_name)
484
- related_qs = related_manager.all()
485
- if (
486
- filters is not None
487
- and (
488
- query_handler := getattr(
489
- self,
490
- f"{m2m_data.related_name}_query_params_handler",
491
- None,
492
- )
493
- )
494
- is not None
495
- ):
496
- related_qs = await query_handler(
497
- related_qs, filters.model_dump()
498
- )
499
- related_objs = [
500
- await rel_util.read_s(
501
- request, rel_obj, model.generate_related_s()
479
+ @unique_view(f"get_{self.model_util.model_name}_{rel_path}")
480
+ @paginate(self.pagination_class)
481
+ async def get_related(
482
+ request: HttpRequest,
483
+ pk: Path[self.path_schema], # type: ignore
484
+ filters: Query[filters_schema] = None, # type: ignore
485
+ ):
486
+ obj = await self.model_util.get_object(request, self._get_pk(pk))
487
+ related_manager = getattr(obj, related_name)
488
+ related_qs = related_manager.all()
489
+ if (
490
+ filters is not None
491
+ and (
492
+ query_handler := getattr(
493
+ self,
494
+ f"{m2m_relation.related_name}_query_params_handler",
495
+ None,
502
496
  )
503
- async for rel_obj in related_qs
504
- ]
505
- return related_objs
506
-
507
- if m2m_add or m2m_remove:
508
- summary = f"{'Add or Remove' if m2m_add and m2m_remove else 'Add' if m2m_add else 'Remove'} {rel_util.model._meta.verbose_name_plural.capitalize()}"
509
- description = f"{'Add or remove' if m2m_add and m2m_remove else 'Add' if m2m_add else 'Remove'} {rel_util.model._meta.verbose_name_plural.capitalize()}"
510
- schema_in = (
511
- M2MSchemaIn
512
- if m2m_add and m2m_remove
513
- else M2MAddSchemaIn
514
- if m2m_add
515
- else M2MRemoveSchemaIn
516
- )
517
-
518
- @self.router.post(
519
- f"{self.path_retrieve}{rel_path}/",
520
- response={
521
- 200: M2MSchemaOut,
522
- self.error_codes: GenericMessageSchema,
523
- },
524
- auth=m2m_auth,
525
- summary=summary,
526
- description=description,
527
- )
528
- @unique_view(f"manage_{self.model_util.model_name}_{rel_path}")
529
- async def manage_related(
530
- request: HttpRequest,
531
- pk: Path[self.path_schema], # type: ignore
532
- data: schema_in, # type: ignore
497
+ )
498
+ is not None
533
499
  ):
534
- obj = await self.model_util.get_object(request, self._get_pk(pk))
535
- related_manager: QuerySet = getattr(obj, related_name)
536
- add_errors, add_details, add_objs = [], [], []
537
- remove_errors, remove_details, remove_objs = [], [], []
538
-
539
- if m2m_add and hasattr(data, "add"):
540
- (
541
- add_errors,
542
- add_details,
543
- add_objs,
544
- ) = await self._check_m2m_objs(
545
- request, data.add, model, related_manager
546
- )
547
- if m2m_remove and hasattr(data, "remove"):
548
- (
549
- remove_errors,
550
- remove_details,
551
- remove_objs,
552
- ) = await self._check_m2m_objs(
553
- request,
554
- data.remove,
555
- model,
556
- related_manager,
557
- remove=True,
558
- )
500
+ related_qs = await query_handler(related_qs, filters.model_dump())
501
+ related_objs = [
502
+ await rel_util.read_s(request, rel_obj, model.generate_related_s())
503
+ async for rel_obj in related_qs
504
+ ]
505
+ return related_objs
506
+
507
+ if m2m_add or m2m_remove:
508
+ summary = f"{'Add or Remove' if m2m_add and m2m_remove else 'Add' if m2m_add else 'Remove'} {rel_util.model._meta.verbose_name_plural.capitalize()}"
509
+ description = f"{'Add or remove' if m2m_add and m2m_remove else 'Add' if m2m_add else 'Remove'} {rel_util.model._meta.verbose_name_plural.capitalize()}"
510
+ schema_in = (
511
+ M2MSchemaIn
512
+ if m2m_add and m2m_remove
513
+ else M2MAddSchemaIn
514
+ if m2m_add
515
+ else M2MRemoveSchemaIn
516
+ )
559
517
 
560
- await asyncio.gather(
561
- related_manager.aadd(*add_objs),
562
- related_manager.aremove(*remove_objs),
518
+ @self.router.post(
519
+ f"{self.path_retrieve}{rel_path}/",
520
+ response={
521
+ 200: M2MSchemaOut,
522
+ self.error_codes: GenericMessageSchema,
523
+ },
524
+ auth=m2m_auth,
525
+ summary=summary,
526
+ description=description,
527
+ )
528
+ @unique_view(f"manage_{self.model_util.model_name}_{rel_path}")
529
+ async def manage_related(
530
+ request: HttpRequest,
531
+ pk: Path[self.path_schema], # type: ignore
532
+ data: schema_in, # type: ignore
533
+ ):
534
+ obj = await self.model_util.get_object(request, self._get_pk(pk))
535
+ related_manager: QuerySet = getattr(obj, related_name)
536
+ add_errors, add_details, add_objs = [], [], []
537
+ remove_errors, remove_details, remove_objs = [], [], []
538
+
539
+ if m2m_add and hasattr(data, "add"):
540
+ (
541
+ add_errors,
542
+ add_details,
543
+ add_objs,
544
+ ) = await self._check_m2m_objs(
545
+ request, data.add, model, related_manager
563
546
  )
564
- results = add_details + remove_details
565
- errors = add_errors + remove_errors
566
-
567
- return {
568
- "results": {
569
- "count": len(results),
570
- "details": results,
571
- },
572
- "errors": {
573
- "count": len(errors),
574
- "details": errors,
575
- },
576
- }
547
+ if m2m_remove and hasattr(data, "remove"):
548
+ (
549
+ remove_errors,
550
+ remove_details,
551
+ remove_objs,
552
+ ) = await self._check_m2m_objs(
553
+ request,
554
+ data.remove,
555
+ model,
556
+ related_manager,
557
+ remove=True,
558
+ )
559
+ await asyncio.gather(
560
+ related_manager.aadd(*add_objs),
561
+ related_manager.aremove(*remove_objs),
562
+ )
563
+ results = add_details + remove_details
564
+ errors = add_errors + remove_errors
565
+
566
+ return {
567
+ "results": {
568
+ "count": len(results),
569
+ "details": results,
570
+ },
571
+ "errors": {
572
+ "count": len(errors),
573
+ "details": errors,
574
+ },
575
+ }
577
576
 
578
577
  def _add_views(self):
579
578
  """
@@ -582,7 +581,8 @@ class APIViewSet:
582
581
  """
583
582
  if "all" in self.disable:
584
583
  if self.m2m_relations:
585
- self._m2m_views()
584
+ for m2m_relation in self.m2m_relations:
585
+ self._m2m_views(m2m_relation)
586
586
  self.views()
587
587
  return self.router
588
588
 
@@ -594,7 +594,8 @@ class APIViewSet:
594
594
 
595
595
  self.views()
596
596
  if self.m2m_relations:
597
- self._m2m_views()
597
+ for m2m_relation in self.m2m_relations:
598
+ self._m2m_views(m2m_relation)
598
599
  return self.router
599
600
 
600
601
  def add_views_to_route(self):
@@ -1,527 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: django-ninja-aio-crud
3
- Version: 1.0.0
4
- Summary: Django Ninja AIO CRUD - Rest Framework
5
- Author: Giuseppe Casillo
6
- Requires-Python: >=3.10
7
- Description-Content-Type: text/markdown
8
- Classifier: Operating System :: OS Independent
9
- Classifier: Topic :: Internet
10
- Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
11
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
- Classifier: Topic :: Software Development :: Libraries
13
- Classifier: Topic :: Software Development
14
- Classifier: Environment :: Web Environment
15
- Classifier: Intended Audience :: Developers
16
- Classifier: License :: OSI Approved :: MIT License
17
- Classifier: Programming Language :: Python :: 3.10
18
- Classifier: Programming Language :: Python :: 3.11
19
- Classifier: Programming Language :: Python :: 3.12
20
- Classifier: Programming Language :: Python :: 3 :: Only
21
- Classifier: Framework :: Django
22
- Classifier: Framework :: AsyncIO
23
- Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
24
- Classifier: Topic :: Internet :: WWW/HTTP
25
- License-File: LICENSE
26
- Requires-Dist: django-ninja >=1.3.0
27
- Requires-Dist: joserfc >=1.0.0
28
- Requires-Dist: orjson >= 3.10.7
29
- Requires-Dist: coverage ; extra == "test"
30
- Project-URL: Documentation, https://caspel26.github.io/django-ninja-aio-crud/
31
- Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
32
- Provides-Extra: test
33
-
34
- # 🥷 django-ninja-aio-crud
35
- ![Test](https://github.com/caspel26/django-ninja-aio-crud/actions/workflows/coverage.yml/badge.svg)
36
- [![codecov](https://codecov.io/gh/caspel26/django-ninja-aio-crud/graph/badge.svg?token=DZ5WDT3S20)](https://codecov.io/gh/caspel26/django-ninja-aio-crud)
37
- [![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/)
38
- [![PyPI - License](https://img.shields.io/pypi/l/django-ninja-aio-crud)](https://github.com/caspel26/django-ninja-aio-crud/blob/main/LICENSE)
39
- [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
40
- > [!NOTE]
41
- > Django ninja aio crud framework is based on **<a href="https://django-ninja.dev/">Django Ninja framework</a>**. It comes out with built-in views and models which are able to make automatic async CRUD operations and codes views class based making the developers' life easier and the code cleaner.
42
-
43
- ## 📝 Instructions
44
-
45
- ### 📚 Prerequisites
46
-
47
- - Install Python from the [official website](https://www.python.org/) (latest version) and ensure it is added to the system Path and environment variables.
48
-
49
- ### 💻 Setup your environment
50
-
51
- - Create a virtual environment
52
- ```bash
53
- python -m venv .venv
54
- ```
55
-
56
- ### ✅ Activate it
57
-
58
- - If you are from linux activate it with
59
-
60
- ```bash
61
- . .venv/bin/activate
62
- ```
63
-
64
- - If you are from windows activate it with
65
-
66
- ```bash
67
- . .venv/Scripts/activate
68
- ```
69
-
70
- ### 📥 Install package
71
-
72
- ```bash
73
- pip install django-ninja-aio-crud
74
- ```
75
-
76
- ## 🚀 Usage
77
-
78
- > [!TIP]
79
- > If you find **django ninja aio crud** useful, consider :star: this project
80
- > and why not ... [Buy me a coffee](https://buymeacoffee.com/caspel26)
81
-
82
- ### ModelSerializer
83
-
84
- - You can serialize your models using ModelSerializer and made them inherit from it. In your models.py import ModelSerializer
85
- ```python
86
- # models.py
87
- from django.db import models
88
- from ninja_aio.models import ModelSerializer
89
-
90
-
91
- class Foo(ModelSerializer):
92
- name = models.CharField(max_length=30)
93
- bar = models.CharField(max_length=30)
94
-
95
- class ReadSerializer:
96
- fields = ["id", "name", "bar"]
97
-
98
- class CreateSerializer:
99
- fields = ["name", "bar"]
100
-
101
- class UpdateSerializer:
102
- fields = ["name", "bar"]
103
- ```
104
-
105
- - ReadSerializer, CreateSerializer, UpdateSerializer are used to define which fields would be included in runtime schemas creation. You can also specify custom fields and handle their function by overriding custom_actions ModelSerializer's method.
106
-
107
- ```python
108
- # models.py
109
- from django.db import models
110
- from ninja_aio.models import ModelSerializer
111
-
112
-
113
- class Foo(ModelSerializer):
114
- name = models.CharField(max_length=30)
115
- bar = models.CharField(max_length=30)
116
- active = models.BooleanField(default=False)
117
-
118
- class ReadSerializer:
119
- fields = ["id", "name", "bar"]
120
-
121
- class CreateSerializer:
122
- customs = [("force_activation", bool, False)]
123
- fields = ["name", "bar"]
124
-
125
- class UpdateSerializer:
126
- fields = ["name", "bar"]
127
-
128
- async def custom_actions(self, payload: dict[str, Any]):
129
- if not payload.get("force_activation"):
130
- return
131
- setattr(self, "force_activation", True)
132
-
133
- async def post_create(self) -> None:
134
- if not hasattr(self, "force_activation") or not getattr(self, "force_activation"):
135
- return
136
- self.active = True
137
- await self.asave()
138
- ```
139
-
140
- - post create method is a custom method that comes out to handle actions which will be excuted after that the object is created. It can be used, indeed, for example to handle custom fields' actions.
141
-
142
- - You can also define optional fields for you Create and Update serializers. To declare an optional fields you have to give the field type too.
143
- ```python
144
- # models.py
145
- from django.db import models
146
- from ninja_aio.models import ModelSerializer
147
-
148
-
149
- class Foo(ModelSerializer):
150
- name = models.CharField(max_length=30)
151
- bar = models.CharField(max_length=30, default="")
152
- active = models.BooleanField(default=False)
153
-
154
- class ReadSerializer:
155
- fields = ["id", "name", "bar"]
156
-
157
- class CreateSerializer:
158
- fields = ["name"]
159
- optionals = [("bar", str), ("active", bool)]
160
-
161
- class UpdateSerializer:
162
- optionals = [("bar", str), ("active", bool)]
163
- ```
164
-
165
- - Instead of declaring your fields maybe you want to exclude some of them. Declaring "excludes" attribute into serializers will exclude the given fields. (You can declare only one between "fields" and "excludes").
166
-
167
- ```python
168
- # models.py
169
- from django.db import models
170
- from ninja_aio.models import ModelSerializer
171
-
172
-
173
- class Foo(ModelSerializer):
174
- name = models.CharField(max_length=30)
175
- bar = models.CharField(max_length=30, default="")
176
- active = models.BooleanField(default=False)
177
-
178
- class ReadSerializer:
179
- excludes = ["bar"]
180
-
181
- class CreateSerializer:
182
- fields = ["name"]
183
- optionals = [("bar", str), ("active", bool)]
184
-
185
- class UpdateSerializer:
186
- excludes = ["id", "name"]
187
- optionals = [("bar", str), ("active", bool)]
188
- ```
189
- - If you want to add into ReadSerializer model properties you must define them as customs.
190
- ```python
191
- # models.py
192
- from django.db import models
193
- from ninja_aio.models import ModelSerializer
194
-
195
-
196
- class Foo(ModelSerializer):
197
- name = models.CharField(max_length=30)
198
- bar = models.CharField(max_length=30, default="")
199
- active = models.BooleanField(default=False)
200
-
201
- @property
202
- def full_name(self):
203
- return f"{self.name} example_full_name"
204
-
205
- class ReadSerializer:
206
- excludes = ["bar"]
207
- customs = [("full_name", str, "")]
208
-
209
- class CreateSerializer:
210
- fields = ["name"]
211
- optionals = [("bar", str), ("active", bool)]
212
-
213
- class UpdateSerializer:
214
- excludes = ["id", "name"]
215
- optionals = [("bar", str), ("active", bool)]
216
- ```
217
- - ModelSerializer comes out also with methods executed on object save and delete, them are:
218
-
219
- 1. on_create_before_save: code executed on object creation but before saving;
220
- 1. on_create_after_save: code executed on object creation but after saving;
221
- 1. before_save: code executed on every save but before saving;
222
- 1. after_save: code executed on every save but after saving;
223
- 1. on_delete: code executed after object delete;
224
-
225
-
226
- ### APIViewSet
227
-
228
- - View class used to automatically generate CRUD views. in your views.py import APIViewSet and define your api using NinjaAIO class. NinjaAIO class uses built-in parser and renderer which use orjson for data serialization.
229
-
230
- ```python
231
- # views.py
232
- from ninja_aio import NinjaAIO
233
- from ninja_aio.views import APIViewSet
234
-
235
- from .models import Foo
236
-
237
- api = NinjaAIO()
238
-
239
-
240
- class FooAPI(APIViewSet):
241
- model = Foo
242
- api = api
243
-
244
-
245
- FooAPI().add_views_to_route()
246
- ```
247
-
248
- - and that's it, your model CRUD will be automatically created. You can also add custom views to CRUD overriding the built-in method "views".
249
-
250
- ```python
251
- # views.py
252
- from ninja import Schema
253
- from ninja_aio import NinjaAIO
254
- from ninja_aio.views import APIViewSet
255
-
256
- from .models import Foo
257
-
258
- api = NinjaAIO()
259
-
260
-
261
- class ExampleSchemaOut(Schema):
262
- sum: float
263
-
264
-
265
- class ExampleSchemaIn(Schema):
266
- n1: float
267
- n2: float
268
-
269
-
270
- class FooAPI(APIViewSet):
271
- model = Foo
272
- api = api
273
-
274
- def views(self):
275
- @self.router.post("numbers-sum/", response={200: ExampleSchemaOut})
276
- async def sum(request: HttpRequest, data: ExampleSchemaIn):
277
- return 200, {sum: data.n1 + data.n2}
278
-
279
-
280
- FooAPI().add_views_to_route()
281
- ```
282
-
283
- - You can also choose to disable any operation from crud by declaring "disbale" attribute. You can give "all" to disable every crud operation except for additional views.
284
-
285
- > [!TIP]
286
- > You can exclude by default an endpoint without declaring the serializer.
287
- > For example if you don't want to give update method to a CRUD just do not declare UpdateSerializer into model.
288
-
289
- ```python
290
- # views.py
291
- from ninja_aio import NinjaAIO
292
- from ninja_aio.views import APIViewSet
293
-
294
- from .models import Foo
295
-
296
- api = NinjaAIO()
297
-
298
-
299
- class FooAPI(APIViewSet):
300
- model = Foo
301
- api = api
302
- disable = ["retrieve", "update"]
303
-
304
-
305
- FooAPI().add_views_to_route()
306
- ```
307
- For the list endpoint you can also set query params and handle them. They will be also visible into swagger.
308
-
309
- ```python
310
- # views.py
311
- from ninja_aio import NinjaAIO
312
- from ninja_aio.views import APIViewSet
313
-
314
- from .models import Foo
315
-
316
- api = NinjaAIO()
317
-
318
-
319
- class FooAPI(APIViewSet):
320
- model = Foo
321
- api = api
322
- query_params = {"name": (str, None), "active": (bool, None)}
323
-
324
- async def query_params_handler(self, queryset, filters):
325
- return queryset.filter(**{k: v for k, v in filters.items() if v is not None})
326
-
327
-
328
- FooAPI().add_views_to_route()
329
- ```
330
-
331
- ### APIView
332
-
333
- - View class to code generic views class based. In your views.py import APIView class.
334
-
335
- ```python
336
- # views.py
337
- from ninja import Schema
338
- from ninja_aio import NinjaAIO
339
- from ninja_aio.views import APIView
340
-
341
- api = NinjaAIO()
342
-
343
-
344
- class ExampleSchemaOut(Schema):
345
- sum: float
346
-
347
-
348
- class ExampleSchemaIn(Schema):
349
- n1: float
350
- n2: float
351
-
352
-
353
- class SumView(APIView):
354
- api = api
355
- api_router_path = "numbers-sum/"
356
- router_tag = "Sum"
357
-
358
- def views(self):
359
- @self.router.post("/", response={200: ExampleSchemaOut})
360
- async def sum(request: HttpRequest, data: ExampleSchemaIn):
361
- return 200, {sum: data.n1 + data.n2}
362
-
363
-
364
- SumView().add_views_to_route()
365
- ```
366
-
367
- ### Relations
368
- - You can also set ForeignKey, OneToOne and ManyToMany relations into serialization(reverse relations are supported too). Django ninja aio crud will serialize every of these relation automatically.
369
-
370
- - Define models:
371
-
372
- ```python
373
- # models.py
374
- class Bar(ModelSerializer):
375
- name = models.CharField(max_length=30)
376
- description = models.TextField(max_length=30)
377
-
378
- # ReadSerializer with reverse OneToMany relation (foos)
379
- class ReadSerializer:
380
- fields = ["id", "name", "description", "foos"]
381
-
382
- class CreateSerializer:
383
- fields = ["name", "description"]
384
-
385
- class UpdateSerializer:
386
- fields = ["name", "description"]
387
-
388
-
389
- class Foo(ModelSerializer):
390
- name = models.CharField(max_length=30)
391
- bar = models.ForeignKey(Bar, on_delete=models.CASCADE, related_name="foos")
392
-
393
- class ReadSerializer:
394
- fields = ["id", "name", "bar"]
395
-
396
- class CreateSerializer:
397
- fields = ["name", "bar"]
398
-
399
- class UpdateSerializer:
400
- fields = ["name"]
401
- ```
402
-
403
- - Define views:
404
-
405
- ```python
406
- # views.py
407
- from ninja_aio import NinjaAIO
408
- from ninja_aio.views import APIViewSet
409
-
410
- from .models import Foo, Bar
411
-
412
- api = NinjaAIO()
413
-
414
-
415
- class FooAPI(APIViewSet):
416
- model = Foo
417
- api = api
418
-
419
-
420
- class BarAPI(APIViewSet):
421
- model = Bar
422
- api = api
423
-
424
-
425
- FooAPI().add_views_to_route()
426
- BarAPI().add_views_to_route()
427
- ```
428
-
429
- - Now run your server and go to /docs url:
430
-
431
- ### Docs
432
-
433
- - Foo Schemas
434
-
435
- ![Swagger UI](docs/images/foo-swagger.png)
436
-
437
- - Bar Schemas with reverse relation
438
-
439
- ![Swagger UI](docs/images/bar-swagger.png)
440
-
441
- ## 🔒 Authentication
442
-
443
- ### Jwt
444
-
445
- - AsyncJWTBearer built-in class is an authenticator class which use joserfc module. It cames out with authenticate method which validate given claims. Override auth handler method to write your own authentication method. Default algorithms used is RS256. a jwt Token istance is set as class atribute so you can use it by self.dcd.
446
-
447
- ```python
448
- from ninja_aio.auth import AsyncJWTBearer
449
- from django.conf import settings
450
- from django.http import HttpRequest
451
-
452
- from .models import Foo
453
-
454
-
455
- class CustomJWTBearer(AsyncJWTBearer):
456
- jwt_public = settings.JWT_PUBLIC
457
- claims = {"foo_id": {"essential": True}}
458
-
459
- async def auth_handler(self, request: HttpRequest):
460
- try:
461
- request.user = await Foo.objects.aget(id=self.dcd.claims["foo_id"])
462
- except Foo.DoesNotExist:
463
- return None
464
- return request.user
465
- ```
466
-
467
- - Then add it to views.
468
-
469
- ```python
470
- # views.py
471
- from ninja import Schema
472
- from ninja_aio import NinjaAIO
473
- from ninja_aio.views import APIViewSet, APIView
474
-
475
- from .models import Foo
476
-
477
- api = NinjaAIO()
478
-
479
-
480
- class FooAPI(APIViewSet):
481
- model = Foo
482
- api = api
483
- auth = CustomJWTBearer()
484
-
485
-
486
- class ExampleSchemaOut(Schema):
487
- sum: float
488
-
489
-
490
- class ExampleSchemaIn(Schema):
491
- n1: float
492
- n2: float
493
-
494
-
495
- class SumView(APIView):
496
- api = api
497
- api_router_path = "numbers-sum/"
498
- router_tag = "Sum"
499
- auth = CustomJWTBearer()
500
-
501
- def views(self):
502
- @self.router.post("/", response={200: ExampleSchemaOut}, auth=self.auth)
503
- async def sum(request: HttpRequest, data: ExampleSchemaIn):
504
- return 200, {sum: data.n1 + data.n2}
505
-
506
-
507
- FooAPI().add_views_to_route()
508
- SumView().add_views_to_route()
509
- ```
510
-
511
- ## 📝 Pagination
512
-
513
- - By default APIViewSet list view uses Django Ninja built-in AsyncPagination class "PageNumberPagination". You can customize and assign it to APIViewSet class. To make your custom pagination consult **<a href="https://django-ninja.dev/guides/response/pagination/#async-pagination">Django Ninja pagination documentation</a>**.
514
-
515
- ```python
516
- # views.py
517
-
518
- class FooAPI(APIViewSet):
519
- model = Foo
520
- api = api
521
- pagination_class = CustomPaginationClass
522
-
523
- ```
524
-
525
- ## 📌 Notes
526
- - Feel free to contribute and improve the program. 🛠️
527
-