django-ninja-aio-crud 2.11.1__tar.gz → 2.12.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.

Potentially problematic release.


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

Files changed (103) hide show
  1. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/PKG-INFO +1 -1
  2. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/models/model_serializer.md +35 -11
  3. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/models/serializers.md +23 -0
  4. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/__init__.py +1 -1
  5. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/models/serializers.py +12 -9
  6. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/models/utils.py +1 -1
  7. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_app/models.py +36 -0
  8. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_serializers.py +182 -0
  9. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/.github/dependabot.yml +0 -0
  10. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/.github/workflows/coverage.yml +0 -0
  11. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/.github/workflows/docs.yml +0 -0
  12. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/.github/workflows/publish.yml +0 -0
  13. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/.gitignore +0 -0
  14. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/.pre-commit-config.yaml +0 -0
  15. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/LICENSE +0 -0
  16. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/README.md +0 -0
  17. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/CNAME +0 -0
  18. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/authentication.md +0 -0
  19. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/models/model_util.md +0 -0
  20. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/pagination.md +0 -0
  21. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/renderers/orjson_renderer.md +0 -0
  22. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/views/api_view.md +0 -0
  23. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/views/api_view_set.md +0 -0
  24. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/views/decorators.md +0 -0
  25. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/api/views/mixins.md +0 -0
  26. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/auth.md +0 -0
  27. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/contributing.md +0 -0
  28. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/extra.css +0 -0
  29. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/getting_started/images/index/foo-index-create-swagger.png +0 -0
  30. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/getting_started/images/index/foo-index-delete-swagger.png +0 -0
  31. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/getting_started/images/index/foo-index-list-swagger.png +0 -0
  32. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/getting_started/images/index/foo-index-retrieve-swagger.png +0 -0
  33. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/getting_started/images/index/foo-index-swagger.png +0 -0
  34. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/getting_started/images/index/foo-index-update-swagger.png +0 -0
  35. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/getting_started/installation.md +0 -0
  36. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/getting_started/quick_start.md +0 -0
  37. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/images/bar-swagger.png +0 -0
  38. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/images/favicon.ico +0 -0
  39. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/images/foo-swagger.png +0 -0
  40. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/images/logo.png +0 -0
  41. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/images/model_util/foo-reverse-relations-swagger.png +0 -0
  42. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/index.md +0 -0
  43. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/release_notes.md +0 -0
  44. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/requirements.txt +0 -0
  45. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/tutorial/authentication.md +0 -0
  46. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/tutorial/crud.md +0 -0
  47. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/tutorial/filtering.md +0 -0
  48. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/docs/tutorial/model.md +0 -0
  49. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/main.py +0 -0
  50. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/mkdocs.yml +0 -0
  51. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/api.py +0 -0
  52. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/auth.py +0 -0
  53. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/decorators/__init__.py +0 -0
  54. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/decorators/operations.py +0 -0
  55. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/decorators/views.py +0 -0
  56. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/exceptions.py +0 -0
  57. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/factory/__init__.py +0 -0
  58. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/factory/operations.py +0 -0
  59. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/helpers/__init__.py +0 -0
  60. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/helpers/api.py +0 -0
  61. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/helpers/query.py +0 -0
  62. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/models/__init__.py +0 -0
  63. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/parsers.py +0 -0
  64. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/renders.py +0 -0
  65. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/schemas/__init__.py +0 -0
  66. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/schemas/api.py +0 -0
  67. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/schemas/filters.py +0 -0
  68. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/schemas/generics.py +0 -0
  69. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/schemas/helpers.py +0 -0
  70. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/types.py +0 -0
  71. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/views/__init__.py +0 -0
  72. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/views/api.py +0 -0
  73. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/ninja_aio/views/mixins.py +0 -0
  74. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/pyproject.toml +0 -0
  75. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/requirements.dev.txt +0 -0
  76. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/run-local-coverage.sh +0 -0
  77. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/__init__.py +0 -0
  78. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/core/__init__.py +0 -0
  79. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/core/test_decorators.py +0 -0
  80. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/core/test_exceptions_api.py +0 -0
  81. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/core/test_renderer_parser.py +0 -0
  82. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/generics/__init__.py +0 -0
  83. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/generics/literals.py +0 -0
  84. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/generics/models.py +0 -0
  85. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/generics/request.py +0 -0
  86. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/generics/views.py +0 -0
  87. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/helpers/__init__.py +0 -0
  88. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/helpers/test_many_to_many_api.py +0 -0
  89. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/models/__init__.py +0 -0
  90. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/models/test_model_util.py +0 -0
  91. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/models/test_models_extra.py +0 -0
  92. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_app/__init__.py +0 -0
  93. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_app/schema.py +0 -0
  94. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_app/serializers.py +0 -0
  95. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_app/views.py +0 -0
  96. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_auth.py +0 -0
  97. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_decorators.py +0 -0
  98. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_exceptions.py +0 -0
  99. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_query_util.py +0 -0
  100. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/test_settings.py +0 -0
  101. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/views/__init__.py +0 -0
  102. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/views/test_views.py +0 -0
  103. {django_ninja_aio_crud-2.11.1 → django_ninja_aio_crud-2.12.0}/tests/views/test_viewset.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.11.1
