django-ninja-aio-crud 2.15.1__tar.gz → 2.16.1__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.

Potentially problematic release.


This version of django-ninja-aio-crud might be problematic. Click here for more details.

Files changed (104) hide show
  1. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/.github/workflows/docs.yml +2 -0
  2. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/PKG-INFO +1 -1
  3. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/views/api_view_set.md +40 -0
  4. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/views/decorators.md +17 -0
  5. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/getting_started/quick_start.md +9 -7
  6. django_ninja_aio_crud-2.16.1/docs/getting_started/quick_start_serializer.md +220 -0
  7. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/mkdocs.yml +2 -1
  8. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/__init__.py +1 -1
  9. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/exceptions.py +1 -1
  10. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/helpers/api.py +11 -1
  11. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/models/serializers.py +11 -2
  12. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/schemas/helpers.py +8 -2
  13. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/helpers/test_many_to_many_api.py +155 -0
  14. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_serializers.py +155 -0
  15. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/.github/dependabot.yml +0 -0
  16. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/.github/workflows/coverage.yml +0 -0
  17. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/.github/workflows/publish.yml +0 -0
  18. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/.gitignore +0 -0
  19. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/.pre-commit-config.yaml +0 -0
  20. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/LICENSE +0 -0
  21. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/README.md +0 -0
  22. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/CNAME +0 -0
  23. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/authentication.md +0 -0
  24. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/models/model_serializer.md +0 -0
  25. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/models/model_util.md +0 -0
  26. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/models/serializers.md +0 -0
  27. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/pagination.md +0 -0
  28. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/renderers/orjson_renderer.md +0 -0
  29. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/views/api_view.md +0 -0
  30. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/api/views/mixins.md +0 -0
  31. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/auth.md +0 -0
  32. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/contributing.md +0 -0
  33. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/extra.css +0 -0
  34. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/getting_started/images/index/foo-index-create-swagger.png +0 -0
  35. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/getting_started/images/index/foo-index-delete-swagger.png +0 -0
  36. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/getting_started/images/index/foo-index-list-swagger.png +0 -0
  37. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/getting_started/images/index/foo-index-retrieve-swagger.png +0 -0
  38. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/getting_started/images/index/foo-index-swagger.png +0 -0
  39. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/getting_started/images/index/foo-index-update-swagger.png +0 -0
  40. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/getting_started/installation.md +0 -0
  41. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/images/bar-swagger.png +0 -0
  42. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/images/favicon.ico +0 -0
  43. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/images/foo-swagger.png +0 -0
  44. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/images/logo.png +0 -0
  45. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/images/model_util/foo-reverse-relations-swagger.png +0 -0
  46. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/index.md +0 -0
  47. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/release_notes.md +0 -0
  48. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/requirements.txt +0 -0
  49. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/tutorial/authentication.md +0 -0
  50. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/tutorial/crud.md +0 -0
  51. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/tutorial/filtering.md +0 -0
  52. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/docs/tutorial/model.md +0 -0
  53. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/main.py +0 -0
  54. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/api.py +0 -0
  55. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/auth.py +0 -0
  56. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/decorators/__init__.py +0 -0
  57. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/decorators/operations.py +0 -0
  58. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/decorators/views.py +0 -0
  59. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/factory/__init__.py +0 -0
  60. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/factory/operations.py +0 -0
  61. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/helpers/__init__.py +0 -0
  62. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/helpers/query.py +0 -0
  63. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/models/__init__.py +0 -0
  64. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/models/utils.py +0 -0
  65. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/parsers.py +0 -0
  66. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/renders.py +0 -0
  67. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/schemas/__init__.py +0 -0
  68. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/schemas/api.py +0 -0
  69. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/schemas/filters.py +0 -0
  70. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/schemas/generics.py +0 -0
  71. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/types.py +0 -0
  72. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/views/__init__.py +0 -0
  73. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/views/api.py +0 -0
  74. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/ninja_aio/views/mixins.py +0 -0
  75. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/pyproject.toml +0 -0
  76. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/requirements.dev.txt +0 -0
  77. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/run-local-coverage.sh +0 -0
  78. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/__init__.py +0 -0
  79. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/core/__init__.py +0 -0
  80. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/core/test_decorators.py +0 -0
  81. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/core/test_exceptions_api.py +0 -0
  82. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/core/test_renderer_parser.py +0 -0
  83. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/generics/__init__.py +0 -0
  84. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/generics/literals.py +0 -0
  85. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/generics/models.py +0 -0
  86. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/generics/request.py +0 -0
  87. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/generics/views.py +0 -0
  88. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/helpers/__init__.py +0 -0
  89. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/models/__init__.py +0 -0
  90. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/models/test_model_util.py +0 -0
  91. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/models/test_models_extra.py +0 -0
  92. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_app/__init__.py +0 -0
  93. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_app/models.py +0 -0
  94. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_app/schema.py +0 -0
  95. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_app/serializers.py +0 -0
  96. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_app/views.py +0 -0
  97. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_auth.py +0 -0
  98. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_decorators.py +0 -0
  99. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_exceptions.py +0 -0
  100. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_query_util.py +0 -0
  101. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/test_settings.py +0 -0
  102. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/views/__init__.py +0 -0
  103. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/views/test_views.py +0 -0
  104. {django_ninja_aio_crud-2.15.1 → django_ninja_aio_crud-2.16.1}/tests/views/test_viewset.py +0 -0
