django-ninja-aio-crud 2.7.0__tar.gz → 2.8.0__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.
Files changed (104) hide show
  1. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/.github/workflows/docs.yml +2 -0
  2. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/PKG-INFO +1 -1
  3. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/models/model_serializer.md +69 -7
  4. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/models/serializers.md +27 -2
  5. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/views/api_view_set.md +80 -20
  6. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/__init__.py +1 -1
  7. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/helpers/api.py +1 -1
  8. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/models/serializers.py +140 -67
  9. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/schemas/helpers.py +32 -6
  10. django_ninja_aio_crud-2.8.0/ninja_aio/types.py +24 -0
  11. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/views/api.py +21 -7
  12. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/generics/views.py +1 -1
  13. django_ninja_aio_crud-2.8.0/tests/helpers/test_many_to_many_api.py +282 -0
  14. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_app/models.py +13 -0
  15. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_serializers.py +121 -0
  16. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/views/test_viewset.py +141 -0
  17. django_ninja_aio_crud-2.7.0/ninja_aio/types.py +0 -19
  18. django_ninja_aio_crud-2.7.0/tests/helpers/test_many_to_many_api.py +0 -118
  19. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/.github/dependabot.yml +0 -0
  20. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/.github/workflows/coverage.yml +0 -0
  21. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/.github/workflows/publish.yml +0 -0
  22. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/.gitignore +0 -0
  23. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/.pre-commit-config.yaml +0 -0
  24. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/LICENSE +0 -0
  25. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/README.md +0 -0
  26. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/CNAME +0 -0
  27. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/authentication.md +0 -0
  28. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/models/model_util.md +0 -0
  29. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/pagination.md +0 -0
  30. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/renderers/orjson_renderer.md +0 -0
  31. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/views/api_view.md +0 -0
  32. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/views/decorators.md +0 -0
  33. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/api/views/mixins.md +0 -0
  34. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/auth.md +0 -0
  35. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/contributing.md +0 -0
  36. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/extra.css +0 -0
  37. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/getting_started/images/index/foo-index-create-swagger.png +0 -0
  38. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/getting_started/images/index/foo-index-delete-swagger.png +0 -0
  39. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/getting_started/images/index/foo-index-list-swagger.png +0 -0
  40. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/getting_started/images/index/foo-index-retrieve-swagger.png +0 -0
  41. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/getting_started/images/index/foo-index-swagger.png +0 -0
  42. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/getting_started/images/index/foo-index-update-swagger.png +0 -0
  43. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/getting_started/installation.md +0 -0
  44. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/getting_started/quick_start.md +0 -0
  45. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/images/bar-swagger.png +0 -0
  46. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/images/favicon.ico +0 -0
  47. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/images/foo-swagger.png +0 -0
  48. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/images/logo.png +0 -0
  49. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/images/model_util/foo-reverse-relations-swagger.png +0 -0
  50. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/index.md +0 -0
  51. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/release_notes.md +0 -0
  52. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/requirements.txt +0 -0
  53. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/tutorial/authentication.md +0 -0
  54. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/tutorial/crud.md +0 -0
  55. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/tutorial/filtering.md +0 -0
  56. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/docs/tutorial/model.md +0 -0
  57. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/main.py +0 -0
  58. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/mkdocs.yml +0 -0
  59. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/api.py +0 -0
  60. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/auth.py +0 -0
  61. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/decorators/__init__.py +0 -0
  62. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/decorators/operations.py +0 -0
  63. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/decorators/views.py +0 -0
  64. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/exceptions.py +0 -0
  65. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/factory/__init__.py +0 -0
  66. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/factory/operations.py +0 -0
  67. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/helpers/__init__.py +0 -0
  68. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/helpers/query.py +0 -0
  69. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/models/__init__.py +0 -0
  70. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/models/utils.py +0 -0
  71. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/parsers.py +0 -0
  72. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/renders.py +0 -0
  73. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/schemas/__init__.py +0 -0
  74. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/schemas/api.py +0 -0
  75. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/schemas/generics.py +0 -0
  76. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/views/__init__.py +0 -0
  77. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/ninja_aio/views/mixins.py +0 -0
  78. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/pyproject.toml +0 -0
  79. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/requirements.dev.txt +0 -0
  80. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/run-local-coverage.sh +0 -0
  81. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/__init__.py +0 -0
  82. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/core/__init__.py +0 -0
  83. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/core/test_decorators.py +0 -0
  84. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/core/test_exceptions_api.py +0 -0
  85. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/core/test_renderer_parser.py +0 -0
  86. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/generics/__init__.py +0 -0
  87. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/generics/literals.py +0 -0
  88. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/generics/models.py +0 -0
  89. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/generics/request.py +0 -0
  90. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/helpers/__init__.py +0 -0
  91. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/models/__init__.py +0 -0
  92. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/models/test_model_util.py +0 -0
  93. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/models/test_models_extra.py +0 -0
  94. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_app/__init__.py +0 -0
  95. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_app/schema.py +0 -0
  96. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_app/serializers.py +0 -0
  97. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_app/views.py +0 -0
  98. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_auth.py +0 -0
  99. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_decorators.py +0 -0
  100. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_exceptions.py +0 -0
  101. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_query_util.py +0 -0
  102. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/test_settings.py +0 -0
  103. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/views/__init__.py +0 -0
  104. {django_ninja_aio_crud-2.7.0 → django_ninja_aio_crud-2.8.0}/tests/views/test_views.py +0 -0
