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.
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(request, pk=None, filters=None, getters=None, with_qs_request=True)
54
- Returns a single object (when pk/getters) or a queryset (otherwise), with
55
- select_related + prefetch_related applied.
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 -> SerializeError({...}, 404).
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 in parse_input_data triggers its own async fetch.
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
- customs = {}
184
- optionals = []
185
- if isinstance(self.model, ModelSerializerMeta):
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 isinstance(self.model, ModelSerializerMeta):
196
- if self.model.is_custom(k) and k not in self.model_fields:
197
- continue
198
- if self.model.is_optional(k) and v is None:
199
- continue
200
- field_obj = (await agetattr(self.model, k)).field
201
- if isinstance(field_obj, models.BinaryField):
202
- try:
203
- payload |= {k: base64.b64decode(v)}
204
- except Exception as exc:
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
- olds_k: list[dict] = []
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
- try:
222
- field_obj = (await agetattr(self.model, k)).field
223
- except AttributeError:
224
- try:
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}
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
- Inner configuration classes
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
- Explicit REQUIRED model field names for creation. If empty, the
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
- Model fields allowed on create but not required. Each tuple:
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
- Non-model / synthetic input fields driving creation logic (e.g.,
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
- Model field names that must be rejected on create (e.g., "id",
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 container describing how to build a read (output) schema for a model.
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 field names to include in the read schema. If empty, an
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
- Model field names to force-exclude even if they would otherwise be included
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
- Additional computed / synthetic attributes to append to the serialized
468
- output. Each tuple is:
469
- (attribute_name, python_type, default_value)
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 container describing how to build an update (partial/full) input schema.
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
- Explicit REQUIRED fields for an update operation (rare; most updates are
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
- Updatable fields that are optional (most typical case). Omitted fields
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
- Non-model / instruction fields guiding update behavior (e.g., "rotate_key",
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
- Fields that must never be updated (immutable or managed fields).
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 this method to return a filtered queryset based
682
- on the request received
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
- Override this method to execute code after the object
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
- Override this method to execute custom actions based on
696
- custom given fields. It could be useful for post create method.
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
- fields = []
734
- reverse_rels = []
735
- rels = []
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
- if isinstance(field_obj, ManyToManyDescriptor):
747
- rel_obj: ModelSerializer = field_obj.field.related_model
748
- if field_obj.reverse:
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
- rel_schema = (
764
- rel_obj.generate_related_s()
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
- rel_obj = field_obj.field.related_model
775
- if not isinstance(rel_obj, ModelSerializerMeta):
964
+ rel_tuple = cls._build_schema_forward_rel(f, field_obj)
965
+ if rel_tuple is True:
776
966
  fields.append(f)
777
- continue
778
- if not rel_obj.get_fields("read") and not rel_obj.get_custom_fields(
779
- "read"
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
- return cls._get_fields(s_type, "customs")
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
- Override this method to execute code after the object
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
- Override this method to execute code before the object
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
- Override this method to execute code after the object
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
- Override this method to execute code before the object
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
- Override this method to execute code after the object
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