@@ -28,6 +28,7 @@ on:
28
28
  - "2.13"
29
29
  - "2.14"
30
30
  - "2.15"
31
+ - "2.16"
31
32
  make_latest:
32
33
  description: 'Set as "latest" and default?'
33
34
  type: boolean
@@ -59,6 +60,7 @@ on:
59
60
  - "2.13"
60
61
  - "2.14"
61
62
  - "2.15"
63
+ - "2.16"
62
64
  delete_confirm:
63
65
  description: 'Confirm deletion of the selected version'
64
66
  type: boolean
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.15.1
3
+ Version: 2.16.1
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -294,6 +294,8 @@ Relations are declared via `M2MRelationSchema` objects (not tuples). Each schema
294
294
  - `serializer_class`: optional `Serializer` class for plain Django models. When provided, `related_schema` is auto-generated from the serializer. Cannot be used when `model` is a `ModelSerializer`.
295
295
  - `append_slash`: bool to control trailing slash for the GET relation endpoint path. Defaults to `False` (no trailing slash) for backward compatibility. When `True`, the GET path ends with a trailing slash.
296
296
  - `verbose_name_plural`: optional human-readable plural name for the related model, used in endpoint summaries and descriptions. When not provided, defaults to the related model's `_meta.verbose_name_plural`.
297
+ - `get_decorators`: optional list of decorators to apply to the GET (list related objects) endpoint. Decorators are applied via `decorate_view()` alongside built-in decorators like `unique_view` and `paginate`.
298
+ - `post_decorators`: optional list of decorators to apply to the POST (add/remove) endpoint. Decorators are applied via `decorate_view()` alongside the built-in `unique_view` decorator.
297
299
 
298
300
  If `path` is empty it falls back to the related model verbose name (lowercase plural).
299
301
  If `filters` is provided, a per-relation filters schema is auto-generated and exposed on the GET relation endpoint:
@@ -489,6 +491,44 @@ M2MRelationSchema(
489
491
  )
490
492
  ```
491
493
 
494
+ Example with custom decorators:
495
+
496
+ ```python
497
+ from functools import wraps
498
+
499
+ def cache_response(timeout=60):
500
+ """Cache decorator for GET endpoints."""
501
+ def decorator(func):
502
+ @wraps(func)
503
+ async def wrapper(*args, **kwargs):
504
+ # caching logic here
505
+ return await func(*args, **kwargs)
506
+ return wrapper
507
+ return decorator
508
+
509
+ def rate_limit(max_calls=100):
510
+ """Rate limiting decorator for POST endpoints."""
511
+ def decorator(func):
512
+ @wraps(func)
513
+ async def wrapper(*args, **kwargs):
514
+ # rate limiting logic here
515
+ return await func(*args, **kwargs)
516
+ return wrapper
517
+ return decorator
518
+
519
+ M2MRelationSchema(
520
+ model=Tag,
521
+ related_name="tags",
522
+ get_decorators=[cache_response(timeout=300)], # Cache GET for 5 minutes
523
+ post_decorators=[rate_limit(max_calls=50)], # Rate limit POST operations
524
+ add=True,
525
+ remove=True,
526
+ get=True,
527
+ )
528
+ ```
529
+
530
+ Note: Decorators are applied in addition to built-in decorators (`unique_view`, `paginate`). The order follows standard Python decorator stacking: decorators listed first are applied outermost.
531
+
492
532
  ## Custom Views
493
533
 
494
534
  Preferred (decorators): see the section above.
@@ -63,6 +63,23 @@ class MyViewSet(APIViewSet):
63
63
 
64
64
  These are applied in combination with built-ins (e.g., unique_view, paginate) using decorate_view in the implementation.
65
65
 
66
+ ## M2MRelationSchema decorators
67
+
68
+ Apply custom decorators to Many-to-Many relation endpoints via `get_decorators` and `post_decorators`:
69
+
70
+ ```python
71
+ from ninja_aio.schemas import M2MRelationSchema
72
+
73
+ M2MRelationSchema(
74
+ model=Tag,
75
+ related_name="tags",
76
+ get_decorators=[cache_decorator, log_decorator], # Applied to GET (list related)
77
+ post_decorators=[rate_limit_decorator], # Applied to POST (add/remove)
78
+ )
79
+ ```
80
+
81
+ These decorators are applied alongside built-in decorators (`unique_view`, `paginate`) using `decorate_view`. See [APIViewSet M2M Relations](api_view_set.md#many-to-many-relations) for more details.
82
+
66
83
  ## ApiMethodFactory.decorators
67
84
 
68
85
  Example: use api_get within a ViewSet with extra decorators:
@@ -1,8 +1,13 @@
1
- ## 🚀 Quick Start
1
+ ## 🚀 Quick Start (ModelSerializer)
2
+
3
+ This guide shows how to create a CRUD API using `ModelSerializer`, which combines your Django model and serialization configuration in a single class.
4
+
5
+ !!! tip "Alternative Approach"
6
+ If you prefer to keep your models unchanged and define serialization separately, see [Quick Start (Serializer)](quick_start_serializer.md).
2
7
 
3
8
  ### 1. Create Your Model
4
9
 
5
- Define your model using `ModelSerializer`:
10
+ Define your model using `ModelSerializer` with embedded serializer configuration:
6
11
 
7
12
  ```python