3
+ Version: 2.12.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -149,16 +149,23 @@ class User(ModelSerializer):
149
149
 
150
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
151
 
152
- **Fallback Behavior:** If `DetailSerializer` is not defined, `generate_detail_s()` automatically falls back to the read schema (same as `generate_read_s()`). This means you only need to define `DetailSerializer` when you want different fields for single-object retrieval vs list views.
152
+ **Fallback Behavior:** `DetailSerializer` supports **per-field-type fallback** to `ReadSerializer`. Each attribute (`fields`, `customs`, `optionals`, `excludes`) is checked independently:
153
+
154
+ - If `DetailSerializer.fields` is empty → uses `ReadSerializer.fields`
155
+ - If `DetailSerializer.customs` is empty → uses `ReadSerializer.customs`
156
+ - If `DetailSerializer.optionals` is empty → uses `ReadSerializer.optionals`
157
+ - If `DetailSerializer.excludes` is empty → uses `ReadSerializer.excludes`
158
+
159
+ This allows partial overrides: define only `DetailSerializer.fields` while inheriting `customs` from `ReadSerializer`.
153
160
 
154
161
  **Attributes:**
155
162
 
156
163
  | Attribute | Type | Description |
157
164
  | ----------- | ------------------------ | ----------------------------------------------------------------------------- |
158
- | `fields` | `list[str]` | Model fields to include in detail view (falls back to ReadSerializer fields if not defined) |
159
- | `excludes` | `list[str]` | Fields to exclude from detail view |
160
- | `customs` | `list[tuple]` | Computed fields: `(name, type)` required; `(name, type, default)` optional |
161
- | `optionals` | `list[tuple[str, type]]` | Optional output fields |
165
+ | `fields` | `list[str]` | Model fields to include in detail view (falls back to ReadSerializer.fields if empty) |
166
+ | `excludes` | `list[str]` | Fields to exclude from detail view (falls back to ReadSerializer.excludes if empty) |
167
+ | `customs` | `list[tuple]` | Computed fields: `(name, type)` required; `(name, type, default)` optional (falls back to ReadSerializer.customs if empty) |
168
+ | `optionals` | `list[tuple[str, type]]` | Optional output fields (falls back to ReadSerializer.optionals if empty) |
162
169
 
163
170
  **Example:**
164
171
 
@@ -174,21 +181,22 @@ class Article(ModelSerializer):
174
181
  class ReadSerializer:
175
182
  # List view: minimal fields for performance
176
183
  fields = ["id", "title", "summary", "author"]
184
+ customs = [
185
+ ("word_count", int, lambda obj: len(obj.content.split())),
186
+ ]
177
187
 
178
188
  class DetailSerializer:
179
189
  # Detail view: all fields including expensive relations
190
+ # customs inherited from ReadSerializer (word_count)
180
191
  fields = ["id", "title", "summary", "content", "author", "tags", "view_count"]
