django-ninja-aio-crud 2.6.1__py3-none-any.whl → 2.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {django_ninja_aio_crud-2.6.1.dist-info → django_ninja_aio_crud-2.8.0.dist-info}/METADATA +1 -1
- {django_ninja_aio_crud-2.6.1.dist-info → django_ninja_aio_crud-2.8.0.dist-info}/RECORD +10 -10
- ninja_aio/__init__.py +1 -1
- ninja_aio/helpers/api.py +1 -1
- ninja_aio/models/serializers.py +281 -89
- ninja_aio/schemas/helpers.py +32 -6
- ninja_aio/types.py +11 -6
- ninja_aio/views/api.py +21 -7
- {django_ninja_aio_crud-2.6.1.dist-info → django_ninja_aio_crud-2.8.0.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-2.6.1.dist-info → django_ninja_aio_crud-2.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
ninja_aio/__init__.py,sha256=
|
|
1
|
+
ninja_aio/__init__.py,sha256=s2uQ_vbFbWOcD6laB1hG40Mv67xvXbSehMTUbkWCcrA,119
|
|
2
2
|
ninja_aio/api.py,sha256=tuC7vdvn7s1GkCnSFy9Kn1zv0glZfYptRQVvo8ZRtGQ,2429
|
|
3
3
|
ninja_aio/auth.py,sha256=4sWdFPjKiQgUL1d_CSGDblVjnY5ptP6LQha6XXdluJA,9157
|
|
4
4
|
ninja_aio/exceptions.py,sha256=_3xFqfFCOfrrMhSA0xbMqgXy8R0UQjhXaExrFvaDAjY,3891
|
|
5
5
|
ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
|
|
6
6
|
ninja_aio/renders.py,sha256=89g46NWUT8nmDG-rG0nxUYbAQWhuXcYKrPh7e1r_Fc4,1735
|
|
7
|
-
ninja_aio/types.py,sha256=
|
|
7
|
+
ninja_aio/types.py,sha256=E3yfXbNKkvLVcr8bvkHTSyIiCRZ4zumzJaXj1aiBi5U,735
|
|
8
8
|
ninja_aio/decorators/__init__.py,sha256=cDDHD_9EI4CP7c5eL1m2mGNl9bR24i8FAkQsT3_RNGM,371
|
|
9
9
|
ninja_aio/decorators/operations.py,sha256=L9yt2ku5oo4CpOLixCADmkcFjLGsWAn-cg-sDcjFhMA,343
|
|
10
10
|
ninja_aio/decorators/views.py,sha256=0RVU4XaM1HvTQ-BOt_NwUtbhwfHau06lh-O8El1LqQk,8139
|
|
11
11
|
ninja_aio/factory/__init__.py,sha256=IdH2z1ZZpv_IqonaDfVo7IsMzkgop6lHqz42RphUYBU,72
|
|
12
12
|
ninja_aio/factory/operations.py,sha256=OgWGqq4WJ4arSQrH9FGAby9kx-HTdS7MOITxHdYMk18,12051
|
|
13
13
|
ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
ninja_aio/helpers/api.py,sha256=
|
|
14
|
+
ninja_aio/helpers/api.py,sha256=2beyexep-ehgaA_1bV5Yuh3zRDVcRCMkrW94nmfDWEA,20819
|
|
15
15
|
ninja_aio/helpers/query.py,sha256=lzaH-htswoJVRT-W736HGMkpMba1VmN98TBLv5cZx9Q,4549
|
|
16
16
|
ninja_aio/models/__init__.py,sha256=L3UQnQAlKoI3F7jinadL-Nn55hkPvnSRPYW0JtnbWFo,114
|
|
17
|
-
ninja_aio/models/serializers.py,sha256=
|
|
17
|
+
ninja_aio/models/serializers.py,sha256=uviKndPf9HRySup-0t_nULJs-vDLCeGFLtkkQGZvi2E,38142
|
|
18
18
|
ninja_aio/models/utils.py,sha256=P-YfbVyzUfxm_s1BrgSd6Zs0HIGdZ79PU1qM0Ud9-Xs,30492
|
|
19
19
|
ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
|
|
20
20
|
ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
|
|
21
21
|
ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
|
|
22
|
-
ninja_aio/schemas/helpers.py,sha256=
|
|
22
|
+
ninja_aio/schemas/helpers.py,sha256=KkbDgT7DwvdeBHZ__wurQQ9A1AIy-toCIL9dCzkTFhM,8350
|
|
23
23
|
ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
|
|
24
|
-
ninja_aio/views/api.py,sha256=
|
|
24
|
+
ninja_aio/views/api.py,sha256=l7z-Cg_BUPRi3TAGpO2EppVA2tUAWUftQAn_qOn6XlM,21963
|
|
25
25
|
ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
|
|
26
|
-
django_ninja_aio_crud-2.
|
|
27
|
-
django_ninja_aio_crud-2.
|
|
28
|
-
django_ninja_aio_crud-2.
|
|
29
|
-
django_ninja_aio_crud-2.
|
|
26
|
+
django_ninja_aio_crud-2.8.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
|
|
27
|
+
django_ninja_aio_crud-2.8.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
28
|
+
django_ninja_aio_crud-2.8.0.dist-info/METADATA,sha256=k3VWPUTlkXEe0tZRt7cWWtdA_5i_31gjkUM3t402nBI,9963
|
|
29
|
+
django_ninja_aio_crud-2.8.0.dist-info/RECORD,,
|
ninja_aio/__init__.py
CHANGED
ninja_aio/helpers/api.py
CHANGED
|
@@ -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
|
ninja_aio/models/serializers.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any, List, Optional
|
|
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
|
|
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,
|
|
@@ -69,19 +75,14 @@ class BaseSerializer:
|
|
|
69
75
|
raise NotImplementedError
|
|
70
76
|
|
|
71
77
|
@classmethod
|
|
72
|
-
def
|
|
78
|
+
def _resolve_string_reference(cls, string_ref: str) -> type:
|
|
73
79
|
"""
|
|
74
|
-
Resolve a serializer reference
|
|
75
|
-
|
|
76
|
-
This method performs lazy resolution, meaning it will attempt to resolve
|
|
77
|
-
string references only when called, allowing for forward references and
|
|
78
|
-
circular dependencies between serializers in the same module.
|
|
80
|
+
Resolve a string serializer reference to an actual class.
|
|
79
81
|
|
|
80
82
|
Parameters
|
|
81
83
|
----------
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
or an actual serializer class.
|
|
84
|
+
string_ref : str
|
|
85
|
+
String reference (local class name or absolute import path).
|
|
85
86
|
|
|
86
87
|
Returns
|
|
87
88
|
-------
|
|
@@ -93,35 +94,155 @@ class BaseSerializer:
|
|
|
93
94
|
ValueError
|
|
94
95
|
If the string reference cannot be resolved.
|
|
95
96
|
"""
|
|
96
|
-
#
|
|
97
|
-
if
|
|
98
|
-
|
|
97
|
+
# Check if it's an absolute import path (contains dots)
|
|
98
|
+
if "." in string_ref:
|
|
99
|
+
# Absolute import path: "myapp.serializers.UserSerializer"
|
|
100
|
+
module_path, class_name = string_ref.rsplit(".", 1)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Try to get or import the module
|
|
104
|
+
module = sys.modules.get(module_path)
|
|
105
|
+
if module is None:
|
|
106
|
+
import importlib
|
|
107
|
+
|
|
108
|
+
module = importlib.import_module(module_path)
|
|
99
109
|
|
|
100
|
-
|
|
110
|
+
# Get the serializer class from the module
|
|
111
|
+
serializer_class = getattr(module, class_name, None)
|
|
112
|
+
|
|
113
|
+
if serializer_class is None:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Cannot resolve serializer reference '{string_ref}': "
|
|
116
|
+
f"class '{class_name}' not found in module '{module_path}'."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return serializer_class
|
|
120
|
+
except ImportError as e:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Cannot resolve serializer reference '{string_ref}': "
|
|
123
|
+
f"failed to import module '{module_path}': {e}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Local reference: simple class name in the same module
|
|
101
127
|
module = sys.modules.get(cls.__module__)
|
|
102
128
|
|
|
103
129
|
if module is None:
|
|
104
130
|
raise ValueError(
|
|
105
|
-
f"Cannot resolve serializer reference '{
|
|
131
|
+
f"Cannot resolve serializer reference '{string_ref}': "
|
|
106
132
|
f"module '{cls.__module__}' not found in sys.modules."
|
|
107
133
|
)
|
|
108
134
|
|
|
109
|
-
|
|
110
|
-
serializer_class = getattr(module, serializer_ref, None)
|
|
135
|
+
serializer_class = getattr(module, string_ref, None)
|
|
111
136
|
|
|
112
137
|
if serializer_class is None:
|
|
113
138
|
raise ValueError(
|
|
114
|
-
f"Cannot resolve serializer reference '{
|
|
115
|
-
f"Make sure the serializer class '{
|
|
139
|
+
f"Cannot resolve serializer reference '{string_ref}' in module '{cls.__module__}'. "
|
|
140
|
+
f"Make sure the serializer class '{string_ref}' is defined in the same module as {cls.__name__}."
|
|
116
141
|
)
|
|
117
142
|
|
|
118
143
|
return serializer_class
|
|
119
144
|
|
|
145
|
+
@classmethod
|
|
146
|
+
def _resolve_serializer_reference(
|
|
147
|
+
cls, serializer_ref: str | type | Any
|
|
148
|
+
) -> type | Any:
|
|
149
|
+
"""
|
|
150
|
+
Resolve a serializer reference that may be a string, a class, or a Union of serializers.
|
|
151
|
+
|
|
152
|
+
This method performs lazy resolution, meaning it will attempt to resolve
|
|
153
|
+
string references only when called, allowing for forward references and
|
|
154
|
+
circular dependencies between serializers in the same module.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
serializer_ref : str | type | Union
|
|
159
|
+
Either a string reference to a serializer class, an actual serializer class,
|
|
160
|
+
or a Union of serializer references. String references can be:
|
|
161
|
+
- A class name in the same module (e.g., "UserSerializer")
|
|
162
|
+
- An absolute import path (e.g., "myapp.serializers.UserSerializer")
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
type | Union
|
|
167
|
+
The resolved serializer class or Union of serializer classes.
|
|
168
|
+
|
|
169
|
+
Raises
|
|
170
|
+
------
|
|
171
|
+
ValueError
|
|
172
|
+
If the string reference cannot be resolved.
|
|
173
|
+
|
|
174
|
+
Examples
|
|
175
|
+
--------
|
|
176
|
+
>>> # Single reference
|
|
177
|
+
>>> cls._resolve_serializer_reference("UserSerializer")
|
|
178
|
+
>>> cls._resolve_serializer_reference(UserSerializer)
|
|
179
|
+
>>>
|
|
180
|
+
>>> # Union reference
|
|
181
|
+
>>> from typing import Union
|
|
182
|
+
>>> cls._resolve_serializer_reference(Union[UserSerializer, AdminSerializer])
|
|
183
|
+
>>> cls._resolve_serializer_reference(Union["UserSerializer", "AdminSerializer"])
|
|
184
|
+
"""
|
|
185
|
+
# Handle Union types
|
|
186
|
+
origin = get_origin(serializer_ref)
|
|
187
|
+
if origin is Union:
|
|
188
|
+
resolved_types = tuple(
|
|
189
|
+
cls._resolve_serializer_reference(arg)
|
|
190
|
+
for arg in get_args(serializer_ref)
|
|
191
|
+
)
|
|
192
|
+
# Optimize single-type unions
|
|
193
|
+
if len(resolved_types) == 1:
|
|
194
|
+
return resolved_types[0]
|
|
195
|
+
# Create Union using indexing syntax for Python 3.10+ compatibility
|
|
196
|
+
return Union[resolved_types]
|
|
197
|
+
|
|
198
|
+
# Handle ForwardRef (created when using Union["StringType"])
|
|
199
|
+
if isinstance(serializer_ref, ForwardRef):
|
|
200
|
+
return cls._resolve_serializer_reference(serializer_ref.__forward_arg__)
|
|
201
|
+
|
|
202
|
+
# Handle string references
|
|
203
|
+
if isinstance(serializer_ref, str):
|
|
204
|
+
return cls._resolve_string_reference(serializer_ref)
|
|
205
|
+
|
|
206
|
+
# Already a class, return as-is
|
|
207
|
+
return serializer_ref
|
|
208
|
+
|
|
120
209
|
@classmethod
|
|
121
210
|
def _get_relations_serializers(cls) -> dict[str, "Serializer"]:
|
|
122
211
|
# Optional in subclasses. Default to no explicit relation serializers.
|
|
123
212
|
return {}
|
|
124
213
|
|
|
214
|
+
@classmethod
|
|
215
|
+
def _generate_union_schema(cls, resolved_union: Any) -> Any:
|
|
216
|
+
"""
|
|
217
|
+
Generate a Union schema from multiple resolved serializers.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
resolved_union : Union
|
|
222
|
+
A Union type containing resolved serializer classes.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
Schema | Union[Schema, ...] | None
|
|
227
|
+
Union of generated schemas or None if all schemas are None.
|
|
228
|
+
"""
|
|
229
|
+
# Generate schemas for each serializer in the Union (single call per serializer)
|
|
230
|
+
schemas = tuple(
|
|
231
|
+
schema
|
|
232
|
+
for serializer_type in get_args(resolved_union)
|
|
233
|
+
if (schema := serializer_type.generate_related_s()) is not None
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if not schemas:
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
# Optimize single-schema unions
|
|
240
|
+
if len(schemas) == 1:
|
|
241
|
+
return schemas[0]
|
|
242
|
+
|
|
243
|
+
# Create Union of schemas using indexing syntax for Python 3.10+ compatibility
|
|
244
|
+
return Union[schemas]
|
|
245
|
+
|
|
125
246
|
@classmethod
|
|
126
247
|
def _resolve_relation_schema(cls, field_name: str, rel_model: models.Model):
|
|
127
248
|
"""
|
|
@@ -136,23 +257,30 @@ class BaseSerializer:
|
|
|
136
257
|
|
|
137
258
|
Returns
|
|
138
259
|
-------
|
|
139
|
-
Schema | None
|
|
140
|
-
Generated schema or None if cannot be resolved.
|
|
260
|
+
Schema | Union[Schema, ...] | None
|
|
261
|
+
Generated schema, Union of schemas, or None if cannot be resolved.
|
|
141
262
|
"""
|
|
142
|
-
#
|
|
263
|
+
# Auto-resolve ModelSerializer with readable fields
|
|
143
264
|
if isinstance(rel_model, ModelSerializerMeta):
|
|
144
265
|
if rel_model.get_fields("read") or rel_model.get_custom_fields("read"):
|
|
145
266
|
return rel_model.generate_related_s()
|
|
146
267
|
return None
|
|
147
268
|
|
|
148
|
-
#
|
|
269
|
+
# Resolve from explicit serializer mapping
|
|
149
270
|
rel_serializers = cls._get_relations_serializers() or {}
|
|
150
271
|
serializer_ref = rel_serializers.get(field_name)
|
|
151
|
-
if serializer_ref:
|
|
152
|
-
serializer = cls._resolve_serializer_reference(serializer_ref)
|
|
153
|
-
return serializer.generate_related_s()
|
|
154
272
|
|
|
155
|
-
|
|
273
|
+
if not serializer_ref:
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
resolved = cls._resolve_serializer_reference(serializer_ref)
|
|
277
|
+
|
|
278
|
+
# Handle Union of serializers
|
|
279
|
+
if get_origin(resolved) is Union:
|
|
280
|
+
return cls._generate_union_schema(resolved)
|
|
281
|
+
|
|
282
|
+
# Handle single serializer
|
|
283
|
+
return resolved.generate_related_s()
|
|
156
284
|
|
|
157
285
|
@classmethod
|
|
158
286
|
def _is_special_field(
|
|
@@ -274,72 +402,100 @@ class BaseSerializer:
|
|
|
274
402
|
return (field_name, schema | None, None)
|
|
275
403
|
|
|
276
404
|
@classmethod
|
|
277
|
-
def
|
|
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"):
|
|
278
465
|
"""
|
|
279
|
-
Collect components for
|
|
280
|
-
|
|
281
|
-
|
|
466
|
+
Collect components for output schema generation (Out or Detail).
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
tuple: (fields, reverse_rels, excludes, customs_with_forward_rels, optionals)
|
|
282
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
|
+
|
|
283
478
|
fields: list[str] = []
|
|
284
479
|
reverse_rels: list[tuple] = []
|
|
285
|
-
|
|
286
|
-
relations_serializers = cls._get_relations_serializers() or {}
|
|
287
|
-
model = cls._get_model()
|
|
480
|
+
forward_rels: list[tuple] = []
|
|
288
481
|
|
|
289
|
-
for
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
field_obj,
|
|
293
|
-
(
|
|
294
|
-
ManyToManyDescriptor,
|
|
295
|
-
ReverseManyToOneDescriptor,
|
|
296
|
-
ReverseOneToOneDescriptor,
|
|
297
|
-
),
|
|
482
|
+
for field_name in cls.get_fields(fields_type):
|
|
483
|
+
plain, reverse, forward = cls._process_field(
|
|
484
|
+
field_name, model, relations_serializers
|
|
298
485
|
)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
is_reverse
|
|
306
|
-
and not isinstance(model, ModelSerializerMeta)
|
|
307
|
-
and f not in relations_serializers
|
|
308
|
-
and not getattr(settings, "NINJA_AIO_TESTING", False)
|
|
309
|
-
):
|
|
310
|
-
warnings.warn(
|
|
311
|
-
f"{cls.__name__}: reverse relation '{f}' is listed in read fields but has no entry in relations_serializers; "
|
|
312
|
-
"it will be auto-resolved only for ModelSerializer relations, otherwise skipped.",
|
|
313
|
-
UserWarning,
|
|
314
|
-
stacklevel=2,
|
|
315
|
-
)
|
|
316
|
-
|
|
317
|
-
# Reverse relations
|
|
318
|
-
if is_reverse:
|
|
319
|
-
rel_tuple = cls._build_schema_reverse_rel(f, field_obj)
|
|
320
|
-
if rel_tuple:
|
|
321
|
-
reverse_rels.append(rel_tuple)
|
|
322
|
-
continue
|
|
323
|
-
|
|
324
|
-
# Forward relations
|
|
325
|
-
if is_forward:
|
|
326
|
-
rel_tuple = cls._build_schema_forward_rel(f, field_obj)
|
|
327
|
-
if rel_tuple is True:
|
|
328
|
-
fields.append(f)
|
|
329
|
-
elif rel_tuple:
|
|
330
|
-
rels.append(rel_tuple)
|
|
331
|
-
# None -> skip entirely
|
|
332
|
-
continue
|
|
333
|
-
|
|
334
|
-
# Plain field
|
|
335
|
-
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)
|
|
336
492
|
|
|
337
493
|
return (
|
|
338
494
|
fields,
|
|
339
495
|
reverse_rels,
|
|
340
|
-
cls.get_excluded_fields(
|
|
341
|
-
cls.get_custom_fields(
|
|
342
|
-
cls.get_optional_fields(
|
|
496
|
+
cls.get_excluded_fields(fields_type),
|
|
497
|
+
cls.get_custom_fields(fields_type) + forward_rels,
|
|
498
|
+
cls.get_optional_fields(fields_type),
|
|
343
499
|
)
|
|
344
500
|
|
|
345
501
|
@classmethod
|
|
@@ -355,15 +511,16 @@ class BaseSerializer:
|
|
|
355
511
|
model = cls._get_model()
|
|
356
512
|
|
|
357
513
|
# Handle special schema types with custom logic
|
|
358
|
-
if schema_type == "Out":
|
|
514
|
+
if schema_type == "Out" or schema_type == "Detail":
|
|
359
515
|
fields, reverse_rels, excludes, customs, optionals = (
|
|
360
|
-
cls.get_schema_out_data()
|
|
516
|
+
cls.get_schema_out_data(schema_type)
|
|
361
517
|
)
|
|
362
518
|
if not any([fields, reverse_rels, excludes, customs]):
|
|
363
519
|
return None
|
|
520
|
+
schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
|
|
364
521
|
return create_schema(
|
|
365
522
|
model=model,
|
|
366
|
-
name=f"{model._meta.model_name}
|
|
523
|
+
name=f"{model._meta.model_name}{schema_name}",
|
|
367
524
|
depth=depth,
|
|
368
525
|
fields=fields,
|
|
369
526
|
custom_fields=reverse_rels + customs + optionals,
|
|
@@ -442,6 +599,11 @@ class BaseSerializer:
|
|
|
442
599
|
"""Generate read (Out) schema."""
|
|
443
600
|
return cls._generate_model_schema("Out", depth)
|
|
444
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
|
+
|
|
445
607
|
@classmethod
|
|
446
608
|
def generate_create_s(cls) -> Schema:
|
|
447
609
|
"""Generate create (In) schema."""
|
|
@@ -540,6 +702,26 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
|
|
|
540
702
|
optionals: list[tuple[str, type]] = []
|
|
541
703
|
excludes: list[str] = []
|
|
542
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
|
+
|
|
543
725
|
class UpdateSerializer:
|
|
544
726
|
"""Configuration describing update (PATCH/PUT) schema.
|
|
545
727
|
|
|
@@ -565,6 +747,7 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
|
|
|
565
747
|
"create": "CreateSerializer",
|
|
566
748
|
"update": "UpdateSerializer",
|
|
567
749
|
"read": "ReadSerializer",
|
|
750
|
+
"detail": "DetailSerializer",
|
|
568
751
|
}
|
|
569
752
|
|
|
570
753
|
@classmethod
|
|
@@ -733,7 +916,7 @@ class SchemaModelConfig(Schema):
|
|
|
733
916
|
customs: Optional[List[tuple[str, type, Any]]] = None
|
|
734
917
|
|
|
735
918
|
|
|
736
|
-
class Serializer(BaseSerializer):
|
|
919
|
+
class Serializer(BaseSerializer, metaclass=SerializerMeta):
|
|
737
920
|
"""
|
|
738
921
|
Serializer
|
|
739
922
|
----------
|
|
@@ -748,6 +931,7 @@ class Serializer(BaseSerializer):
|
|
|
748
931
|
"create": "in",
|
|
749
932
|
"update": "update",
|
|
750
933
|
"read": "out",
|
|
934
|
+
"detail": "detail",
|
|
751
935
|
}
|
|
752
936
|
|
|
753
937
|
def __init_subclass__(cls, **kwargs):
|
|
@@ -765,6 +949,7 @@ class Serializer(BaseSerializer):
|
|
|
765
949
|
schema_in: Optional[SchemaModelConfig] = None
|
|
766
950
|
schema_out: Optional[SchemaModelConfig] = None
|
|
767
951
|
schema_update: Optional[SchemaModelConfig] = None
|
|
952
|
+
schema_detail: Optional[SchemaModelConfig] = None
|
|
768
953
|
relations_serializers: dict[str, "Serializer"] = {}
|
|
769
954
|
|
|
770
955
|
@classmethod
|
|
@@ -789,6 +974,8 @@ class Serializer(BaseSerializer):
|
|
|
789
974
|
return cls._get_meta_data("schema_out")
|
|
790
975
|
case "update":
|
|
791
976
|
return cls._get_meta_data("schema_update")
|
|
977
|
+
case "detail":
|
|
978
|
+
return cls._get_meta_data("schema_detail")
|
|
792
979
|
case _:
|
|
793
980
|
return None
|
|
794
981
|
|
|
@@ -908,7 +1095,12 @@ class Serializer(BaseSerializer):
|
|
|
908
1095
|
dict
|
|
909
1096
|
Serialized data.
|
|
910
1097
|
"""
|
|
911
|
-
|
|
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)
|
|
912
1104
|
|
|
913
1105
|
async def models_dump(
|
|
914
1106
|
self, instances: models.QuerySet[models.Model]
|
ninja_aio/schemas/helpers.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from typing import List, Optional, Type
|
|
2
2
|
|
|
3
3
|
from ninja import Schema
|
|
4
|
-
from ninja_aio.types import ModelSerializerMeta
|
|
4
|
+
from ninja_aio.types import ModelSerializerMeta, SerializerMeta
|
|
5
5
|
from django.db.models import Model
|
|
6
6
|
from pydantic import BaseModel, ConfigDict, model_validator
|
|
7
7
|
|
|
@@ -38,6 +38,10 @@ class M2MRelationSchema(BaseModel):
|
|
|
38
38
|
Optional explicit schema to represent related objects in responses.
|
|
39
39
|
If `model` is a ModelSerializerMeta, this is auto-derived via `model.generate_related_s()`.
|
|
40
40
|
If `model` is a plain Django model, this must be provided.
|
|
41
|
+
If `model` is a plain DJango model and this is not provided but serializer_class is provided this last one would be used to generate it.
|
|
42
|
+
serializer_class (Serializer | None):
|
|
43
|
+
Optional serializer class associated with the related model. If provided alongside a plain Django model,
|
|
44
|
+
it can be used to auto-generate the `related_schema`.
|
|
41
45
|
append_slash (bool):
|
|
42
46
|
Whether to append a trailing slash to the generated GET endpoint path. Defaults to False for backward compatibility.
|
|
43
47
|
|
|
@@ -63,6 +67,7 @@ class M2MRelationSchema(BaseModel):
|
|
|
63
67
|
auth: Optional[list] = None
|
|
64
68
|
filters: Optional[dict[str, tuple]] = None
|
|
65
69
|
related_schema: Optional[Type[Schema]] = None
|
|
70
|
+
serializer_class: Optional[SerializerMeta] = None
|
|
66
71
|
append_slash: bool = False
|
|
67
72
|
|
|
68
73
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
@@ -70,15 +75,30 @@ class M2MRelationSchema(BaseModel):
|
|
|
70
75
|
@model_validator(mode="before")
|
|
71
76
|
@classmethod
|
|
72
77
|
def validate_related_schema(cls, data):
|
|
73
|
-
related_schema
|
|
74
|
-
if related_schema is not None:
|
|
78
|
+
# Early return if related_schema is already provided
|
|
79
|
+
if data.get("related_schema") is not None:
|
|
75
80
|
return data
|
|
81
|
+
|
|
76
82
|
model = data.get("model")
|
|
77
|
-
|
|
83
|
+
serializer_class = data.get("serializer_class")
|
|
84
|
+
is_model_serializer = isinstance(model, ModelSerializerMeta)
|
|
85
|
+
|
|
86
|
+
# Validate incompatible parameters
|
|
87
|
+
if is_model_serializer and serializer_class:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"Cannot provide serializer_class when model is a ModelSerializerMeta"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Generate related_schema based on available information
|
|
93
|
+
if is_model_serializer:
|
|
94
|
+
data["related_schema"] = model.generate_related_s()
|
|
95
|
+
elif serializer_class:
|
|
96
|
+
data["related_schema"] = serializer_class.generate_related_s()
|
|
97
|
+
else:
|
|
78
98
|
raise ValueError(
|
|
79
|
-
"related_schema must be provided if model is not a ModelSerializer"
|
|
99
|
+
"related_schema must be provided if model is not a ModelSerializer"
|
|
80
100
|
)
|
|
81
|
-
|
|
101
|
+
|
|
82
102
|
return data
|
|
83
103
|
|
|
84
104
|
|
|
@@ -95,6 +115,7 @@ class ModelQuerySetExtraSchema(ModelQuerySetSchema):
|
|
|
95
115
|
select_related (Optional[list[str]]): List of related fields for select_related optimization.
|
|
96
116
|
prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
|
|
97
117
|
"""
|
|
118
|
+
|
|
98
119
|
scope: str
|
|
99
120
|
|
|
100
121
|
|
|
@@ -106,6 +127,7 @@ class ObjectQuerySchema(ModelQuerySetSchema):
|
|
|
106
127
|
select_related (Optional[list[str]]): List of related fields for select_related optimization.
|
|
107
128
|
prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
|
|
108
129
|
"""
|
|
130
|
+
|
|
109
131
|
getters: Optional[dict] = {}
|
|
110
132
|
|
|
111
133
|
|
|
@@ -117,6 +139,7 @@ class ObjectsQuerySchema(ModelQuerySetSchema):
|
|
|
117
139
|
select_related (Optional[list[str]]): List of related fields for select_related optimization.
|
|
118
140
|
prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
|
|
119
141
|
"""
|
|
142
|
+
|
|
120
143
|
filters: Optional[dict] = {}
|
|
121
144
|
|
|
122
145
|
|
|
@@ -129,6 +152,7 @@ class QuerySchema(ModelQuerySetSchema):
|
|
|
129
152
|
select_related (Optional[list[str]]): List of related fields for select_related optimization.
|
|
130
153
|
prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
|
|
131
154
|
"""
|
|
155
|
+
|
|
132
156
|
filters: Optional[dict] = {}
|
|
133
157
|
getters: Optional[dict] = {}
|
|
134
158
|
|
|
@@ -140,6 +164,7 @@ class QueryUtilBaseScopesSchema(BaseModel):
|
|
|
140
164
|
READ (str): Scope for read operations.
|
|
141
165
|
QUERYSET_REQUEST (str): Scope for queryset request operations.
|
|
142
166
|
"""
|
|
167
|
+
|
|
143
168
|
READ: str = "read"
|
|
144
169
|
QUERYSET_REQUEST: str = "queryset_request"
|
|
145
170
|
|
|
@@ -163,6 +188,7 @@ class DecoratorsSchema(Schema):
|
|
|
163
188
|
Consider initializing these in __init__ or using default_factory (if using pydantic/dataclasses)
|
|
164
189
|
to avoid unintended side effects.
|
|
165
190
|
"""
|
|
191
|
+
|
|
166
192
|
list: Optional[List] = []
|
|
167
193
|
retrieve: Optional[List] = []
|
|
168
194
|
create: Optional[List] = []
|
ninja_aio/types.py
CHANGED
|
@@ -4,16 +4,21 @@ from joserfc import jwk
|
|
|
4
4
|
from django.db.models import Model
|
|
5
5
|
from typing import TypeAlias
|
|
6
6
|
|
|
7
|
-
S_TYPES = Literal["read", "create", "update"]
|
|
7
|
+
S_TYPES = Literal["read", "detail", "create", "update"]
|
|
8
8
|
F_TYPES = Literal["fields", "customs", "optionals", "excludes"]
|
|
9
|
-
SCHEMA_TYPES = Literal["In", "Out", "Patch", "Related"]
|
|
9
|
+
SCHEMA_TYPES = Literal["In", "Out", "Detail", "Patch", "Related"]
|
|
10
10
|
VIEW_TYPES = Literal["list", "retrieve", "create", "update", "delete", "all"]
|
|
11
11
|
JwtKeys: TypeAlias = jwk.RSAKey | jwk.ECKey | jwk.OctKey
|
|
12
12
|
|
|
13
|
-
class ModelSerializerType(type):
|
|
14
|
-
def __repr__(self):
|
|
15
|
-
return self.__name__
|
|
16
13
|
|
|
14
|
+
class SerializerMeta(type):
|
|
15
|
+
"""Metaclass for serializers - extend with custom behavior as needed."""
|
|
16
|
+
|
|
17
|
+
def __repr__(cls):
|
|
18
|
+
return cls.__name__
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModelSerializerMeta(SerializerMeta, type(Model)):
|
|
22
|
+
"""Metaclass combining SerializerMeta with Django's ModelBase."""
|
|
17
23
|
|
|
18
|
-
class ModelSerializerMeta(ModelSerializerType, type(Model)):
|
|
19
24
|
pass
|
ninja_aio/views/api.py
CHANGED
|
@@ -225,6 +225,7 @@ class APIViewSet(API):
|
|
|
225
225
|
serializer_class: serializers.Serializer | None = None
|
|
226
226
|
schema_in: Schema | None = None
|
|
227
227
|
schema_out: Schema | None = None
|
|
228
|
+
schema_detail: Schema | None = None
|
|
228
229
|
schema_update: Schema | None = None
|
|
229
230
|
get_auth: list | None = NOT_SET
|
|
230
231
|
post_auth: list | None = NOT_SET
|
|
@@ -262,7 +263,9 @@ class APIViewSet(API):
|
|
|
262
263
|
if not isinstance(self.model, ModelSerializerMeta)
|
|
263
264
|
else self.model.util
|
|
264
265
|
)
|
|
265
|
-
self.schema_out, self.schema_in, self.schema_update =
|
|
266
|
+
self.schema_out, self.schema_detail, self.schema_in, self.schema_update = (
|
|
267
|
+
self.get_schemas()
|
|
268
|
+
)
|
|
266
269
|
self.path_schema = self._generate_path_schema()
|
|
267
270
|
self.filters_schema = self._generate_filters_schema()
|
|
268
271
|
self.model_verbose_name = (
|
|
@@ -365,22 +368,24 @@ class APIViewSet(API):
|
|
|
365
368
|
|
|
366
369
|
def get_schemas(self):
|
|
367
370
|
"""
|
|
368
|
-
Compute and return (schema_out, schema_in, schema_update).
|
|
371
|
+
Compute and return (schema_out, schema_detail, schema_in, schema_update).
|
|
369
372
|
|
|
370
|
-
- If model is a ModelSerializer (ModelSerializerMeta), auto-generate read/create/update schemas.
|
|
373
|
+
- If model is a ModelSerializer (ModelSerializerMeta), auto-generate read/detail/create/update schemas.
|
|
371
374
|
- Otherwise, use existing schemas or generate from serializer_class if provided.
|
|
372
375
|
"""
|
|
373
376
|
# ModelSerializer case: prefer explicitly set schemas, otherwise generate from the model
|
|
374
377
|
if isinstance(self.model, ModelSerializerMeta):
|
|
375
378
|
return (
|
|
376
379
|
self.schema_out or self.model.generate_read_s(),
|
|
380
|
+
self.schema_detail or self.model.generate_detail_s(),
|
|
377
381
|
self.schema_in or self.model.generate_create_s(),
|
|
378
382
|
self.schema_update or self.model.generate_update_s(),
|
|
379
383
|
)
|
|
380
384
|
|
|
381
385
|
# Non-ModelSerializer: start from provided schemas
|
|
382
|
-
schema_out, schema_in, schema_update = (
|
|
386
|
+
schema_out, schema_detail, schema_in, schema_update = (
|
|
383
387
|
self.schema_out,
|
|
388
|
+
self.schema_detail,
|
|
384
389
|
self.schema_in,
|
|
385
390
|
self.schema_update,
|
|
386
391
|
)
|
|
@@ -389,9 +394,10 @@ class APIViewSet(API):
|
|
|
389
394
|
if self.serializer_class:
|
|
390
395
|
schema_in = schema_in or self.serializer_class.generate_create_s()
|
|
391
396
|
schema_out = schema_out or self.serializer_class.generate_read_s()
|
|
397
|
+
schema_detail = schema_detail or self.serializer_class.generate_detail_s()
|
|
392
398
|
schema_update = schema_update or self.serializer_class.generate_update_s()
|
|
393
399
|
|
|
394
|
-
return (schema_out, schema_in, schema_update)
|
|
400
|
+
return (schema_out, schema_detail, schema_in, schema_update)
|
|
395
401
|
|
|
396
402
|
async def query_params_handler(
|
|
397
403
|
self, queryset: QuerySet[ModelSerializer], filters: dict
|
|
@@ -456,23 +462,31 @@ class APIViewSet(API):
|
|
|
456
462
|
|
|
457
463
|
return list
|
|
458
464
|
|
|
465
|
+
def _get_retrieve_schema(self) -> Schema:
|
|
466
|
+
"""
|
|
467
|
+
Return the schema to use for retrieve endpoint.
|
|
468
|
+
Uses schema_detail if available, otherwise falls back to schema_out.
|
|
469
|
+
"""
|
|
470
|
+
return self.schema_detail or self.schema_out
|
|
471
|
+
|
|
459
472
|
def retrieve_view(self):
|
|
460
473
|
"""
|
|
461
474
|
Register retrieve endpoint.
|
|
462
475
|
"""
|
|
476
|
+
retrieve_schema = self._get_retrieve_schema()
|
|
463
477
|
|
|
464
478
|
@self.router.get(
|
|
465
479
|
self.get_path_retrieve,
|
|
466
480
|
auth=self.get_view_auth(),
|
|
467
481
|
summary=f"Retrieve {self.model_verbose_name}",
|
|
468
482
|
description=self.retrieve_docs,
|
|
469
|
-
response={200:
|
|
483
|
+
response={200: retrieve_schema, self.error_codes: GenericMessageSchema},
|
|
470
484
|
)
|
|
471
485
|
@decorate_view(unique_view(self), *self.extra_decorators.retrieve)
|
|
472
486
|
async def retrieve(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
|
|
473
487
|
query_data = self._get_query_data()
|
|
474
488
|
return await self.model_util.read_s(
|
|
475
|
-
|
|
489
|
+
retrieve_schema,
|
|
476
490
|
request,
|
|
477
491
|
query_data=QuerySchema(
|
|
478
492
|
getters={"pk": self._get_pk(pk)}, **query_data.model_dump()
|
|
File without changes
|
{django_ninja_aio_crud-2.6.1.dist-info → django_ninja_aio_crud-2.8.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|