django-ninja-aio-crud 1.0.1__py3-none-any.whl → 1.0.2__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-1.0.1.dist-info → django_ninja_aio_crud-1.0.2.dist-info}/METADATA +3 -1
- django_ninja_aio_crud-1.0.2.dist-info/RECORD +17 -0
- ninja_aio/__init__.py +1 -1
- ninja_aio/exceptions.py +1 -1
- ninja_aio/helpers/__init__.py +3 -0
- ninja_aio/helpers/api.py +432 -0
- ninja_aio/models.py +677 -361
- ninja_aio/renders.py +17 -24
- ninja_aio/schemas.py +16 -1
- ninja_aio/views.py +14 -189
- django_ninja_aio_crud-1.0.1.dist-info/RECORD +0 -15
- {django_ninja_aio_crud-1.0.1.dist-info → django_ninja_aio_crud-1.0.2.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-1.0.1.dist-info → django_ninja_aio_crud-1.0.2.dist-info}/licenses/LICENSE +0 -0
ninja_aio/models.py
CHANGED
|
@@ -22,6 +22,23 @@ from .types import S_TYPES, F_TYPES, SCHEMA_TYPES, ModelSerializerMeta
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
async def agetattr(obj, name: str, default=None):
|
|
25
|
+
"""
|
|
26
|
+
Async wrapper around getattr using sync_to_async.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
obj : Any
|
|
31
|
+
Object from which to retrieve the attribute.
|
|
32
|
+
name : str
|
|
33
|
+
Attribute name.
|
|
34
|
+
default : Any, optional
|
|
35
|
+
Default value if attribute is missing.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
Any
|
|
40
|
+
Attribute value (or default).
|
|
41
|
+
"""
|
|
25
42
|
return await sync_to_async(getattr)(obj, name, default)
|
|
26
43
|
|
|
27
44
|
|
|
@@ -50,84 +67,112 @@ class ModelUtil:
|
|
|
50
67
|
|
|
51
68
|
Key Methods
|
|
52
69
|
-----------
|
|
53
|
-
- get_object(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
- get_reverse_relations()
|
|
57
|
-
Discovers reverse relation names for safe prefetch_related usage.
|
|
58
|
-
- parse_input_data(request, data)
|
|
59
|
-
Converts a Schema into a model-ready dict:
|
|
60
|
-
* Strips custom + ignored optionals.
|
|
61
|
-
* Decodes BinaryField (base64 -> bytes).
|
|
62
|
-
* Replaces FK ids with related instances.
|
|
63
|
-
Returns (payload, customs_dict).
|
|
64
|
-
- parse_output_data(request, data)
|
|
65
|
-
Post-processes serialized output; replaces nested FK/OneToOne dicts
|
|
66
|
-
with authoritative related instances and rewrites nested FK keys to <name>_id.
|
|
70
|
+
- get_object()
|
|
71
|
+
- parse_input_data()
|
|
72
|
+
- parse_output_data()
|
|
67
73
|
- create_s / read_s / update_s / delete_s
|
|
68
|
-
High-level async CRUD operations wrapping the above transformations and hooks.
|
|
69
74
|
|
|
70
75
|
Error Handling
|
|
71
76
|
--------------
|
|
72
|
-
- Missing objects ->
|
|
73
|
-
- Bad base64 -> SerializeError({...}, 400)
|
|
77
|
+
- Missing objects -> NotFoundError(...)
|
|
78
|
+
- Bad base64 -> SerializeError({...}, 400)
|
|
74
79
|
|
|
75
80
|
Performance Notes
|
|
76
81
|
-----------------
|
|
77
|
-
- Each FK
|
|
78
|
-
- Reverse relation prefetching is opportunistic; trim serializable fields
|
|
79
|
-
if over-fetching becomes an issue.
|
|
80
|
-
|
|
81
|
-
Return Shapes
|
|
82
|
-
-------------
|
|
83
|
-
- create_s / read_s / update_s -> dict (post-processed schema dump).
|
|
84
|
-
- delete_s -> None.
|
|
85
|
-
- get_object -> model instance or queryset.
|
|
86
|
-
|
|
87
|
-
Design Choices
|
|
88
|
-
--------------
|
|
89
|
-
- Stateless aside from holding the target model (safe to instantiate per request).
|
|
90
|
-
- Avoids caching; callers may add caching where profiling justifies it.
|
|
91
|
-
- Treats absent optional fields as "leave unchanged" (update) or "omit" (create).
|
|
92
|
-
|
|
93
|
-
Assumptions
|
|
94
|
-
-----------
|
|
95
|
-
- Schema provides model_dump(mode="json").
|
|
96
|
-
- Django async ORM (Django 4.1+).
|
|
97
|
-
- BinaryField inputs are base64 strings.
|
|
98
|
-
- Related primary keys are simple scalars on input.
|
|
82
|
+
- Each FK resolution is an async DB hit; batch when necessary externally.
|
|
99
83
|
|
|
84
|
+
Design
|
|
85
|
+
------
|
|
86
|
+
- Stateless wrapper; safe per-request instantiation.
|
|
100
87
|
"""
|
|
101
88
|
|
|
102
89
|
def __init__(self, model: type["ModelSerializer"] | models.Model):
|
|
90
|
+
"""
|
|
91
|
+
Initialize with a Django model or ModelSerializer subclass.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
model : Model | ModelSerializerMeta
|
|
96
|
+
Target model class.
|
|
97
|
+
"""
|
|
103
98
|
self.model = model
|
|
104
99
|
|
|
105
100
|
@property
|
|
106
101
|
def serializable_fields(self):
|
|
102
|
+
"""
|
|
103
|
+
List of fields considered serializable for read operations.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
list[str]
|
|
108
|
+
Explicit read fields if ModelSerializerMeta, otherwise all model fields.
|
|
109
|
+
"""
|
|
107
110
|
if isinstance(self.model, ModelSerializerMeta):
|
|
108
111
|
return self.model.get_fields("read")
|
|
109
112
|
return self.model_fields
|
|
110
113
|
|
|
111
114
|
@property
|
|
112
115
|
def model_fields(self):
|
|
116
|
+
"""
|
|
117
|
+
Raw model field names (including forward relations).
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
list[str]
|
|
122
|
+
"""
|
|
113
123
|
return [field.name for field in self.model._meta.get_fields()]
|
|
114
124
|
|
|
115
125
|
@property
|
|
116
126
|
def model_name(self) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Django internal model name.
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
str
|
|
133
|
+
"""
|
|
117
134
|
return self.model._meta.model_name
|
|
118
135
|
|
|
119
136
|
@property
|
|
120
137
|
def model_pk_name(self) -> str:
|
|
138
|
+
"""
|
|
139
|
+
Primary key attribute name (attname).
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
str
|
|
144
|
+
"""
|
|
121
145
|
return self.model._meta.pk.attname
|
|
122
146
|
|
|
123
147
|
@property
|
|
124
148
|
def model_verbose_name_plural(self) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Human readable plural verbose name.
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
str
|
|
155
|
+
"""
|
|
125
156
|
return self.model._meta.verbose_name_plural
|
|
126
157
|
|
|
127
158
|
def verbose_name_path_resolver(self) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Slugify plural verbose name for URL path usage.
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
str
|
|
165
|
+
"""
|
|
128
166
|
return "-".join(self.model_verbose_name_plural.split(" "))
|
|
129
167
|
|
|
130
168
|
def verbose_name_view_resolver(self) -> str:
|
|
169
|
+
"""
|
|
170
|
+
Camel-case plural verbose name for view name usage.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
str
|
|
175
|
+
"""
|
|
131
176
|
return self.model_verbose_name_plural.replace(" ", "")
|
|
132
177
|
|
|
133
178
|
async def get_object(
|
|
@@ -142,6 +187,34 @@ class ModelUtil:
|
|
|
142
187
|
| models.Model
|
|
143
188
|
| models.QuerySet[type["ModelSerializer"] | models.Model]
|
|
144
189
|
):
|
|
190
|
+
"""
|
|
191
|
+
Retrieve a single instance (by pk/getters) or a queryset if no lookup criteria.
|
|
192
|
+
|
|
193
|
+
Applies queryset_request (if ModelSerializerMeta), select_related, and
|
|
194
|
+
prefetch_related on discovered reverse relations.
|
|
195
|
+
|
|
196
|
+
Parameters
|
|
197
|
+
----------
|
|
198
|
+
request : HttpRequest
|
|
199
|
+
pk : int | str, optional
|
|
200
|
+
Primary key lookup.
|
|
201
|
+
filters : dict, optional
|
|
202
|
+
Additional filter kwargs.
|
|
203
|
+
getters : dict, optional
|
|
204
|
+
Field lookups combined with pk lookup.
|
|
205
|
+
with_qs_request : bool
|
|
206
|
+
Whether to apply model-level queryset_request hook.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
Model | QuerySet
|
|
211
|
+
Instance if lookup provided; otherwise queryset.
|
|
212
|
+
|
|
213
|
+
Raises
|
|
214
|
+
------
|
|
215
|
+
NotFoundError
|
|
216
|
+
If instance not found by lookup criteria.
|
|
217
|
+
"""
|
|
145
218
|
get_q = {self.model_pk_name: pk} if pk is not None else {}
|
|
146
219
|
if getters:
|
|
147
220
|
get_q |= getters
|
|
@@ -165,6 +238,14 @@ class ModelUtil:
|
|
|
165
238
|
return obj
|
|
166
239
|
|
|
167
240
|
def get_reverse_relations(self) -> list[str]:
|
|
241
|
+
"""
|
|
242
|
+
Discover reverse relation names for safe prefetching.
|
|
243
|
+
|
|
244
|
+
Returns
|
|
245
|
+
-------
|
|
246
|
+
list[str]
|
|
247
|
+
Relation attribute names.
|
|
248
|
+
"""
|
|
168
249
|
reverse_rels = []
|
|
169
250
|
for f in self.serializable_fields:
|
|
170
251
|
field_obj = getattr(self.model, f)
|
|
@@ -178,11 +259,108 @@ class ModelUtil:
|
|
|
178
259
|
reverse_rels.append(field_obj.related.name)
|
|
179
260
|
return reverse_rels
|
|
180
261
|
|
|
262
|
+
async def _get_field(self, k: str):
|
|
263
|
+
return (await agetattr(self.model, k)).field
|
|
264
|
+
|
|
265
|
+
def _decode_binary(self, payload: dict, k: str, v: Any, field_obj: models.Field):
|
|
266
|
+
if not isinstance(field_obj, models.BinaryField):
|
|
267
|
+
return
|
|
268
|
+
try:
|
|
269
|
+
payload[k] = base64.b64decode(v)
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
raise SerializeError({k: ". ".join(exc.args)}, 400)
|
|
272
|
+
|
|
273
|
+
async def _resolve_fk(
|
|
274
|
+
self,
|
|
275
|
+
request: HttpRequest,
|
|
276
|
+
payload: dict,
|
|
277
|
+
k: str,
|
|
278
|
+
v: Any,
|
|
279
|
+
field_obj: models.Field,
|
|
280
|
+
):
|
|
281
|
+
if not isinstance(field_obj, models.ForeignKey):
|
|
282
|
+
return
|
|
283
|
+
rel_util = ModelUtil(field_obj.related_model)
|
|
284
|
+
rel = await rel_util.get_object(request, v, with_qs_request=False)
|
|
285
|
+
payload[k] = rel
|
|
286
|
+
|
|
287
|
+
def _extract_field_obj(self, field_name: str):
|
|
288
|
+
"""
|
|
289
|
+
Return the underlying Django Field (if any) for a given attribute name.
|
|
290
|
+
"""
|
|
291
|
+
descriptor = getattr(self.model, field_name, None)
|
|
292
|
+
if descriptor is None:
|
|
293
|
+
return None
|
|
294
|
+
return getattr(descriptor, "field", None) or getattr(
|
|
295
|
+
descriptor, "related", None
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _should_process_nested(self, value: Any, field_obj: Any) -> bool:
|
|
299
|
+
"""
|
|
300
|
+
Determine if a payload entry represents a nested FK / O2O relation dict.
|
|
301
|
+
"""
|
|
302
|
+
if not isinstance(value, dict):
|
|
303
|
+
return False
|
|
304
|
+
return isinstance(field_obj, (models.ForeignKey, models.OneToOneField))
|
|
305
|
+
|
|
306
|
+
async def _fetch_related_instance(
|
|
307
|
+
self, request, field_obj: models.Field, nested_dict: dict
|
|
308
|
+
):
|
|
309
|
+
"""
|
|
310
|
+
Resolve the related instance from its primary key inside the nested dict.
|
|
311
|
+
"""
|
|
312
|
+
rel_util = ModelUtil(field_obj.related_model)
|
|
313
|
+
rel_pk = nested_dict.get(rel_util.model_pk_name)
|
|
314
|
+
return await rel_util.get_object(request, rel_pk)
|
|
315
|
+
|
|
316
|
+
def _rewrite_nested_foreign_keys(self, rel_obj, nested_dict: dict):
|
|
317
|
+
"""
|
|
318
|
+
Rewrite foreign key keys inside a nested dict from <key> to <key>_id.
|
|
319
|
+
"""
|
|
320
|
+
keys_to_rewrite: list[str] = []
|
|
321
|
+
for rel_k in nested_dict.keys():
|
|
322
|
+
attr = getattr(rel_obj.__class__, rel_k, None)
|
|
323
|
+
fk_field = getattr(attr, "field", None)
|
|
324
|
+
if isinstance(fk_field, models.ForeignKey):
|
|
325
|
+
keys_to_rewrite.append(rel_k)
|
|
326
|
+
for old_k in keys_to_rewrite:
|
|
327
|
+
nested_dict[f"{old_k}_id"] = nested_dict.pop(old_k)
|
|
328
|
+
|
|
181
329
|
async def parse_input_data(self, request: HttpRequest, data: Schema):
|
|
330
|
+
"""
|
|
331
|
+
Transform inbound schema data to a model-ready payload.
|
|
332
|
+
|
|
333
|
+
Steps
|
|
334
|
+
-----
|
|
335
|
+
- Strip custom fields (retain separately).
|
|
336
|
+
- Drop optional fields with None (ModelSerializer only).
|
|
337
|
+
- Decode BinaryField base64 values.
|
|
338
|
+
- Resolve ForeignKey ids to model instances.
|
|
339
|
+
|
|
340
|
+
Parameters
|
|
341
|
+
----------
|
|
342
|
+
request : HttpRequest
|
|
343
|
+
data : Schema
|
|
344
|
+
Incoming validated schema instance.
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
tuple[dict, dict]
|
|
349
|
+
(payload_without_customs, customs_dict)
|
|
350
|
+
|
|
351
|
+
Raises
|
|
352
|
+
------
|
|
353
|
+
SerializeError
|
|
354
|
+
On base64 decoding failure.
|
|
355
|
+
"""
|
|
182
356
|
payload = data.model_dump(mode="json")
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
357
|
+
|
|
358
|
+
is_serializer = isinstance(self.model, ModelSerializerMeta)
|
|
359
|
+
|
|
360
|
+
# Collect custom and optional fields (only if ModelSerializerMeta)
|
|
361
|
+
customs: dict[str, Any] = {}
|
|
362
|
+
optionals: list[str] = []
|
|
363
|
+
if is_serializer:
|
|
186
364
|
customs = {
|
|
187
365
|
k: v
|
|
188
366
|
for k, v in payload.items()
|
|
@@ -191,62 +369,85 @@ class ModelUtil:
|
|
|
191
369
|
optionals = [
|
|
192
370
|
k for k, v in payload.items() if self.model.is_optional(k) and v is None
|
|
193
371
|
]
|
|
372
|
+
|
|
373
|
+
skip_keys = set()
|
|
374
|
+
if is_serializer:
|
|
375
|
+
# Keys to skip during model field processing
|
|
376
|
+
skip_keys = {
|
|
377
|
+
k
|
|
378
|
+
for k, v in payload.items()
|
|
379
|
+
if (self.model.is_custom(k) and k not in self.model_fields)
|
|
380
|
+
or (self.model.is_optional(k) and v is None)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
# Process payload fields
|
|
194
384
|
for k, v in payload.items():
|
|
195
|
-
if
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
raise SerializeError({k: ". ".join(exc.args)}, 400)
|
|
206
|
-
if isinstance(field_obj, models.ForeignKey):
|
|
207
|
-
rel_util = ModelUtil(field_obj.related_model)
|
|
208
|
-
rel: ModelSerializer = await rel_util.get_object(
|
|
209
|
-
request, v, with_qs_request=False
|
|
210
|
-
)
|
|
211
|
-
payload |= {k: rel}
|
|
212
|
-
new_payload = {
|
|
213
|
-
k: v for k, v in payload.items() if k not in (customs.keys() or optionals)
|
|
214
|
-
}
|
|
385
|
+
if k in skip_keys:
|
|
386
|
+
continue
|
|
387
|
+
field_obj = await self._get_field(k)
|
|
388
|
+
self._decode_binary(payload, k, v, field_obj)
|
|
389
|
+
await self._resolve_fk(request, payload, k, v, field_obj)
|
|
390
|
+
|
|
391
|
+
# Preserve original exclusion semantics (customs if present else optionals)
|
|
392
|
+
exclude_keys = customs.keys() or optionals
|
|
393
|
+
new_payload = {k: v for k, v in payload.items() if k not in exclude_keys}
|
|
394
|
+
|
|
215
395
|
return new_payload, customs
|
|
216
396
|
|
|
217
397
|
async def parse_output_data(self, request: HttpRequest, data: Schema):
|
|
218
|
-
|
|
398
|
+
"""
|
|
399
|
+
Post-process serialized output.
|
|
400
|
+
|
|
401
|
+
For nested FK / OneToOne dicts:
|
|
402
|
+
- Replace dict with authoritative related instance.
|
|
403
|
+
- Rewrite nested FK keys to <name>_id for nested foreign keys.
|
|
404
|
+
|
|
405
|
+
Parameters
|
|
406
|
+
----------
|
|
407
|
+
request : HttpRequest
|
|
408
|
+
data : Schema
|
|
409
|
+
Schema (from_orm) instance.
|
|
410
|
+
|
|
411
|
+
Returns
|
|
412
|
+
-------
|
|
413
|
+
dict
|
|
414
|
+
Normalized output payload.
|
|
415
|
+
"""
|
|
219
416
|
payload = data.model_dump(mode="json")
|
|
417
|
+
|
|
220
418
|
for k, v in payload.items():
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
rel_util = ModelUtil(field_obj.related_model)
|
|
233
|
-
rel: ModelSerializer = await rel_util.get_object(
|
|
234
|
-
request, v.get(rel_util.model_pk_name)
|
|
235
|
-
)
|
|
236
|
-
if isinstance(field_obj, models.ForeignKey):
|
|
237
|
-
for rel_k, rel_v in v.items():
|
|
238
|
-
field_rel_obj = await agetattr(rel, rel_k)
|
|
239
|
-
if isinstance(field_rel_obj, models.ForeignKey):
|
|
240
|
-
olds_k.append({rel_k: rel_v})
|
|
241
|
-
for obj in olds_k:
|
|
242
|
-
for old_k, old_v in obj.items():
|
|
243
|
-
v.pop(old_k)
|
|
244
|
-
v |= {f"{old_k}_id": old_v}
|
|
245
|
-
olds_k = []
|
|
246
|
-
payload |= {k: rel}
|
|
419
|
+
field_obj = self._extract_field_obj(k)
|
|
420
|
+
if not self._should_process_nested(v, field_obj):
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
rel_instance = await self._fetch_related_instance(request, field_obj, v)
|
|
424
|
+
|
|
425
|
+
if isinstance(field_obj, models.ForeignKey):
|
|
426
|
+
self._rewrite_nested_foreign_keys(rel_instance, v)
|
|
427
|
+
|
|
428
|
+
payload[k] = rel_instance
|
|
429
|
+
|
|
247
430
|
return payload
|
|
248
431
|
|
|
249
432
|
async def create_s(self, request: HttpRequest, data: Schema, obj_schema: Schema):
|
|
433
|
+
"""
|
|
434
|
+
Create a new instance and return serialized output.
|
|
435
|
+
|
|
436
|
+
Applies custom_actions + post_create hooks if available.
|
|
437
|
+
|
|
438
|
+
Parameters
|
|
439
|
+
----------
|
|
440
|
+
request : HttpRequest
|
|
441
|
+
data : Schema
|
|
442
|
+
Input schema instance.
|
|
443
|
+
obj_schema : Schema
|
|
444
|
+
Read schema class for output.
|
|
445
|
+
|
|
446
|
+
Returns
|
|
447
|
+
-------
|
|
448
|
+
dict
|
|
449
|
+
Serialized created object.
|
|
450
|
+
"""
|
|
250
451
|
payload, customs = await self.parse_input_data(request, data)
|
|
251
452
|
pk = (await self.model.objects.acreate(**payload)).pk
|
|
252
453
|
obj = await self.get_object(request, pk)
|
|
@@ -260,6 +461,27 @@ class ModelUtil:
|
|
|
260
461
|
obj: type["ModelSerializer"],
|
|
261
462
|
obj_schema: Schema,
|
|
262
463
|
):
|
|
464
|
+
"""
|
|
465
|
+
Serialize an existing instance with the provided read schema.
|
|
466
|
+
|
|
467
|
+
Parameters
|
|
468
|
+
----------
|
|
469
|
+
request : HttpRequest
|
|
470
|
+
obj : Model
|
|
471
|
+
Target instance.
|
|
472
|
+
obj_schema : Schema
|
|
473
|
+
Read schema class.
|
|
474
|
+
|
|
475
|
+
Returns
|
|
476
|
+
-------
|
|
477
|
+
dict
|
|
478
|
+
Serialized payload.
|
|
479
|
+
|
|
480
|
+
Raises
|
|
481
|
+
------
|
|
482
|
+
SerializeError
|
|
483
|
+
If obj_schema not provided.
|
|
484
|
+
"""
|
|
263
485
|
if obj_schema is None:
|
|
264
486
|
raise SerializeError({"obj_schema": "must be provided"}, 400)
|
|
265
487
|
return await self.parse_output_data(
|
|
@@ -269,6 +491,26 @@ class ModelUtil:
|
|
|
269
491
|
async def update_s(
|
|
270
492
|
self, request: HttpRequest, data: Schema, pk: int | str, obj_schema: Schema
|
|
271
493
|
):
|
|
494
|
+
"""
|
|
495
|
+
Update an existing instance and return serialized output.
|
|
496
|
+
|
|
497
|
+
Only non-null fields are applied to the instance.
|
|
498
|
+
|
|
499
|
+
Parameters
|
|
500
|
+
----------
|
|
501
|
+
request : HttpRequest
|
|
502
|
+
data : Schema
|
|
503
|
+
Input update schema instance.
|
|
504
|
+
pk : int | str
|
|
505
|
+
Primary key of target object.
|
|
506
|
+
obj_schema : Schema
|
|
507
|
+
Read schema class for output.
|
|
508
|
+
|
|
509
|
+
Returns
|
|
510
|
+
-------
|
|
511
|
+
dict
|
|
512
|
+
Serialized updated object.
|
|
513
|
+
"""
|
|
272
514
|
obj = await self.get_object(request, pk)
|
|
273
515
|
payload, customs = await self.parse_input_data(request, data)
|
|
274
516
|
for k, v in payload.items():
|
|
@@ -281,6 +523,19 @@ class ModelUtil:
|
|
|
281
523
|
return await self.read_s(request, updated_object, obj_schema)
|
|
282
524
|
|
|
283
525
|
async def delete_s(self, request: HttpRequest, pk: int | str):
|
|
526
|
+
"""
|
|
527
|
+
Delete an instance by primary key.
|
|
528
|
+
|
|
529
|
+
Parameters
|
|
530
|
+
----------
|
|
531
|
+
request : HttpRequest
|
|
532
|
+
pk : int | str
|
|
533
|
+
Primary key.
|
|
534
|
+
|
|
535
|
+
Returns
|
|
536
|
+
-------
|
|
537
|
+
None
|
|
538
|
+
"""
|
|
284
539
|
obj = await self.get_object(request, pk)
|
|
285
540
|
await obj.adelete()
|
|
286
541
|
return None
|
|
@@ -295,105 +550,13 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
295
550
|
related schemas.
|
|
296
551
|
|
|
297
552
|
Goals
|
|
553
|
+
-----
|
|
298
554
|
- Remove duplication between Model and separate serializer classes.
|
|
299
555
|
- Provide clear extension points (sync + async hooks, custom synthetic fields).
|
|
300
556
|
|
|
301
|
-
|
|
302
|
-
- CreateSerializer
|
|
303
|
-
fields : required model fields on create
|
|
304
|
-
optionals : optional model fields (accepted if present; ignored if None)
|
|
305
|
-
customs : [(name, type, default)] synthetic (non‑model) inputs
|
|
306
|
-
excludes : model fields disallowed on create
|
|
307
|
-
- UpdateSerializer (same structure; usually use optionals for PATCH-like behavior)
|
|
308
|
-
- ReadSerializer
|
|
309
|
-
fields : model fields to expose
|
|
310
|
-
excludes : fields always excluded (e.g. password)
|
|
311
|
-
customs : [(name, type, default)] computed outputs (property > callable > default)
|
|
312
|
-
|
|
313
|
-
Generated schema helpers
|
|
314
|
-
- generate_create_s() -> input schema ("In")
|
|
315
|
-
- generate_update_s() -> input schema for partial/full update ("Patch")
|
|
316
|
-
- generate_read_s() -> detailed output schema ("Out")
|
|
317
|
-
- generate_related_s() -> compact nested schema ("Related")
|
|
318
|
-
|
|
319
|
-
Relation handling (only if related model is also a ModelSerializer)
|
|
320
|
-
- Forward FK / OneToOne serialized as single nested objects
|
|
321
|
-
- Reverse OneToOne / Reverse FK / M2M serialized as single or list
|
|
322
|
-
- Relations skipped if related model exposes no read/custom fields
|
|
323
|
-
|
|
324
|
-
Classification helpers
|
|
325
|
-
- is_custom(field) -> True if declared in create/update customs
|
|
326
|
-
- is_optional(field) -> True if declared in create/update optionals
|
|
327
|
-
|
|
328
|
-
Sync lifecycle hooks (override as needed)
|
|
329
|
-
save():
|
|
330
|
-
on_create_before_save() (only first insert)
|
|
331
|
-
before_save()
|
|
332
|
-
super().save()
|
|
333
|
-
on_create_after_save() (only first insert)
|
|
334
|
-
after_save()
|
|
335
|
-
delete():
|
|
336
|
-
super().delete()
|
|
337
|
-
on_delete()
|
|
338
|
-
|
|
339
|
-
Async extension points
|
|
340
|
-
- queryset_request(request): request-scoped queryset filtering
|
|
341
|
-
- post_create(): async logic after creation
|
|
342
|
-
- custom_actions(payload_customs): react to synthetic fields
|
|
343
|
-
|
|
344
|
-
Utilities
|
|
345
|
-
- has_changed(field): compares in-memory value vs DB persisted value
|
|
346
|
-
- verbose_name_path_resolver(): slugified plural verbose name
|
|
347
|
-
|
|
348
|
-
Implementation notes
|
|
349
|
-
- If both fields and excludes are empty (create/update), optionals are used as the base.
|
|
350
|
-
- customs + optionals are passed as custom_fields to the schema factory.
|
|
351
|
-
- Nested relation schemas generated only if the related model explicitly declares
|
|
352
|
-
readable or custom fields.
|
|
353
|
-
|
|
354
|
-
Minimal example
|
|
355
|
-
```python
|
|
356
|
-
from django.db import models
|
|
357
|
-
from ninja_aio.models import ModelSerializer
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
class User(ModelSerializer):
|
|
361
|
-
username = models.CharField(max_length=150, unique=True)
|
|
362
|
-
email = models.EmailField(unique=True)
|
|
363
|
-
|
|
364
|
-
class CreateSerializer:
|
|
365
|
-
fields = ["username", "email"]
|
|
366
|
-
|
|
367
|
-
class ReadSerializer:
|
|
368
|
-
fields = ["id", "username", "email"]
|
|
369
|
-
|
|
370
|
-
def __str__(self):
|
|
371
|
-
return self.username
|
|
372
|
-
```
|
|
373
|
-
-------------------------------------
|
|
374
|
-
Conceptual Equivalent (Ninja example)
|
|
375
|
-
Using django-ninja you might otherwise write:
|
|
376
|
-
```python
|
|
377
|
-
from ninja import ModelSchema
|
|
378
|
-
from api.models import User
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
class UserIn(ModelSchema):
|
|
382
|
-
class Meta:
|
|
383
|
-
model = User
|
|
384
|
-
fields = ["username", "email"]
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
class UserOut(ModelSchema):
|
|
388
|
-
class Meta:
|
|
389
|
-
model = User
|
|
390
|
-
model_fields = ["id", "username", "email"]
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
Summary
|
|
394
|
-
Centralizes serialization intent on the model, reducing boilerplate and keeping
|
|
395
|
-
API and model definitions consistent.
|
|
557
|
+
See inline docstrings for per-method behavior.
|
|
396
558
|
"""
|
|
559
|
+
|
|
397
560
|
class Meta:
|
|
398
561
|
abstract = True
|
|
399
562
|
|
|
@@ -409,156 +572,52 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
409
572
|
Attributes
|
|
410
573
|
----------
|
|
411
574
|
fields : list[str]
|
|
412
|
-
|
|
413
|
-
implementation may infer required fields from the model (excluding
|
|
414
|
-
auto / read-only fields). Prefer being explicit.
|
|
575
|
+
REQUIRED model fields.
|
|
415
576
|
optionals : list[tuple[str, type]]
|
|
416
|
-
|
|
417
|
-
(field_name, python_type)
|
|
418
|
-
If omitted in the payload they are ignored; if present with null/None
|
|
419
|
-
the caller signals an intentional null (subject to model constraints).
|
|
577
|
+
Optional model fields (nullable / patch-like).
|
|
420
578
|
customs : list[tuple[str, type, Any]]
|
|
421
|
-
|
|
422
|
-
password confirmation, initial related IDs, flags). Each tuple:
|
|
423
|
-
(name, python_type, default_value)
|
|
424
|
-
Resolution order (implementation-dependent):
|
|
425
|
-
1. Value provided by the incoming payload.
|
|
426
|
-
2. If default_value is callable -> invoked (passing model class or
|
|
427
|
-
context if supported).
|
|
428
|
-
3. Literal default_value.
|
|
429
|
-
These values are typically consumed inside custom_actions or post_create
|
|
430
|
-
hooks and are NOT persisted directly unless you do so manually.
|
|
579
|
+
Synthetic input fields (non-model).
|
|
431
580
|
excludes : list[str]
|
|
432
|
-
|
|
433
|
-
audit fields, computed columns).
|
|
434
|
-
|
|
435
|
-
Recommended Conventions
|
|
436
|
-
-----------------------
|
|
437
|
-
- Always exclude primary keys and auto-managed timestamps.
|
|
438
|
-
- Keep customs minimal and clearly documented.
|
|
439
|
-
- Use optionals instead of putting nullable fields in fields if they are
|
|
440
|
-
not logically required for initial creation.
|
|
441
|
-
|
|
442
|
-
Extensibility
|
|
443
|
-
-------------
|
|
444
|
-
A higher-level builder can:
|
|
445
|
-
1. Collect fields + optionals + customs.
|
|
446
|
-
2. Build a schema where fields are required, optionals are Optional[…],
|
|
447
|
-
and customs become additional inputs not mapped directly to the model.
|
|
581
|
+
Disallowed model fields on create (e.g., id, timestamps).
|
|
448
582
|
"""
|
|
583
|
+
|
|
449
584
|
fields: list[str] = []
|
|
450
585
|
customs: list[tuple[str, type, Any]] = []
|
|
451
586
|
optionals: list[tuple[str, type]] = []
|
|
452
587
|
excludes: list[str] = []
|
|
453
588
|
|
|
454
589
|
class ReadSerializer:
|
|
455
|
-
"""Configuration
|
|
590
|
+
"""Configuration describing how to build a read (output) schema.
|
|
456
591
|
|
|
457
592
|
Attributes
|
|
458
|
-
|
|
593
|
+
----------
|
|
459
594
|
fields : list[str]
|
|
460
|
-
Explicit model
|
|
461
|
-
implementation may choose to include all model fields (or none, depending
|
|
462
|
-
on the consuming logic).
|
|
595
|
+
Explicit model fields to include.
|
|
463
596
|
excludes : list[str]
|
|
464
|
-
|
|
465
|
-
(e.g., sensitive columns like password, secrets, internal flags).
|
|
597
|
+
Fields to force exclude (safety).
|
|
466
598
|
customs : list[tuple[str, type, Any]]
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
The attribute is resolved in the following preferred order (implementation
|
|
471
|
-
dependent):
|
|
472
|
-
1. Attribute / property on the model instance with that name.
|
|
473
|
-
2. Callable (if the default_value is a callable) invoked to produce a value.
|
|
474
|
-
3. Fallback to the literal default_value.
|
|
475
|
-
Example:
|
|
476
|
-
customs = [
|
|
477
|
-
("full_name", str, lambda obj: f"{obj.first_name} {obj.last_name}".strip())
|
|
478
|
-
]
|
|
479
|
-
|
|
480
|
-
Conceptual Equivalent (Ninja example)
|
|
481
|
-
-------------------------------------
|
|
482
|
-
Using django-ninja you might otherwise write:
|
|
483
|
-
|
|
484
|
-
```python
|
|
485
|
-
from ninja import ModelSchema
|
|
486
|
-
from api.models import User
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
class UserOut(ModelSchema):
|
|
490
|
-
class Meta:
|
|
491
|
-
model = User
|
|
492
|
-
model_fields = ["id", "username", "email"]
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
This ReadSerializer object centralizes the same intent in a lightweight,
|
|
496
|
-
framework-agnostic configuration primitive that can be inspected to build
|
|
497
|
-
schemas dynamically.
|
|
498
|
-
|
|
499
|
-
Recommended Conventions
|
|
500
|
-
-----------------------
|
|
501
|
-
- Keep fields minimal; prefer explicit inclusion over implicit broad exposure.
|
|
502
|
-
- Use excludes as a safety net (e.g., always exclude "password").
|
|
503
|
-
- For customs, always specify a concrete python_type for better downstream
|
|
504
|
-
validation / OpenAPI generation.
|
|
505
|
-
- Prefer callables as default_value when computing derived data; use simple
|
|
506
|
-
literals only for static fallbacks.
|
|
507
|
-
|
|
508
|
-
Extensibility Notes
|
|
509
|
-
-------------------
|
|
510
|
-
A higher-level factory or metaclass can:
|
|
511
|
-
1. Read these lists.
|
|
512
|
-
2. Reflect on the model.
|
|
513
|
-
3. Generate a Pydantic / Ninja schema class at runtime.
|
|
514
|
-
This separation enables cleaner unit testing (the config is pure data) and
|
|
515
|
-
reduces coupling to a specific serialization framework."""
|
|
599
|
+
Computed / synthetic output attributes.
|
|
600
|
+
"""
|
|
601
|
+
|
|
516
602
|
fields: list[str] = []
|
|
517
603
|
excludes: list[str] = []
|
|
518
604
|
customs: list[tuple[str, type, Any]] = []
|
|
519
605
|
|
|
520
606
|
class UpdateSerializer:
|
|
521
|
-
"""Configuration
|
|
522
|
-
|
|
523
|
-
Purpose
|
|
524
|
-
-------
|
|
525
|
-
Defines which fields can be changed and how they are treated when updating
|
|
526
|
-
an existing instance (PATCH / PUT–style operations).
|
|
607
|
+
"""Configuration describing update (PATCH/PUT) schema.
|
|
527
608
|
|
|
528
609
|
Attributes
|
|
529
610
|
----------
|
|
530
611
|
fields : list[str]
|
|
531
|
-
|
|
532
|
-
partial so this is often left empty). If non-empty, these must be present
|
|
533
|
-
in the payload.
|
|
612
|
+
Required update fields (rare).
|
|
534
613
|
optionals : list[tuple[str, type]]
|
|
535
|
-
|
|
536
|
-
are left untouched. Provided null/None values indicate an explicit attempt
|
|
537
|
-
to nullify (subject to model constraints).
|
|
614
|
+
Editable optional fields.
|
|
538
615
|
customs : list[tuple[str, type, Any]]
|
|
539
|
-
|
|
540
|
-
"regenerate_token"). Each tuple:
|
|
541
|
-
(name, python_type, default_value)
|
|
542
|
-
Resolution order mirrors CreateSerializer (payload > callable > literal).
|
|
543
|
-
Typically consumed in custom_actions before or after saving.
|
|
616
|
+
Synthetic operational inputs.
|
|
544
617
|
excludes : list[str]
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
Recommended Conventions
|
|
548
|
-
-----------------------
|
|
549
|
-
- Prefer listing editable columns in optionals rather than fields to facilitate
|
|
550
|
-
partial updates.
|
|
551
|
-
- Use customs for operational flags (e.g., "reset_password": bool).
|
|
552
|
-
- Keep excludes synchronized with CreateSerializer excludes where appropriate.
|
|
553
|
-
|
|
554
|
-
Extensibility
|
|
555
|
-
-------------
|
|
556
|
-
A schema builder can:
|
|
557
|
-
1. Treat fields as required.
|
|
558
|
-
2. Treat optionals as Optional[…].
|
|
559
|
-
3. Inject customs as additional validated inputs.
|
|
560
|
-
4. Enforce excludes by rejecting them if present in incoming data.
|
|
618
|
+
Immutable / blocked fields.
|
|
561
619
|
"""
|
|
620
|
+
|
|
562
621
|
fields: list[str] = []
|
|
563
622
|
customs: list[tuple[str, type, Any]] = []
|
|
564
623
|
optionals: list[tuple[str, type]] = []
|
|
@@ -566,30 +625,63 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
566
625
|
|
|
567
626
|
@property
|
|
568
627
|
def has_custom_fields_create(self):
|
|
628
|
+
"""
|
|
629
|
+
Whether CreateSerializer declares custom fields.
|
|
630
|
+
"""
|
|
569
631
|
return hasattr(self.CreateSerializer, "customs")
|
|
570
632
|
|
|
571
633
|
@property
|
|
572
634
|
def has_custom_fields_update(self):
|
|
635
|
+
"""
|
|
636
|
+
Whether UpdateSerializer declares custom fields.
|
|
637
|
+
"""
|
|
573
638
|
return hasattr(self.UpdateSerializer, "customs")
|
|
574
639
|
|
|
575
640
|
@property
|
|
576
641
|
def has_custom_fields(self):
|
|
642
|
+
"""
|
|
643
|
+
Whether any serializer declares custom fields.
|
|
644
|
+
"""
|
|
577
645
|
return self.has_custom_fields_create or self.has_custom_fields_update
|
|
578
646
|
|
|
579
647
|
@property
|
|
580
648
|
def has_optional_fields_create(self):
|
|
649
|
+
"""
|
|
650
|
+
Whether CreateSerializer declares optional fields.
|
|
651
|
+
"""
|
|
581
652
|
return hasattr(self.CreateSerializer, "optionals")
|
|
582
653
|
|
|
583
654
|
@property
|
|
584
655
|
def has_optional_fields_update(self):
|
|
656
|
+
"""
|
|
657
|
+
Whether UpdateSerializer declares optional fields.
|
|
658
|
+
"""
|
|
585
659
|
return hasattr(self.UpdateSerializer, "optionals")
|
|
586
660
|
|
|
587
661
|
@property
|
|
588
662
|
def has_optional_fields(self):
|
|
663
|
+
"""
|
|
664
|
+
Whether any serializer declares optional fields.
|
|
665
|
+
"""
|
|
589
666
|
return self.has_optional_fields_create or self.has_optional_fields_update
|
|
590
667
|
|
|
591
668
|
@classmethod
|
|
592
669
|
def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
|
|
670
|
+
"""
|
|
671
|
+
Internal accessor for raw configuration lists.
|
|
672
|
+
|
|
673
|
+
Parameters
|
|
674
|
+
----------
|
|
675
|
+
s_type : str
|
|
676
|
+
Serializer type ("create" | "update" | "read").
|
|
677
|
+
f_type : str
|
|
678
|
+
Field category ("fields" | "optionals" | "customs" | "excludes").
|
|
679
|
+
|
|
680
|
+
Returns
|
|
681
|
+
-------
|
|
682
|
+
list
|
|
683
|
+
Raw configuration list or empty list.
|
|
684
|
+
"""
|
|
593
685
|
match s_type:
|
|
594
686
|
case "create":
|
|
595
687
|
fields = getattr(cls.CreateSerializer, f_type, [])
|
|
@@ -603,6 +695,19 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
603
695
|
def _is_special_field(
|
|
604
696
|
cls, s_type: type[S_TYPES], field: str, f_type: type[F_TYPES]
|
|
605
697
|
):
|
|
698
|
+
"""
|
|
699
|
+
Determine if a field is declared in a given category for a serializer type.
|
|
700
|
+
|
|
701
|
+
Parameters
|
|
702
|
+
----------
|
|
703
|
+
s_type : str
|
|
704
|
+
field : str
|
|
705
|
+
f_type : str
|
|
706
|
+
|
|
707
|
+
Returns
|
|
708
|
+
-------
|
|
709
|
+
bool
|
|
710
|
+
"""
|
|
606
711
|
special_fields = cls._get_fields(s_type, f_type)
|
|
607
712
|
return any(field in special_f for special_f in special_fields)
|
|
608
713
|
|
|
@@ -612,6 +717,21 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
612
717
|
schema_type: type[SCHEMA_TYPES],
|
|
613
718
|
depth: int = None,
|
|
614
719
|
) -> Schema:
|
|
720
|
+
"""
|
|
721
|
+
Core schema factory bridging configuration and ninja.orm.create_schema.
|
|
722
|
+
|
|
723
|
+
Parameters
|
|
724
|
+
----------
|
|
725
|
+
schema_type : str
|
|
726
|
+
"In" | "Patch" | "Out" | "Related".
|
|
727
|
+
depth : int, optional
|
|
728
|
+
Relation depth for read schema.
|
|
729
|
+
|
|
730
|
+
Returns
|
|
731
|
+
-------
|
|
732
|
+
Schema | None
|
|
733
|
+
Generated schema class or None if no fields.
|
|
734
|
+
"""
|
|
615
735
|
match schema_type:
|
|
616
736
|
case "In":
|
|
617
737
|
s_type = "create"
|
|
@@ -660,11 +780,28 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
660
780
|
|
|
661
781
|
@classmethod
|
|
662
782
|
def verbose_name_path_resolver(cls) -> str:
|
|
783
|
+
"""
|
|
784
|
+
Slugify plural verbose name for URL path segment.
|
|
785
|
+
|
|
786
|
+
Returns
|
|
787
|
+
-------
|
|
788
|
+
str
|
|
789
|
+
"""
|
|
663
790
|
return "-".join(cls._meta.verbose_name_plural.split(" "))
|
|
664
791
|
|
|
665
792
|
def has_changed(self, field: str) -> bool:
|
|
666
793
|
"""
|
|
667
|
-
Check if a model field has changed
|
|
794
|
+
Check if a model field has changed compared to the persisted value.
|
|
795
|
+
|
|
796
|
+
Parameters
|
|
797
|
+
----------
|
|
798
|
+
field : str
|
|
799
|
+
Field name.
|
|
800
|
+
|
|
801
|
+
Returns
|
|
802
|
+
-------
|
|
803
|
+
bool
|
|
804
|
+
True if in-memory value differs from DB value.
|
|
668
805
|
"""
|
|
669
806
|
if not self.pk:
|
|
670
807
|
return False
|
|
@@ -678,27 +815,45 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
678
815
|
@classmethod
|
|
679
816
|
async def queryset_request(cls, request: HttpRequest):
|
|
680
817
|
"""
|
|
681
|
-
Override
|
|
682
|
-
|
|
818
|
+
Override to return a request-scoped filtered queryset.
|
|
819
|
+
|
|
820
|
+
Parameters
|
|
821
|
+
----------
|
|
822
|
+
request : HttpRequest
|
|
823
|
+
|
|
824
|
+
Returns
|
|
825
|
+
-------
|
|
826
|
+
QuerySet
|
|
683
827
|
"""
|
|
684
828
|
return cls.objects.select_related().all()
|
|
685
829
|
|
|
686
830
|
async def post_create(self) -> None:
|
|
687
831
|
"""
|
|
688
|
-
|
|
689
|
-
has been created
|
|
832
|
+
Async hook executed after first persistence (create path).
|
|
690
833
|
"""
|
|
691
834
|
pass
|
|
692
835
|
|
|
693
836
|
async def custom_actions(self, payload: dict[str, Any]):
|
|
694
837
|
"""
|
|
695
|
-
|
|
696
|
-
|
|
838
|
+
Async hook for reacting to provided custom (synthetic) fields.
|
|
839
|
+
|
|
840
|
+
Parameters
|
|
841
|
+
----------
|
|
842
|
+
payload : dict
|
|
843
|
+
Custom field name/value pairs.
|
|
697
844
|
"""
|
|
698
845
|
pass
|
|
699
846
|
|
|
700
847
|
@classmethod
|
|
701
848
|
def get_related_schema_data(cls):
|
|
849
|
+
"""
|
|
850
|
+
Build field/custom lists for 'Related' schema (flattening non-relational fields).
|
|
851
|
+
|
|
852
|
+
Returns
|
|
853
|
+
-------
|
|
854
|
+
tuple[list[str] | None, list[tuple] | None]
|
|
855
|
+
(related_fields, custom_related_fields) or (None, None)
|
|
856
|
+
"""
|
|
702
857
|
fields = cls.get_fields("read")
|
|
703
858
|
custom_f = {
|
|
704
859
|
name: (value, default)
|
|
@@ -728,13 +883,67 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
728
883
|
related_fields = [f for f in _related_fields if f not in custom_f]
|
|
729
884
|
return related_fields, custom_related_fields
|
|
730
885
|
|
|
886
|
+
@classmethod
|
|
887
|
+
def _build_schema_reverse_rel(cls, field_name: str, descriptor: Any):
|
|
888
|
+
"""
|
|
889
|
+
Build a reverse relation schema component for 'Out' schema generation.
|
|
890
|
+
"""
|
|
891
|
+
if isinstance(descriptor, ManyToManyDescriptor):
|
|
892
|
+
rel_model: ModelSerializer = descriptor.field.related_model
|
|
893
|
+
if descriptor.reverse: # reverse side of M2M
|
|
894
|
+
rel_model = descriptor.field.model
|
|
895
|
+
rel_type = "many"
|
|
896
|
+
elif isinstance(descriptor, ReverseManyToOneDescriptor):
|
|
897
|
+
rel_model = descriptor.field.model
|
|
898
|
+
rel_type = "many"
|
|
899
|
+
else: # ReverseOneToOneDescriptor
|
|
900
|
+
rel_model = descriptor.related.related_model
|
|
901
|
+
rel_type = "one"
|
|
902
|
+
|
|
903
|
+
if not isinstance(rel_model, ModelSerializerMeta):
|
|
904
|
+
return None
|
|
905
|
+
if not rel_model.get_fields("read") and not rel_model.get_custom_fields("read"):
|
|
906
|
+
return None
|
|
907
|
+
|
|
908
|
+
rel_schema = (
|
|
909
|
+
rel_model.generate_related_s()
|
|
910
|
+
if rel_type == "one"
|
|
911
|
+
else list[rel_model.generate_related_s()]
|
|
912
|
+
)
|
|
913
|
+
return (field_name, rel_schema | None, None)
|
|
914
|
+
|
|
915
|
+
@classmethod
|
|
916
|
+
def _build_schema_forward_rel(cls, field_name: str, descriptor: Any):
|
|
917
|
+
"""
|
|
918
|
+
Build a forward relation schema component for 'Out' schema generation.
|
|
919
|
+
"""
|
|
920
|
+
rel_model = descriptor.field.related_model
|
|
921
|
+
if not isinstance(rel_model, ModelSerializerMeta):
|
|
922
|
+
return True # Signal: treat as plain field
|
|
923
|
+
if not rel_model.get_fields("read") and not rel_model.get_custom_fields("read"):
|
|
924
|
+
return None # Skip entirely
|
|
925
|
+
rel_schema = rel_model.generate_related_s()
|
|
926
|
+
return (field_name, rel_schema | None, None)
|
|
927
|
+
|
|
731
928
|
@classmethod
|
|
732
929
|
def get_schema_out_data(cls):
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
930
|
+
"""
|
|
931
|
+
Collect components for 'Out' read schema generation.
|
|
932
|
+
|
|
933
|
+
Returns
|
|
934
|
+
-------
|
|
935
|
+
tuple
|
|
936
|
+
(fields, reverse_rel_descriptors, excludes, custom_fields_with_forward_relations)
|
|
937
|
+
"""
|
|
938
|
+
|
|
939
|
+
fields: list[str] = []
|
|
940
|
+
reverse_rels: list[tuple] = []
|
|
941
|
+
rels: list[tuple] = []
|
|
942
|
+
|
|
736
943
|
for f in cls.get_fields("read"):
|
|
737
944
|
field_obj = getattr(cls, f)
|
|
945
|
+
|
|
946
|
+
# Reverse relations
|
|
738
947
|
if isinstance(
|
|
739
948
|
field_obj,
|
|
740
949
|
(
|
|
@@ -743,46 +952,26 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
743
952
|
ReverseOneToOneDescriptor,
|
|
744
953
|
),
|
|
745
954
|
):
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
rel_obj = field_obj.field.model
|
|
750
|
-
rel_type = "many"
|
|
751
|
-
elif isinstance(field_obj, ReverseManyToOneDescriptor):
|
|
752
|
-
rel_obj = field_obj.field.model
|
|
753
|
-
rel_type = "many"
|
|
754
|
-
else: # ReverseOneToOneDescriptor
|
|
755
|
-
rel_obj = field_obj.related.related_model
|
|
756
|
-
rel_type = "one"
|
|
757
|
-
if not isinstance(rel_obj, ModelSerializerMeta):
|
|
758
|
-
continue
|
|
759
|
-
if not rel_obj.get_fields("read") and not rel_obj.get_custom_fields(
|
|
760
|
-
"read"
|
|
761
|
-
):
|
|
955
|
+
rel_tuple = cls._build_schema_reverse_rel(f, field_obj)
|
|
956
|
+
if rel_tuple:
|
|
957
|
+
reverse_rels.append(rel_tuple)
|
|
762
958
|
continue
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if rel_type != "many"
|
|
766
|
-
else list[rel_obj.generate_related_s()]
|
|
767
|
-
)
|
|
768
|
-
rel_data = (f, rel_schema | None, None)
|
|
769
|
-
reverse_rels.append(rel_data)
|
|
770
|
-
continue
|
|
959
|
+
|
|
960
|
+
# Forward relations
|
|
771
961
|
if isinstance(
|
|
772
962
|
field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
|
|
773
963
|
):
|
|
774
|
-
|
|
775
|
-
if
|
|
964
|
+
rel_tuple = cls._build_schema_forward_rel(f, field_obj)
|
|
965
|
+
if rel_tuple is True:
|
|
776
966
|
fields.append(f)
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
):
|
|
781
|
-
continue
|
|
782
|
-
rel_data = (f, rel_obj.generate_related_s() | None, None)
|
|
783
|
-
rels.append(rel_data)
|
|
967
|
+
elif rel_tuple:
|
|
968
|
+
rels.append(rel_tuple)
|
|
969
|
+
# If rel_tuple is None -> skip
|
|
784
970
|
continue
|
|
971
|
+
|
|
972
|
+
# Plain field
|
|
785
973
|
fields.append(f)
|
|
974
|
+
|
|
786
975
|
return (
|
|
787
976
|
fields,
|
|
788
977
|
reverse_rels,
|
|
@@ -792,22 +981,88 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
792
981
|
|
|
793
982
|
@classmethod
|
|
794
983
|
def is_custom(cls, field: str):
|
|
984
|
+
"""
|
|
985
|
+
Check if a field is declared as a custom input (create or update).
|
|
986
|
+
|
|
987
|
+
Parameters
|
|
988
|
+
----------
|
|
989
|
+
field : str
|
|
990
|
+
|
|
991
|
+
Returns
|
|
992
|
+
-------
|
|
993
|
+
bool
|
|
994
|
+
"""
|
|
795
995
|
return cls._is_special_field(
|
|
796
996
|
"create", field, "customs"
|
|
797
997
|
) or cls._is_special_field("update", field, "customs")
|
|
798
998
|
|
|
799
999
|
@classmethod
|
|
800
1000
|
def is_optional(cls, field: str):
|
|
1001
|
+
"""
|
|
1002
|
+
Check if a field is declared as optional (create or update).
|
|
1003
|
+
|
|
1004
|
+
Parameters
|
|
1005
|
+
----------
|
|
1006
|
+
field : str
|
|
1007
|
+
|
|
1008
|
+
Returns
|
|
1009
|
+
-------
|
|
1010
|
+
bool
|
|
1011
|
+
"""
|
|
801
1012
|
return cls._is_special_field(
|
|
802
1013
|
"create", field, "optionals"
|
|
803
1014
|
) or cls._is_special_field("update", field, "optionals")
|
|
804
1015
|
|
|
805
1016
|
@classmethod
|
|
806
|
-
def get_custom_fields(cls, s_type: type[S_TYPES]) -> list[tuple]:
|
|
807
|
-
|
|
1017
|
+
def get_custom_fields(cls, s_type: type[S_TYPES]) -> list[tuple[str, type, Any]]:
|
|
1018
|
+
"""
|
|
1019
|
+
Normalize declared custom field specs into (name, py_type, default) triples.
|
|
1020
|
+
|
|
1021
|
+
Accepted tuple shapes:
|
|
1022
|
+
(name, py_type, default) -> keeps provided default (callable or literal)
|
|
1023
|
+
(name, py_type) -> marks as required (default = Ellipsis)
|
|
1024
|
+
Any other arity raises ValueError.
|
|
1025
|
+
|
|
1026
|
+
Parameters
|
|
1027
|
+
----------
|
|
1028
|
+
s_type : str
|
|
1029
|
+
"create" | "update" | "read"
|
|
1030
|
+
|
|
1031
|
+
Returns
|
|
1032
|
+
-------
|
|
1033
|
+
list[tuple[str, type, Any]]
|
|
1034
|
+
"""
|
|
1035
|
+
raw_customs = cls._get_fields(s_type, "customs") or []
|
|
1036
|
+
normalized: list[tuple[str, type, Any]] = []
|
|
1037
|
+
for spec in raw_customs:
|
|
1038
|
+
if not isinstance(spec, tuple):
|
|
1039
|
+
raise ValueError(f"Custom field spec must be a tuple, got {type(spec)}")
|
|
1040
|
+
match len(spec):
|
|
1041
|
+
case 3:
|
|
1042
|
+
name, py_type, default = spec
|
|
1043
|
+
case 2:
|
|
1044
|
+
name, py_type = spec
|
|
1045
|
+
default = ...
|
|
1046
|
+
case _:
|
|
1047
|
+
raise ValueError(
|
|
1048
|
+
f"Custom field tuple must have length 2 or 3 (name, type[, default]); got {len(spec)}"
|
|
1049
|
+
)
|
|
1050
|
+
normalized.append((name, py_type, default))
|
|
1051
|
+
return normalized
|
|
808
1052
|
|
|
809
1053
|
@classmethod
|
|
810
1054
|
def get_optional_fields(cls, s_type: type[S_TYPES]):
|
|
1055
|
+
"""
|
|
1056
|
+
Return optional field specifications normalized to (name, type, None).
|
|
1057
|
+
|
|
1058
|
+
Parameters
|
|
1059
|
+
----------
|
|
1060
|
+
s_type : str
|
|
1061
|
+
|
|
1062
|
+
Returns
|
|
1063
|
+
-------
|
|
1064
|
+
list[tuple[str, type, None]]
|
|
1065
|
+
"""
|
|
811
1066
|
return [
|
|
812
1067
|
(field, field_type, None)
|
|
813
1068
|
for field, field_type in cls._get_fields(s_type, "optionals")
|
|
@@ -815,64 +1070,117 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
815
1070
|
|
|
816
1071
|
@classmethod
|
|
817
1072
|
def get_excluded_fields(cls, s_type: type[S_TYPES]):
|
|
1073
|
+
"""
|
|
1074
|
+
Return excluded field names for a serializer type.
|
|
1075
|
+
|
|
1076
|
+
Parameters
|
|
1077
|
+
----------
|
|
1078
|
+
s_type : str
|
|
1079
|
+
|
|
1080
|
+
Returns
|
|
1081
|
+
-------
|
|
1082
|
+
list[str]
|
|
1083
|
+
"""
|
|
818
1084
|
return cls._get_fields(s_type, "excludes")
|
|
819
1085
|
|
|
820
1086
|
@classmethod
|
|
821
1087
|
def get_fields(cls, s_type: type[S_TYPES]):
|
|
1088
|
+
"""
|
|
1089
|
+
Return explicit declared fields for a serializer type.
|
|
1090
|
+
|
|
1091
|
+
Parameters
|
|
1092
|
+
----------
|
|
1093
|
+
s_type : str
|
|
1094
|
+
|
|
1095
|
+
Returns
|
|
1096
|
+
-------
|
|
1097
|
+
list[str]
|
|
1098
|
+
"""
|
|
822
1099
|
return cls._get_fields(s_type, "fields")
|
|
823
1100
|
|
|
824
1101
|
@classmethod
|
|
825
1102
|
def generate_read_s(cls, depth: int = 1) -> Schema:
|
|
1103
|
+
"""
|
|
1104
|
+
Generate read (Out) schema.
|
|
1105
|
+
|
|
1106
|
+
Parameters
|
|
1107
|
+
----------
|
|
1108
|
+
depth : int
|
|
1109
|
+
Relation depth.
|
|
1110
|
+
|
|
1111
|
+
Returns
|
|
1112
|
+
-------
|
|
1113
|
+
Schema | None
|
|
1114
|
+
"""
|
|
826
1115
|
return cls._generate_model_schema("Out", depth)
|
|
827
1116
|
|
|
828
1117
|
@classmethod
|
|
829
1118
|
def generate_create_s(cls) -> Schema:
|
|
1119
|
+
"""
|
|
1120
|
+
Generate create (In) schema.
|
|
1121
|
+
|
|
1122
|
+
Returns
|
|
1123
|
+
-------
|
|
1124
|
+
Schema | None
|
|
1125
|
+
"""
|
|
830
1126
|
return cls._generate_model_schema("In")
|
|
831
1127
|
|
|
832
1128
|
@classmethod
|
|
833
1129
|
def generate_update_s(cls) -> Schema:
|
|
1130
|
+
"""
|
|
1131
|
+
Generate update (Patch) schema.
|
|
1132
|
+
|
|
1133
|
+
Returns
|
|
1134
|
+
-------
|
|
1135
|
+
Schema | None
|
|
1136
|
+
"""
|
|
834
1137
|
return cls._generate_model_schema("Patch")
|
|
835
1138
|
|
|
836
1139
|
@classmethod
|
|
837
1140
|
def generate_related_s(cls) -> Schema:
|
|
1141
|
+
"""
|
|
1142
|
+
Generate related (nested) schema.
|
|
1143
|
+
|
|
1144
|
+
Returns
|
|
1145
|
+
-------
|
|
1146
|
+
Schema | None
|
|
1147
|
+
"""
|
|
838
1148
|
return cls._generate_model_schema("Related")
|
|
839
1149
|
|
|
840
1150
|
def after_save(self):
|
|
841
1151
|
"""
|
|
842
|
-
|
|
843
|
-
has been saved
|
|
1152
|
+
Sync hook executed after any save (create or update).
|
|
844
1153
|
"""
|
|
845
1154
|
pass
|
|
846
1155
|
|
|
847
1156
|
def before_save(self):
|
|
848
1157
|
"""
|
|
849
|
-
|
|
850
|
-
has been saved
|
|
1158
|
+
Sync hook executed before any save (create or update).
|
|
851
1159
|
"""
|
|
852
1160
|
pass
|
|
853
1161
|
|
|
854
1162
|
def on_create_after_save(self):
|
|
855
1163
|
"""
|
|
856
|
-
|
|
857
|
-
has been created
|
|
1164
|
+
Sync hook executed only after initial creation save.
|
|
858
1165
|
"""
|
|
859
1166
|
pass
|
|
860
1167
|
|
|
861
1168
|
def on_create_before_save(self):
|
|
862
1169
|
"""
|
|
863
|
-
|
|
864
|
-
has been created
|
|
1170
|
+
Sync hook executed only before initial creation save.
|
|
865
1171
|
"""
|
|
866
1172
|
pass
|
|
867
1173
|
|
|
868
1174
|
def on_delete(self):
|
|
869
1175
|
"""
|
|
870
|
-
|
|
871
|
-
has been deleted
|
|
1176
|
+
Sync hook executed after delete.
|
|
872
1177
|
"""
|
|
873
1178
|
pass
|
|
874
1179
|
|
|
875
1180
|
def save(self, *args, **kwargs):
|
|
1181
|
+
"""
|
|
1182
|
+
Override save lifecycle to inject create/update hooks.
|
|
1183
|
+
"""
|
|
876
1184
|
if self._state.adding:
|
|
877
1185
|
self.on_create_before_save()
|
|
878
1186
|
self.before_save()
|
|
@@ -882,6 +1190,14 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
882
1190
|
self.after_save()
|
|
883
1191
|
|
|
884
1192
|
def delete(self, *args, **kwargs):
|
|
1193
|
+
"""
|
|
1194
|
+
Override delete to inject on_delete hook.
|
|
1195
|
+
|
|
1196
|
+
Returns
|
|
1197
|
+
-------
|
|
1198
|
+
tuple(int, dict)
|
|
1199
|
+
Django delete return signature.
|
|
1200
|
+
"""
|
|
885
1201
|
res = super().delete(*args, **kwargs)
|
|
886
1202
|
self.on_delete()
|
|
887
1203
|
return res
|