django-ninja-aio-crud 2.16.1__tar.gz → 2.17.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 (104) hide show
  1. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/PKG-INFO +1 -1
  2. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/models/model_serializer.md +26 -9
  3. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/models/serializers.md +38 -1
  4. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/__init__.py +1 -1
  5. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/models/serializers.py +96 -43
  6. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_app/models.py +10 -0
  7. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_serializers.py +229 -0
  8. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/.github/dependabot.yml +0 -0
  9. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/.github/workflows/coverage.yml +0 -0
  10. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/.github/workflows/docs.yml +0 -0
  11. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/.github/workflows/publish.yml +0 -0
  12. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/.gitignore +0 -0
  13. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/.pre-commit-config.yaml +0 -0
  14. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/LICENSE +0 -0
  15. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/README.md +0 -0
  16. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/CNAME +0 -0
  17. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/authentication.md +0 -0
  18. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/models/model_util.md +0 -0
  19. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/pagination.md +0 -0
  20. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/renderers/orjson_renderer.md +0 -0
  21. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/views/api_view.md +0 -0
  22. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/views/api_view_set.md +0 -0
  23. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/views/decorators.md +0 -0
  24. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/api/views/mixins.md +0 -0
  25. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/auth.md +0 -0
  26. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/contributing.md +0 -0
  27. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/extra.css +0 -0
  28. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/getting_started/images/index/foo-index-create-swagger.png +0 -0
  29. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/getting_started/images/index/foo-index-delete-swagger.png +0 -0
  30. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/getting_started/images/index/foo-index-list-swagger.png +0 -0
  31. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/getting_started/images/index/foo-index-retrieve-swagger.png +0 -0
  32. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/getting_started/images/index/foo-index-swagger.png +0 -0
  33. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/getting_started/images/index/foo-index-update-swagger.png +0 -0
  34. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/getting_started/installation.md +0 -0
  35. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/getting_started/quick_start.md +0 -0
  36. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/getting_started/quick_start_serializer.md +0 -0
  37. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/images/bar-swagger.png +0 -0
  38. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/images/favicon.ico +0 -0
  39. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/images/foo-swagger.png +0 -0
  40. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/images/logo.png +0 -0
  41. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/images/model_util/foo-reverse-relations-swagger.png +0 -0
  42. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/index.md +0 -0
  43. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/release_notes.md +0 -0
  44. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/requirements.txt +0 -0
  45. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/tutorial/authentication.md +0 -0
  46. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/tutorial/crud.md +0 -0
  47. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/tutorial/filtering.md +0 -0
  48. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/docs/tutorial/model.md +0 -0
  49. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/main.py +0 -0
  50. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/mkdocs.yml +0 -0
  51. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/api.py +0 -0
  52. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/auth.py +0 -0
  53. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/decorators/__init__.py +0 -0
  54. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/decorators/operations.py +0 -0
  55. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/decorators/views.py +0 -0
  56. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/exceptions.py +0 -0
  57. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/factory/__init__.py +0 -0
  58. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/factory/operations.py +0 -0
  59. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/helpers/__init__.py +0 -0
  60. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/helpers/api.py +0 -0
  61. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/helpers/query.py +0 -0
  62. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/models/__init__.py +0 -0
  63. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/models/utils.py +0 -0
  64. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/parsers.py +0 -0
  65. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/renders.py +0 -0
  66. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/schemas/__init__.py +0 -0
  67. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/schemas/api.py +0 -0
  68. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/schemas/filters.py +0 -0
  69. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/schemas/generics.py +0 -0
  70. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/schemas/helpers.py +0 -0
  71. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/types.py +0 -0
  72. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/views/__init__.py +0 -0
  73. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/views/api.py +0 -0
  74. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/ninja_aio/views/mixins.py +0 -0
  75. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/pyproject.toml +0 -0
  76. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/requirements.dev.txt +0 -0
  77. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/run-local-coverage.sh +0 -0
  78. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/__init__.py +0 -0
  79. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/core/__init__.py +0 -0
  80. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/core/test_decorators.py +0 -0
  81. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/core/test_exceptions_api.py +0 -0
  82. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/core/test_renderer_parser.py +0 -0
  83. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/generics/__init__.py +0 -0
  84. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/generics/literals.py +0 -0
  85. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/generics/models.py +0 -0
  86. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/generics/request.py +0 -0
  87. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/generics/views.py +0 -0
  88. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/helpers/__init__.py +0 -0
  89. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/helpers/test_many_to_many_api.py +0 -0
  90. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/models/__init__.py +0 -0
  91. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/models/test_model_util.py +0 -0
  92. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/models/test_models_extra.py +0 -0
  93. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_app/__init__.py +0 -0
  94. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_app/schema.py +0 -0
  95. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_app/serializers.py +0 -0
  96. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_app/views.py +0 -0
  97. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_auth.py +0 -0
  98. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_decorators.py +0 -0
  99. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_exceptions.py +0 -0
  100. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_query_util.py +0 -0
  101. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/test_settings.py +0 -0
  102. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/views/__init__.py +0 -0
  103. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.0}/tests/views/test_views.py +0 -0
  104. {django_ninja_aio_crud-2.16.1 → django_ninja_aio_crud-2.17.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.16.1
3
+ Version: 2.17.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -49,7 +49,7 @@ Describes how to build a create (input) schema for a model.
49
49
 
50
50
  | Attribute | Type | Description |
51
51
  | ----------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
52
- | `fields` | `list[str]` | REQUIRED model field names for creation |
52
+ | `fields` | `list[str \| tuple]` | REQUIRED model field names for creation. Can also include inline custom tuples (see below) |
53
53
  | `optionals` | `list[tuple[str, type]]` | Optional model fields: `(field_name, python_type)` |
54
54
  | `customs` | `list[tuple]` | Synthetic inputs. Tuple forms: `(name, type)` = required (no default); `(name, type, default)` = optional (literal or callable) |
55
55
  | `excludes` | `list[str]` | Field names rejected on create |
@@ -75,6 +75,23 @@ class User(ModelSerializer):
75
75
  excludes = ["id", "created_at"]
76
76
  ```
77
77
 
78
+ **Inline Custom Fields:**
79
+
80
+ You can also define custom fields directly in the `fields` list as tuples:
81
+
82
+ ```python
83
+ class User(ModelSerializer):
84
+ class CreateSerializer:
85
+ fields = [
86
+ "username",
87
+ "email",
88
+ ("password_confirm", str), # 2-tuple: required
89
+ ("send_welcome", bool, True), # 3-tuple: optional with default
90
+ ]
91
+ ```
92
+
93
+ This is equivalent to using the separate `customs` list but keeps field definitions together.
94
+
78
95
  **Resolution Order for `customs`:**
79
96
 
80
97
  1. Payload value (if provided)
@@ -100,12 +117,12 @@ Describes how to build a read (output) schema for a model.
100
117
 
101
118
  **Attributes**
102
119
 
103
- | Attribute | Type | Description |
104
- |------------------|-----------------|-------------|
105
- | `fields` | `list[str]` | **REQUIRED.** Model fields / related names explicitly included in the read (output) schema. |
106
- | `excludes` | `list[str]` | Fields / related names to always omit (takes precedence over `fields` and `optionals`). Use for sensitive or noisy data (e.g., passwords, internal flags). |
107
- | `customs` | `list[tuple]` | Computed / synthetic output values. Tuple formats:<br>• `(name, type)` = required resolvable attribute (object attribute or property). Serialization error if not resolvable.<br>• `(name, type, default)` = optional; default may be a callable (`lambda obj: ...`) or a literal value. |
108
- | `relations_as_id`| `list[str]` | Relation fields to serialize as IDs instead of nested objects. Works with forward FK, forward O2O, reverse FK, reverse O2O, and M2M relations. |
120
+ | Attribute | Type | Description |
121
+ |------------------|----------------------|-------------|
122
+ | `fields` | `list[str \| tuple]` | **REQUIRED.** Model fields / related names explicitly included in the read (output) schema. Can also include inline custom tuples. |
123
+ | `excludes` | `list[str]` | Fields / related names to always omit (takes precedence over `fields` and `optionals`). Use for sensitive or noisy data (e.g., passwords, internal flags). |
124
+ | `customs` | `list[tuple]` | Computed / synthetic output values. Tuple formats:<br>• `(name, type)` = required resolvable attribute (object attribute or property). Serialization error if not resolvable.<br>• `(name, type, default)` = optional; default may be a callable (`lambda obj: ...`) or a literal value. |
125
+ | `relations_as_id`| `list[str]` | Relation fields to serialize as IDs instead of nested objects. Works with forward FK, forward O2O, reverse FK, reverse O2O, and M2M relations. |
109
126
 
110
127
  **Example:**
111
128
 
@@ -163,7 +180,7 @@ This allows partial overrides: define only `DetailSerializer.fields` while inher
163
180
 
164
181
  | Attribute | Type | Description |
165
182
  | ----------- | ------------------------ | ----------------------------------------------------------------------------- |
166
- | `fields` | `list[str]` | Model fields to include in detail view (falls back to ReadSerializer.fields if empty) |
183
+ | `fields` | `list[str \| tuple]` | Model fields to include in detail view. Can include inline custom tuples. Falls back to ReadSerializer.fields if empty |
167
184
  | `excludes` | `list[str]` | Fields to exclude from detail view (falls back to ReadSerializer.excludes if empty) |
168
185
  | `customs` | `list[tuple]` | Computed fields: `(name, type)` required; `(name, type, default)` optional (falls back to ReadSerializer.customs if empty) |
169
186
  | `optionals` | `list[tuple[str, type]]` | Optional output fields (falls back to ReadSerializer.optionals if empty) |
@@ -240,7 +257,7 @@ Describes how to build an update (partial/full) input schema.
240
257
 
241
258
  | Attribute | Type | Description |
242
259
  | ----------- | ------------------------ | ----------------------------------------------------------------------------- |
243
- | `fields` | `list[str]` | REQUIRED fields for update (rarely used) |
260
+ | `fields` | `list[str \| tuple]` | REQUIRED fields for update (rarely used). Can include inline custom tuples |
244
261
  | `optionals` | `list[tuple[str, type]]` | Updatable optional fields (typical for PATCH) |
245
262
  | `customs` | `list[tuple]` | Instruction fields: `(name, type)` required; `(name, type, default)` optional |
246
263
  | `excludes` | `list[str]` | Immutable fields that cannot be updated |
@@ -45,11 +45,48 @@ Define a Serializer subclass with a nested Meta:
45
45
 
46
46
  SchemaModelConfig fields:
47
47
 
48
- - **fields**: `list[str]` - Model field names to include
48
+ - **fields**: `list[str | tuple]` - Model field names to include. Can also contain inline custom field tuples (see below)
49
49
  - **optionals**: `list[tuple[str, type]]` - Optional fields with their types
50
50
  - **exclude**: `list[str]` - Fields to exclude from schema
51
51
  - **customs**: `list[tuple[str, type, Any]]` - Custom/computed fields
52
52
 
53
+ ### Inline Custom Fields
54
+
55
+ You can define custom fields directly in the `fields` list as tuples, providing a more concise syntax:
56
+
57
+ ```python
58
+ class ArticleSerializer(serializers.Serializer):
59
+ class Meta:
60
+ model = models.Article
61
+ schema_out = serializers.SchemaModelConfig(
62
+ # Mix regular fields with inline custom tuples
63
+ fields=[
64
+ "id",
65
+ "title",
66
+ ("word_count", int, 0), # 3-tuple: (name, type, default)
67
+ ("is_featured", bool), # 2-tuple: (name, type) - required
68
+ ]
69
+ )
70
+ ```
71
+
72
+ **Tuple formats:**
73
+
74
+ - **2-tuple**: `(name, type)` - Required field (equivalent to default `...`)
75
+ - **3-tuple**: `(name, type, default)` - Optional field with default value
76
+
77
+ This is equivalent to using the separate `customs` list but keeps field definitions together:
78
+
79
+ ```python
80
+ # These two are equivalent:
81
+
82
+ # Using inline customs
83
+ fields=["id", "title", ("extra", str, "default")]
84
+
85
+ # Using separate customs list
86
+ fields=["id", "title"]
87
+ customs=[("extra", str, "default")]
88
+ ```
89
+
53
90
  ### Schema Generation
54
91
 
55
92
  Generate schemas explicitly using these methods:
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.16.1"
3
+ __version__ = "2.17.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -305,7 +305,9 @@ class BaseSerializer:
305
305
  """
306
306
  # Auto-resolve ModelSerializer with readable fields
307
307
  if isinstance(rel_model, ModelSerializerMeta):
308
- has_readable_fields = rel_model.get_fields("read") or rel_model.get_custom_fields("read")
308
+ has_readable_fields = rel_model.get_fields(
309
+ "read"
310
+ ) or rel_model.get_custom_fields("read")
309
311
  return rel_model.generate_related_s() if has_readable_fields else None
310
312
 
311
313
  # Resolve from explicit serializer mapping
@@ -341,7 +343,7 @@ class BaseSerializer:
341
343
  - (name, py_type) -> default Ellipsis (required)
342
344
  """
343
345
  raw_customs = cls._get_fields(s_type, "customs") or []
344
- normalized: list[tuple[str, type, Any]] = []
346
+ normalized: list[tuple[str, Any, Any]] = []
345
347
  for spec in raw_customs:
346
348
  if not isinstance(spec, tuple):
347
349
  raise ValueError(f"Custom field spec must be a tuple, got {type(spec)}")
@@ -373,8 +375,39 @@ class BaseSerializer:
373
375
 
374
376
  @classmethod
375
377
  def get_fields(cls, s_type: S_TYPES):
376
- """Return explicit declared fields for the serializer type."""
377
- return cls._get_fields(s_type, "fields")
378
+ """Return explicit declared field names for the serializer type (excludes inline customs)."""
379
+ fields = cls._get_fields(s_type, "fields")
380
+ # Filter out inline custom field tuples, return only string field names
381
+ return [f for f in fields if isinstance(f, str)]
382
+
383
+ @classmethod
384
+ def get_inline_customs(cls, s_type: S_TYPES) -> list[tuple[str, Any, Any]]:
385
+ """
386
+ Return inline custom field tuples declared directly in the fields list.
387
+
388
+ These are tuples in the format (name, type, default) or (name, type) mixed
389
+ with regular string field names in the fields list.
390
+
391
+ Returns
392
+ -------
393
+ list[tuple[str, Any, Any]]
394
+ Normalized list of (name, type, default) tuples.
395
+ """
396
+ fields = cls._get_fields(s_type, "fields")
397
+ inline_customs: list[tuple[str, Any, Any]] = []
398
+ for spec in fields:
399
+ if isinstance(spec, tuple):
400
+ match len(spec):
401
+ case 3:
402
+ inline_customs.append(spec)
403
+ case 2:
404
+ name, py_type = spec
405
+ inline_customs.append((name, py_type, ...))
406
+ case _:
407
+ raise ValueError(
408
+ f"Inline custom field tuple must have length 2 or 3 (name, type[, default]); got {len(spec)}"
409
+ )
410
+ return inline_customs
378
411
 
379
412
  @classmethod
380
413
  def is_custom(cls, field: str) -> bool:
@@ -430,10 +463,15 @@ class BaseSerializer:
430
463
  # Handle relations_as_id for reverse relations
431
464
  if field_name in relations_as_id:
432
465
  from ninja_aio.models.utils import ModelUtil
466
+
433
467
  pk_field_type = ModelUtil(rel_model).pk_field_type
434
468
  if many:
435
469
  # For many relations, use PkFromModel to extract PKs from model instances
436
- return (field_name, list[PkFromModel[pk_field_type]], Field(default_factory=list))
470
+ return (
471
+ field_name,
472
+ list[PkFromModel[pk_field_type]],
473
+ Field(default_factory=list),
474
+ )
437
475
  else:
438
476
  # For single reverse relations (ReverseOneToOne), extract pk
439
477
  return (field_name, PkFromModel[pk_field_type] | None, None)
@@ -472,6 +510,7 @@ class BaseSerializer:
472
510
  # Handle relations_as_id: serialize as the raw FK ID
473
511
  if field_name in relations_as_id:
474
512
  from ninja_aio.models.utils import ModelUtil
513
+
475
514
  pk_field_type = ModelUtil(rel_model).pk_field_type
476
515
  # Use PkFromModel to extract pk from the related instance during serialization
477
516
  return (field_name, PkFromModel[pk_field_type] | None, None)
@@ -614,11 +653,18 @@ class BaseSerializer:
614
653
  if forward:
615
654
  forward_rels.append(forward)
616
655
 
656
+ # Combine explicit customs, inline customs, and forward relation schemas
657
+ all_customs = (
658
+ cls.get_custom_fields(fields_type)
659
+ + cls.get_inline_customs(fields_type)
660
+ + forward_rels
661
+ )
662
+
617
663
  return (
618
664
  fields,
619
665
  reverse_rels,
620
666
  cls.get_excluded_fields(fields_type),
621
- cls.get_custom_fields(fields_type) + forward_rels,
667
+ all_customs,
622
668
  cls.get_optional_fields(fields_type),
623
669
  )
624
670
 
@@ -666,7 +712,11 @@ class BaseSerializer:
666
712
  s_type = "create" if schema_type == "In" else "update"
667
713
  fields = cls.get_fields(s_type)
668
714
  optionals = cls.get_optional_fields(s_type)
669
- customs = cls.get_custom_fields(s_type) + optionals
715
+ customs = (
716
+ cls.get_custom_fields(s_type)
717
+ + optionals
718
+ + cls.get_inline_customs(s_type)
719
+ )
670
720
  excludes = cls.get_excluded_fields(s_type)
671
721
 
672
722
  # If no explicit fields and no excludes specified
@@ -698,17 +748,19 @@ class BaseSerializer:
698
748
  def get_related_schema_data(cls):
699
749
  """
700
750
  Build field/custom lists for 'Related' schema, flattening non-relational fields.
751
+
752
+ Custom fields (both explicit and inline) are always included since they
753
+ are computed/synthetic and not relation descriptors.
701
754
  """
702
755
  fields = cls.get_fields("read")
703
- custom_f = {
704
- name: (value, default)
705
- for name, value, default in cls.get_custom_fields("read")
706
- }
707
- _related_fields = []
756
+ customs = cls.get_custom_fields("read") + cls.get_inline_customs("read")
708
757
  model = cls._get_model()
709
- for f in fields + list(custom_f.keys()):
710
- field_obj = getattr(model, f)
711
- if not isinstance(
758
+
759
+ # Filter out relation fields from model fields
760
+ non_relation_fields = []
761
+ for f in fields:
762
+ field_obj = getattr(model, f, None)
763
+ if field_obj is None or not isinstance(
712
764
  field_obj,
713
765
  (
714
766
  ManyToManyDescriptor,
@@ -718,14 +770,13 @@ class BaseSerializer:
718
770
  ForwardOneToOneDescriptor,
719
771
  ),
720
772
  ):
721
- _related_fields.append(f)
722
- if not _related_fields:
773
+ non_relation_fields.append(f)
774
+
775
+ # No fields or customs means nothing to include
776
+ if not non_relation_fields and not customs:
723
777
  return None, None
724
- custom_related_fields = [
725
- (f, *custom_f[f]) for f in _related_fields if f in custom_f
726
- ]
727
- related_fields = [f for f in _related_fields if f not in custom_f]
728
- return related_fields, custom_related_fields
778
+
779
+ return non_relation_fields, customs
729
780
 
730
781
  @classmethod
731
782
  def generate_read_s(cls, depth: int = 1) -> Schema:
@@ -810,9 +861,9 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
810
861
  Disallowed model fields on create (e.g., id, timestamps).
811
862
  """
812
863
 
813
- fields: list[str] = []
814
- customs: list[tuple[str, type, Any]] = []
815
- optionals: list[tuple[str, type]] = []
864
+ fields: list[str | tuple[str, Any, Any]] = []
865
+ customs: list[tuple[str, Any, Any]] = []
866
+ optionals: list[tuple[str, Any]] = []
816
867
  excludes: list[str] = []
817
868
 
818
869
  class ReadSerializer:
@@ -832,9 +883,9 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
832
883
  Relation fields to serialize as IDs instead of nested objects.
833
884
  """
834
885
 
835
- fields: list[str] = []
836
- customs: list[tuple[str, type, Any]] = []
837
- optionals: list[tuple[str, type]] = []
886
+ fields: list[str | tuple[str, Any, Any]] = []
887
+ customs: list[tuple[str, Any, Any]] = []
888
+ optionals: list[tuple[str, Any]] = []
838
889
  excludes: list[str] = []
839
890
  relations_as_id: list[str] = []
840
891
 
@@ -853,9 +904,9 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
853
904
  Optional output fields.
854
905
  """
855
906
 
856
- fields: list[str] = []
857
- customs: list[tuple[str, type, Any]] = []
858
- optionals: list[tuple[str, type]] = []
907
+ fields: list[str | tuple[str, Any, Any]] = []
908
+ customs: list[tuple[str, Any, Any]] = []
909
+ optionals: list[tuple[str, Any]] = []
859
910
  excludes: list[str] = []
860
911
 
861
912
  class UpdateSerializer:
@@ -873,9 +924,9 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
873
924
  Immutable / blocked fields.
874
925
  """
875
926
 
876
- fields: list[str] = []
877
- customs: list[tuple[str, type, Any]] = []
878
- optionals: list[tuple[str, type]] = []
927
+ fields: list[str | tuple[str, Any, Any]] = []
928
+ customs: list[tuple[str, Any, Any]] = []
929
+ optionals: list[tuple[str, Any]] = []
879
930
  excludes: list[str] = []
880
931
 
881
932
  # Serializer type to configuration class mapping
@@ -1044,20 +1095,22 @@ class SchemaModelConfig(Schema):
1044
1095
  Configuration container for declarative schema definitions.
1045
1096
  Attributes
1046
1097
  ----------
1047
- fields : Optional[List[str]]
1048
- Explicit model fields to include.
1049
- optionals : Optional[List[tuple[str, type]]]
1050
- Optional model fields.
1098
+ fields : Optional[List[str | tuple]]
1099
+ Explicit model fields to include. Can also contain inline custom field tuples:
1100
+ - 2-tuple: (name, type) - required field
1101
+ - 3-tuple: (name, type, default) - optional field with default
1102
+ optionals : Optional[List[tuple[str, Any]]]
1103
+ Optional model fields. Type can be any valid type annotation including Union.
1051
1104
  exclude : Optional[List[str]]
1052
1105
  Model fields to exclude.
1053
- customs : Optional[List[tuple[str, type, Any]]]
1054
- Custom / synthetic fields.
1106
+ customs : Optional[List[tuple[str, Any, Any]]]
1107
+ Custom / synthetic fields. Type can be any valid type annotation including Union.
1055
1108
  """
1056
1109
 
1057
- fields: Optional[List[str]] = None
1058
- optionals: Optional[List[tuple[str, type]]] = None
1110
+ fields: Optional[List[str | tuple[str, Any, Any] | tuple[str, Any]]] = None
1111
+ optionals: Optional[List[tuple[str, Any]]] = None
1059
1112
  exclude: Optional[List[str]] = None
1060
- customs: Optional[List[tuple[str, type, Any]]] = None
1113
+ customs: Optional[List[tuple[str, Any, Any]]] = None
1061
1114
 
1062
1115
 
1063
1116
  class Serializer(BaseSerializer, metaclass=SerializerMeta):
@@ -227,6 +227,16 @@ class TestModelSerializerWithBothSerializers(BaseTestModelSerializer):
227
227
  # No customs defined - should NOT inherit from read
228
228
 
229
229
 
230
+ class TestModelSerializerInlineCustoms(BaseTestModelSerializer):
231
+ """Model with inline custom fields defined directly in the fields list."""
232
+
233
+ class ReadSerializer:
234
+ fields = ["id", "name", ("inline_computed", str, "computed_value")]
235
+
236
+ class CreateSerializer:
237
+ fields = ["name", ("extra_create_input", str, "")]
238
+
239
+
230
240
  # ==========================================================
231
241
  # RELATIONS AS ID TEST MODELS
232
242
  # ==========================================================
@@ -1506,3 +1506,232 @@ class RelationsAsIdStringPKIntegrationTestCase(TestCase):
1506
1506
  self.assertIsInstance(result.articles_str[0], str)
1507
1507
  self.assertIn(self.article.pk, result.articles_str)
1508
1508
  self.assertEqual(result.articles_str[0], "article-001")
1509
+
1510
+
1511
+ @tag("serializers", "inline_customs")
1512
+ class InlineCustomsSerializerTestCase(TestCase):
1513
+ """Test cases for inline custom fields defined directly in the fields list."""
1514
+
1515
+ def setUp(self):
1516
+ warnings.simplefilter("ignore", UserWarning)
1517
+
1518
+ def test_serializer_read_schema_with_inline_customs_3_tuple(self):
1519
+ """Test that inline customs (3-tuple) in schema_out fields work correctly."""
1520
+
1521
+ class InlineCustomsReadSerializer(serializers.Serializer):
1522
+ class Meta:
1523
+ model = TestModelForeignKey
1524
+ schema_out = serializers.SchemaModelConfig(
1525
+ fields=["id", "name", ("custom_read", str, "default_value")]
1526
+ )
1527
+
1528
+ schema = InlineCustomsReadSerializer.generate_read_s()
1529
+ self.assertIsNotNone(schema)
1530
+ self.assertIn("id", schema.model_fields)
1531
+ self.assertIn("name", schema.model_fields)
1532
+ self.assertIn("custom_read", schema.model_fields)
1533
+
1534
+ def test_serializer_read_schema_with_inline_customs_2_tuple(self):
1535
+ """Test that inline customs (2-tuple, required) in schema_out fields work correctly."""
1536
+
1537
+ class InlineCustoms2TupleSerializer(serializers.Serializer):
1538
+ class Meta:
1539
+ model = TestModelForeignKey
1540
+ schema_out = serializers.SchemaModelConfig(
1541
+ fields=["id", "name", ("required_custom", int)]
1542
+ )
1543
+
1544
+ schema = InlineCustoms2TupleSerializer.generate_read_s()
1545
+ self.assertIsNotNone(schema)
1546
+ self.assertIn("id", schema.model_fields)
1547
+ self.assertIn("name", schema.model_fields)
1548
+ self.assertIn("required_custom", schema.model_fields)
1549
+
1550
+ def test_serializer_create_schema_with_inline_customs(self):
1551
+ """Test that inline customs in schema_in fields work correctly."""
1552
+
1553
+ class InlineCustomsCreateSerializer(serializers.Serializer):
1554
+ class Meta:
1555
+ model = TestModelForeignKey
1556
+ schema_in = serializers.SchemaModelConfig(
1557
+ fields=["name", ("extra_input", str, "")]
1558
+ )
1559
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1560
+
1561
+ schema = InlineCustomsCreateSerializer.generate_create_s()
1562
+ self.assertIsNotNone(schema)
1563
+ self.assertIn("name", schema.model_fields)
1564
+ self.assertIn("extra_input", schema.model_fields)
1565
+ # Should NOT have model fields that weren't explicitly listed
1566
+ self.assertNotIn("description", schema.model_fields)
1567
+
1568
+ def test_serializer_update_schema_with_inline_customs(self):
1569
+ """Test that inline customs in schema_update fields work correctly."""
1570
+
1571
+ class InlineCustomsUpdateSerializer(serializers.Serializer):
1572
+ class Meta:
1573
+ model = TestModelForeignKey
1574
+ schema_update = serializers.SchemaModelConfig(
1575
+ fields=[("update_flag", bool, False)],
1576
+ optionals=[("name", str)],
1577
+ )
1578
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1579
+
1580
+ schema = InlineCustomsUpdateSerializer.generate_update_s()
1581
+ self.assertIsNotNone(schema)
1582
+ self.assertIn("update_flag", schema.model_fields)
1583
+ self.assertIn("name", schema.model_fields)
1584
+
1585
+ def test_serializer_inline_customs_combined_with_explicit_customs(self):
1586
+ """Test that inline customs and explicit customs can coexist."""
1587
+
1588
+ class CombinedCustomsSerializer(serializers.Serializer):
1589
+ class Meta:
1590
+ model = TestModelForeignKey
1591
+ schema_out = serializers.SchemaModelConfig(
1592
+ fields=["id", "name", ("inline_custom", str, "inline")],
1593
+ customs=[("explicit_custom", int, 0)],
1594
+ )
1595
+
1596
+ schema = CombinedCustomsSerializer.generate_read_s()
1597
+ self.assertIsNotNone(schema)
1598
+ self.assertIn("id", schema.model_fields)
1599
+ self.assertIn("name", schema.model_fields)
1600
+ self.assertIn("inline_custom", schema.model_fields)
1601
+ self.assertIn("explicit_custom", schema.model_fields)
1602
+
1603
+ def test_serializer_get_fields_excludes_inline_customs(self):
1604
+ """Test that get_fields() returns only string field names."""
1605
+
1606
+ class FieldsOnlySerializer(serializers.Serializer):
1607
+ class Meta:
1608
+ model = TestModelForeignKey
1609
+ schema_out = serializers.SchemaModelConfig(
1610
+ fields=["id", "name", ("custom", str, "val")]
1611
+ )
1612
+
1613
+ fields = FieldsOnlySerializer.get_fields("read")
1614
+ self.assertEqual(fields, ["id", "name"])
1615
+ # Should not include tuples
1616
+ self.assertNotIn(("custom", str, "val"), fields)
1617
+
1618
+ def test_serializer_get_inline_customs_returns_only_tuples(self):
1619
+ """Test that get_inline_customs() returns only inline custom tuples."""
1620
+
1621
+ class InlineCustomsOnlySerializer(serializers.Serializer):
1622
+ class Meta:
1623
+ model = TestModelForeignKey
1624
+ schema_out = serializers.SchemaModelConfig(
1625
+ fields=["id", "name", ("custom1", str, "val"), ("custom2", int)]
1626
+ )
1627
+
1628
+ inline_customs = InlineCustomsOnlySerializer.get_inline_customs("read")
1629
+ self.assertEqual(len(inline_customs), 2)
1630
+ # First is a 3-tuple
1631
+ self.assertEqual(inline_customs[0], ("custom1", str, "val"))
1632
+ # Second is normalized from 2-tuple to 3-tuple with Ellipsis
1633
+ self.assertEqual(inline_customs[1], ("custom2", int, ...))
1634
+
1635
+ def test_serializer_detail_schema_with_inline_customs(self):
1636
+ """Test that inline customs work in schema_detail."""
1637
+
1638
+ class DetailInlineCustomsSerializer(serializers.Serializer):
1639
+ class Meta:
1640
+ model = TestModelForeignKey
1641
+ schema_out = serializers.SchemaModelConfig(fields=["id", "name"])
1642
+ schema_detail = serializers.SchemaModelConfig(
1643
+ fields=["id", "name", "description", ("detail_extra", str, "extra")]
1644
+ )
1645
+
1646
+ read_schema = DetailInlineCustomsSerializer.generate_read_s()
1647
+ detail_schema = DetailInlineCustomsSerializer.generate_detail_s()
1648
+
1649
+ # Read schema should NOT have detail_extra
1650
+ self.assertNotIn("detail_extra", read_schema.model_fields)
1651
+
1652
+ # Detail schema should have all fields including inline custom
1653
+ self.assertIn("id", detail_schema.model_fields)
1654
+ self.assertIn("name", detail_schema.model_fields)
1655
+ self.assertIn("description", detail_schema.model_fields)
1656
+ self.assertIn("detail_extra", detail_schema.model_fields)
1657
+
1658
+ def test_serializer_related_schema_with_inline_customs(self):
1659
+ """Test that inline customs are included in related schema for non-relation fields."""
1660
+
1661
+ class RelatedInlineCustomsSerializer(serializers.Serializer):
1662
+ class Meta:
1663
+ model = TestModelForeignKey
1664
+ schema_out = serializers.SchemaModelConfig(
1665
+ fields=["id", "name", ("computed", str, "computed_value")]
1666
+ )
1667
+
1668
+ related_schema = RelatedInlineCustomsSerializer.generate_related_s()
1669
+ self.assertIsNotNone(related_schema)
1670
+ self.assertIn("id", related_schema.model_fields)
1671
+ self.assertIn("name", related_schema.model_fields)
1672
+ self.assertIn("computed", related_schema.model_fields)
1673
+
1674
+ def test_inline_customs_only_schema(self):
1675
+ """Test schema with only inline customs (no regular fields)."""
1676
+
1677
+ class OnlyInlineCustomsSerializer(serializers.Serializer):
1678
+ class Meta:
1679
+ model = TestModelForeignKey
1680
+ schema_in = serializers.SchemaModelConfig(
1681
+ fields=[("custom_only", str, ...)]
1682
+ )
1683
+ schema_out = serializers.SchemaModelConfig(fields=["id"])
1684
+
1685
+ schema = OnlyInlineCustomsSerializer.generate_create_s()
1686
+ self.assertIsNotNone(schema)
1687
+ self.assertIn("custom_only", schema.model_fields)
1688
+ # Should NOT have any model fields auto-included
1689
+ self.assertNotIn("name", schema.model_fields)
1690
+ self.assertNotIn("description", schema.model_fields)
1691
+
1692
+
1693
+ @tag("serializers", "inline_customs", "model_serializer")
1694
+ class InlineCustomsModelSerializerTestCase(TestCase):
1695
+ """Test cases for inline custom fields with ModelSerializer."""
1696
+
1697
+ def setUp(self):
1698
+ warnings.simplefilter("ignore", UserWarning)
1699
+
1700
+ def test_model_serializer_read_schema_with_inline_customs(self):
1701
+ """Test ModelSerializer ReadSerializer with inline customs."""
1702
+ from tests.test_app.models import TestModelSerializerInlineCustoms
1703
+
1704
+ schema = TestModelSerializerInlineCustoms.generate_read_s()
1705
+ self.assertIsNotNone(schema)
1706
+ self.assertIn("id", schema.model_fields)
1707
+ self.assertIn("name", schema.model_fields)
1708
+ self.assertIn("inline_computed", schema.model_fields)
1709
+
1710
+ def test_model_serializer_create_schema_with_inline_customs(self):
1711
+ """Test ModelSerializer CreateSerializer with inline customs."""
1712
+ from tests.test_app.models import TestModelSerializerInlineCustoms
1713
+
1714
+ schema = TestModelSerializerInlineCustoms.generate_create_s()
1715
+ self.assertIsNotNone(schema)
1716
+ self.assertIn("name", schema.model_fields)
1717
+ self.assertIn("extra_create_input", schema.model_fields)
1718
+
1719
+ def test_model_serializer_get_inline_customs(self):
1720
+ """Test get_inline_customs() works for ModelSerializer."""
1721
+ from tests.test_app.models import TestModelSerializerInlineCustoms
1722
+
1723
+ inline_customs = TestModelSerializerInlineCustoms.get_inline_customs("read")
1724
+ self.assertEqual(len(inline_customs), 1)
1725
+ self.assertEqual(inline_customs[0][0], "inline_computed")
1726
+
1727
+ def test_model_serializer_get_fields_excludes_inline_customs(self):
1728
+ """Test get_fields() excludes inline customs for ModelSerializer."""
1729
+ from tests.test_app.models import TestModelSerializerInlineCustoms
1730
+
1731
+ fields = TestModelSerializerInlineCustoms.get_fields("read")
1732
+ self.assertIn("id", fields)
1733
+ self.assertIn("name", fields)
1734
+ self.assertNotIn("inline_computed", fields)
1735
+ # Should not contain tuples
1736
+ for f in fields:
1737
+ self.assertIsInstance(f, str)