django-ninja-aio-crud 2.3.2__py3-none-any.whl → 2.5.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.3.2.dist-info → django_ninja_aio_crud-2.5.0.dist-info}/METADATA +31 -6
- {django_ninja_aio_crud-2.3.2.dist-info → django_ninja_aio_crud-2.5.0.dist-info}/RECORD +10 -8
- ninja_aio/__init__.py +1 -1
- ninja_aio/helpers/query.py +2 -2
- ninja_aio/models/__init__.py +4 -0
- ninja_aio/models/serializers.py +888 -0
- ninja_aio/{models.py → models/utils.py} +75 -670
- ninja_aio/views/api.py +44 -19
- {django_ninja_aio_crud-2.3.2.dist-info → django_ninja_aio_crud-2.5.0.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-2.3.2.dist-info → django_ninja_aio_crud-2.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
from typing import Any, List, Optional
|
|
2
|
+
import warnings
|
|
3
|
+
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from ninja import Schema
|
|
6
|
+
from ninja.orm import create_schema
|
|
7
|
+
from django.db import models
|
|
8
|
+
from django.http import HttpRequest
|
|
9
|
+
from django.db.models.fields.related_descriptors import (
|
|
10
|
+
ReverseManyToOneDescriptor,
|
|
11
|
+
ReverseOneToOneDescriptor,
|
|
12
|
+
ManyToManyDescriptor,
|
|
13
|
+
ForwardManyToOneDescriptor,
|
|
14
|
+
ForwardOneToOneDescriptor,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from ninja_aio.types import S_TYPES, F_TYPES, SCHEMA_TYPES, ModelSerializerMeta
|
|
18
|
+
from ninja_aio.schemas.helpers import (
|
|
19
|
+
ModelQuerySetSchema,
|
|
20
|
+
ModelQuerySetExtraSchema,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseSerializer:
|
|
25
|
+
"""
|
|
26
|
+
BaseSerializer
|
|
27
|
+
--------------
|
|
28
|
+
Shared serializer utilities used by both ModelSerializer (model-bound) and
|
|
29
|
+
Serializer (Meta-driven). Centralizes common field normalization, relation
|
|
30
|
+
schema construction and schema generation helpers.
|
|
31
|
+
|
|
32
|
+
Subclasses must implement:
|
|
33
|
+
- _get_fields(s_type, f_type): source raw config for fields/optionals/customs/excludes
|
|
34
|
+
- _get_model(): return the Django model class associated with the serializer
|
|
35
|
+
- _get_relations_serializers(): optional mapping of relation field -> serializer (may be empty)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
class QuerySet:
|
|
39
|
+
"""
|
|
40
|
+
Configuration container describing how to build query schemas for a model.
|
|
41
|
+
Purpose
|
|
42
|
+
-------
|
|
43
|
+
Describes which fields and extras are available when querying for model
|
|
44
|
+
instances. A factory/metaclass can read this configuration to generate
|
|
45
|
+
Pydantic / Ninja query schemas.
|
|
46
|
+
Attributes
|
|
47
|
+
----------
|
|
48
|
+
read : ModelQuerySetSchema
|
|
49
|
+
Schema configuration for read operations.
|
|
50
|
+
queryset_request : ModelQuerySetSchema
|
|
51
|
+
Schema configuration for queryset_request hook.
|
|
52
|
+
extras : list[ModelQuerySetExtraSchema]
|
|
53
|
+
Additional computed / synthetic query parameters.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
read = ModelQuerySetSchema()
|
|
57
|
+
queryset_request = ModelQuerySetSchema()
|
|
58
|
+
extras: list[ModelQuerySetExtraSchema] = []
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
|
|
62
|
+
# Subclasses provide implementation.
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def _get_model(cls) -> models.Model:
|
|
67
|
+
# Subclasses provide implementation.
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def _get_relations_serializers(cls) -> dict[str, "Serializer"]:
|
|
72
|
+
# Optional in subclasses. Default to no explicit relation serializers.
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def _is_special_field(
|
|
77
|
+
cls, s_type: type[S_TYPES], field: str, f_type: type[F_TYPES]
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Return True if field appears in the given category for s_type."""
|
|
80
|
+
special_fields = cls._get_fields(s_type, f_type)
|
|
81
|
+
return any(field in special_f for special_f in special_fields)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def get_custom_fields(cls, s_type: type[S_TYPES]) -> list[tuple[str, type, Any]]:
|
|
85
|
+
"""
|
|
86
|
+
Normalize declared custom field specs into (name, py_type, default).
|
|
87
|
+
Accepted tuple shapes:
|
|
88
|
+
- (name, py_type, default)
|
|
89
|
+
- (name, py_type) -> default Ellipsis (required)
|
|
90
|
+
"""
|
|
91
|
+
raw_customs = cls._get_fields(s_type, "customs") or []
|
|
92
|
+
normalized: list[tuple[str, type, Any]] = []
|
|
93
|
+
for spec in raw_customs:
|
|
94
|
+
if not isinstance(spec, tuple):
|
|
95
|
+
raise ValueError(f"Custom field spec must be a tuple, got {type(spec)}")
|
|
96
|
+
match len(spec):
|
|
97
|
+
case 3:
|
|
98
|
+
name, py_type, default = spec
|
|
99
|
+
case 2:
|
|
100
|
+
name, py_type = spec
|
|
101
|
+
default = ...
|
|
102
|
+
case _:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Custom field tuple must have length 2 or 3 (name, type[, default]); got {len(spec)}"
|
|
105
|
+
)
|
|
106
|
+
normalized.append((name, py_type, default))
|
|
107
|
+
return normalized
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def get_optional_fields(cls, s_type: type[S_TYPES]):
|
|
111
|
+
"""Return optional field specs normalized to (name, type, None)."""
|
|
112
|
+
return [
|
|
113
|
+
(field, field_type, None)
|
|
114
|
+
for field, field_type in cls._get_fields(s_type, "optionals")
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def get_excluded_fields(cls, s_type: type[S_TYPES]):
|
|
119
|
+
"""Return excluded field names for the serializer type."""
|
|
120
|
+
return cls._get_fields(s_type, "excludes")
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def get_fields(cls, s_type: type[S_TYPES]):
|
|
124
|
+
"""Return explicit declared fields for the serializer type."""
|
|
125
|
+
return cls._get_fields(s_type, "fields")
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def is_custom(cls, field: str) -> bool:
|
|
129
|
+
"""True if field is declared as a custom input in create or update."""
|
|
130
|
+
return cls._is_special_field(
|
|
131
|
+
"create", field, "customs"
|
|
132
|
+
) or cls._is_special_field("update", field, "customs")
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def is_optional(cls, field: str) -> bool:
|
|
136
|
+
"""True if field is declared as optional in create or update."""
|
|
137
|
+
return cls._is_special_field(
|
|
138
|
+
"create", field, "optionals"
|
|
139
|
+
) or cls._is_special_field("update", field, "optionals")
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def _build_schema_reverse_rel(cls, field_name: str, descriptor: Any):
|
|
143
|
+
"""
|
|
144
|
+
Build a reverse relation schema component for 'Out' schema generation.
|
|
145
|
+
Returns a custom field tuple or None to skip.
|
|
146
|
+
"""
|
|
147
|
+
# Resolve related model and cardinality
|
|
148
|
+
if isinstance(descriptor, ManyToManyDescriptor):
|
|
149
|
+
# M2M uses the same descriptor on both sides; rely on .reverse to pick the model
|
|
150
|
+
rel_model: models.Model = (
|
|
151
|
+
descriptor.field.model
|
|
152
|
+
if descriptor.reverse
|
|
153
|
+
else descriptor.field.related_model
|
|
154
|
+
)
|
|
155
|
+
many = True
|
|
156
|
+
elif isinstance(descriptor, ReverseManyToOneDescriptor):
|
|
157
|
+
rel_model = descriptor.field.model
|
|
158
|
+
many = True
|
|
159
|
+
else: # ReverseOneToOneDescriptor
|
|
160
|
+
rel_model = descriptor.related.related_model
|
|
161
|
+
many = False
|
|
162
|
+
|
|
163
|
+
schema = None
|
|
164
|
+
if isinstance(rel_model, ModelSerializerMeta):
|
|
165
|
+
# Auto-include if related model exposes readable data
|
|
166
|
+
if rel_model.get_fields("read") or rel_model.get_custom_fields("read"):
|
|
167
|
+
schema = rel_model.generate_related_s()
|
|
168
|
+
else:
|
|
169
|
+
# Use explicit serializer when provided by subclasses
|
|
170
|
+
rel_serializers = cls._get_relations_serializers() or {}
|
|
171
|
+
serializer = rel_serializers.get(field_name)
|
|
172
|
+
if serializer:
|
|
173
|
+
schema = serializer.generate_related_s()
|
|
174
|
+
|
|
175
|
+
if not schema:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
rel_schema_type = schema if not many else list[schema]
|
|
179
|
+
return (field_name, rel_schema_type | None, None)
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def _build_schema_forward_rel(cls, field_name: str, descriptor: Any):
|
|
183
|
+
"""
|
|
184
|
+
Build a forward relation schema component for 'Out' schema generation.
|
|
185
|
+
Returns True to treat as plain field, a custom field tuple to include relation schema,
|
|
186
|
+
or None to skip entirely.
|
|
187
|
+
"""
|
|
188
|
+
rel_model = descriptor.field.related_model
|
|
189
|
+
|
|
190
|
+
schema = None
|
|
191
|
+
if isinstance(rel_model, ModelSerializerMeta):
|
|
192
|
+
# Prefer auto-inclusion when the related model is a ModelSerializer
|
|
193
|
+
if rel_model.get_fields("read") or rel_model.get_custom_fields("read"):
|
|
194
|
+
schema = rel_model.generate_related_s()
|
|
195
|
+
else:
|
|
196
|
+
# Explicit ModelSerializer with no readable fields -> skip entirely
|
|
197
|
+
return None
|
|
198
|
+
else:
|
|
199
|
+
# Fall back to an explicitly provided serializer mapping
|
|
200
|
+
rel_serializers = cls._get_relations_serializers() or {}
|
|
201
|
+
serializer = rel_serializers.get(field_name)
|
|
202
|
+
if serializer:
|
|
203
|
+
schema = serializer.generate_related_s()
|
|
204
|
+
|
|
205
|
+
if not schema:
|
|
206
|
+
# Could not build a schema: treat as a plain field (serialize as-is)
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
# Forward relations are single objects; allow nullability
|
|
210
|
+
return (field_name, schema | None, None)
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def get_schema_out_data(cls):
|
|
214
|
+
"""
|
|
215
|
+
Collect components for 'Out' read schema generation.
|
|
216
|
+
Returns (fields, reverse_rel_descriptors, excludes, custom_fields_with_forward_relations, optionals).
|
|
217
|
+
Enforces relation serializers only when provided by subclass via _get_relations_serializers.
|
|
218
|
+
"""
|
|
219
|
+
fields: list[str] = []
|
|
220
|
+
reverse_rels: list[tuple] = []
|
|
221
|
+
rels: list[tuple] = []
|
|
222
|
+
relations_serializers = cls._get_relations_serializers() or {}
|
|
223
|
+
model = cls._get_model()
|
|
224
|
+
|
|
225
|
+
for f in cls.get_fields("read"):
|
|
226
|
+
field_obj = getattr(model, f)
|
|
227
|
+
is_reverse = isinstance(
|
|
228
|
+
field_obj,
|
|
229
|
+
(
|
|
230
|
+
ManyToManyDescriptor,
|
|
231
|
+
ReverseManyToOneDescriptor,
|
|
232
|
+
ReverseOneToOneDescriptor,
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
is_forward = isinstance(
|
|
236
|
+
field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# If explicit relation serializers are declared, require mapping presence.
|
|
240
|
+
if (
|
|
241
|
+
is_reverse
|
|
242
|
+
and not isinstance(model, ModelSerializerMeta)
|
|
243
|
+
and f not in relations_serializers
|
|
244
|
+
and not getattr(settings, "NINJA_AIO_TESTING", False)
|
|
245
|
+
):
|
|
246
|
+
warnings.warn(
|
|
247
|
+
f"{cls.__name__}: reverse relation '{f}' is listed in read fields but has no entry in relations_serializers; "
|
|
248
|
+
"it will be auto-resolved only for ModelSerializer relations, otherwise skipped.",
|
|
249
|
+
UserWarning,
|
|
250
|
+
stacklevel=2,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Reverse relations
|
|
254
|
+
if is_reverse:
|
|
255
|
+
rel_tuple = cls._build_schema_reverse_rel(f, field_obj)
|
|
256
|
+
if rel_tuple:
|
|
257
|
+
reverse_rels.append(rel_tuple)
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Forward relations
|
|
261
|
+
if is_forward:
|
|
262
|
+
rel_tuple = cls._build_schema_forward_rel(f, field_obj)
|
|
263
|
+
if rel_tuple is True:
|
|
264
|
+
fields.append(f)
|
|
265
|
+
elif rel_tuple:
|
|
266
|
+
rels.append(rel_tuple)
|
|
267
|
+
# None -> skip entirely
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# Plain field
|
|
271
|
+
fields.append(f)
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
fields,
|
|
275
|
+
reverse_rels,
|
|
276
|
+
cls.get_excluded_fields("read"),
|
|
277
|
+
cls.get_custom_fields("read") + rels,
|
|
278
|
+
cls.get_optional_fields("read"),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def _generate_model_schema(
|
|
283
|
+
cls,
|
|
284
|
+
schema_type: type[SCHEMA_TYPES],
|
|
285
|
+
depth: int = None,
|
|
286
|
+
) -> Schema:
|
|
287
|
+
"""
|
|
288
|
+
Core schema factory bridging configuration to ninja.orm.create_schema.
|
|
289
|
+
Handles In/Patch/Out/Related.
|
|
290
|
+
"""
|
|
291
|
+
model = cls._get_model()
|
|
292
|
+
match schema_type:
|
|
293
|
+
case "In":
|
|
294
|
+
s_type = "create"
|
|
295
|
+
case "Patch":
|
|
296
|
+
s_type = "update"
|
|
297
|
+
case "Out":
|
|
298
|
+
fields, reverse_rels, excludes, customs, optionals = (
|
|
299
|
+
cls.get_schema_out_data()
|
|
300
|
+
)
|
|
301
|
+
if not fields and not reverse_rels and not excludes and not customs:
|
|
302
|
+
return None
|
|
303
|
+
return create_schema(
|
|
304
|
+
model=model,
|
|
305
|
+
name=f"{model._meta.model_name}SchemaOut",
|
|
306
|
+
depth=depth,
|
|
307
|
+
fields=fields,
|
|
308
|
+
custom_fields=reverse_rels + customs + optionals,
|
|
309
|
+
exclude=excludes,
|
|
310
|
+
)
|
|
311
|
+
case "Related":
|
|
312
|
+
# Related schema includes only non-relational declared fields + customs
|
|
313
|
+
fields, customs = cls.get_related_schema_data()
|
|
314
|
+
if not fields and not customs:
|
|
315
|
+
return None
|
|
316
|
+
return create_schema(
|
|
317
|
+
model=model,
|
|
318
|
+
name=f"{model._meta.model_name}SchemaRelated",
|
|
319
|
+
fields=fields,
|
|
320
|
+
custom_fields=customs,
|
|
321
|
+
)
|
|
322
|
+
fields = cls.get_fields(s_type)
|
|
323
|
+
optionals = cls.get_optional_fields(s_type)
|
|
324
|
+
customs = cls.get_custom_fields(s_type) + optionals
|
|
325
|
+
excludes = cls.get_excluded_fields(s_type)
|
|
326
|
+
if not fields and not excludes:
|
|
327
|
+
fields = [f[0] for f in optionals]
|
|
328
|
+
return (
|
|
329
|
+
create_schema(
|
|
330
|
+
model=model,
|
|
331
|
+
name=f"{model._meta.model_name}Schema{schema_type}",
|
|
332
|
+
fields=fields,
|
|
333
|
+
custom_fields=customs,
|
|
334
|
+
exclude=excludes,
|
|
335
|
+
)
|
|
336
|
+
if fields or customs or excludes
|
|
337
|
+
else None
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def get_related_schema_data(cls):
|
|
342
|
+
"""
|
|
343
|
+
Build field/custom lists for 'Related' schema, flattening non-relational fields.
|
|
344
|
+
"""
|
|
345
|
+
fields = cls.get_fields("read")
|
|
346
|
+
custom_f = {
|
|
347
|
+
name: (value, default)
|
|
348
|
+
for name, value, default in cls.get_custom_fields("read")
|
|
349
|
+
}
|
|
350
|
+
_related_fields = []
|
|
351
|
+
model = cls._get_model()
|
|
352
|
+
for f in fields + list(custom_f.keys()):
|
|
353
|
+
field_obj = getattr(model, f)
|
|
354
|
+
if not isinstance(
|
|
355
|
+
field_obj,
|
|
356
|
+
(
|
|
357
|
+
ManyToManyDescriptor,
|
|
358
|
+
ReverseManyToOneDescriptor,
|
|
359
|
+
ReverseOneToOneDescriptor,
|
|
360
|
+
ForwardManyToOneDescriptor,
|
|
361
|
+
ForwardOneToOneDescriptor,
|
|
362
|
+
),
|
|
363
|
+
):
|
|
364
|
+
_related_fields.append(f)
|
|
365
|
+
if not _related_fields:
|
|
366
|
+
return None, None
|
|
367
|
+
custom_related_fields = [
|
|
368
|
+
(f, *custom_f[f]) for f in _related_fields if f in custom_f
|
|
369
|
+
]
|
|
370
|
+
related_fields = [f for f in _related_fields if f not in custom_f]
|
|
371
|
+
return related_fields, custom_related_fields
|
|
372
|
+
|
|
373
|
+
@classmethod
|
|
374
|
+
def generate_read_s(cls, depth: int = 1) -> Schema:
|
|
375
|
+
"""Generate read (Out) schema."""
|
|
376
|
+
return cls._generate_model_schema("Out", depth)
|
|
377
|
+
|
|
378
|
+
@classmethod
|
|
379
|
+
def generate_create_s(cls) -> Schema:
|
|
380
|
+
"""Generate create (In) schema."""
|
|
381
|
+
return cls._generate_model_schema("In")
|
|
382
|
+
|
|
383
|
+
@classmethod
|
|
384
|
+
def generate_update_s(cls) -> Schema:
|
|
385
|
+
"""Generate update (Patch) schema."""
|
|
386
|
+
return cls._generate_model_schema("Patch")
|
|
387
|
+
|
|
388
|
+
@classmethod
|
|
389
|
+
def generate_related_s(cls) -> Schema:
|
|
390
|
+
"""Generate related (nested) schema."""
|
|
391
|
+
return cls._generate_model_schema("Related")
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
async def queryset_request(cls, request: HttpRequest):
|
|
395
|
+
"""
|
|
396
|
+
Override to return a request-scoped filtered queryset.
|
|
397
|
+
|
|
398
|
+
Parameters
|
|
399
|
+
----------
|
|
400
|
+
request : HttpRequest
|
|
401
|
+
|
|
402
|
+
Returns
|
|
403
|
+
-------
|
|
404
|
+
QuerySet
|
|
405
|
+
"""
|
|
406
|
+
raise NotImplementedError
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMeta):
|
|
410
|
+
"""
|
|
411
|
+
ModelSerializer
|
|
412
|
+
=================
|
|
413
|
+
Model-bound serializer mixin centralizing declarative configuration directly
|
|
414
|
+
on the model class. Inherits common behavior from BaseSerializer and adds
|
|
415
|
+
lifecycle hooks and query utilities.
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
def __init_subclass__(cls, **kwargs):
|
|
419
|
+
super().__init_subclass__(**kwargs)
|
|
420
|
+
from ninja_aio.models.utils import ModelUtil
|
|
421
|
+
from ninja_aio.helpers.query import QueryUtil
|
|
422
|
+
|
|
423
|
+
# Bind a ModelUtil instance to the subclass for convenient access
|
|
424
|
+
cls.util = ModelUtil(cls)
|
|
425
|
+
cls.query_util = QueryUtil(cls)
|
|
426
|
+
|
|
427
|
+
class Meta:
|
|
428
|
+
abstract = True
|
|
429
|
+
|
|
430
|
+
class CreateSerializer:
|
|
431
|
+
"""Configuration container describing how to build a create (input) schema for a model.
|
|
432
|
+
|
|
433
|
+
Purpose
|
|
434
|
+
-------
|
|
435
|
+
Describes which fields are accepted (and in what form) when creating a new
|
|
436
|
+
instance. A factory/metaclass can read this configuration to generate a
|
|
437
|
+
Pydantic / Ninja input schema.
|
|
438
|
+
|
|
439
|
+
Attributes
|
|
440
|
+
----------
|
|
441
|
+
fields : list[str]
|
|
442
|
+
REQUIRED model fields.
|
|
443
|
+
optionals : list[tuple[str, type]]
|
|
444
|
+
Optional model fields (nullable / patch-like).
|
|
445
|
+
customs : list[tuple[str, type, Any]]
|
|
446
|
+
Synthetic input fields (non-model).
|
|
447
|
+
excludes : list[str]
|
|
448
|
+
Disallowed model fields on create (e.g., id, timestamps).
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
fields: list[str] = []
|
|
452
|
+
customs: list[tuple[str, type, Any]] = []
|
|
453
|
+
optionals: list[tuple[str, type]] = []
|
|
454
|
+
excludes: list[str] = []
|
|
455
|
+
|
|
456
|
+
class ReadSerializer:
|
|
457
|
+
"""Configuration describing how to build a read (output) schema.
|
|
458
|
+
|
|
459
|
+
Attributes
|
|
460
|
+
----------
|
|
461
|
+
fields : list[str]
|
|
462
|
+
Explicit model fields to include.
|
|
463
|
+
excludes : list[str]
|
|
464
|
+
Fields to force exclude (safety).
|
|
465
|
+
customs : list[tuple[str, type, Any]]
|
|
466
|
+
Computed / synthetic output attributes.
|
|
467
|
+
optionals : list[tuple[str, type]]
|
|
468
|
+
Optional output fields.
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
fields: list[str] = []
|
|
472
|
+
customs: list[tuple[str, type, Any]] = []
|
|
473
|
+
optionals: list[tuple[str, type]] = []
|
|
474
|
+
excludes: list[str] = []
|
|
475
|
+
|
|
476
|
+
class UpdateSerializer:
|
|
477
|
+
"""Configuration describing update (PATCH/PUT) schema.
|
|
478
|
+
|
|
479
|
+
Attributes
|
|
480
|
+
----------
|
|
481
|
+
fields : list[str]
|
|
482
|
+
Required update fields (rare).
|
|
483
|
+
optionals : list[tuple[str, type]]
|
|
484
|
+
Editable optional fields.
|
|
485
|
+
customs : list[tuple[str, type, Any]]
|
|
486
|
+
Synthetic operational inputs.
|
|
487
|
+
excludes : list[str]
|
|
488
|
+
Immutable / blocked fields.
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
fields: list[str] = []
|
|
492
|
+
customs: list[tuple[str, type, Any]] = []
|
|
493
|
+
optionals: list[tuple[str, type]] = []
|
|
494
|
+
excludes: list[str] = []
|
|
495
|
+
|
|
496
|
+
@classmethod
|
|
497
|
+
def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
|
|
498
|
+
"""
|
|
499
|
+
Internal accessor for raw configuration lists.
|
|
500
|
+
|
|
501
|
+
Parameters
|
|
502
|
+
----------
|
|
503
|
+
s_type : str
|
|
504
|
+
Serializer type ("create" | "update" | "read").
|
|
505
|
+
f_type : str
|
|
506
|
+
Field category ("fields" | "optionals" | "customs" | "excludes").
|
|
507
|
+
|
|
508
|
+
Returns
|
|
509
|
+
-------
|
|
510
|
+
list
|
|
511
|
+
Raw configuration list or empty list.
|
|
512
|
+
"""
|
|
513
|
+
match s_type:
|
|
514
|
+
case "create":
|
|
515
|
+
fields = getattr(cls.CreateSerializer, f_type, [])
|
|
516
|
+
case "update":
|
|
517
|
+
fields = getattr(cls.UpdateSerializer, f_type, [])
|
|
518
|
+
case "read":
|
|
519
|
+
fields = getattr(cls.ReadSerializer, f_type, [])
|
|
520
|
+
return fields
|
|
521
|
+
|
|
522
|
+
@classmethod
|
|
523
|
+
def _get_model(cls) -> "ModelSerializer":
|
|
524
|
+
"""Return the model class itself."""
|
|
525
|
+
return cls
|
|
526
|
+
|
|
527
|
+
@classmethod
|
|
528
|
+
def verbose_name_path_resolver(cls) -> str:
|
|
529
|
+
"""
|
|
530
|
+
Slugify plural verbose name for URL path segment.
|
|
531
|
+
|
|
532
|
+
Returns
|
|
533
|
+
-------
|
|
534
|
+
str
|
|
535
|
+
"""
|
|
536
|
+
return "-".join(cls._meta.verbose_name_plural.split(" "))
|
|
537
|
+
|
|
538
|
+
def has_changed(self, field: str) -> bool:
|
|
539
|
+
"""
|
|
540
|
+
Check if a model field has changed compared to the persisted value.
|
|
541
|
+
|
|
542
|
+
Parameters
|
|
543
|
+
----------
|
|
544
|
+
field : str
|
|
545
|
+
Field name.
|
|
546
|
+
|
|
547
|
+
Returns
|
|
548
|
+
-------
|
|
549
|
+
bool
|
|
550
|
+
True if in-memory value differs from DB value.
|
|
551
|
+
"""
|
|
552
|
+
if not self.pk:
|
|
553
|
+
return False
|
|
554
|
+
old_value = (
|
|
555
|
+
self.__class__._default_manager.filter(pk=self.pk)
|
|
556
|
+
.values(field)
|
|
557
|
+
.get()[field]
|
|
558
|
+
)
|
|
559
|
+
return getattr(self, field) != old_value
|
|
560
|
+
|
|
561
|
+
@classmethod
|
|
562
|
+
async def queryset_request(cls, request: HttpRequest):
|
|
563
|
+
return cls.query_util.apply_queryset_optimizations(
|
|
564
|
+
queryset=cls.objects.all(),
|
|
565
|
+
scope=cls.query_util.SCOPES.QUERYSET_REQUEST,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
async def post_create(self) -> None:
|
|
569
|
+
"""
|
|
570
|
+
Async hook executed after first persistence (create path).
|
|
571
|
+
"""
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
async def custom_actions(self, payload: dict[str, Any]):
|
|
575
|
+
"""
|
|
576
|
+
Async hook for reacting to provided custom (synthetic) fields.
|
|
577
|
+
|
|
578
|
+
Parameters
|
|
579
|
+
----------
|
|
580
|
+
payload : dict
|
|
581
|
+
Custom field name/value pairs.
|
|
582
|
+
"""
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
def after_save(self):
|
|
586
|
+
"""
|
|
587
|
+
Sync hook executed after any save (create or update).
|
|
588
|
+
"""
|
|
589
|
+
pass
|
|
590
|
+
|
|
591
|
+
def before_save(self):
|
|
592
|
+
"""
|
|
593
|
+
Sync hook executed before any save (create or update).
|
|
594
|
+
"""
|
|
595
|
+
pass
|
|
596
|
+
|
|
597
|
+
def on_create_after_save(self):
|
|
598
|
+
"""
|
|
599
|
+
Sync hook executed only after initial creation save.
|
|
600
|
+
"""
|
|
601
|
+
pass
|
|
602
|
+
|
|
603
|
+
def on_create_before_save(self):
|
|
604
|
+
"""
|
|
605
|
+
Sync hook executed only before initial creation save.
|
|
606
|
+
"""
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
def on_delete(self):
|
|
610
|
+
"""
|
|
611
|
+
Sync hook executed after delete.
|
|
612
|
+
"""
|
|
613
|
+
pass
|
|
614
|
+
|
|
615
|
+
def save(self, *args, **kwargs):
|
|
616
|
+
"""
|
|
617
|
+
Override save lifecycle to inject create/update hooks.
|
|
618
|
+
"""
|
|
619
|
+
state_adding = self._state.adding
|
|
620
|
+
if state_adding:
|
|
621
|
+
self.on_create_before_save()
|
|
622
|
+
self.before_save()
|
|
623
|
+
super().save(*args, **kwargs)
|
|
624
|
+
if state_adding:
|
|
625
|
+
self.on_create_after_save()
|
|
626
|
+
self.after_save()
|
|
627
|
+
|
|
628
|
+
def delete(self, *args, **kwargs):
|
|
629
|
+
"""
|
|
630
|
+
Override delete to inject on_delete hook.
|
|
631
|
+
|
|
632
|
+
Returns
|
|
633
|
+
-------
|
|
634
|
+
tuple(int, dict)
|
|
635
|
+
Django delete return signature.
|
|
636
|
+
"""
|
|
637
|
+
res = super().delete(*args, **kwargs)
|
|
638
|
+
self.on_delete()
|
|
639
|
+
return res
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
class SchemaModelConfig(Schema):
|
|
643
|
+
"""
|
|
644
|
+
SchemaModelConfig
|
|
645
|
+
-----------------
|
|
646
|
+
Configuration container for declarative schema definitions.
|
|
647
|
+
Attributes
|
|
648
|
+
----------
|
|
649
|
+
fields : Optional[List[str]]
|
|
650
|
+
Explicit model fields to include.
|
|
651
|
+
optionals : Optional[List[tuple[str, type]]]
|
|
652
|
+
Optional model fields.
|
|
653
|
+
exclude : Optional[List[str]]
|
|
654
|
+
Model fields to exclude.
|
|
655
|
+
customs : Optional[List[tuple[str, type, Any]]]
|
|
656
|
+
Custom / synthetic fields.
|
|
657
|
+
"""
|
|
658
|
+
|
|
659
|
+
fields: Optional[List[str]] = None
|
|
660
|
+
optionals: Optional[List[tuple[str, type]]] = None
|
|
661
|
+
exclude: Optional[List[str]] = None
|
|
662
|
+
customs: Optional[List[tuple[str, type, Any]]] = None
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class Serializer(BaseSerializer):
|
|
666
|
+
"""
|
|
667
|
+
Serializer
|
|
668
|
+
----------
|
|
669
|
+
Meta-driven serializer for arbitrary Django models. Shares common behavior
|
|
670
|
+
from BaseSerializer but sources configuration from the nested Meta class.
|
|
671
|
+
Supports optional relations_serializers mapping to explicitly include related
|
|
672
|
+
schema components during read schema generation.
|
|
673
|
+
"""
|
|
674
|
+
|
|
675
|
+
def __init_subclass__(cls, **kwargs):
|
|
676
|
+
super().__init_subclass__(**kwargs)
|
|
677
|
+
from ninja_aio.models.utils import ModelUtil
|
|
678
|
+
from ninja_aio.helpers.query import QueryUtil
|
|
679
|
+
|
|
680
|
+
cls.model = cls._get_model()
|
|
681
|
+
cls.schema_in = cls.generate_create_s()
|
|
682
|
+
cls.schema_out = cls.generate_read_s()
|
|
683
|
+
cls.schema_update = cls.generate_update_s()
|
|
684
|
+
cls.schema_related = cls.generate_related_s()
|
|
685
|
+
cls.util = ModelUtil(cls._get_model(), serializer_class=cls)
|
|
686
|
+
cls.query_util = QueryUtil(cls)
|
|
687
|
+
|
|
688
|
+
class Meta:
|
|
689
|
+
model: models.Model = None
|
|
690
|
+
schema_in: Optional[SchemaModelConfig] = None
|
|
691
|
+
schema_out: Optional[SchemaModelConfig] = None
|
|
692
|
+
schema_update: Optional[SchemaModelConfig] = None
|
|
693
|
+
relations_serializers: dict[str, "Serializer"] = {}
|
|
694
|
+
|
|
695
|
+
@classmethod
|
|
696
|
+
def _get_meta_data(cls, attr_name: str) -> Any:
|
|
697
|
+
return getattr(cls.Meta, attr_name, None)
|
|
698
|
+
|
|
699
|
+
@classmethod
|
|
700
|
+
def _get_model(cls) -> models.Model:
|
|
701
|
+
return cls._validate_model()
|
|
702
|
+
|
|
703
|
+
@classmethod
|
|
704
|
+
def _get_relations_serializers(cls) -> dict[str, "Serializer"]:
|
|
705
|
+
relations_serializers = cls._get_meta_data("relations_serializers")
|
|
706
|
+
return relations_serializers or {}
|
|
707
|
+
|
|
708
|
+
@classmethod
|
|
709
|
+
def _get_schema_meta(cls, schema_type: str) -> SchemaModelConfig | None:
|
|
710
|
+
match schema_type:
|
|
711
|
+
case "in":
|
|
712
|
+
return cls._get_meta_data("schema_in")
|
|
713
|
+
case "out":
|
|
714
|
+
return cls._get_meta_data("schema_out")
|
|
715
|
+
case "update":
|
|
716
|
+
return cls._get_meta_data("schema_update")
|
|
717
|
+
case _:
|
|
718
|
+
return None
|
|
719
|
+
|
|
720
|
+
@classmethod
|
|
721
|
+
def _validate_model(cls):
|
|
722
|
+
model = cls._get_meta_data("model")
|
|
723
|
+
if not model:
|
|
724
|
+
raise ValueError("Meta.model must be defined for Serializer.")
|
|
725
|
+
if not issubclass(model, models.Model):
|
|
726
|
+
raise ValueError("Meta.model must be a Django model")
|
|
727
|
+
return model
|
|
728
|
+
|
|
729
|
+
@classmethod
|
|
730
|
+
def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
|
|
731
|
+
"""Internal accessor for raw configuration lists from Meta schemas."""
|
|
732
|
+
schema = {
|
|
733
|
+
"create": cls._get_schema_meta("in"),
|
|
734
|
+
"update": cls._get_schema_meta("update"),
|
|
735
|
+
"read": cls._get_schema_meta("out"),
|
|
736
|
+
}.get(s_type)
|
|
737
|
+
if not schema:
|
|
738
|
+
return []
|
|
739
|
+
return getattr(schema, f_type, []) or []
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@classmethod
|
|
743
|
+
async def queryset_request(cls, request: HttpRequest):
|
|
744
|
+
return cls.query_util.apply_queryset_optimizations(
|
|
745
|
+
queryset=cls.model._default_manager.all(),
|
|
746
|
+
scope=cls.query_util.SCOPES.QUERYSET_REQUEST,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
async def post_create(self, instance: models.Model) -> None:
|
|
750
|
+
"""
|
|
751
|
+
Async hook executed after first persistence (create path).
|
|
752
|
+
"""
|
|
753
|
+
pass
|
|
754
|
+
|
|
755
|
+
async def custom_actions(self, payload: dict[str, Any], instance: models.Model):
|
|
756
|
+
"""
|
|
757
|
+
Async hook for reacting to provided custom (synthetic) fields.
|
|
758
|
+
|
|
759
|
+
Parameters
|
|
760
|
+
----------
|
|
761
|
+
payload : dict
|
|
762
|
+
Custom field name/value pairs.
|
|
763
|
+
"""
|
|
764
|
+
pass
|
|
765
|
+
|
|
766
|
+
async def save(self, instance: models.Model) -> models.Model:
|
|
767
|
+
"""
|
|
768
|
+
Async helper to save a model instance with lifecycle hooks.
|
|
769
|
+
|
|
770
|
+
Parameters
|
|
771
|
+
----------
|
|
772
|
+
instance : models.Model
|
|
773
|
+
The model instance to save.
|
|
774
|
+
"""
|
|
775
|
+
creation = instance._state.adding
|
|
776
|
+
if creation:
|
|
777
|
+
self.on_create_before_save(instance)
|
|
778
|
+
self.before_save(instance)
|
|
779
|
+
await instance.asave()
|
|
780
|
+
if creation:
|
|
781
|
+
self.on_create_after_save(instance)
|
|
782
|
+
self.after_save(instance)
|
|
783
|
+
return instance
|
|
784
|
+
|
|
785
|
+
async def create(self, payload: dict[str, Any]) -> models.Model:
|
|
786
|
+
"""
|
|
787
|
+
Create a new model instance from the provided payload.
|
|
788
|
+
|
|
789
|
+
Parameters
|
|
790
|
+
----------
|
|
791
|
+
payload : dict
|
|
792
|
+
Input data.
|
|
793
|
+
|
|
794
|
+
Returns
|
|
795
|
+
-------
|
|
796
|
+
models.Model
|
|
797
|
+
Created model instance.
|
|
798
|
+
"""
|
|
799
|
+
instance: models.Model = self.model(**payload)
|
|
800
|
+
return await self.save(instance)
|
|
801
|
+
|
|
802
|
+
async def update(
|
|
803
|
+
self, instance: models.Model, payload: dict[str, Any]
|
|
804
|
+
) -> models.Model:
|
|
805
|
+
"""
|
|
806
|
+
Update an existing model instance with the provided payload.
|
|
807
|
+
|
|
808
|
+
Parameters
|
|
809
|
+
----------
|
|
810
|
+
instance : models.Model
|
|
811
|
+
The model instance to update.
|
|
812
|
+
payload : dict
|
|
813
|
+
Input data.
|
|
814
|
+
|
|
815
|
+
Returns
|
|
816
|
+
-------
|
|
817
|
+
models.Model
|
|
818
|
+
Updated model instance.
|
|
819
|
+
"""
|
|
820
|
+
for attr, value in payload.items():
|
|
821
|
+
setattr(instance, attr, value)
|
|
822
|
+
return await self.save(instance)
|
|
823
|
+
|
|
824
|
+
async def model_dump(self, instance: models.Model) -> dict[str, Any]:
|
|
825
|
+
"""
|
|
826
|
+
Serialize a model instance to a dictionary using the Out schema.
|
|
827
|
+
|
|
828
|
+
Parameters
|
|
829
|
+
----------
|
|
830
|
+
instance : models.Model
|
|
831
|
+
The model instance to serialize.
|
|
832
|
+
|
|
833
|
+
Returns
|
|
834
|
+
-------
|
|
835
|
+
dict
|
|
836
|
+
Serialized data.
|
|
837
|
+
"""
|
|
838
|
+
return await self.model_util.read_s(schema=self.schema_out, instance=instance)
|
|
839
|
+
|
|
840
|
+
async def models_dump(
|
|
841
|
+
self, instances: models.QuerySet[models.Model]
|
|
842
|
+
) -> list[dict[str, Any]]:
|
|
843
|
+
"""
|
|
844
|
+
Serialize a list of model instances to a list of dictionaries using the Out schema.
|
|
845
|
+
|
|
846
|
+
Parameters
|
|
847
|
+
----------
|
|
848
|
+
instances : list[models.Model]
|
|
849
|
+
The list of model instances to serialize.
|
|
850
|
+
|
|
851
|
+
Returns
|
|
852
|
+
-------
|
|
853
|
+
list[dict]
|
|
854
|
+
List of serialized data.
|
|
855
|
+
"""
|
|
856
|
+
return await self.model_util.list_read_s(
|
|
857
|
+
schema=self.schema_out, instances=instances
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
def after_save(self, instance: models.Model):
|
|
861
|
+
"""
|
|
862
|
+
Sync hook executed after any save (create or update).
|
|
863
|
+
"""
|
|
864
|
+
pass
|
|
865
|
+
|
|
866
|
+
def before_save(self, instance: models.Model):
|
|
867
|
+
"""
|
|
868
|
+
Sync hook executed before any save (create or update).
|
|
869
|
+
"""
|
|
870
|
+
pass
|
|
871
|
+
|
|
872
|
+
def on_create_after_save(self, instance: models.Model):
|
|
873
|
+
"""
|
|
874
|
+
Sync hook executed only after initial creation save.
|
|
875
|
+
"""
|
|
876
|
+
pass
|
|
877
|
+
|
|
878
|
+
def on_create_before_save(self, instance: models.Model):
|
|
879
|
+
"""
|
|
880
|
+
Sync hook executed only before initial creation save.
|
|
881
|
+
"""
|
|
882
|
+
pass
|
|
883
|
+
|
|
884
|
+
def on_delete(self, instance: models.Model):
|
|
885
|
+
"""
|
|
886
|
+
Sync hook executed after delete.
|
|
887
|
+
"""
|
|
888
|
+
pass
|