181
- customs = [
182
- ("reading_time", int, lambda obj: len(obj.content.split()) // 200),
183
- ]
184
192
  ```
185
193
 
186
194
  **Generated Output (List):**
187
195
 
188
196
  ```json
189
197
  [
190
- {"id": 1, "title": "Getting Started", "summary": "...", "author": {...}},
191
- {"id": 2, "title": "Advanced Topics", "summary": "...", "author": {...}}
198
+ {"id": 1, "title": "Getting Started", "summary": "...", "author": {...}, "word_count": 500},
199
+ {"id": 2, "title": "Advanced Topics", "summary": "...", "author": {...}, "word_count": 1200}
192
200
  ]
193
201
  ```
194
202
 
@@ -203,10 +211,26 @@ class Article(ModelSerializer):
203
211
  "author": {...},
204
212
  "tags": [{"id": 1, "name": "python"}, {"id": 2, "name": "django"}],
205
213
  "view_count": 1234,
206
- "reading_time": 5
214
+ "word_count": 500
207
215
  }
208
216
  ```
209
217
 
218
+ **Example with Custom Override:**
219
+
220
+ ```python
221
+ class Article(ModelSerializer):
222
+ # ... fields ...
223
+
224
+ class ReadSerializer:
225
+ fields = ["id", "title", "summary"]
226
+ customs = [("word_count", int, lambda obj: len(obj.content.split()))]
227
+
228
+ class DetailSerializer:
229
+ fields = ["id", "title", "summary", "content"]
230
+ # Override customs - reading_time instead of word_count
231
+ customs = [("reading_time", int, lambda obj: len(obj.content.split()) // 200)]
232
+ ```
233
+
210
234
  ### UpdateSerializer
211
235
 
212
236
  Describes how to build an update (partial/full) input schema.
@@ -87,6 +87,29 @@ When used with `APIViewSet`:
87
87
  - **List endpoint** (`GET /articles/`) uses `schema_out`
88
88
  - **Retrieve endpoint** (`GET /articles/{pk}`) uses `schema_detail` (falls back to `schema_out` if not defined)
89
89
 
90
+ **Fallback Behavior:** Unlike `ModelSerializer`, `Serializer` uses **schema-level fallback**:
91
+
92
+ - If `schema_detail` is **not defined** → all field types (`fields`, `customs`, `optionals`, `exclude`) fall back to `schema_out`
93
+ - If `schema_detail` **is defined** → no inheritance from `schema_out`, even for empty field types
94
+
95
+ This means you must explicitly define all needed configurations in `schema_detail` if you define it at all:
96
+
97
+ ```python
98
+ class ArticleSerializer(serializers.Serializer):
99
+ class Meta:
100
+ model = models.Article
101
+ schema_out = serializers.SchemaModelConfig(
102
+ fields=["id", "title"],
103
+ customs=[("word_count", int, 0)], # This custom...
104
+ )
105
+ schema_detail = serializers.SchemaModelConfig(
106
+ fields=["id", "title", "content"],
107
+ # ...is NOT inherited here because schema_detail is defined
108
+ # You must explicitly add it if needed:
109
+ # customs=[("word_count", int, 0)],
110
+ )
111
+ ```
112
+
90
113
  ## Example: simple FK
91
114
 
92
115
  ```python
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.11.1"
3
+ __version__ = "2.12.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -333,11 +333,7 @@ class BaseSerializer:
333
333
  @classmethod
334
334
  def get_fields(cls, s_type: S_TYPES):
335
335
  """Return explicit declared fields for the serializer type."""
336
- fields = cls._get_fields(s_type, "fields")
337
- # Detail schema falls back to read fields if none declared
338
- if not fields and s_type == "detail":
339
- return cls._get_fields("read", "fields")
340
- return fields
336
+ return cls._get_fields(s_type, "fields")
341
337
 
342
338
  @classmethod
343
339
  def is_custom(cls, field: str) -> bool:
@@ -428,8 +424,9 @@ class BaseSerializer:
428
424
  @classmethod
429
425
  def _warn_missing_relation_serializer(cls, field_name: str, model) -> None:
430
426
  """Emit warning for reverse relations without explicit serializer mapping."""
431
- if not isinstance(model, ModelSerializerMeta) and not getattr(
432
- settings, "NINJA_AIO_TESTING", False
427
+ if not isinstance(model, ModelSerializerMeta) and (
428
+ not getattr(settings, "NINJA_AIO_TESTING", False)
429
+ or getattr(settings, "NINJA_AIO_RAISE_SERIALIZATION_WARNINGS", False)
433
430
  ):
434
431
  warnings.warn(
435
432
  f"{cls.__name__}: reverse relation '{field_name}' is listed in read fields "
@@ -781,7 +778,10 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
781
778
  if not config_class_name:
782
779
  return []
783
780
  config_class = getattr(cls, config_class_name)
784
- return getattr(config_class, f_type, [])
781
+ fields = getattr(config_class, f_type, [])
782
+ if not fields and s_type == "detail":
783
+ fields = getattr(cls.ReadSerializer, f_type, [])
784
+ return fields
785
785
 
786
786
  @classmethod
787
787
  def _get_model(cls) -> "ModelSerializer":
@@ -1006,7 +1006,10 @@ class Serializer(BaseSerializer, metaclass=SerializerMeta):
1006
1006
  return []
1007
1007
  schema = cls._get_schema_meta(schema_key)
1008
1008
  if not schema:
1009
- return []
1009
+ if s_type == "detail":
1010
+ schema = cls._get_schema_meta("out")
1011
+ else:
1012
+ return []
1010
1013
  return getattr(schema, f_type, []) or []
1011
1014
 
1012
1015
  @classmethod
@@ -601,7 +601,7 @@ class ModelUtil:
601
601
  async def _bump_object_from_schema(
602
602
  self, obj: type["ModelSerializer"] | models.Model, schema: Schema
603
603
  ):
604
- return (await sync_to_async(schema.from_orm)(obj)).model_dump(mode="json")
604
+ return (await sync_to_async(schema.from_orm)(obj)).model_dump()
605
605
 
606
606
  def _validate_read_params(self, request: HttpRequest, query_data: QuerySchema):
607
607
  """Validate required parameters for read operations."""
@@ -187,3 +187,39 @@ class TestModelSerializerWithDetail(BaseTestModelSerializer):
187
187
  class DetailSerializer:
188
188
  fields = ["id", "name", "description", "extra_info"]
189
189
  customs = [("computed_field", str, "computed_value")]
190
+
191
+
192
+ class TestModelSerializerWithReadCustoms(BaseTestModelSerializer):
193
+ """Model with customs on ReadSerializer but no DetailSerializer."""
194
+
195
+ class ReadSerializer:
196
+ fields = ["id", "name"]
197
+ customs = [("custom_field", str, "default")]
198
+
199
+
200
+ class TestModelSerializerWithReadOptionals(BaseTestModelSerializer):
201
+ """Model with optionals on ReadSerializer but no DetailSerializer."""
202
+
203
+ class ReadSerializer:
204
+ fields = ["id", "name"]
205
+ optionals = [("description", str)]
206
+
207
+
208
+ class TestModelSerializerWithReadExcludes(BaseTestModelSerializer):
209
+ """Model with excludes on ReadSerializer but no DetailSerializer."""
210
+
211
+ class ReadSerializer:
212
+ fields = ["id", "name", "description"]
213
+ excludes = ["description"]
214
+
215
+
216
+ class TestModelSerializerWithBothSerializers(BaseTestModelSerializer):
217
+ """Model with both ReadSerializer and DetailSerializer configured."""
218
+
219
+ class ReadSerializer:
220
+ fields = ["id", "name"]
221
+ customs = [("read_custom", str, "default")]
222
+
223
+ class DetailSerializer:
224
+ fields = ["id", "name", "description"]
225
+ # No customs defined - should NOT inherit from read
@@ -298,6 +298,94 @@ class DetailSerializerTestCase(TestCase):
298
298
  def setUp(self):
299
299
  warnings.simplefilter("ignore", UserWarning)
300
300
 
301
+ def test_detail_fallback_customs_from_read(self):
302
+ """Test that detail schema falls back to read customs when not configured."""
303
+
304
+ class DetailFallbackCustomsSerializer(serializers.Serializer):
305
+ class Meta:
306
+ model = TestModelForeignKey
307
+ schema_out = serializers.SchemaModelConfig(
308
+ fields=["id", "name"],
309
+ customs=[("read_custom", str, "default")],
310
+ )
311
+ # No schema_detail defined - should fall back to schema_out
312
+
313
+ # Detail should inherit customs from read schema
314
+ schema_detail = DetailFallbackCustomsSerializer.generate_detail_s()
315
+ self.assertIsNotNone(schema_detail)
316
+ self.assertIn("id", schema_detail.model_fields)
317
+ self.assertIn("name", schema_detail.model_fields)
318
+ self.assertIn("read_custom", schema_detail.model_fields)
319
+
320
+ def test_detail_fallback_optionals_from_read(self):
321
+ """Test that detail schema falls back to read optionals when not configured."""
322
+
323
+ class DetailFallbackOptionalsSerializer(serializers.Serializer):
324
+ class Meta:
325
+ model = TestModelForeignKey
326
+ schema_out = serializers.SchemaModelConfig(
327
+ fields=["id", "name"],
328
+ optionals=[("description", str)],
329
+ )
330
+ # No schema_detail defined - should fall back to schema_out
331
+
332
+ # Detail should inherit optionals from read schema
333
+ schema_detail = DetailFallbackOptionalsSerializer.generate_detail_s()
334
+ self.assertIsNotNone(schema_detail)
335
+ self.assertIn("id", schema_detail.model_fields)
336
+ self.assertIn("name", schema_detail.model_fields)
337
+ self.assertIn("description", schema_detail.model_fields)
338
+
339
+ def test_detail_fallback_excludes_from_read(self):
340
+ """Test that detail schema falls back to read excludes when not configured."""
341
+
342
+ class DetailFallbackExcludesSerializer(serializers.Serializer):
343
+ class Meta:
344
+ model = TestModelForeignKey
345
+ schema_out = serializers.SchemaModelConfig(
346
+ fields=["id", "name"],
347
+ exclude=["test_model"], # Exclude forward relation
348
+ )
349
+ # No schema_detail defined - should fall back to schema_out
350
+
351
+ # Detail should inherit excludes from read schema
352
+ schema_detail = DetailFallbackExcludesSerializer.generate_detail_s()
353
+ read_schema = DetailFallbackExcludesSerializer.generate_read_s()
354
+ self.assertIsNotNone(schema_detail)
355
+ # Both should have the same excludes applied
356
+ self.assertEqual(
357
+ set(schema_detail.model_fields.keys()),
358
+ set(read_schema.model_fields.keys()),
359
+ )
360
+
361
+ def test_detail_does_not_inherit_when_defined(self):
362
+ """Test that detail does NOT inherit from read when schema_detail is defined."""
363
+
364
+ class DetailDoesNotInheritSerializer(serializers.Serializer):
365
+ class Meta:
366
+ model = TestModelForeignKey
367
+ schema_out = serializers.SchemaModelConfig(
368
+ fields=["id", "name"],
369
+ customs=[("read_custom", str, "default")],
370
+ )
371
+ schema_detail = serializers.SchemaModelConfig(
372
+ fields=["id", "name", "description"],
373
+ # No customs defined - does NOT inherit from read
374
+ # because schema_detail is defined (fallback is at schema level)
375
+ )
376
+
377
+ # Read schema should have the custom
378
+ schema_read = DetailDoesNotInheritSerializer.generate_read_s()
379
+ self.assertIn("read_custom", schema_read.model_fields)
380
+
381
+ # Detail schema does NOT inherit customs from read because schema_detail is defined
382
+ # (Serializer fallback is at the schema level, not per-field-type)
383
+ schema_detail = DetailDoesNotInheritSerializer.generate_detail_s()
384
+ self.assertIn("id", schema_detail.model_fields)
385
+ self.assertIn("name", schema_detail.model_fields)
386
+ self.assertIn("description", schema_detail.model_fields)
387
+ self.assertNotIn("read_custom", schema_detail.model_fields)
388
+
301
389
  def test_generate_detail_schema_with_serializer(self):
302
390
  """Test generate_detail_s() with Serializer class."""
303
391
 
@@ -416,3 +504,97 @@ class DetailSerializerTestCase(TestCase):
416
504
  self.assertIn("id", schema_detail.model_fields)
417
505
  self.assertIn("name", schema_detail.model_fields)
418
506
  self.assertIn("description", schema_detail.model_fields)
507
+
508
+
509
+ @tag("serializers", "detail", "model_serializer")
510
+ class ModelSerializerDetailFallbackTestCase(TestCase):
511
+ """Test cases for ModelSerializer detail->read fallback behavior."""
512
+
513
+ def setUp(self):
514
+ warnings.simplefilter("ignore", UserWarning)
515
+
516
+ def test_model_serializer_detail_fallback_fields(self):
517
+ """Test ModelSerializer detail falls back to read fields when not configured."""
518
+ from tests.test_app.models import TestModelSerializer
519
+
520
+ # TestModelSerializer has ReadSerializer with fields but no DetailSerializer
521
+ read_fields = TestModelSerializer.get_fields("read")
522
+ detail_fields = TestModelSerializer.get_fields("detail")
523
+
524
+ # Detail should fall back to read fields
525
+ self.assertEqual(read_fields, detail_fields)
526
+
527
+ def test_model_serializer_detail_fallback_customs(self):
528
+ """Test ModelSerializer detail falls back to read customs when not configured."""
529
+ from tests.test_app.models import TestModelSerializerWithReadCustoms
530
+
531
+ read_customs = TestModelSerializerWithReadCustoms.get_custom_fields("read")
532
+ detail_customs = TestModelSerializerWithReadCustoms.get_custom_fields("detail")
533
+
534
+ # Detail should fall back to read customs
535
+ self.assertEqual(read_customs, detail_customs)
536
+ self.assertEqual(len(detail_customs), 1)
537
+ self.assertEqual(detail_customs[0][0], "custom_field")
538
+
539
+ def test_model_serializer_detail_fallback_optionals(self):
540
+ """Test ModelSerializer detail falls back to read optionals when not configured."""
541
+ from tests.test_app.models import TestModelSerializerWithReadOptionals
542
+
543
+ read_optionals = TestModelSerializerWithReadOptionals.get_optional_fields("read")
544
+ detail_optionals = TestModelSerializerWithReadOptionals.get_optional_fields("detail")
545
+
546
+ # Detail should fall back to read optionals
547
+ self.assertEqual(read_optionals, detail_optionals)
548
+ self.assertEqual(len(detail_optionals), 1)
549
+ self.assertEqual(detail_optionals[0][0], "description")
550
+
551
+ def test_model_serializer_detail_fallback_excludes(self):
552
+ """Test ModelSerializer detail falls back to read excludes when not configured."""
553
+ from tests.test_app.models import TestModelSerializerWithReadExcludes
554
+
555
+ read_excludes = TestModelSerializerWithReadExcludes.get_excluded_fields("read")
556
+ detail_excludes = TestModelSerializerWithReadExcludes.get_excluded_fields("detail")
557
+
558
+ # Detail should fall back to read excludes
559
+ self.assertEqual(read_excludes, detail_excludes)
560
+ self.assertIn("description", detail_excludes)
561
+
562
+ def test_model_serializer_detail_inherits_per_field_type(self):
563
+ """Test ModelSerializer detail inherits from read per-field-type when empty."""
564
+ from tests.test_app.models import TestModelSerializerWithBothSerializers
565
+
566
+ read_customs = TestModelSerializerWithBothSerializers.get_custom_fields("read")
567
+ detail_customs = TestModelSerializerWithBothSerializers.get_custom_fields("detail")
568
+
569
+ # Read has customs defined
570
+ self.assertEqual(len(read_customs), 1)
571
+ # Detail inherits customs from read because DetailSerializer.customs is empty
572
+ # (fallback is per-field-type, not per-serializer)
573
+ self.assertEqual(len(detail_customs), 1)
574
+ self.assertEqual(detail_customs[0][0], "read_custom")
575
+
576
+ # But fields are different (DetailSerializer.fields overrides)
577
+ read_fields = TestModelSerializerWithBothSerializers.get_fields("read")
578
+ detail_fields = TestModelSerializerWithBothSerializers.get_fields("detail")
579
+ self.assertEqual(read_fields, ["id", "name"])
580
+ self.assertEqual(detail_fields, ["id", "name", "description"])
581
+
582
+ def test_model_serializer_with_detail_generates_different_schemas(self):
583
+ """Test that ModelSerializer with DetailSerializer generates distinct schemas."""
584
+ from tests.test_app.models import TestModelSerializerWithDetail
585
+
586
+ read_schema = TestModelSerializerWithDetail.generate_read_s()
587
+ detail_schema = TestModelSerializerWithDetail.generate_detail_s()
588
+
589
+ # Read schema should have fewer fields
590
+ self.assertIn("id", read_schema.model_fields)
591
+ self.assertIn("name", read_schema.model_fields)
592
+ self.assertNotIn("description", read_schema.model_fields)
593
+ self.assertNotIn("extra_info", read_schema.model_fields)
594
+
595
+ # Detail schema should have more fields plus custom
596
+ self.assertIn("id", detail_schema.model_fields)
597
+ self.assertIn("name", detail_schema.model_fields)
598
+ self.assertIn("description", detail_schema.model_fields)
599
+ self.assertIn("extra_info", detail_schema.model_fields)
600
+ self.assertIn("computed_field", detail_schema.model_fields)