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.
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,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
- customs = {}
184
- optionals = []
185
- if isinstance(self.model, ModelSerializerMeta):
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 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
- }
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
- olds_k: list[dict] = []
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
- 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}
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
- 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.
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
- 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.
555
+ REQUIRED model fields.
415
556
  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).
557
+ Optional model fields (nullable / patch-like).
420
558
  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.
559
+ Synthetic input fields (non-model).
431
560
  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.
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 container describing how to build a read (output) schema for a model.
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 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).
575
+ Explicit model fields to include.
463
576
  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).
577
+ Fields to force exclude (safety).
466
578
  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."""
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 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).
587
+ """Configuration describing update (PATCH/PUT) schema.
527
588
 
528
589
  Attributes
529
590
  ----------
530
591
  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.
592
+ Required update fields (rare).
534
593
  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).
594
+ Editable optional fields.
538
595
  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.
596
+ Synthetic operational inputs.
544
597
  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.
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 this method to return a filtered queryset based
682
- on the request received
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
- Override this method to execute code after the object
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
- Override this method to execute custom actions based on
696
- custom given fields. It could be useful for post create method.
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
- fields = []
734
- reverse_rels = []
735
- rels = []
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
- 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
- ):
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
- 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
939
+
940
+ # Forward relations
771
941
  if isinstance(
772
942
  field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
773
943
  ):
774
- rel_obj = field_obj.field.related_model
775
- if not isinstance(rel_obj, ModelSerializerMeta):
944
+ rel_tuple = cls._build_schema_forward_rel(f, field_obj)
945
+ if rel_tuple is True:
776
946
  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)
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
- return cls._get_fields(s_type, "customs")
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
- Override this method to execute code after the object
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
- Override this method to execute code before the object
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
- Override this method to execute code after the object
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
- Override this method to execute code before the object
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
- Override this method to execute code after the object
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