django-ninja-aio-crud 1.0.1__py3-none-any.whl → 1.0.3__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.3.dist-info}/METADATA +3 -1
- django_ninja_aio_crud-1.0.3.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 +657 -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.3.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-1.0.1.dist-info → django_ninja_aio_crud-1.0.3.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,95 @@ 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
|
+
|
|
181
316
|
async def parse_input_data(self, request: HttpRequest, data: Schema):
|
|
317
|
+
"""
|
|
318
|
+
Transform inbound schema data to a model-ready payload.
|
|
319
|
+
|
|
320
|
+
Steps
|
|
321
|
+
-----
|
|
322
|
+
- Strip custom fields (retain separately).
|
|
323
|
+
- Drop optional fields with None (ModelSerializer only).
|
|
324
|
+
- Decode BinaryField base64 values.
|
|
325
|
+
- Resolve ForeignKey ids to model instances.
|
|
326
|
+
|
|
327
|
+
Parameters
|
|
328
|
+
----------
|
|
329
|
+
request : HttpRequest
|
|
330
|
+
data : Schema
|
|
331
|
+
Incoming validated schema instance.
|
|
332
|
+
|
|
333
|
+
Returns
|
|
334
|
+
-------
|
|
335
|
+
tuple[dict, dict]
|
|
336
|
+
(payload_without_customs, customs_dict)
|
|
337
|
+
|
|
338
|
+
Raises
|
|
339
|
+
------
|
|
340
|
+
SerializeError
|
|
341
|
+
On base64 decoding failure.
|
|
342
|
+
"""
|
|
182
343
|
payload = data.model_dump(mode="json")
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
344
|
+
|
|
345
|
+
is_serializer = isinstance(self.model, ModelSerializerMeta)
|
|
346
|
+
|
|
347
|
+
# Collect custom and optional fields (only if ModelSerializerMeta)
|
|
348
|
+
customs: dict[str, Any] = {}
|
|
349
|
+
optionals: list[str] = []
|
|
350
|
+
if is_serializer:
|
|
186
351
|
customs = {
|
|
187
352
|
k: v
|
|
188
353
|
for k, v in payload.items()
|
|
@@ -191,62 +356,78 @@ class ModelUtil:
|
|
|
191
356
|
optionals = [
|
|
192
357
|
k for k, v in payload.items() if self.model.is_optional(k) and v is None
|
|
193
358
|
]
|
|
359
|
+
|
|
360
|
+
skip_keys = set()
|
|
361
|
+
if is_serializer:
|
|
362
|
+
# Keys to skip during model field processing
|
|
363
|
+
skip_keys = {
|
|
364
|
+
k
|
|
365
|
+
for k, v in payload.items()
|
|
366
|
+
if (self.model.is_custom(k) and k not in self.model_fields)
|
|
367
|
+
or (self.model.is_optional(k) and v is None)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Process payload fields
|
|
194
371
|
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
|
-
}
|
|
372
|
+
if k in skip_keys:
|
|
373
|
+
continue
|
|
374
|
+
field_obj = await self._get_field(k)
|
|
375
|
+
self._decode_binary(payload, k, v, field_obj)
|
|
376
|
+
await self._resolve_fk(request, payload, k, v, field_obj)
|
|
377
|
+
|
|
378
|
+
# Preserve original exclusion semantics (customs if present else optionals)
|
|
379
|
+
exclude_keys = customs.keys() or optionals
|
|
380
|
+
new_payload = {k: v for k, v in payload.items() if k not in exclude_keys}
|
|
381
|
+
|
|
215
382
|
return new_payload, customs
|
|
216
383
|
|
|
217
384
|
async def parse_output_data(self, request: HttpRequest, data: Schema):
|
|
218
|
-
|
|
385
|
+
"""
|
|
386
|
+
Post-process serialized output.
|
|
387
|
+
|
|
388
|
+
For nested FK / OneToOne dicts:
|
|
389
|
+
- Replace dict with authoritative related instance.
|
|
390
|
+
- Rewrite nested FK keys to <name>_id for nested foreign keys.
|
|
391
|
+
|
|
392
|
+
Parameters
|
|
393
|
+
----------
|
|
394
|
+
request : HttpRequest
|
|
395
|
+
data : Schema
|
|
396
|
+
Schema (from_orm) instance.
|
|
397
|
+
|
|
398
|
+
Returns
|
|
399
|
+
-------
|
|
400
|
+
dict
|
|
401
|
+
Normalized output payload.
|
|
402
|
+
"""
|
|
219
403
|
payload = data.model_dump(mode="json")
|
|
404
|
+
|
|
220
405
|
for k, v in payload.items():
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
field_obj = (await agetattr(self.model, k)).related
|
|
226
|
-
except AttributeError:
|
|
227
|
-
pass
|
|
228
|
-
if isinstance(v, dict) and (
|
|
229
|
-
isinstance(field_obj, models.ForeignKey)
|
|
230
|
-
or isinstance(field_obj, models.OneToOneField)
|
|
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}
|
|
406
|
+
field_obj = self._extract_field_obj(k)
|
|
407
|
+
if not self._should_process_nested(v, field_obj):
|
|
408
|
+
continue
|
|
409
|
+
payload[k] = await self._fetch_related_instance(request, field_obj, v)
|
|
247
410
|
return payload
|
|
248
411
|
|
|
249
412
|
async def create_s(self, request: HttpRequest, data: Schema, obj_schema: Schema):
|
|
413
|
+
"""
|
|
414
|
+
Create a new instance and return serialized output.
|
|
415
|
+
|
|
416
|
+
Applies custom_actions + post_create hooks if available.
|
|
417
|
+
|
|
418
|
+
Parameters
|
|
419
|
+
----------
|
|
420
|
+
request : HttpRequest
|
|
421
|
+
data : Schema
|
|
422
|
+
Input schema instance.
|
|
423
|
+
obj_schema : Schema
|
|
424
|
+
Read schema class for output.
|
|
425
|
+
|
|
426
|
+
Returns
|
|
427
|
+
-------
|
|
428
|
+
dict
|
|
429
|
+
Serialized created object.
|
|
430
|
+
"""
|
|
250
431
|
payload, customs = await self.parse_input_data(request, data)
|
|
251
432
|
pk = (await self.model.objects.acreate(**payload)).pk
|
|
252
433
|
obj = await self.get_object(request, pk)
|
|
@@ -260,6 +441,27 @@ class ModelUtil:
|
|
|
260
441
|
obj: type["ModelSerializer"],
|
|
261
442
|
obj_schema: Schema,
|
|
262
443
|
):
|
|
444
|
+
"""
|
|
445
|
+
Serialize an existing instance with the provided read schema.
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
request : HttpRequest
|
|
450
|
+
obj : Model
|
|
451
|
+
Target instance.
|
|
452
|
+
obj_schema : Schema
|
|
453
|
+
Read schema class.
|
|
454
|
+
|
|
455
|
+
Returns
|
|
456
|
+
-------
|
|
457
|
+
dict
|
|
458
|
+
Serialized payload.
|
|
459
|
+
|
|
460
|
+
Raises
|
|
461
|
+
------
|
|
462
|
+
SerializeError
|
|
463
|
+
If obj_schema not provided.
|
|
464
|
+
"""
|
|
263
465
|
if obj_schema is None:
|
|
264
466
|
raise SerializeError({"obj_schema": "must be provided"}, 400)
|
|
265
467
|
return await self.parse_output_data(
|
|
@@ -269,6 +471,26 @@ class ModelUtil:
|
|
|
269
471
|
async def update_s(
|
|
270
472
|
self, request: HttpRequest, data: Schema, pk: int | str, obj_schema: Schema
|
|
271
473
|
):
|
|
474
|
+
"""
|
|
475
|
+
Update an existing instance and return serialized output.
|
|
476
|
+
|
|
477
|
+
Only non-null fields are applied to the instance.
|
|
478
|
+
|
|
479
|
+
Parameters
|
|
480
|
+
----------
|
|
481
|
+
request : HttpRequest
|
|
482
|
+
data : Schema
|
|
483
|
+
Input update schema instance.
|
|
484
|
+
pk : int | str
|
|
485
|
+
Primary key of target object.
|
|
486
|
+
obj_schema : Schema
|
|
487
|
+
Read schema class for output.
|
|
488
|
+
|
|
489
|
+
Returns
|
|
490
|
+
-------
|
|
491
|
+
dict
|
|
492
|
+
Serialized updated object.
|
|
493
|
+
"""
|
|
272
494
|
obj = await self.get_object(request, pk)
|
|
273
495
|
payload, customs = await self.parse_input_data(request, data)
|
|
274
496
|
for k, v in payload.items():
|
|
@@ -281,6 +503,19 @@ class ModelUtil:
|
|
|
281
503
|
return await self.read_s(request, updated_object, obj_schema)
|
|
282
504
|
|
|
283
505
|
async def delete_s(self, request: HttpRequest, pk: int | str):
|
|
506
|
+
"""
|
|
507
|
+
Delete an instance by primary key.
|
|
508
|
+
|
|
509
|
+
Parameters
|
|
510
|
+
----------
|
|
511
|
+
request : HttpRequest
|
|
512
|
+
pk : int | str
|
|
513
|
+
Primary key.
|
|
514
|
+
|
|
515
|
+
Returns
|
|
516
|
+
-------
|
|
517
|
+
None
|
|
518
|
+
"""
|
|
284
519
|
obj = await self.get_object(request, pk)
|
|
285
520
|
await obj.adelete()
|
|
286
521
|
return None
|
|
@@ -295,105 +530,13 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
295
530
|
related schemas.
|
|
296
531
|
|
|
297
532
|
Goals
|
|
533
|
+
-----
|
|
298
534
|
- Remove duplication between Model and separate serializer classes.
|
|
299
535
|
- Provide clear extension points (sync + async hooks, custom synthetic fields).
|
|
300
536
|
|
|
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.
|
|
537
|
+
See inline docstrings for per-method behavior.
|
|
396
538
|
"""
|
|
539
|
+
|
|
397
540
|
class Meta:
|
|
398
541
|
abstract = True
|
|
399
542
|
|
|
@@ -409,156 +552,52 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
409
552
|
Attributes
|
|
410
553
|
----------
|
|
411
554
|
fields : list[str]
|
|
412
|
-
|
|
413
|
-
implementation may infer required fields from the model (excluding
|
|
414
|
-
auto / read-only fields). Prefer being explicit.
|
|
555
|
+
REQUIRED model fields.
|
|
415
556
|
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).
|
|
557
|
+
Optional model fields (nullable / patch-like).
|
|
420
558
|
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.
|
|
559
|
+
Synthetic input fields (non-model).
|
|
431
560
|
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.
|
|
561
|
+
Disallowed model fields on create (e.g., id, timestamps).
|
|
448
562
|
"""
|
|
563
|
+
|
|
449
564
|
fields: list[str] = []
|
|
450
565
|
customs: list[tuple[str, type, Any]] = []
|
|
451
566
|
optionals: list[tuple[str, type]] = []
|
|
452
567
|
excludes: list[str] = []
|
|
453
568
|
|
|
454
569
|
class ReadSerializer:
|
|
455
|
-
"""Configuration
|
|
570
|
+
"""Configuration describing how to build a read (output) schema.
|
|
456
571
|
|
|
457
572
|
Attributes
|
|
458
|
-
|
|
573
|
+
----------
|
|
459
574
|
fields : list[str]
|
|
460
|
-
Explicit model
|
|
461
|
-
implementation may choose to include all model fields (or none, depending
|
|
462
|
-
on the consuming logic).
|
|
575
|
+
Explicit model fields to include.
|
|
463
576
|
excludes : list[str]
|
|
464
|
-
|
|
465
|
-
(e.g., sensitive columns like password, secrets, internal flags).
|
|
577
|
+
Fields to force exclude (safety).
|
|
466
578
|
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."""
|
|
579
|
+
Computed / synthetic output attributes.
|
|
580
|
+
"""
|
|
581
|
+
|
|
516
582
|
fields: list[str] = []
|
|
517
583
|
excludes: list[str] = []
|
|
518
584
|
customs: list[tuple[str, type, Any]] = []
|
|
519
585
|
|
|
520
586
|
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).
|
|
587
|
+
"""Configuration describing update (PATCH/PUT) schema.
|
|
527
588
|
|
|
528
589
|
Attributes
|
|
529
590
|
----------
|
|
530
591
|
fields : list[str]
|
|
531
|
-
|
|
532
|
-
partial so this is often left empty). If non-empty, these must be present
|
|
533
|
-
in the payload.
|
|
592
|
+
Required update fields (rare).
|
|
534
593
|
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).
|
|
594
|
+
Editable optional fields.
|
|
538
595
|
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.
|
|
596
|
+
Synthetic operational inputs.
|
|
544
597
|
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.
|
|
598
|
+
Immutable / blocked fields.
|
|
561
599
|
"""
|
|
600
|
+
|
|
562
601
|
fields: list[str] = []
|
|
563
602
|
customs: list[tuple[str, type, Any]] = []
|
|
564
603
|
optionals: list[tuple[str, type]] = []
|
|
@@ -566,30 +605,63 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
566
605
|
|
|
567
606
|
@property
|
|
568
607
|
def has_custom_fields_create(self):
|
|
608
|
+
"""
|
|
609
|
+
Whether CreateSerializer declares custom fields.
|
|
610
|
+
"""
|
|
569
611
|
return hasattr(self.CreateSerializer, "customs")
|
|
570
612
|
|
|
571
613
|
@property
|
|
572
614
|
def has_custom_fields_update(self):
|
|
615
|
+
"""
|
|
616
|
+
Whether UpdateSerializer declares custom fields.
|
|
617
|
+
"""
|
|
573
618
|
return hasattr(self.UpdateSerializer, "customs")
|
|
574
619
|
|
|
575
620
|
@property
|
|
576
621
|
def has_custom_fields(self):
|
|
622
|
+
"""
|
|
623
|
+
Whether any serializer declares custom fields.
|
|
624
|
+
"""
|
|
577
625
|
return self.has_custom_fields_create or self.has_custom_fields_update
|
|
578
626
|
|
|
579
627
|
@property
|
|
580
628
|
def has_optional_fields_create(self):
|
|
629
|
+
"""
|
|
630
|
+
Whether CreateSerializer declares optional fields.
|
|
631
|
+
"""
|
|
581
632
|
return hasattr(self.CreateSerializer, "optionals")
|
|
582
633
|
|
|
583
634
|
@property
|
|
584
635
|
def has_optional_fields_update(self):
|
|
636
|
+
"""
|
|
637
|
+
Whether UpdateSerializer declares optional fields.
|
|
638
|
+
"""
|
|
585
639
|
return hasattr(self.UpdateSerializer, "optionals")
|
|
586
640
|
|
|
587
641
|
@property
|
|
588
642
|
def has_optional_fields(self):
|
|
643
|
+
"""
|
|
644
|
+
Whether any serializer declares optional fields.
|
|
645
|
+
"""
|
|
589
646
|
return self.has_optional_fields_create or self.has_optional_fields_update
|
|
590
647
|
|
|
591
648
|
@classmethod
|
|
592
649
|
def _get_fields(cls, s_type: type[S_TYPES], f_type: type[F_TYPES]):
|
|
650
|
+
"""
|
|
651
|
+
Internal accessor for raw configuration lists.
|
|
652
|
+
|
|
653
|
+
Parameters
|
|
654
|
+
----------
|
|
655
|
+
s_type : str
|
|
656
|
+
Serializer type ("create" | "update" | "read").
|
|
657
|
+
f_type : str
|
|
658
|
+
Field category ("fields" | "optionals" | "customs" | "excludes").
|
|
659
|
+
|
|
660
|
+
Returns
|
|
661
|
+
-------
|
|
662
|
+
list
|
|
663
|
+
Raw configuration list or empty list.
|
|
664
|
+
"""
|
|
593
665
|
match s_type:
|
|
594
666
|
case "create":
|
|
595
667
|
fields = getattr(cls.CreateSerializer, f_type, [])
|
|
@@ -603,6 +675,19 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
603
675
|
def _is_special_field(
|
|
604
676
|
cls, s_type: type[S_TYPES], field: str, f_type: type[F_TYPES]
|
|
605
677
|
):
|
|
678
|
+
"""
|
|
679
|
+
Determine if a field is declared in a given category for a serializer type.
|
|
680
|
+
|
|
681
|
+
Parameters
|
|
682
|
+
----------
|
|
683
|
+
s_type : str
|
|
684
|
+
field : str
|
|
685
|
+
f_type : str
|
|
686
|
+
|
|
687
|
+
Returns
|
|
688
|
+
-------
|
|
689
|
+
bool
|
|
690
|
+
"""
|
|
606
691
|
special_fields = cls._get_fields(s_type, f_type)
|
|
607
692
|
return any(field in special_f for special_f in special_fields)
|
|
608
693
|
|
|
@@ -612,6 +697,21 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
612
697
|
schema_type: type[SCHEMA_TYPES],
|
|
613
698
|
depth: int = None,
|
|
614
699
|
) -> Schema:
|
|
700
|
+
"""
|
|
701
|
+
Core schema factory bridging configuration and ninja.orm.create_schema.
|
|
702
|
+
|
|
703
|
+
Parameters
|
|
704
|
+
----------
|
|
705
|
+
schema_type : str
|
|
706
|
+
"In" | "Patch" | "Out" | "Related".
|
|
707
|
+
depth : int, optional
|
|
708
|
+
Relation depth for read schema.
|
|
709
|
+
|
|
710
|
+
Returns
|
|
711
|
+
-------
|
|
712
|
+
Schema | None
|
|
713
|
+
Generated schema class or None if no fields.
|
|
714
|
+
"""
|
|
615
715
|
match schema_type:
|
|
616
716
|
case "In":
|
|
617
717
|
s_type = "create"
|
|
@@ -660,11 +760,28 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
660
760
|
|
|
661
761
|
@classmethod
|
|
662
762
|
def verbose_name_path_resolver(cls) -> str:
|
|
763
|
+
"""
|
|
764
|
+
Slugify plural verbose name for URL path segment.
|
|
765
|
+
|
|
766
|
+
Returns
|
|
767
|
+
-------
|
|
768
|
+
str
|
|
769
|
+
"""
|
|
663
770
|
return "-".join(cls._meta.verbose_name_plural.split(" "))
|
|
664
771
|
|
|
665
772
|
def has_changed(self, field: str) -> bool:
|
|
666
773
|
"""
|
|
667
|
-
Check if a model field has changed
|
|
774
|
+
Check if a model field has changed compared to the persisted value.
|
|
775
|
+
|
|
776
|
+
Parameters
|
|
777
|
+
----------
|
|
778
|
+
field : str
|
|
779
|
+
Field name.
|
|
780
|
+
|
|
781
|
+
Returns
|
|
782
|
+
-------
|
|
783
|
+
bool
|
|
784
|
+
True if in-memory value differs from DB value.
|
|
668
785
|
"""
|
|
669
786
|
if not self.pk:
|
|
670
787
|
return False
|
|
@@ -678,27 +795,45 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
678
795
|
@classmethod
|
|
679
796
|
async def queryset_request(cls, request: HttpRequest):
|
|
680
797
|
"""
|
|
681
|
-
Override
|
|
682
|
-
|
|
798
|
+
Override to return a request-scoped filtered queryset.
|
|
799
|
+
|
|
800
|
+
Parameters
|
|
801
|
+
----------
|
|
802
|
+
request : HttpRequest
|
|
803
|
+
|
|
804
|
+
Returns
|
|
805
|
+
-------
|
|
806
|
+
QuerySet
|
|
683
807
|
"""
|
|
684
808
|
return cls.objects.select_related().all()
|
|
685
809
|
|
|
686
810
|
async def post_create(self) -> None:
|
|
687
811
|
"""
|
|
688
|
-
|
|
689
|
-
has been created
|
|
812
|
+
Async hook executed after first persistence (create path).
|
|
690
813
|
"""
|
|
691
814
|
pass
|
|
692
815
|
|
|
693
816
|
async def custom_actions(self, payload: dict[str, Any]):
|
|
694
817
|
"""
|
|
695
|
-
|
|
696
|
-
|
|
818
|
+
Async hook for reacting to provided custom (synthetic) fields.
|
|
819
|
+
|
|
820
|
+
Parameters
|
|
821
|
+
----------
|
|
822
|
+
payload : dict
|
|
823
|
+
Custom field name/value pairs.
|
|
697
824
|
"""
|
|
698
825
|
pass
|
|
699
826
|
|
|
700
827
|
@classmethod
|
|
701
828
|
def get_related_schema_data(cls):
|
|
829
|
+
"""
|
|
830
|
+
Build field/custom lists for 'Related' schema (flattening non-relational fields).
|
|
831
|
+
|
|
832
|
+
Returns
|
|
833
|
+
-------
|
|
834
|
+
tuple[list[str] | None, list[tuple] | None]
|
|
835
|
+
(related_fields, custom_related_fields) or (None, None)
|
|
836
|
+
"""
|
|
702
837
|
fields = cls.get_fields("read")
|
|
703
838
|
custom_f = {
|
|
704
839
|
name: (value, default)
|
|
@@ -728,13 +863,67 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
728
863
|
related_fields = [f for f in _related_fields if f not in custom_f]
|
|
729
864
|
return related_fields, custom_related_fields
|
|
730
865
|
|
|
866
|
+
@classmethod
|
|
867
|
+
def _build_schema_reverse_rel(cls, field_name: str, descriptor: Any):
|
|
868
|
+
"""
|
|
869
|
+
Build a reverse relation schema component for 'Out' schema generation.
|
|
870
|
+
"""
|
|
871
|
+
if isinstance(descriptor, ManyToManyDescriptor):
|
|
872
|
+
rel_model: ModelSerializer = descriptor.field.related_model
|
|
873
|
+
if descriptor.reverse: # reverse side of M2M
|
|
874
|
+
rel_model = descriptor.field.model
|
|
875
|
+
rel_type = "many"
|
|
876
|
+
elif isinstance(descriptor, ReverseManyToOneDescriptor):
|
|
877
|
+
rel_model = descriptor.field.model
|
|
878
|
+
rel_type = "many"
|
|
879
|
+
else: # ReverseOneToOneDescriptor
|
|
880
|
+
rel_model = descriptor.related.related_model
|
|
881
|
+
rel_type = "one"
|
|
882
|
+
|
|
883
|
+
if not isinstance(rel_model, ModelSerializerMeta):
|
|
884
|
+
return None
|
|
885
|
+
if not rel_model.get_fields("read") and not rel_model.get_custom_fields("read"):
|
|
886
|
+
return None
|
|
887
|
+
|
|
888
|
+
rel_schema = (
|
|
889
|
+
rel_model.generate_related_s()
|
|
890
|
+
if rel_type == "one"
|
|
891
|
+
else list[rel_model.generate_related_s()]
|
|
892
|
+
)
|
|
893
|
+
return (field_name, rel_schema | None, None)
|
|
894
|
+
|
|
895
|
+
@classmethod
|
|
896
|
+
def _build_schema_forward_rel(cls, field_name: str, descriptor: Any):
|
|
897
|
+
"""
|
|
898
|
+
Build a forward relation schema component for 'Out' schema generation.
|
|
899
|
+
"""
|
|
900
|
+
rel_model = descriptor.field.related_model
|
|
901
|
+
if not isinstance(rel_model, ModelSerializerMeta):
|
|
902
|
+
return True # Signal: treat as plain field
|
|
903
|
+
if not rel_model.get_fields("read") and not rel_model.get_custom_fields("read"):
|
|
904
|
+
return None # Skip entirely
|
|
905
|
+
rel_schema = rel_model.generate_related_s()
|
|
906
|
+
return (field_name, rel_schema | None, None)
|
|
907
|
+
|
|
731
908
|
@classmethod
|
|
732
909
|
def get_schema_out_data(cls):
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
910
|
+
"""
|
|
911
|
+
Collect components for 'Out' read schema generation.
|
|
912
|
+
|
|
913
|
+
Returns
|
|
914
|
+
-------
|
|
915
|
+
tuple
|
|
916
|
+
(fields, reverse_rel_descriptors, excludes, custom_fields_with_forward_relations)
|
|
917
|
+
"""
|
|
918
|
+
|
|
919
|
+
fields: list[str] = []
|
|
920
|
+
reverse_rels: list[tuple] = []
|
|
921
|
+
rels: list[tuple] = []
|
|
922
|
+
|
|
736
923
|
for f in cls.get_fields("read"):
|
|
737
924
|
field_obj = getattr(cls, f)
|
|
925
|
+
|
|
926
|
+
# Reverse relations
|
|
738
927
|
if isinstance(
|
|
739
928
|
field_obj,
|
|
740
929
|
(
|
|
@@ -743,46 +932,26 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
743
932
|
ReverseOneToOneDescriptor,
|
|
744
933
|
),
|
|
745
934
|
):
|
|
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
|
-
):
|
|
935
|
+
rel_tuple = cls._build_schema_reverse_rel(f, field_obj)
|
|
936
|
+
if rel_tuple:
|
|
937
|
+
reverse_rels.append(rel_tuple)
|
|
762
938
|
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
|
|
939
|
+
|
|
940
|
+
# Forward relations
|
|
771
941
|
if isinstance(
|
|
772
942
|
field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
|
|
773
943
|
):
|
|
774
|
-
|
|
775
|
-
if
|
|
944
|
+
rel_tuple = cls._build_schema_forward_rel(f, field_obj)
|
|
945
|
+
if rel_tuple is True:
|
|
776
946
|
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)
|
|
947
|
+
elif rel_tuple:
|
|
948
|
+
rels.append(rel_tuple)
|
|
949
|
+
# If rel_tuple is None -> skip
|
|
784
950
|
continue
|
|
951
|
+
|
|
952
|
+
# Plain field
|
|
785
953
|
fields.append(f)
|
|
954
|
+
|
|
786
955
|
return (
|
|
787
956
|
fields,
|
|
788
957
|
reverse_rels,
|
|
@@ -792,22 +961,88 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
792
961
|
|
|
793
962
|
@classmethod
|
|
794
963
|
def is_custom(cls, field: str):
|
|
964
|
+
"""
|
|
965
|
+
Check if a field is declared as a custom input (create or update).
|
|
966
|
+
|
|
967
|
+
Parameters
|
|
968
|
+
----------
|
|
969
|
+
field : str
|
|
970
|
+
|
|
971
|
+
Returns
|
|
972
|
+
-------
|
|
973
|
+
bool
|
|
974
|
+
"""
|
|
795
975
|
return cls._is_special_field(
|
|
796
976
|
"create", field, "customs"
|
|
797
977
|
) or cls._is_special_field("update", field, "customs")
|
|
798
978
|
|
|
799
979
|
@classmethod
|
|
800
980
|
def is_optional(cls, field: str):
|
|
981
|
+
"""
|
|
982
|
+
Check if a field is declared as optional (create or update).
|
|
983
|
+
|
|
984
|
+
Parameters
|
|
985
|
+
----------
|
|
986
|
+
field : str
|
|
987
|
+
|
|
988
|
+
Returns
|
|
989
|
+
-------
|
|
990
|
+
bool
|
|
991
|
+
"""
|
|
801
992
|
return cls._is_special_field(
|
|
802
993
|
"create", field, "optionals"
|
|
803
994
|
) or cls._is_special_field("update", field, "optionals")
|
|
804
995
|
|
|
805
996
|
@classmethod
|
|
806
|
-
def get_custom_fields(cls, s_type: type[S_TYPES]) -> list[tuple]:
|
|
807
|
-
|
|
997
|
+
def get_custom_fields(cls, s_type: type[S_TYPES]) -> list[tuple[str, type, Any]]:
|
|
998
|
+
"""
|
|
999
|
+
Normalize declared custom field specs into (name, py_type, default) triples.
|
|
1000
|
+
|
|
1001
|
+
Accepted tuple shapes:
|
|
1002
|
+
(name, py_type, default) -> keeps provided default (callable or literal)
|
|
1003
|
+
(name, py_type) -> marks as required (default = Ellipsis)
|
|
1004
|
+
Any other arity raises ValueError.
|
|
1005
|
+
|
|
1006
|
+
Parameters
|
|
1007
|
+
----------
|
|
1008
|
+
s_type : str
|
|
1009
|
+
"create" | "update" | "read"
|
|
1010
|
+
|
|
1011
|
+
Returns
|
|
1012
|
+
-------
|
|
1013
|
+
list[tuple[str, type, Any]]
|
|
1014
|
+
"""
|
|
1015
|
+
raw_customs = cls._get_fields(s_type, "customs") or []
|
|
1016
|
+
normalized: list[tuple[str, type, Any]] = []
|
|
1017
|
+
for spec in raw_customs:
|
|
1018
|
+
if not isinstance(spec, tuple):
|
|
1019
|
+
raise ValueError(f"Custom field spec must be a tuple, got {type(spec)}")
|
|
1020
|
+
match len(spec):
|
|
1021
|
+
case 3:
|
|
1022
|
+
name, py_type, default = spec
|
|
1023
|
+
case 2:
|
|
1024
|
+
name, py_type = spec
|
|
1025
|
+
default = ...
|
|
1026
|
+
case _:
|
|
1027
|
+
raise ValueError(
|
|
1028
|
+
f"Custom field tuple must have length 2 or 3 (name, type[, default]); got {len(spec)}"
|
|
1029
|
+
)
|
|
1030
|
+
normalized.append((name, py_type, default))
|
|
1031
|
+
return normalized
|
|
808
1032
|
|
|
809
1033
|
@classmethod
|
|
810
1034
|
def get_optional_fields(cls, s_type: type[S_TYPES]):
|
|
1035
|
+
"""
|
|
1036
|
+
Return optional field specifications normalized to (name, type, None).
|
|
1037
|
+
|
|
1038
|
+
Parameters
|
|
1039
|
+
----------
|
|
1040
|
+
s_type : str
|
|
1041
|
+
|
|
1042
|
+
Returns
|
|
1043
|
+
-------
|
|
1044
|
+
list[tuple[str, type, None]]
|
|
1045
|
+
"""
|
|
811
1046
|
return [
|
|
812
1047
|
(field, field_type, None)
|
|
813
1048
|
for field, field_type in cls._get_fields(s_type, "optionals")
|
|
@@ -815,64 +1050,117 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
815
1050
|
|
|
816
1051
|
@classmethod
|
|
817
1052
|
def get_excluded_fields(cls, s_type: type[S_TYPES]):
|
|
1053
|
+
"""
|
|
1054
|
+
Return excluded field names for a serializer type.
|
|
1055
|
+
|
|
1056
|
+
Parameters
|
|
1057
|
+
----------
|
|
1058
|
+
s_type : str
|
|
1059
|
+
|
|
1060
|
+
Returns
|
|
1061
|
+
-------
|
|
1062
|
+
list[str]
|
|
1063
|
+
"""
|
|
818
1064
|
return cls._get_fields(s_type, "excludes")
|
|
819
1065
|
|
|
820
1066
|
@classmethod
|
|
821
1067
|
def get_fields(cls, s_type: type[S_TYPES]):
|
|
1068
|
+
"""
|
|
1069
|
+
Return explicit declared fields for a serializer type.
|
|
1070
|
+
|
|
1071
|
+
Parameters
|
|
1072
|
+
----------
|
|
1073
|
+
s_type : str
|
|
1074
|
+
|
|
1075
|
+
Returns
|
|
1076
|
+
-------
|
|
1077
|
+
list[str]
|
|
1078
|
+
"""
|
|
822
1079
|
return cls._get_fields(s_type, "fields")
|
|
823
1080
|
|
|
824
1081
|
@classmethod
|
|
825
1082
|
def generate_read_s(cls, depth: int = 1) -> Schema:
|
|
1083
|
+
"""
|
|
1084
|
+
Generate read (Out) schema.
|
|
1085
|
+
|
|
1086
|
+
Parameters
|
|
1087
|
+
----------
|
|
1088
|
+
depth : int
|
|
1089
|
+
Relation depth.
|
|
1090
|
+
|
|
1091
|
+
Returns
|
|
1092
|
+
-------
|
|
1093
|
+
Schema | None
|
|
1094
|
+
"""
|
|
826
1095
|
return cls._generate_model_schema("Out", depth)
|
|
827
1096
|
|
|
828
1097
|
@classmethod
|
|
829
1098
|
def generate_create_s(cls) -> Schema:
|
|
1099
|
+
"""
|
|
1100
|
+
Generate create (In) schema.
|
|
1101
|
+
|
|
1102
|
+
Returns
|
|
1103
|
+
-------
|
|
1104
|
+
Schema | None
|
|
1105
|
+
"""
|
|
830
1106
|
return cls._generate_model_schema("In")
|
|
831
1107
|
|
|
832
1108
|
@classmethod
|
|
833
1109
|
def generate_update_s(cls) -> Schema:
|
|
1110
|
+
"""
|
|
1111
|
+
Generate update (Patch) schema.
|
|
1112
|
+
|
|
1113
|
+
Returns
|
|
1114
|
+
-------
|
|
1115
|
+
Schema | None
|
|
1116
|
+
"""
|
|
834
1117
|
return cls._generate_model_schema("Patch")
|
|
835
1118
|
|
|
836
1119
|
@classmethod
|
|
837
1120
|
def generate_related_s(cls) -> Schema:
|
|
1121
|
+
"""
|
|
1122
|
+
Generate related (nested) schema.
|
|
1123
|
+
|
|
1124
|
+
Returns
|
|
1125
|
+
-------
|
|
1126
|
+
Schema | None
|
|
1127
|
+
"""
|
|
838
1128
|
return cls._generate_model_schema("Related")
|
|
839
1129
|
|
|
840
1130
|
def after_save(self):
|
|
841
1131
|
"""
|
|
842
|
-
|
|
843
|
-
has been saved
|
|
1132
|
+
Sync hook executed after any save (create or update).
|
|
844
1133
|
"""
|
|
845
1134
|
pass
|
|
846
1135
|
|
|
847
1136
|
def before_save(self):
|
|
848
1137
|
"""
|
|
849
|
-
|
|
850
|
-
has been saved
|
|
1138
|
+
Sync hook executed before any save (create or update).
|
|
851
1139
|
"""
|
|
852
1140
|
pass
|
|
853
1141
|
|
|
854
1142
|
def on_create_after_save(self):
|
|
855
1143
|
"""
|
|
856
|
-
|
|
857
|
-
has been created
|
|
1144
|
+
Sync hook executed only after initial creation save.
|
|
858
1145
|
"""
|
|
859
1146
|
pass
|
|
860
1147
|
|
|
861
1148
|
def on_create_before_save(self):
|
|
862
1149
|
"""
|
|
863
|
-
|
|
864
|
-
has been created
|
|
1150
|
+
Sync hook executed only before initial creation save.
|
|
865
1151
|
"""
|
|
866
1152
|
pass
|
|
867
1153
|
|
|
868
1154
|
def on_delete(self):
|
|
869
1155
|
"""
|
|
870
|
-
|
|
871
|
-
has been deleted
|
|
1156
|
+
Sync hook executed after delete.
|
|
872
1157
|
"""
|
|
873
1158
|
pass
|
|
874
1159
|
|
|
875
1160
|
def save(self, *args, **kwargs):
|
|
1161
|
+
"""
|
|
1162
|
+
Override save lifecycle to inject create/update hooks.
|
|
1163
|
+
"""
|
|
876
1164
|
if self._state.adding:
|
|
877
1165
|
self.on_create_before_save()
|
|
878
1166
|
self.before_save()
|
|
@@ -882,6 +1170,14 @@ class ModelSerializer(models.Model, metaclass=ModelSerializerMeta):
|
|
|
882
1170
|
self.after_save()
|
|
883
1171
|
|
|
884
1172
|
def delete(self, *args, **kwargs):
|
|
1173
|
+
"""
|
|
1174
|
+
Override delete to inject on_delete hook.
|
|
1175
|
+
|
|
1176
|
+
Returns
|
|
1177
|
+
-------
|
|
1178
|
+
tuple(int, dict)
|
|
1179
|
+
Django delete return signature.
|
|
1180
|
+
"""
|
|
885
1181
|
res = super().delete(*args, **kwargs)
|
|
886
1182
|
self.on_delete()
|
|
887
1183
|
return res
|