8
13
  # models.py
@@ -44,12 +49,9 @@ from .models import Article
44
49
  api = NinjaAIO(title="My Blog API", version="1.0.0")
45
50
 
46
51
 
52
+ @api.viewset(model=Article)
47
53
  class ArticleViewSet(APIViewSet):
48
- model = Article
49
- api = api
50
-
51
-
52
- ArticleViewSet().add_views_to_route()
54
+ pass
53
55
  ```
54
56
 
55
57
  ### 3. Configure URLs
@@ -0,0 +1,220 @@
1
+ ## 🚀 Quick Start (Serializer)
2
+
3
+ This guide shows how to create a CRUD API using the `Serializer` class with plain Django models. This approach keeps your models unchanged and defines API configuration separately.
4
+
5
+ !!! tip "Alternative Approach"
6
+ If you prefer an all-in-one approach with embedded serialization, see [Quick Start (ModelSerializer)](quick_start.md).
7
+
8
+ ### When to Use Serializer
9
+
10
+ Choose the `Serializer` approach when:
11
+
12
+ - You have existing Django models you don't want to modify
13
+ - You want to keep models and API concerns separated
14
+ - You're adding API functionality to an existing project
15
+ - Multiple teams work on models vs. API layers
16
+
17
+ ### 1. Create Your Model
18
+
19
+ Use a standard Django model:
20
+
21
+ ```python
22
+ # models.py
23
+ from django.db import models
24
+
25
+
26
+ class Article(models.Model):
27
+ title = models.CharField(max_length=200)
28
+ content = models.TextField()
29
+ is_published = models.BooleanField(default=False)
30
+ created_at = models.DateTimeField(auto_now_add=True)
31
+
32
+ class Meta:
33
+ ordering = ["-created_at"]
34
+ ```
35
+
36
+ ### 2. Create Your Serializer
37
+
38
+ Define a `Serializer` class with API configuration in a nested `Meta` class:
39
+
40
+ ```python
41
+ # serializers.py
42
+ from ninja_aio.models import serializers
43
+ from .models import Article
44
+
45
+
46
+ class ArticleSerializer(serializers.Serializer):
47
+ class Meta:
48
+ model = Article
49
+ schema_in = serializers.SchemaModelConfig(
50
+ fields=["title", "content"],
51
+ optionals=[("is_published", bool)],
52
+ )
53
+ schema_out = serializers.SchemaModelConfig(
54
+ fields=["id", "title", "content", "is_published", "created_at"]
55
+ )
56
+ schema_update = serializers.SchemaModelConfig(
57
+ optionals=[
58
+ ("title", str),
59
+ ("content", str),
60
+ ("is_published", bool),
61
+ ]
62
+ )
63
+ ```
64
+
65
+ ### 3. Create Your ViewSet
66
+
67
+ Define your API views using `APIViewSet` with `serializer_class`:
68
+
69
+ ```python
70
+ # views.py
71
+ from ninja_aio import NinjaAIO
72
+ from ninja_aio.views import APIViewSet
73
+ from .models import Article
74
+ from .serializers import ArticleSerializer
75
+
76
+ api = NinjaAIO(title="My Blog API", version="1.0.0")
77
+
78
+
79
+ @api.viewset(model=Article)
80
+ class ArticleViewSet(APIViewSet):
81
+ serializer_class = ArticleSerializer
82
+ ```
83
+
84
+ ### 4. Configure URLs
85
+
86
+ Add the API to your URL configuration:
87
+
88
+ ```python
89
+ # urls.py
90
+ from django.urls import path
91
+ from .views import api
92
+
93
+ urlpatterns = [
94
+ path("api/", api.urls),
95
+ ]
96
+ ```
97
+
98
+ ### 5. Run Your Server
99
+
100
+ ```bash
101
+ python manage.py runserver
102
+ ```
103
+
104
+ Visit **[http://localhost:8000/api/docs](http://localhost:8000/api/docs)** to see your auto-generated API documentation!
105
+
106
+ ## Adding Relationships
107
+
108
+ The `Serializer` approach supports nested serialization for related models:
109
+
110
+ ```python
111
+ # models.py
112
+ from django.db import models
113
+
114
+
115
+ class Author(models.Model):
116
+ name = models.CharField(max_length=200)
117
+ email = models.EmailField()
118
+
119
+
120
+ class Article(models.Model):
121
+ title = models.CharField(max_length=200)
122
+ content = models.TextField()
123
+ author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="articles")
124
+ is_published = models.BooleanField(default=False)
125
+ created_at = models.DateTimeField(auto_now_add=True)
126
+ ```
127
+
128
+ ```python
129
+ # serializers.py
130
+ from ninja_aio.models import serializers
131
+ from .models import Author, Article
132
+
133
+
134
+ class AuthorSerializer(serializers.Serializer):
135
+ class Meta:
136
+ model = Author
137
+ schema_out = serializers.SchemaModelConfig(
138
+ fields=["id", "name", "email"]
139
+ )
140
+
141
+
142
+ class ArticleSerializer(serializers.Serializer):
143
+ class Meta:
144
+ model = Article
145
+ schema_in = serializers.SchemaModelConfig(
146
+ fields=["title", "content", "author"],
147
+ )
148
+ schema_out = serializers.SchemaModelConfig(
149
+ fields=["id", "title", "content", "author", "is_published", "created_at"]
150
+ )
151
+ schema_update = serializers.SchemaModelConfig(
152
+ optionals=[
153
+ ("title", str),
154
+ ("content", str),
155
+ ("is_published", bool),
156
+ ]
157
+ )
158
+ # Nested serialization for the author field
159
+ relations_serializers = {
160
+ "author": AuthorSerializer,
161
+ }
162
+
163
+ class QuerySet:
164
+ # Optimize queries with select_related
165
+ read = serializers.ModelQuerySetSchema(
166
+ select_related=["author"]
167
+ )
168
+ ```
169
+
170
+ ## Adding Lifecycle Hooks
171
+
172
+ Add custom logic to CRUD operations using hooks:
173
+
174
+ ```python
175
+ # serializers.py
176
+ from ninja_aio.models import serializers
177
+ from .models import Article
178
+
179
+
180
+ class ArticleSerializer(serializers.Serializer):
181
+ class Meta:
182
+ model = Article
183
+ schema_in = serializers.SchemaModelConfig(
184
+ fields=["title", "content"],
185
+ customs=[("notify_subscribers", bool, False)], # Custom field
186
+ )
187
+ schema_out = serializers.SchemaModelConfig(
188
+ fields=["id", "title", "content", "is_published", "created_at"]
189
+ )
190
+ schema_update = serializers.SchemaModelConfig(
191
+ optionals=[("title", str), ("content", str), ("is_published", bool)]
192
+ )
193
+
194
+ async def custom_actions(self, payload, instance):
195
+ """Execute after field assignment, before save."""
196
+ if payload.get("notify_subscribers"):
197
+ await send_notification(instance.title)
198
+
199
+ async def post_create(self, instance):
200
+ """Execute after instance creation."""
201
+ await AuditLog.objects.acreate(
202
+ action="article_created",
203
+ article_id=instance.id
204
+ )
205
+
206
+ def before_save(self, instance):
207
+ """Execute before any save (sync hook)."""
208
+ instance.slug = slugify(instance.title)
209
+
210
+ def on_delete(self, instance):
211
+ """Execute after deletion (sync hook)."""
212
+ logger.info(f"Article {instance.id} deleted")
213
+ ```
214
+
215
+ ## Next Steps
216
+
217
+ - Learn more about [Serializer configuration](../api/models/serializers.md)
218
+ - Explore [APIViewSet features](../api/views/api_view_set.md)
219
+ - Add [Authentication](../api/authentication.md) to your API
220
+ - Configure [Pagination](../api/pagination.md)
@@ -9,7 +9,8 @@ nav:
9
9
  - Home: index.md
10
10
  - Getting Started:
11
11
  - Installation: getting_started/installation.md
12
- - Quick Start: getting_started/quick_start.md
12
+ - Quick Start (ModelSerializer): getting_started/quick_start.md
13
+ - Quick Start (Serializer): getting_started/quick_start_serializer.md
13
14
  - Tutorial:
14
15
  - 'Step 1: Define Your Model': tutorial/model.md
15
16
  - 'Step 2: Create CRUD Views': tutorial/crud.md
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.15.1"
3
+ __version__ = "2.16.1"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -78,7 +78,7 @@ def _default_error(
78
78
 
79
79
 
80
80
  def _pydantic_validation_error(
81
- request: HttpRequest, exc: ValidationError, api: type[NinjaAPI]
81
+ request: HttpRequest, exc: PydanticValidationError, api: type[NinjaAPI]
82
82
  ) -> HttpResponse:
83
83
  """Translate a pydantic ValidationError into a normalized API error response."""
84
84
  error = PydanticValidationError(exc.errors(include_input=False))
@@ -359,6 +359,7 @@ class ManyToManyAPI:
359
359
  filters_schema,
360
360
  append_slash: bool,
361
361
  verbose_name_plural: str,
362
+ decorators: list,
362
363
  ):
363
364
  @self.router.get(
364
365
  self._get_api_path(rel_path, append_slash=append_slash),
@@ -373,6 +374,7 @@ class ManyToManyAPI:
373
374
  @decorate_view(
374
375
  unique_view(f"get_{self.related_model_util.model_name}_{rel_path}"),
375
376
  paginate(self.pagination_class),
377
+ *decorators,
376
378
  )
377
379
  async def get_related(
378
380
  request: HttpRequest,
@@ -407,6 +409,7 @@ class ManyToManyAPI:
407
409
  m2m_add: bool,
408
410
  m2m_remove: bool,
409
411
  verbose_name_plural: str,
412
+ decorators: list,
410
413
  ):
411
414
  action, schema_in = self._resolve_action_schema(m2m_add, m2m_remove)
412
415
  plural = verbose_name_plural
@@ -422,7 +425,10 @@ class ManyToManyAPI:
422
425
  summary=summary,
423
426
  description=summary,
424
427
  )
425
- @unique_view(f"manage_{self.related_model_util.model_name}_{rel_path}")
428
+ @decorate_view(
429
+ unique_view(f"manage_{self.related_model_util.model_name}_{rel_path}"),
430
+ *decorators,
431
+ )
426
432
  async def manage_related(
427
433
  request: HttpRequest,
428
434
  pk: Path[self.path_schema], # type: ignore
@@ -483,6 +489,8 @@ class ManyToManyAPI:
483
489
  relation.verbose_name_plural
484
490
  or rel_util.model._meta.verbose_name_plural.capitalize()
485
491
  )
492
+ get_decorators = relation.get_decorators or []
493
+ post_decorators = relation.post_decorators or []
486
494
 
487
495
  if m2m_get:
488
496
  self._register_get_relation_view(
@@ -494,6 +502,7 @@ class ManyToManyAPI:
494
502
  filters_schema=filters_schema,
495
503
  append_slash=append_slash,
496
504
  verbose_name_plural=verbose_name_plural,
505
+ decorators=get_decorators,
497
506
  )
498
507
 
499
508
  if m2m_add or m2m_remove:
@@ -505,6 +514,7 @@ class ManyToManyAPI:
505
514
  m2m_add=m2m_add,
506
515
  m2m_remove=m2m_remove,
507
516
  verbose_name_plural=verbose_name_plural,
517
+ decorators=post_decorators,
508
518
  )
509
519
 
510
520
  def _add_views(self):
@@ -669,9 +669,18 @@ class BaseSerializer:
669
669
  customs = cls.get_custom_fields(s_type) + optionals
670
670
  excludes = cls.get_excluded_fields(s_type)
671
671
 
672
- # If no explicit fields but have optionals, use optional field names as fields
672
+ # If no explicit fields and no excludes specified
673
673
  if not fields and not excludes:
674
- fields = [f[0] for f in optionals]
674
+ if optionals:
675
+ # Use optional field names as the fields to include
676
+ fields = [f[0] for f in optionals]
677
+ elif customs:
678
+ # Only customs defined - exclude all model fields to prevent auto-inclusion
679
+ excludes = [
680
+ f.name
681
+ for f in model._meta.get_fields()
682
+ if getattr(f, "concrete", False)
683
+ ]
675
684
 
676
685
  # Only create schema if we have something to include
677
686
  if not any([fields, customs, excludes]):
@@ -1,4 +1,4 @@
1
- from typing import List, Optional, Type
1
+ from typing import Callable, List, Optional, Type
2
2
 
3
3
  from ninja import Schema
4
4
  from ninja_aio.types import ModelSerializerMeta, SerializerMeta
@@ -46,7 +46,11 @@ class M2MRelationSchema(BaseModel):
46
46
  Whether to append a trailing slash to the generated GET endpoint path. Defaults to False for backward compatibility.
47
47
  verbose_name_plural (str | None):
48
48
  Optional human-readable plural name for the related model, used in documentation and responses.
49
-
49
+ get_decorators (List | None):
50
+ Optional list of decorators to apply to the GET endpoint.
51
+ post_decorators (List | None):
52
+ Optional list of decorators to apply to the POST (add/remove) endpoints.
53
+
50
54
  Validation:
51
55
  - If `model` is not a ModelSerializerMeta, `related_schema` is required.
52
56
  - When `model` is a ModelSerializerMeta and `related_schema` is not provided, it will be
@@ -72,6 +76,8 @@ class M2MRelationSchema(BaseModel):
72
76
  serializer_class: Optional[SerializerMeta] = None
73
77
  append_slash: bool = False
74
78
  verbose_name_plural: Optional[str] = None
79
+ get_decorators: Optional[List[Callable]] = []
80
+ post_decorators: Optional[List[Callable]] = []
75
81
 
76
82
  model_config = ConfigDict(arbitrary_types_allowed=True)
77
83
 
@@ -280,3 +280,158 @@ class M2MRelationSchemaValidationTestCase(TestCase):
280
280
  serializer_class=TestModelReverseManyToManySerializer,
281
281
  )
282
282
  self.assertEqual(schema.related_schema, CustomSchema)
283
+
284
+
285
+ # Decorator tracking for tests
286
+ _decorator_calls = {"get": 0, "post": 0}
287
+
288
+
289
+ def track_get_decorator(func):
290
+ """Decorator that tracks GET endpoint calls."""
291
+ from functools import wraps
292
+
293
+ @wraps(func)
294
+ async def wrapper(*args, **kwargs):
295
+ _decorator_calls["get"] += 1
296
+ return await func(*args, **kwargs)
297
+
298
+ return wrapper
299
+
300
+
301
+ def track_post_decorator(func):
302
+ """Decorator that tracks POST endpoint calls."""
303
+ from functools import wraps
304
+
305
+ @wraps(func)
306
+ async def wrapper(*args, **kwargs):
307
+ _decorator_calls["post"] += 1
308
+ return await func(*args, **kwargs)
309
+
310
+ return wrapper
311
+
312
+
313
+ class TestM2MWithDecoratorsViewSet(GenericAPIViewSet):
314
+ """ViewSet using M2M relations with custom decorators."""
315
+
316
+ model = models.TestModelSerializerManyToMany
317
+ m2m_relations = [
318
+ M2MRelationSchema(
319
+ model=models.TestModelSerializerReverseManyToMany,
320
+ related_name="test_model_serializers",
321
+ filters={"name": (str, "")},
322
+ get_decorators=[track_get_decorator],
323
+ post_decorators=[track_post_decorator],
324
+ append_slash=True,
325
+ )
326
+ ]
327
+
328
+ def test_model_serializers_query_params_handler(self, queryset, filters):
329
+ name_filter = filters.get("name")
330
+ if name_filter:
331
+ queryset = queryset.filter(name=name_filter)
332
+ return queryset
333
+
334
+
335
+ @tag("many_to_many_api", "decorators")
336
+ class M2MRelationSchemaDecoratorsTestCase(Tests.BaseManyToManyAPITestCase):
337
+ """Test M2M API with custom decorators applied to GET and POST endpoints."""
338
+
339
+ viewset_class = TestM2MWithDecoratorsViewSet
340
+ related_model = models.TestModelSerializerReverseManyToMany
341
+ related_name = "test_model_serializers"
342
+ api_namespace = "m2m_decorators_test"
343
+ related_names = ["dec_a", "dec_b", "dec_c"]
344
+
345
+ def setUp(self):
346
+ super().setUp()
347
+ # Reset decorator call counts before each test
348
+ _decorator_calls["get"] = 0
349
+ _decorator_calls["post"] = 0
350
+
351
+ async def test_get_decorator_is_applied(self):
352
+ """Test that get_decorators are applied to the GET endpoint."""
353
+ # First add some related objects
354
+ await self._add_related()
355
+ initial_get_count = _decorator_calls["get"]
356
+
357
+ # Call the GET endpoint
358
+ await self.get_view(request=self.request.get(), pk=self.path_schema)
359
+
360
+ # Verify the get decorator was called
361
+ self.assertEqual(
362
+ _decorator_calls["get"],
363
+ initial_get_count + 1,
364
+ "GET decorator should have been called once",
365
+ )
366
+
367
+ async def test_post_decorator_is_applied(self):
368
+ """Test that post_decorators are applied to the POST endpoint."""
369
+ initial_post_count = _decorator_calls["post"]
370
+
371
+ # Call the POST endpoint to add related objects
372
+ await self._add_related()
373
+
374
+ # Verify the post decorator was called
375
+ self.assertEqual(
376
+ _decorator_calls["post"],
377
+ initial_post_count + 1,
378
+ "POST decorator should have been called once",
379
+ )
380
+
381
+ async def test_decorators_independent(self):
382
+ """Test that GET and POST decorators are applied independently."""
383
+ # Reset counts
384
+ _decorator_calls["get"] = 0
385
+ _decorator_calls["post"] = 0
386
+
387
+ # Call POST endpoint
388
+ await self._add_related()
389
+ self.assertEqual(_decorator_calls["post"], 1)
390
+ self.assertEqual(_decorator_calls["get"], 0)
391
+
392
+ # Call GET endpoint
393
+ await self.get_view(request=self.request.get(), pk=self.path_schema)
394
+ self.assertEqual(_decorator_calls["post"], 1) # Still 1
395
+ self.assertEqual(_decorator_calls["get"], 1)
396
+
397
+
398
+ @tag("many_to_many_api", "decorators", "schema")
399
+ class M2MRelationSchemaDecoratorsFieldTestCase(TestCase):
400
+ """Test cases for M2MRelationSchema decorator fields."""
401
+
402
+ def test_decorators_default_to_empty_list(self):
403
+ """Test that decorators default to empty lists."""
404
+ schema = M2MRelationSchema(
405
+ model=models.TestModelSerializerReverseManyToMany,
406
+ related_name="test_model_serializers",
407
+ )
408
+ self.assertEqual(schema.get_decorators, [])
409
+ self.assertEqual(schema.post_decorators, [])
410
+
411
+ def test_decorators_accept_list_of_callables(self):
412
+ """Test that decorators accept a list of callables."""
413
+
414
+ def custom_decorator(func):
415
+ return func
416
+
417
+ schema = M2MRelationSchema(
418
+ model=models.TestModelSerializerReverseManyToMany,
419
+ related_name="test_model_serializers",
420
+ get_decorators=[custom_decorator],
421
+ post_decorators=[custom_decorator, custom_decorator],
422
+ )
423
+ self.assertEqual(len(schema.get_decorators), 1)
424
+ self.assertEqual(len(schema.post_decorators), 2)
425
+ self.assertEqual(schema.get_decorators[0], custom_decorator)
426
+
427
+ def test_decorators_can_be_none(self):
428
+ """Test that decorators can explicitly be set to None."""
429
+ schema = M2MRelationSchema(
430
+ model=models.TestModelSerializerReverseManyToMany,
431
+ related_name="test_model_serializers",
432
+ get_decorators=None,
433
+ post_decorators=None,
434
+ )
435
+ # None values should be accepted
436
+ self.assertIsNone(schema.get_decorators)
437
+ self.assertIsNone(schema.post_decorators)
@@ -1227,6 +1227,161 @@ class RelationsAsIdStringPKModelSerializerTestCase(TestCase):
1227
1227
  )
1228
1228
 
1229
1229
 
1230
+ @tag("serializers", "customs_only")
1231
+ class CustomsOnlySchemaTestCase(TestCase):
1232
+ """Test cases for schemas with only customs/optionals defined (no fields/excludes)."""
1233
+
1234
+ def setUp(self):
1235
+ warnings.simplefilter("ignore", UserWarning)
1236
+
1237
+ def test_serializer_create_schema_with_only_customs(self):
1238
+ """Test that create schema with only customs does NOT include model fields."""
1239
+
1240
+ class CustomsOnlyCreateSerializer(serializers.Serializer):
1241
+ class Meta:
1242
+ model = TestModelForeignKey
1243
+ schema_in = serializers.SchemaModelConfig(
1244
+ customs=[("custom_input", str, ...)]
1245
+ )
1246
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1247
+
1248
+ schema = CustomsOnlyCreateSerializer.generate_create_s()
1249
+ self.assertIsNotNone(schema)
1250
+ # Should only have the custom field, not model fields
1251
+ self.assertIn("custom_input", schema.model_fields)
1252
+ # Should NOT have model fields auto-included
1253
+ self.assertNotIn("name", schema.model_fields)
1254
+ self.assertNotIn("description", schema.model_fields)
1255
+ self.assertNotIn("test_model", schema.model_fields)
1256
+ self.assertNotIn("id", schema.model_fields)
1257
+
1258
+ def test_serializer_update_schema_with_only_customs(self):
1259
+ """Test that update schema with only customs does NOT include model fields."""
1260
+
1261
+ class CustomsOnlyUpdateSerializer(serializers.Serializer):
1262
+ class Meta:
1263
+ model = TestModelForeignKey
1264
+ schema_update = serializers.SchemaModelConfig(
1265
+ customs=[("custom_patch_field", str, None)]
1266
+ )
1267
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1268
+
1269
+ schema = CustomsOnlyUpdateSerializer.generate_update_s()
1270
+ self.assertIsNotNone(schema)
1271
+ # Should only have the custom field
1272
+ self.assertIn("custom_patch_field", schema.model_fields)
1273
+ # Should NOT have model fields auto-included
1274
+ self.assertNotIn("name", schema.model_fields)
1275
+ self.assertNotIn("description", schema.model_fields)
1276
+ self.assertNotIn("test_model", schema.model_fields)
1277
+ self.assertNotIn("id", schema.model_fields)
1278
+
1279
+ def test_serializer_create_schema_with_customs_and_optionals(self):
1280
+ """Test create schema with customs + optionals includes only those fields."""
1281
+
1282
+ class CustomsAndOptionalsSerializer(serializers.Serializer):
1283
+ class Meta:
1284
+ model = TestModelForeignKey
1285
+ schema_in = serializers.SchemaModelConfig(
1286
+ customs=[("custom_field", str, ...)],
1287
+ optionals=[("name", str)], # name is a model field made optional
1288
+ )
1289
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1290
+
1291
+ schema = CustomsAndOptionalsSerializer.generate_create_s()
1292
+ self.assertIsNotNone(schema)
1293
+ # Should have custom field
1294
+ self.assertIn("custom_field", schema.model_fields)
1295
+ # Should have the optional model field
1296
+ self.assertIn("name", schema.model_fields)
1297
+ # Should NOT have other model fields
1298
+ self.assertNotIn("description", schema.model_fields)
1299
+ self.assertNotIn("test_model", schema.model_fields)
1300
+ self.assertNotIn("id", schema.model_fields)
1301
+
1302
+ def test_serializer_with_fields_still_works(self):
1303
+ """Test that defining fields still works as expected."""
1304
+
1305
+ class FieldsDefinedSerializer(serializers.Serializer):
1306
+ class Meta:
1307
+ model = TestModelForeignKey
1308
+ schema_in = serializers.SchemaModelConfig(
1309
+ fields=["name", "description"],
1310
+ customs=[("extra", int, 0)],
1311
+ )
1312
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1313
+
1314
+ schema = FieldsDefinedSerializer.generate_create_s()
1315
+ self.assertIsNotNone(schema)
1316
+ # Should have specified fields
1317
+ self.assertIn("name", schema.model_fields)
1318
+ self.assertIn("description", schema.model_fields)
1319
+ # Should have custom field
1320
+ self.assertIn("extra", schema.model_fields)
1321
+ # Should NOT have other model fields
1322
+ self.assertNotIn("test_model", schema.model_fields)
1323
+ self.assertNotIn("id", schema.model_fields)
1324
+
1325
+ def test_serializer_with_only_excludes_and_customs(self):
1326
+ """Test schema with excludes and customs but no fields only has customs."""
1327
+
1328
+ class ExcludesOnlySerializer(serializers.Serializer):
1329
+ class Meta:
1330
+ model = TestModelForeignKey
1331
+ schema_in = serializers.SchemaModelConfig(
1332
+ exclude=["id", "test_model"],
1333
+ customs=[("extra", int, 0)],
1334
+ )
1335
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1336
+
1337
+ schema = ExcludesOnlySerializer.generate_create_s()
1338
+ self.assertIsNotNone(schema)
1339
+ # With excludes but no fields, only customs are included
1340
+ # because fields=[] is passed to create_schema (no model fields)
1341
+ self.assertIn("extra", schema.model_fields)
1342
+ # This is expected behavior: without explicit fields, no model fields included
1343
+ self.assertEqual(len(schema.model_fields), 1)
1344
+
1345
+ def test_serializer_empty_schema_returns_none(self):
1346
+ """Test that schema with nothing defined returns None."""
1347
+
1348
+ class EmptySchemaSerializer(serializers.Serializer):
1349
+ class Meta:
1350
+ model = TestModelForeignKey
1351
+ schema_in = serializers.SchemaModelConfig()
1352
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1353
+
1354
+ schema = EmptySchemaSerializer.generate_create_s()
1355
+ self.assertIsNone(schema)
1356
+
1357
+ def test_serializer_multiple_customs_no_model_fields(self):
1358
+ """Test schema with multiple customs but no model fields."""
1359
+
1360
+ class MultipleCustomsSerializer(serializers.Serializer):
1361
+ class Meta:
1362
+ model = TestModelForeignKey
1363
+ schema_in = serializers.SchemaModelConfig(
1364
+ customs=[
1365
+ ("custom1", str, ...),
1366
+ ("custom2", int, 0),
1367
+ ("custom3", bool, False),
1368
+ ]
1369
+ )
1370
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1371
+
1372
+ schema = MultipleCustomsSerializer.generate_create_s()
1373
+ self.assertIsNotNone(schema)
1374
+ # Should have all custom fields
1375
+ self.assertIn("custom1", schema.model_fields)
1376
+ self.assertIn("custom2", schema.model_fields)
1377
+ self.assertIn("custom3", schema.model_fields)
1378
+ # Should NOT have any model fields
1379
+ self.assertNotIn("name", schema.model_fields)
1380
+ self.assertNotIn("description", schema.model_fields)
1381
+ self.assertNotIn("test_model", schema.model_fields)
1382
+ self.assertNotIn("id", schema.model_fields)
1383
+
1384
+
1230
1385
  @tag("serializers", "relations_as_id", "string_pk", "integration")
1231
1386
  class RelationsAsIdStringPKIntegrationTestCase(TestCase):
1232
1387
  """Integration tests for relations_as_id with string primary keys and actual data serialization."""