@@ -19,6 +19,7 @@ on:
19
19
  - "2.5"
20
20
  - "2.6"
21
21
  - "2.6.1"
22
+ - "2.7.0"
22
23
  make_latest:
23
24
  description: 'Set as "latest" and default?'
24
25
  type: boolean
@@ -41,6 +42,7 @@ on:
41
42
  - "2.5"
42
43
  - "2.6"
43
44
  - "2.6.1"
45
+ - "2.7.0"
44
46
  delete_confirm:
45
47
  description: 'Confirm deletion of the selected version'
46
48
  type: boolean
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.7.0
3
+ Version: 2.8.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -145,6 +145,66 @@ class User(ModelSerializer):
145
145
  }
146
146
  ```
147
147
 
148
+ ### DetailSerializer
149
+
150
+ Describes how to build a detail (single object) output schema. Use this when you want the retrieve endpoint to return more fields than the list endpoint.
151
+
152
+ **Attributes:**
153
+
154
+ | Attribute | Type | Description |
155
+ | ----------- | ------------------------ | ----------------------------------------------------------------------------- |
156
+ | `fields` | `list[str]` | Model fields to include in detail view |
157
+ | `excludes` | `list[str]` | Fields to exclude from detail view |
158
+ | `customs` | `list[tuple]` | Computed fields: `(name, type)` required; `(name, type, default)` optional |
159
+ | `optionals` | `list[tuple[str, type]]` | Optional output fields |
160
+
161
+ **Example:**
162
+
163
+ ```python
164
+ class Article(ModelSerializer):
165
+ title = models.CharField(max_length=200)
166
+ summary = models.TextField()
167
+ content = models.TextField()
168
+ author = models.ForeignKey(User, on_delete=models.CASCADE)
169
+ tags = models.ManyToManyField(Tag)
170
+ view_count = models.IntegerField(default=0)
171
+
172
+ class ReadSerializer:
173
+ # List view: minimal fields for performance
174
+ fields = ["id", "title", "summary", "author"]
175
+
176
+ class DetailSerializer:
177
+ # Detail view: all fields including expensive relations
178
+ fields = ["id", "title", "summary", "content", "author", "tags", "view_count"]
179
+ customs = [
180
+ ("reading_time", int, lambda obj: len(obj.content.split()) // 200),
181
+ ]
182
+ ```
183
+
184
+ **Generated Output (List):**
185
+
186
+ ```json
187
+ [
188
+ {"id": 1, "title": "Getting Started", "summary": "...", "author": {...}},
189
+ {"id": 2, "title": "Advanced Topics", "summary": "...", "author": {...}}
190
+ ]
191
+ ```
192
+
193
+ **Generated Output (Detail):**
194
+
195
+ ```json
196
+ {
197
+ "id": 1,
198
+ "title": "Getting Started",
199
+ "summary": "...",
200
+ "content": "Full article content here...",
201
+ "author": {...},
202
+ "tags": [{"id": 1, "name": "python"}, {"id": 2, "name": "django"}],
203
+ "view_count": 1234,
204
+ "reading_time": 5
205
+ }
206
+ ```
207
+
148
208
  ### UpdateSerializer
149
209
 
150
210
  Describes how to build an update (partial/full) input schema.
@@ -194,14 +254,15 @@ class User(ModelSerializer):
194
254
 
195
255
  ### Auto-Generated Schemas
196
256
 
197
- ModelSerializer automatically generates four schema types:
257
+ ModelSerializer automatically generates five schema types:
198
258
 
199
- | Method | Schema Type | Purpose |
200
- | -------------------------- | ------------------ | ------------------------------ |
201
- | `generate_create_s()` | Input ("In") | POST endpoint payload |
202
- | `generate_update_s()` | Input ("Patch") | PATCH/PUT endpoint payload |
203
- | `generate_read_s(depth=1)` | Output ("Out") | Response with nested relations |
204
- | `generate_related_s()` | Output ("Related") | Compact nested representation |
259
+ | Method | Schema Type | Purpose |
260
+ | ---------------------------- | ------------------ | ------------------------------------ |
261
+ | `generate_create_s()` | Input ("In") | POST endpoint payload |
262
+ | `generate_update_s()` | Input ("Patch") | PATCH/PUT endpoint payload |
263
+ | `generate_read_s(depth=1)` | Output ("Out") | List response with nested relations |
264
+ | `generate_detail_s(depth=1)` | Output ("Detail") | Single object response (retrieve) |
265
+ | `generate_related_s()` | Output ("Related") | Compact nested representation |
205
266
 
206
267
  **Example:**
207
268
 
@@ -219,6 +280,7 @@ class User(ModelSerializer):
219
280
  # Auto-generate schemas
220
281
  UserCreateSchema = User.generate_create_s()
221
282
  UserReadSchema = User.generate_read_s()
283
+ UserDetailSchema = User.generate_detail_s() # Returns None if DetailSerializer not defined
222
284
  UserUpdateSchema = User.generate_update_s()
223
285
  UserRelatedSchema = User.generate_related_s()
224
286
  ```
@@ -37,7 +37,8 @@ Define a Serializer subclass with a nested Meta:
37
37
 
38
38
  - **model**: Django model class
39
39
  - **schema_in**: SchemaModelConfig for create inputs
40
- - **schema_out**: SchemaModelConfig for read outputs
40
+ - **schema_out**: SchemaModelConfig for read outputs (list endpoint)
41
+ - **schema_detail**: SchemaModelConfig for detail outputs (retrieve endpoint)
41
42
  - **schema_update**: SchemaModelConfig for patch/update inputs
42
43
  - **relations_serializers**: Mapping of relation field name -> Serializer class, **string reference**, or **Union of serializers** (supports forward/circular dependencies and polymorphic relations)
43
44
 
@@ -55,13 +56,37 @@ Generate schemas explicitly using these methods:
55
56
  ```python
56
57
  # Explicitly generate schemas when needed
57
58
  ArticleSerializer.generate_create_s() # Returns create (In) schema
58
- ArticleSerializer.generate_read_s() # Returns read (Out) schema
59
+ ArticleSerializer.generate_read_s() # Returns read (Out) schema for list endpoint
60
+ ArticleSerializer.generate_detail_s() # Returns detail (Out) schema for retrieve endpoint
59
61
  ArticleSerializer.generate_update_s() # Returns update (Patch) schema
60
62
  ArticleSerializer.generate_related_s() # Returns related (nested) schema
61
63
  ```
62
64
 
63
65
  Schemas support **forward references and circular dependencies** via string references in `relations_serializers`.
64
66
 
67
+ ### Detail Schema for Retrieve Endpoint
68
+
69
+ Use `schema_detail` when you want the retrieve endpoint to return more fields than the list endpoint:
70
+
71
+ ```python
72
+ class ArticleSerializer(serializers.Serializer):
73
+ class Meta:
74
+ model = models.Article
75
+ schema_out = serializers.SchemaModelConfig(
76
+ # List view: minimal fields for performance
77
+ fields=["id", "title", "summary"]
78
+ )
79
+ schema_detail = serializers.SchemaModelConfig(
80
+ # Detail view: all fields including expensive relations
81
+ fields=["id", "title", "summary", "content", "author", "tags"],
82
+ customs=[("reading_time", int, lambda obj: len(obj.content.split()) // 200)]
83
+ )
84
+ ```
85
+
86
+ When used with `APIViewSet`:
87
+ - **List endpoint** (`GET /articles/`) uses `schema_out`
88
+ - **Retrieve endpoint** (`GET /articles/{pk}`) uses `schema_detail` (falls back to `schema_out` if not defined)
89
+
65
90
  ## Example: simple FK
66
91
 
67
92
  ```python
@@ -4,13 +4,13 @@
4
4
 
5
5
  ## Generated CRUD Endpoints
6
6
 
7
- | Method | Path | Summary | Response |
8
- | ------ | --------------- | -------------- | ---------------------------------- |
9
- | POST | `/{base}/` | Create Model | `201 schema_out` |
10
- | GET | `/{base}/` | List Models | `200 List[schema_out]` (paginated) |
11
- | GET | `/{base}/{pk}` | Retrieve Model | `200 schema_out` |
12
- | PATCH | `/{base}/{pk}/` | Update Model | `200 schema_out` |
13
- | DELETE | `/{base}/{pk}/` | Delete Model | `204 No Content` |
7
+ | Method | Path | Summary | Response |
8
+ | ------ | --------------- | -------------- | --------------------------------------------- |
9
+ | POST | `/{base}/` | Create Model | `201 schema_out` |
10
+ | GET | `/{base}/` | List Models | `200 List[schema_out]` (paginated) |
11
+ | GET | `/{base}/{pk}` | Retrieve Model | `200 schema_detail` (or `schema_out` if none) |
12
+ | PATCH | `/{base}/{pk}/` | Update Model | `200 schema_out` |
13
+ | DELETE | `/{base}/{pk}/` | Delete Model | `204 No Content` |
14
14
 
15
15
  Notes:
16
16
 
@@ -100,7 +100,8 @@ class ArticleViewSet(APIViewSet):
100
100
  | `api` | `NinjaAPI` | — | API instance (required) |
101
101
  | `serializer_class` | `Serializer \| None` | `None` | Serializer class for plain models (alternative to ModelSerializer) |
102
102
  | `schema_in` | `Schema \| None` | `None` (auto) | Create input schema override |
103
- | `schema_out` | `Schema \| None` | `None` (auto) | Read/output schema override |
103
+ | `schema_out` | `Schema \| None` | `None` (auto) | List/output schema override |
104
+ | `schema_detail` | `Schema \| None` | `None` (auto) | Retrieve/detail schema override (falls back to `schema_out`) |
104
105
  | `schema_update` | `Schema \| None` | `None` (auto) | Update input schema override |
105
106
  | `pagination_class` | `type[AsyncPaginationBase]` | `PageNumberPagination` | Pagination strategy |
106
107
  | `query_params` | `dict[str, tuple[type, ...]]` | `{}` | List endpoint filters definition |
@@ -150,6 +151,7 @@ The transaction behavior is applied by default. Custom decorators can be added v
150
151
  If `model` is a subclass of `ModelSerializerMeta`:
151
152
 
152
153
  - `schema_out` is generated from `ReadSerializer`
154
+ - `schema_detail` is generated from `DetailSerializer` (optional, falls back to `schema_out`)
153
155
  - `schema_in` from `CreateSerializer`
154
156
  - `schema_update` from `UpdateSerializer`
155
157
 
@@ -173,7 +175,42 @@ class ArticleViewSet(APIViewSet):
173
175
  serializer_class = ArticleSerializer
174
176
  ```
175
177
 
176
- Otherwise provide schemas manually via `schema_in`, `schema_out`, and `schema_update` attributes.
178
+ Otherwise provide schemas manually via `schema_in`, `schema_out`, `schema_detail`, and `schema_update` attributes.
179
+
180
+ ### Detail Schema for Retrieve Endpoint
181
+
182
+ Use `schema_detail` (or `DetailSerializer` on ModelSerializer) when you want the retrieve endpoint to return more fields than the list endpoint. This is useful for:
183
+
184
+ - **Performance optimization**: List endpoints return minimal fields, retrieve endpoints include expensive relations
185
+ - **API design**: Clients get a summary in lists and full details on individual requests
186
+
187
+ ```python
188
+ from ninja_aio.models import ModelSerializer
189
+ from django.db import models
190
+
191
+ class Article(ModelSerializer):
192
+ title = models.CharField(max_length=200)
193
+ summary = models.TextField()
194
+ content = models.TextField()
195
+ author = models.ForeignKey(User, on_delete=models.CASCADE)
196
+ tags = models.ManyToManyField(Tag)
197
+
198
+ class ReadSerializer:
199
+ # List view: minimal fields
200
+ fields = ["id", "title", "summary"]
201
+
202
+ class DetailSerializer:
203
+ # Detail view: all fields
204
+ fields = ["id", "title", "summary", "content", "author", "tags"]
205
+
206
+ @api.viewset(model=Article)
207
+ class ArticleViewSet(APIViewSet):
208
+ pass # Schemas auto-generated from model
209
+ ```
210
+
211
+ Endpoints behavior:
212
+ - `GET /articles/` returns `[{"id": 1, "title": "...", "summary": "..."}, ...]`
213
+ - `GET /articles/1` returns `{"id": 1, "title": "...", "summary": "...", "content": "...", "author": {...}, "tags": [...]}`
177
214
 
178
215
  ## List Filtering
179
216
 
@@ -254,6 +291,7 @@ Relations are declared via `M2MRelationSchema` objects (not tuples). Each schema
254
291
  - `get`: enable GET listing (bool)
255
292
  - `filters`: dict of `{param_name: (type, default)}` for relation-level filtering
256
293
  - `related_schema`: optional pre-built schema for the related model (auto-generated if the `model` is a `ModelSerializer`)
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`.
257
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.
258
296
 
259
297
  If `path` is empty it falls back to the related model verbose name (lowercase plural).
@@ -285,21 +323,43 @@ async def tags_query_params_handler(self, queryset, filters_dict):
285
323
 
286
324
  Warning: Model support
287
325
 
288
- - You can supply a standard Django `Model` (not a `ModelSerializer`) in `M2MRelationSchema.model`. When doing so you must provide `related_schema` manually:
326
+ - You can supply a standard Django `Model` (not a `ModelSerializer`) in `M2MRelationSchema.model`. When doing so you must provide either `related_schema` manually or `serializer_class`:
289
327
 
290
- ```python
291
- M2MRelationSchema(
292
- model=Tag, # plain django.db.models.Model
293
- related_name="tags",
294
- related_schema=TagOut, # a Pydantic/Ninja Schema you define
295
- add=True,
296
- remove=True,
297
- get=True,
298
- )
299
- ```
328
+ === "With related_schema"
329
+ ```python
330
+ M2MRelationSchema(
331
+ model=Tag, # plain django.db.models.Model
332
+ related_name="tags",
333
+ related_schema=TagOut, # a Pydantic/Ninja Schema you define
334
+ add=True,
335
+ remove=True,
336
+ get=True,
337
+ )
338
+ ```
339
+
340
+ === "With serializer_class"
341
+ ```python
342
+ from ninja_aio.models import serializers
343
+
344
+ class TagSerializer(serializers.Serializer):
345
+ class Meta:
346
+ model = Tag
347
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
348
+
349
+ M2MRelationSchema(
350
+ model=Tag, # plain django.db.models.Model
351
+ related_name="tags",
352
+ serializer_class=TagSerializer, # auto-generates related_schema
353
+ add=True,
354
+ remove=True,
355
+ get=True,
356
+ )
357
+ ```
300
358
 
301
359
  For `ModelSerializer` models, `related_schema` can be inferred automatically (via internal helpers).
302
360
 
361
+ Note: You cannot use `serializer_class` when `model` is already a `ModelSerializer` - this will raise a `ValueError`.
362
+
303
363
  Example with filters:
304
364
 
305
365
  ```python
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.7.0"
3
+ __version__ = "2.8.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -472,7 +472,7 @@ class ManyToManyAPI:
472
472
  model = relation.model
473
473
  related_name = relation.related_name
474
474
  m2m_auth = relation.auth or self.default_auth
475
- rel_util = ModelUtil(model)
475
+ rel_util = ModelUtil(model, serializer_class=relation.serializer_class)
476
476
  rel_path = relation.path or rel_util.verbose_name_path_resolver()
477
477
  related_schema = relation.related_schema
478
478
  m2m_add, m2m_remove, m2m_get = relation.add, relation.remove, relation.get
@@ -1,4 +1,4 @@
1
- from typing import Any, List, Optional, Union, get_args, get_origin, ForwardRef
1
+ from typing import Any, List, Literal, Optional, Union, get_args, get_origin, ForwardRef
2
2
  import warnings
3
3
  import sys
4
4
 
@@ -15,7 +15,13 @@ from django.db.models.fields.related_descriptors import (
15
15
  ForwardOneToOneDescriptor,
16
16
  )
17
17
 
18
- from ninja_aio.types import S_TYPES, F_TYPES, SCHEMA_TYPES, ModelSerializerMeta
18
+ from ninja_aio.types import (
19
+ S_TYPES,
20
+ F_TYPES,
21
+ SCHEMA_TYPES,
22
+ ModelSerializerMeta,
23
+ SerializerMeta,
24
+ )
19
25
  from ninja_aio.schemas.helpers import (
20
26
  ModelQuerySetSchema,
21
27
  ModelQuerySetExtraSchema,
@@ -98,6 +104,7 @@ class BaseSerializer:
98
104
  module = sys.modules.get(module_path)
99
105
  if module is None:
100
106
  import importlib
107
+
101
108
  module = importlib.import_module(module_path)
102
109
 
103
110
  # Get the serializer class from the module
@@ -136,7 +143,9 @@ class BaseSerializer:
136
143
  return serializer_class
137
144
 
138
145
  @classmethod
139
- def _resolve_serializer_reference(cls, serializer_ref: str | type | Any) -> type | Any:
146
+ def _resolve_serializer_reference(
147
+ cls, serializer_ref: str | type | Any
148
+ ) -> type | Any:
140
149
  """
141
150
  Resolve a serializer reference that may be a string, a class, or a Union of serializers.
142
151
 
@@ -217,11 +226,11 @@ class BaseSerializer:
217
226
  Schema | Union[Schema, ...] | None
218
227
  Union of generated schemas or None if all schemas are None.
219
228
  """
220
- # Generate schemas for each serializer in the Union
229
+ # Generate schemas for each serializer in the Union (single call per serializer)
221
230
  schemas = tuple(
222
- serializer_type.generate_related_s()
231
+ schema
223
232
  for serializer_type in get_args(resolved_union)
224
- if serializer_type.generate_related_s() is not None
233
+ if (schema := serializer_type.generate_related_s()) is not None
225
234
  )
226
235
 
227
236
  if not schemas:
@@ -393,72 +402,100 @@ class BaseSerializer:
393
402
  return (field_name, schema | None, None)
394
403
 
395
404
  @classmethod
396
- def get_schema_out_data(cls):
405
+ def _is_reverse_relation(cls, field_obj) -> bool:
406
+ """Check if field is a reverse relation (M2M, reverse FK, reverse O2O)."""
407
+ return isinstance(
408
+ field_obj,
409
+ (ManyToManyDescriptor, ReverseManyToOneDescriptor, ReverseOneToOneDescriptor),
410
+ )
411
+
412
+ @classmethod
413
+ def _is_forward_relation(cls, field_obj) -> bool:
414
+ """Check if field is a forward relation (FK, O2O)."""
415
+ return isinstance(
416
+ field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
417
+ )
418
+
419
+ @classmethod
420
+ def _warn_missing_relation_serializer(cls, field_name: str, model) -> None:
421
+ """Emit warning for reverse relations without explicit serializer mapping."""
422
+ if (
423
+ not isinstance(model, ModelSerializerMeta)
424
+ and not getattr(settings, "NINJA_AIO_TESTING", False)
425
+ ):
426
+ warnings.warn(
427
+ f"{cls.__name__}: reverse relation '{field_name}' is listed in read fields "
428
+ "but has no entry in relations_serializers; it will be auto-resolved only "
429
+ "for ModelSerializer relations, otherwise skipped.",
430
+ UserWarning,
431
+ stacklevel=3,
432
+ )
433
+
434
+ @classmethod
435
+ def _process_field(
436
+ cls,
437
+ field_name: str,
438
+ model,
439
+ relations_serializers: dict,
440
+ ) -> tuple[str | None, tuple | None, tuple | None]:
441
+ """
442
+ Process a single field and determine its classification.
443
+
444
+ Returns:
445
+ (plain_field, reverse_rel, forward_rel) - only one will be non-None
446
+ """
447
+ field_obj = getattr(model, field_name)
448
+
449
+ if cls._is_reverse_relation(field_obj):
450
+ if field_name not in relations_serializers:
451
+ cls._warn_missing_relation_serializer(field_name, model)
452
+ rel_tuple = cls._build_schema_reverse_rel(field_name, field_obj)
453
+ return (None, rel_tuple, None)
454
+
455
+ if cls._is_forward_relation(field_obj):
456
+ rel_tuple = cls._build_schema_forward_rel(field_name, field_obj)
457
+ if rel_tuple is True:
458
+ return (field_name, None, None)
459
+ return (None, None, rel_tuple)
460
+
461
+ return (field_name, None, None)
462
+
463
+ @classmethod
464
+ def get_schema_out_data(cls, schema_type: Literal["Out", "Detail"] = "Out"):
397
465
  """
398
- Collect components for 'Out' read schema generation.
399
- Returns (fields, reverse_rel_descriptors, excludes, custom_fields_with_forward_relations, optionals).
400
- Enforces relation serializers only when provided by subclass via _get_relations_serializers.
466
+ Collect components for output schema generation (Out or Detail).
467
+
468
+ Returns:
469
+ tuple: (fields, reverse_rels, excludes, customs_with_forward_rels, optionals)
401
470
  """
471
+ if schema_type not in ("Out", "Detail"):
472
+ raise ValueError("get_schema_out_data only supports 'Out' or 'Detail' types")
473
+
474
+ fields_type = "read" if schema_type == "Out" else "detail"
475
+ model = cls._get_model()
476
+ relations_serializers = cls._get_relations_serializers() or {}
477
+
402
478
  fields: list[str] = []
403
479
  reverse_rels: list[tuple] = []
404
- rels: list[tuple] = []
405
- relations_serializers = cls._get_relations_serializers() or {}
406
- model = cls._get_model()
480
+ forward_rels: list[tuple] = []
407
481
 
408
- for f in cls.get_fields("read"):
409
- field_obj = getattr(model, f)
410
- is_reverse = isinstance(
411
- field_obj,
412
- (
413
- ManyToManyDescriptor,
414
- ReverseManyToOneDescriptor,
415
- ReverseOneToOneDescriptor,
416
- ),
482
+ for field_name in cls.get_fields(fields_type):
483
+ plain, reverse, forward = cls._process_field(
484
+ field_name, model, relations_serializers
417
485
  )
418
- is_forward = isinstance(
419
- field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
420
- )
421
-
422
- # If explicit relation serializers are declared, require mapping presence.
423
- if (
424
- is_reverse
425
- and not isinstance(model, ModelSerializerMeta)
426
- and f not in relations_serializers
427
- and not getattr(settings, "NINJA_AIO_TESTING", False)
428
- ):
429
- warnings.warn(
430
- f"{cls.__name__}: reverse relation '{f}' is listed in read fields but has no entry in relations_serializers; "
431
- "it will be auto-resolved only for ModelSerializer relations, otherwise skipped.",
432
- UserWarning,
433
- stacklevel=2,
434
- )
435
-
436
- # Reverse relations
437
- if is_reverse:
438
- rel_tuple = cls._build_schema_reverse_rel(f, field_obj)
439
- if rel_tuple:
440
- reverse_rels.append(rel_tuple)
441
- continue
442
-
443
- # Forward relations
444
- if is_forward:
445
- rel_tuple = cls._build_schema_forward_rel(f, field_obj)
446
- if rel_tuple is True:
447
- fields.append(f)
448
- elif rel_tuple:
449
- rels.append(rel_tuple)
450
- # None -> skip entirely
451
- continue
452
-
453
- # Plain field
454
- fields.append(f)
486
+ if plain:
487
+ fields.append(plain)
488
+ if reverse:
489
+ reverse_rels.append(reverse)
490
+ if forward:
491
+ forward_rels.append(forward)
455
492
 
456
493
  return (
457
494
  fields,
458
495
  reverse_rels,
459
- cls.get_excluded_fields("read"),
460
- cls.get_custom_fields("read") + rels,
461
- cls.get_optional_fields("read"),
496
+ cls.get_excluded_fields(fields_type),
497
+ cls.get_custom_fields(fields_type) + forward_rels,
498
+ cls.get_optional_fields(fields_type),
462
499
  )
463
500
 
464
501
  @classmethod
@@ -474,15 +511,16 @@ class BaseSerializer:
474
511
  model = cls._get_model()
475
512
 
476
513
  # Handle special schema types with custom logic
477
- if schema_type == "Out":
514
+ if schema_type == "Out" or schema_type == "Detail":
478
515
  fields, reverse_rels, excludes, customs, optionals = (
479
- cls.get_schema_out_data()
516
+ cls.get_schema_out_data(schema_type)
480
517
  )
481
518
  if not any([fields, reverse_rels, excludes, customs]):
482
519
  return None
520
+ schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
483
521
  return create_schema(
484
522
  model=model,
485
- name=f"{model._meta.model_name}SchemaOut",
523
+ name=f"{model._meta.model_name}{schema_name}",
486
524
  depth=depth,
487
525
  fields=fields,
488
526
  custom_fields=reverse_rels + customs + optionals,
@@ -561,6 +599,11 @@ class BaseSerializer:
561
599
  """Generate read (Out) schema."""
562
600
  return cls._generate_model_schema("Out", depth)
563
601
 
602
+ @classmethod
603
+ def generate_detail_s(cls, depth: int = 1) -> Schema:
604
+ """Generate detail (single object Out) schema."""
605
+ return cls._generate_model_schema("Detail", depth)
606
+
564
607
  @classmethod
565
608
  def generate_create_s(cls) -> Schema:
566
609
  """Generate create (In) schema."""
@@ -659,6 +702,26 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
659
702
  optionals: list[tuple[str, type]] = []
660
703
  excludes: list[str] = []
661
704
 
705
+ class DetailSerializer:
706
+ """Configuration describing detail (single object) read schema.
707
+
708
+ Attributes
709
+ ----------
710
+ fields : list[str]
711
+ Explicit model fields to include.
712
+ excludes : list[str]
713
+ Fields to force exclude (safety).
714
+ customs : list[tuple[str, type, Any]]
715
+ Computed / synthetic output attributes.
716
+ optionals : list[tuple[str, type]]
717
+ Optional output fields.
718
+ """
719
+
720
+ fields: list[str] = []
721
+ customs: list[tuple[str, type, Any]] = []
722
+ optionals: list[tuple[str, type]] = []
723
+ excludes: list[str] = []
724
+
662
725
  class UpdateSerializer:
663
726
  """Configuration describing update (PATCH/PUT) schema.
664
727
 
@@ -684,6 +747,7 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
684
747
  "create": "CreateSerializer",
685
748
  "update": "UpdateSerializer",
686
749
  "read": "ReadSerializer",
750
+ "detail": "DetailSerializer",
687
751
  }
688
752
 
689
753
  @classmethod
@@ -852,7 +916,7 @@ class SchemaModelConfig(Schema):
852
916
  customs: Optional[List[tuple[str, type, Any]]] = None
853
917
 
854
918
 
855
- class Serializer(BaseSerializer):
919
+ class Serializer(BaseSerializer, metaclass=SerializerMeta):
856
920
  """
857
921
  Serializer
858
922
  ----------
@@ -867,6 +931,7 @@ class Serializer(BaseSerializer):
867
931
  "create": "in",
868
932
  "update": "update",
869
933
  "read": "out",
934
+ "detail": "detail",
870
935
  }
871
936
 
872
937
  def __init_subclass__(cls, **kwargs):
@@ -884,6 +949,7 @@ class Serializer(BaseSerializer):
884
949
  schema_in: Optional[SchemaModelConfig] = None
885
950
  schema_out: Optional[SchemaModelConfig] = None
886
951
  schema_update: Optional[SchemaModelConfig] = None
952
+ schema_detail: Optional[SchemaModelConfig] = None
887
953
  relations_serializers: dict[str, "Serializer"] = {}
888
954
 
889
955
  @classmethod
@@ -908,6 +974,8 @@ class Serializer(BaseSerializer):
908
974
  return cls._get_meta_data("schema_out")
909
975
  case "update":
910
976
  return cls._get_meta_data("schema_update")
977
+ case "detail":
978
+ return cls._get_meta_data("schema_detail")
911
979
  case _:
912
980
  return None
913
981
 
@@ -1027,7 +1095,12 @@ class Serializer(BaseSerializer):
1027
1095
  dict
1028
1096
  Serialized data.
1029
1097
  """
1030
- return await self.util.read_s(schema=self.generate_read_s(), instance=instance)
1098
+ schema = (
1099
+ self.generate_read_s()
1100
+ if self.generate_detail_s() is None
1101
+ else self.generate_detail_s()
1102
+ )
1103
+ return await self.util.read_s(schema=schema, instance=instance)
1031
1104
 
1032
1105
  async def models_dump(
1033
1106
  self, instances: models.QuerySet[models.Model]