django-ninja-aio-crud 2.6.1__py3-none-any.whl → 2.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.6.1
3
+ Version: 2.8.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -1,29 +1,29 @@
1
- ninja_aio/__init__.py,sha256=ww2hnE9C7lFXzVyNnpZ97wv8erYcXdxhjzu8PRiM8bc,119
1
+ ninja_aio/__init__.py,sha256=s2uQ_vbFbWOcD6laB1hG40Mv67xvXbSehMTUbkWCcrA,119
2
2
  ninja_aio/api.py,sha256=tuC7vdvn7s1GkCnSFy9Kn1zv0glZfYptRQVvo8ZRtGQ,2429
3
3
  ninja_aio/auth.py,sha256=4sWdFPjKiQgUL1d_CSGDblVjnY5ptP6LQha6XXdluJA,9157
4
4
  ninja_aio/exceptions.py,sha256=_3xFqfFCOfrrMhSA0xbMqgXy8R0UQjhXaExrFvaDAjY,3891
5
5
  ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
6
6
  ninja_aio/renders.py,sha256=89g46NWUT8nmDG-rG0nxUYbAQWhuXcYKrPh7e1r_Fc4,1735
7
- ninja_aio/types.py,sha256=nFqWEopm7eoEaHRzbi6EyA9WZ5Cneyd602ilFKypeQI,577
7
+ ninja_aio/types.py,sha256=E3yfXbNKkvLVcr8bvkHTSyIiCRZ4zumzJaXj1aiBi5U,735
8
8
  ninja_aio/decorators/__init__.py,sha256=cDDHD_9EI4CP7c5eL1m2mGNl9bR24i8FAkQsT3_RNGM,371
9
9
  ninja_aio/decorators/operations.py,sha256=L9yt2ku5oo4CpOLixCADmkcFjLGsWAn-cg-sDcjFhMA,343
10
10
  ninja_aio/decorators/views.py,sha256=0RVU4XaM1HvTQ-BOt_NwUtbhwfHau06lh-O8El1LqQk,8139
11
11
  ninja_aio/factory/__init__.py,sha256=IdH2z1ZZpv_IqonaDfVo7IsMzkgop6lHqz42RphUYBU,72
12
12
  ninja_aio/factory/operations.py,sha256=OgWGqq4WJ4arSQrH9FGAby9kx-HTdS7MOITxHdYMk18,12051
13
13
  ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- ninja_aio/helpers/api.py,sha256=YMzuZ4-ZpUrJBQIabE26gb_GYwsH2rVosWRE95YfdPQ,20775
14
+ ninja_aio/helpers/api.py,sha256=2beyexep-ehgaA_1bV5Yuh3zRDVcRCMkrW94nmfDWEA,20819
15
15
  ninja_aio/helpers/query.py,sha256=lzaH-htswoJVRT-W736HGMkpMba1VmN98TBLv5cZx9Q,4549
16
16
  ninja_aio/models/__init__.py,sha256=L3UQnQAlKoI3F7jinadL-Nn55hkPvnSRPYW0JtnbWFo,114
17
- ninja_aio/models/serializers.py,sha256=zV3gRI4isnCuLCAQbPhQlut3nKT0XuQOLy2ABiaJ6Y4,31372
17
+ ninja_aio/models/serializers.py,sha256=uviKndPf9HRySup-0t_nULJs-vDLCeGFLtkkQGZvi2E,38142
18
18
  ninja_aio/models/utils.py,sha256=P-YfbVyzUfxm_s1BrgSd6Zs0HIGdZ79PU1qM0Ud9-Xs,30492
19
19
  ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
20
20
  ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
21
21
  ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
22
- ninja_aio/schemas/helpers.py,sha256=W6IeHi5Tmbjh3FXwDYqjqlLBTVj5uTYq3_JVkNUWayo,7355
22
+ ninja_aio/schemas/helpers.py,sha256=KkbDgT7DwvdeBHZ__wurQQ9A1AIy-toCIL9dCzkTFhM,8350
23
23
  ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
24
- ninja_aio/views/api.py,sha256=GRtjCvAv7jAp3TxpOirsbMVKpBd8hymSMILdE-JLxvI,21327
24
+ ninja_aio/views/api.py,sha256=l7z-Cg_BUPRi3TAGpO2EppVA2tUAWUftQAn_qOn6XlM,21963
25
25
  ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
26
- django_ninja_aio_crud-2.6.1.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
27
- django_ninja_aio_crud-2.6.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
28
- django_ninja_aio_crud-2.6.1.dist-info/METADATA,sha256=aBT8gpjs9TVS_jcs_c4m--5K8YFLjpAUpNPa0ib2T-0,9963
29
- django_ninja_aio_crud-2.6.1.dist-info/RECORD,,
26
+ django_ninja_aio_crud-2.8.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
27
+ django_ninja_aio_crud-2.8.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
28
+ django_ninja_aio_crud-2.8.0.dist-info/METADATA,sha256=k3VWPUTlkXEe0tZRt7cWWtdA_5i_31gjkUM3t402nBI,9963
29
+ django_ninja_aio_crud-2.8.0.dist-info/RECORD,,
ninja_aio/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.6.1"
3
+ __version__ = "2.8.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
ninja_aio/helpers/api.py CHANGED
@@ -472,7 +472,7 @@ class ManyToManyAPI:
472
472
  model = relation.model
473
473
  related_name = relation.related_name
474
474
  m2m_auth = relation.auth or self.default_auth
475
- rel_util = ModelUtil(model)
475
+ rel_util = ModelUtil(model, serializer_class=relation.serializer_class)
476
476
  rel_path = relation.path or rel_util.verbose_name_path_resolver()
477
477
  related_schema = relation.related_schema
478
478
  m2m_add, m2m_remove, m2m_get = relation.add, relation.remove, relation.get
@@ -1,4 +1,4 @@
1
- from typing import Any, List, Optional
1
+ from typing import Any, List, Literal, Optional, Union, get_args, get_origin, ForwardRef
2
2
  import warnings
3
3
  import sys
4
4
 
@@ -15,7 +15,13 @@ from django.db.models.fields.related_descriptors import (
15
15
  ForwardOneToOneDescriptor,
16
16
  )
17
17
 
18
- from ninja_aio.types import S_TYPES, F_TYPES, SCHEMA_TYPES, ModelSerializerMeta
18
+ from ninja_aio.types import (
19
+ S_TYPES,
20
+ F_TYPES,
21
+ SCHEMA_TYPES,
22
+ ModelSerializerMeta,
23
+ SerializerMeta,
24
+ )
19
25
  from ninja_aio.schemas.helpers import (
20
26
  ModelQuerySetSchema,
21
27
  ModelQuerySetExtraSchema,
@@ -69,19 +75,14 @@ class BaseSerializer:
69
75
  raise NotImplementedError
70
76
 
71
77
  @classmethod
72
- def _resolve_serializer_reference(cls, serializer_ref: str | type) -> type:
78
+ def _resolve_string_reference(cls, string_ref: str) -> type:
73
79
  """
74
- Resolve a serializer reference that may be a string or a class.
75
-
76
- This method performs lazy resolution, meaning it will attempt to resolve
77
- string references only when called, allowing for forward references and
78
- circular dependencies between serializers in the same module.
80
+ Resolve a string serializer reference to an actual class.
79
81
 
80
82
  Parameters
81
83
  ----------
82
- serializer_ref : str | type
83
- Either a string reference to a serializer class name in the same module,
84
- or an actual serializer class.
84
+ string_ref : str
85
+ String reference (local class name or absolute import path).
85
86
 
86
87
  Returns
87
88
  -------
@@ -93,35 +94,155 @@ class BaseSerializer:
93
94
  ValueError
94
95
  If the string reference cannot be resolved.
95
96
  """
96
- # If it's already a class, return it directly
97
- if not isinstance(serializer_ref, str):
98
- return serializer_ref
97
+ # Check if it's an absolute import path (contains dots)
98
+ if "." in string_ref:
99
+ # Absolute import path: "myapp.serializers.UserSerializer"
100
+ module_path, class_name = string_ref.rsplit(".", 1)
101
+
102
+ try:
103
+ # Try to get or import the module
104
+ module = sys.modules.get(module_path)
105
+ if module is None:
106
+ import importlib
107
+
108
+ module = importlib.import_module(module_path)
99
109
 
100
- # Get the module where the current serializer class is defined
110
+ # Get the serializer class from the module
111
+ serializer_class = getattr(module, class_name, None)
112
+
113
+ if serializer_class is None:
114
+ raise ValueError(
115
+ f"Cannot resolve serializer reference '{string_ref}': "
116
+ f"class '{class_name}' not found in module '{module_path}'."
117
+ )
118
+
119
+ return serializer_class
120
+ except ImportError as e:
121
+ raise ValueError(
122
+ f"Cannot resolve serializer reference '{string_ref}': "
123
+ f"failed to import module '{module_path}': {e}"
124
+ )
125
+
126
+ # Local reference: simple class name in the same module
101
127
  module = sys.modules.get(cls.__module__)
102
128
 
103
129
  if module is None:
104
130
  raise ValueError(
105
- f"Cannot resolve serializer reference '{serializer_ref}': "
131
+ f"Cannot resolve serializer reference '{string_ref}': "
106
132
  f"module '{cls.__module__}' not found in sys.modules."
107
133
  )
108
134
 
109
- # Try to get the serializer class from the module
110
- serializer_class = getattr(module, serializer_ref, None)
135
+ serializer_class = getattr(module, string_ref, None)
111
136
 
112
137
  if serializer_class is None:
113
138
  raise ValueError(
114
- f"Cannot resolve serializer reference '{serializer_ref}' in module '{cls.__module__}'. "
115
- f"Make sure the serializer class '{serializer_ref}' is defined in the same module as {cls.__name__}."
139
+ f"Cannot resolve serializer reference '{string_ref}' in module '{cls.__module__}'. "
140
+ f"Make sure the serializer class '{string_ref}' is defined in the same module as {cls.__name__}."
116
141
  )
117
142
 
118
143
  return serializer_class
119
144
 
145
+ @classmethod
146
+ def _resolve_serializer_reference(
147
+ cls, serializer_ref: str | type | Any
148
+ ) -> type | Any:
149
+ """
150
+ Resolve a serializer reference that may be a string, a class, or a Union of serializers.
151
+
152
+ This method performs lazy resolution, meaning it will attempt to resolve
153
+ string references only when called, allowing for forward references and
154
+ circular dependencies between serializers in the same module.
155
+
156
+ Parameters
157
+ ----------
158
+ serializer_ref : str | type | Union
159
+ Either a string reference to a serializer class, an actual serializer class,
160
+ or a Union of serializer references. String references can be:
161
+ - A class name in the same module (e.g., "UserSerializer")
162
+ - An absolute import path (e.g., "myapp.serializers.UserSerializer")
163
+
164
+ Returns
165
+ -------
166
+ type | Union
167
+ The resolved serializer class or Union of serializer classes.
168
+
169
+ Raises
170
+ ------
171
+ ValueError
172
+ If the string reference cannot be resolved.
173
+
174
+ Examples
175
+ --------
176
+ >>> # Single reference
177
+ >>> cls._resolve_serializer_reference("UserSerializer")
178
+ >>> cls._resolve_serializer_reference(UserSerializer)
179
+ >>>
180
+ >>> # Union reference
181
+ >>> from typing import Union
182
+ >>> cls._resolve_serializer_reference(Union[UserSerializer, AdminSerializer])
183
+ >>> cls._resolve_serializer_reference(Union["UserSerializer", "AdminSerializer"])
184
+ """
185
+ # Handle Union types
186
+ origin = get_origin(serializer_ref)
187
+ if origin is Union:
188
+ resolved_types = tuple(
189
+ cls._resolve_serializer_reference(arg)
190
+ for arg in get_args(serializer_ref)
191
+ )
192
+ # Optimize single-type unions
193
+ if len(resolved_types) == 1:
194
+ return resolved_types[0]
195
+ # Create Union using indexing syntax for Python 3.10+ compatibility
196
+ return Union[resolved_types]
197
+
198
+ # Handle ForwardRef (created when using Union["StringType"])
199
+ if isinstance(serializer_ref, ForwardRef):
200
+ return cls._resolve_serializer_reference(serializer_ref.__forward_arg__)
201
+
202
+ # Handle string references
203
+ if isinstance(serializer_ref, str):
204
+ return cls._resolve_string_reference(serializer_ref)
205
+
206
+ # Already a class, return as-is
207
+ return serializer_ref
208
+
120
209
  @classmethod
121
210
  def _get_relations_serializers(cls) -> dict[str, "Serializer"]:
122
211
  # Optional in subclasses. Default to no explicit relation serializers.
123
212
  return {}
124
213
 
214
+ @classmethod
215
+ def _generate_union_schema(cls, resolved_union: Any) -> Any:
216
+ """
217
+ Generate a Union schema from multiple resolved serializers.
218
+
219
+ Parameters
220
+ ----------
221
+ resolved_union : Union
222
+ A Union type containing resolved serializer classes.
223
+
224
+ Returns
225
+ -------
226
+ Schema | Union[Schema, ...] | None
227
+ Union of generated schemas or None if all schemas are None.
228
+ """
229
+ # Generate schemas for each serializer in the Union (single call per serializer)
230
+ schemas = tuple(
231
+ schema
232
+ for serializer_type in get_args(resolved_union)
233
+ if (schema := serializer_type.generate_related_s()) is not None
234
+ )
235
+
236
+ if not schemas:
237
+ return None
238
+
239
+ # Optimize single-schema unions
240
+ if len(schemas) == 1:
241
+ return schemas[0]
242
+
243
+ # Create Union of schemas using indexing syntax for Python 3.10+ compatibility
244
+ return Union[schemas]
245
+
125
246
  @classmethod
126
247
  def _resolve_relation_schema(cls, field_name: str, rel_model: models.Model):
127
248
  """
@@ -136,23 +257,30 @@ class BaseSerializer:
136
257
 
137
258
  Returns
138
259
  -------
139
- Schema | None
140
- Generated schema or None if cannot be resolved.
260
+ Schema | Union[Schema, ...] | None
261
+ Generated schema, Union of schemas, or None if cannot be resolved.
141
262
  """
142
- # Check if related model is a ModelSerializer with readable fields
263
+ # Auto-resolve ModelSerializer with readable fields
143
264
  if isinstance(rel_model, ModelSerializerMeta):
144
265
  if rel_model.get_fields("read") or rel_model.get_custom_fields("read"):
145
266
  return rel_model.generate_related_s()
146
267
  return None
147
268
 
148
- # Fall back to explicit serializer mapping
269
+ # Resolve from explicit serializer mapping
149
270
  rel_serializers = cls._get_relations_serializers() or {}
150
271
  serializer_ref = rel_serializers.get(field_name)
151
- if serializer_ref:
152
- serializer = cls._resolve_serializer_reference(serializer_ref)
153
- return serializer.generate_related_s()
154
272
 
155
- return None
273
+ if not serializer_ref:
274
+ return None
275
+
276
+ resolved = cls._resolve_serializer_reference(serializer_ref)
277
+
278
+ # Handle Union of serializers
279
+ if get_origin(resolved) is Union:
280
+ return cls._generate_union_schema(resolved)
281
+
282
+ # Handle single serializer
283
+ return resolved.generate_related_s()
156
284
 
157
285
  @classmethod
158
286
  def _is_special_field(
@@ -274,72 +402,100 @@ class BaseSerializer:
274
402
  return (field_name, schema | None, None)
275
403
 
276
404
  @classmethod
277
- def get_schema_out_data(cls):
405
+ def _is_reverse_relation(cls, field_obj) -> bool:
406
+ """Check if field is a reverse relation (M2M, reverse FK, reverse O2O)."""
407
+ return isinstance(
408
+ field_obj,
409
+ (ManyToManyDescriptor, ReverseManyToOneDescriptor, ReverseOneToOneDescriptor),
410
+ )
411
+
412
+ @classmethod
413
+ def _is_forward_relation(cls, field_obj) -> bool:
414
+ """Check if field is a forward relation (FK, O2O)."""
415
+ return isinstance(
416
+ field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
417
+ )
418
+
419
+ @classmethod
420
+ def _warn_missing_relation_serializer(cls, field_name: str, model) -> None:
421
+ """Emit warning for reverse relations without explicit serializer mapping."""
422
+ if (
423
+ not isinstance(model, ModelSerializerMeta)
424
+ and not getattr(settings, "NINJA_AIO_TESTING", False)
425
+ ):
426
+ warnings.warn(
427
+ f"{cls.__name__}: reverse relation '{field_name}' is listed in read fields "
428
+ "but has no entry in relations_serializers; it will be auto-resolved only "
429
+ "for ModelSerializer relations, otherwise skipped.",
430
+ UserWarning,
431
+ stacklevel=3,
432
+ )
433
+
434
+ @classmethod
435
+ def _process_field(
436
+ cls,
437
+ field_name: str,
438
+ model,
439
+ relations_serializers: dict,
440
+ ) -> tuple[str | None, tuple | None, tuple | None]:
441
+ """
442
+ Process a single field and determine its classification.
443
+
444
+ Returns:
445
+ (plain_field, reverse_rel, forward_rel) - only one will be non-None
446
+ """
447
+ field_obj = getattr(model, field_name)
448
+
449
+ if cls._is_reverse_relation(field_obj):
450
+ if field_name not in relations_serializers:
451
+ cls._warn_missing_relation_serializer(field_name, model)
452
+ rel_tuple = cls._build_schema_reverse_rel(field_name, field_obj)
453
+ return (None, rel_tuple, None)
454
+
455
+ if cls._is_forward_relation(field_obj):
456
+ rel_tuple = cls._build_schema_forward_rel(field_name, field_obj)
457
+ if rel_tuple is True:
458
+ return (field_name, None, None)
459
+ return (None, None, rel_tuple)
460
+
461
+ return (field_name, None, None)
462
+
463
+ @classmethod
464
+ def get_schema_out_data(cls, schema_type: Literal["Out", "Detail"] = "Out"):
278
465
  """
279
- Collect components for 'Out' read schema generation.
280
- Returns (fields, reverse_rel_descriptors, excludes, custom_fields_with_forward_relations, optionals).
281
- Enforces relation serializers only when provided by subclass via _get_relations_serializers.
466
+ Collect components for output schema generation (Out or Detail).
467
+
468
+ Returns:
469
+ tuple: (fields, reverse_rels, excludes, customs_with_forward_rels, optionals)
282
470
  """
471
+ if schema_type not in ("Out", "Detail"):
472
+ raise ValueError("get_schema_out_data only supports 'Out' or 'Detail' types")
473
+
474
+ fields_type = "read" if schema_type == "Out" else "detail"
475
+ model = cls._get_model()
476
+ relations_serializers = cls._get_relations_serializers() or {}
477
+
283
478
  fields: list[str] = []
284
479
  reverse_rels: list[tuple] = []
285
- rels: list[tuple] = []
286
- relations_serializers = cls._get_relations_serializers() or {}
287
- model = cls._get_model()
480
+ forward_rels: list[tuple] = []
288
481
 
289
- for f in cls.get_fields("read"):
290
- field_obj = getattr(model, f)
291
- is_reverse = isinstance(
292
- field_obj,
293
- (
294
- ManyToManyDescriptor,
295
- ReverseManyToOneDescriptor,
296
- ReverseOneToOneDescriptor,
297
- ),
482
+ for field_name in cls.get_fields(fields_type):
483
+ plain, reverse, forward = cls._process_field(
484
+ field_name, model, relations_serializers
298
485
  )
299
- is_forward = isinstance(
300
- field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
301
- )
302
-
303
- # If explicit relation serializers are declared, require mapping presence.
304
- if (
305
- is_reverse
306
- and not isinstance(model, ModelSerializerMeta)
307
- and f not in relations_serializers
308
- and not getattr(settings, "NINJA_AIO_TESTING", False)
309
- ):
310
- warnings.warn(
311
- f"{cls.__name__}: reverse relation '{f}' is listed in read fields but has no entry in relations_serializers; "
312
- "it will be auto-resolved only for ModelSerializer relations, otherwise skipped.",
313
- UserWarning,
314
- stacklevel=2,
315
- )
316
-
317
- # Reverse relations
318
- if is_reverse:
319
- rel_tuple = cls._build_schema_reverse_rel(f, field_obj)
320
- if rel_tuple:
321
- reverse_rels.append(rel_tuple)
322
- continue
323
-
324
- # Forward relations
325
- if is_forward:
326
- rel_tuple = cls._build_schema_forward_rel(f, field_obj)
327
- if rel_tuple is True:
328
- fields.append(f)
329
- elif rel_tuple:
330
- rels.append(rel_tuple)
331
- # None -> skip entirely
332
- continue
333
-
334
- # Plain field
335
- fields.append(f)
486
+ if plain:
487
+ fields.append(plain)
488
+ if reverse:
489
+ reverse_rels.append(reverse)
490
+ if forward:
491
+ forward_rels.append(forward)
336
492
 
337
493
  return (
338
494
  fields,
339
495
  reverse_rels,
340
- cls.get_excluded_fields("read"),
341
- cls.get_custom_fields("read") + rels,
342
- cls.get_optional_fields("read"),
496
+ cls.get_excluded_fields(fields_type),
497
+ cls.get_custom_fields(fields_type) + forward_rels,
498
+ cls.get_optional_fields(fields_type),
343
499
  )
344
500
 
345
501
  @classmethod
@@ -355,15 +511,16 @@ class BaseSerializer:
355
511
  model = cls._get_model()
356
512
 
357
513
  # Handle special schema types with custom logic
358
- if schema_type == "Out":
514
+ if schema_type == "Out" or schema_type == "Detail":
359
515
  fields, reverse_rels, excludes, customs, optionals = (
360
- cls.get_schema_out_data()
516
+ cls.get_schema_out_data(schema_type)
361
517
  )
362
518
  if not any([fields, reverse_rels, excludes, customs]):
363
519
  return None
520
+ schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
364
521
  return create_schema(
365
522
  model=model,
366
- name=f"{model._meta.model_name}SchemaOut",
523
+ name=f"{model._meta.model_name}{schema_name}",
367
524
  depth=depth,
368
525
  fields=fields,
369
526
  custom_fields=reverse_rels + customs + optionals,
@@ -442,6 +599,11 @@ class BaseSerializer:
442
599
  """Generate read (Out) schema."""
443
600
  return cls._generate_model_schema("Out", depth)
444
601
 
602
+ @classmethod
603
+ def generate_detail_s(cls, depth: int = 1) -> Schema:
604
+ """Generate detail (single object Out) schema."""
605
+ return cls._generate_model_schema("Detail", depth)
606
+
445
607
  @classmethod
446
608
  def generate_create_s(cls) -> Schema:
447
609
  """Generate create (In) schema."""
@@ -540,6 +702,26 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
540
702
  optionals: list[tuple[str, type]] = []
541
703
  excludes: list[str] = []
542
704
 
705
+ class DetailSerializer:
706
+ """Configuration describing detail (single object) read schema.
707
+
708
+ Attributes
709
+ ----------
710
+ fields : list[str]
711
+ Explicit model fields to include.
712
+ excludes : list[str]
713
+ Fields to force exclude (safety).
714
+ customs : list[tuple[str, type, Any]]
715
+ Computed / synthetic output attributes.
716
+ optionals : list[tuple[str, type]]
717
+ Optional output fields.
718
+ """
719
+
720
+ fields: list[str] = []
721
+ customs: list[tuple[str, type, Any]] = []
722
+ optionals: list[tuple[str, type]] = []
723
+ excludes: list[str] = []
724
+
543
725
  class UpdateSerializer:
544
726
  """Configuration describing update (PATCH/PUT) schema.
545
727
 
@@ -565,6 +747,7 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
565
747
  "create": "CreateSerializer",
566
748
  "update": "UpdateSerializer",
567
749
  "read": "ReadSerializer",
750
+ "detail": "DetailSerializer",
568
751
  }
569
752
 
570
753
  @classmethod
@@ -733,7 +916,7 @@ class SchemaModelConfig(Schema):
733
916
  customs: Optional[List[tuple[str, type, Any]]] = None
734
917
 
735
918
 
736
- class Serializer(BaseSerializer):
919
+ class Serializer(BaseSerializer, metaclass=SerializerMeta):
737
920
  """
738
921
  Serializer
739
922
  ----------
@@ -748,6 +931,7 @@ class Serializer(BaseSerializer):
748
931
  "create": "in",
749
932
  "update": "update",
750
933
  "read": "out",
934
+ "detail": "detail",
751
935
  }
752
936
 
753
937
  def __init_subclass__(cls, **kwargs):
@@ -765,6 +949,7 @@ class Serializer(BaseSerializer):
765
949
  schema_in: Optional[SchemaModelConfig] = None
766
950
  schema_out: Optional[SchemaModelConfig] = None
767
951
  schema_update: Optional[SchemaModelConfig] = None
952
+ schema_detail: Optional[SchemaModelConfig] = None
768
953
  relations_serializers: dict[str, "Serializer"] = {}
769
954
 
770
955
  @classmethod
@@ -789,6 +974,8 @@ class Serializer(BaseSerializer):
789
974
  return cls._get_meta_data("schema_out")
790
975
  case "update":
791
976
  return cls._get_meta_data("schema_update")
977
+ case "detail":
978
+ return cls._get_meta_data("schema_detail")
792
979
  case _:
793
980
  return None
794
981
 
@@ -908,7 +1095,12 @@ class Serializer(BaseSerializer):
908
1095
  dict
909
1096
  Serialized data.
910
1097
  """
911
- return await self.util.read_s(schema=self.generate_read_s(), instance=instance)
1098
+ schema = (
1099
+ self.generate_read_s()
1100
+ if self.generate_detail_s() is None
1101
+ else self.generate_detail_s()
1102
+ )
1103
+ return await self.util.read_s(schema=schema, instance=instance)
912
1104
 
913
1105
  async def models_dump(
914
1106
  self, instances: models.QuerySet[models.Model]
@@ -1,7 +1,7 @@
1
1
  from typing import List, Optional, Type
2
2
 
3
3
  from ninja import Schema
4
- from ninja_aio.types import ModelSerializerMeta
4
+ from ninja_aio.types import ModelSerializerMeta, SerializerMeta
5
5
  from django.db.models import Model
6
6
  from pydantic import BaseModel, ConfigDict, model_validator
7
7
 
@@ -38,6 +38,10 @@ class M2MRelationSchema(BaseModel):
38
38
  Optional explicit schema to represent related objects in responses.
39
39
  If `model` is a ModelSerializerMeta, this is auto-derived via `model.generate_related_s()`.
40
40
  If `model` is a plain Django model, this must be provided.
41
+ If `model` is a plain DJango model and this is not provided but serializer_class is provided this last one would be used to generate it.
42
+ serializer_class (Serializer | None):
43
+ Optional serializer class associated with the related model. If provided alongside a plain Django model,
44
+ it can be used to auto-generate the `related_schema`.
41
45
  append_slash (bool):
42
46
  Whether to append a trailing slash to the generated GET endpoint path. Defaults to False for backward compatibility.
43
47
 
@@ -63,6 +67,7 @@ class M2MRelationSchema(BaseModel):
63
67
  auth: Optional[list] = None
64
68
  filters: Optional[dict[str, tuple]] = None
65
69
  related_schema: Optional[Type[Schema]] = None
70
+ serializer_class: Optional[SerializerMeta] = None
66
71
  append_slash: bool = False
67
72
 
68
73
  model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -70,15 +75,30 @@ class M2MRelationSchema(BaseModel):
70
75
  @model_validator(mode="before")
71
76
  @classmethod
72
77
  def validate_related_schema(cls, data):
73
- related_schema = data.get("related_schema")
74
- if related_schema is not None:
78
+ # Early return if related_schema is already provided
79
+ if data.get("related_schema") is not None:
75
80
  return data
81
+
76
82
  model = data.get("model")
77
- if not isinstance(model, ModelSerializerMeta):
83
+ serializer_class = data.get("serializer_class")
84
+ is_model_serializer = isinstance(model, ModelSerializerMeta)
85
+
86
+ # Validate incompatible parameters
87
+ if is_model_serializer and serializer_class:
88
+ raise ValueError(
89
+ "Cannot provide serializer_class when model is a ModelSerializerMeta"
90
+ )
91
+
92
+ # Generate related_schema based on available information
93
+ if is_model_serializer:
94
+ data["related_schema"] = model.generate_related_s()
95
+ elif serializer_class:
96
+ data["related_schema"] = serializer_class.generate_related_s()
97
+ else:
78
98
  raise ValueError(
79
- "related_schema must be provided if model is not a ModelSerializer",
99
+ "related_schema must be provided if model is not a ModelSerializer"
80
100
  )
81
- data["related_schema"] = model.generate_related_s()
101
+
82
102
  return data
83
103
 
84
104
 
@@ -95,6 +115,7 @@ class ModelQuerySetExtraSchema(ModelQuerySetSchema):
95
115
  select_related (Optional[list[str]]): List of related fields for select_related optimization.
96
116
  prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
97
117
  """
118
+
98
119
  scope: str
99
120
 
100
121
 
@@ -106,6 +127,7 @@ class ObjectQuerySchema(ModelQuerySetSchema):
106
127
  select_related (Optional[list[str]]): List of related fields for select_related optimization.
107
128
  prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
108
129
  """
130
+
109
131
  getters: Optional[dict] = {}
110
132
 
111
133
 
@@ -117,6 +139,7 @@ class ObjectsQuerySchema(ModelQuerySetSchema):
117
139
  select_related (Optional[list[str]]): List of related fields for select_related optimization.
118
140
  prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
119
141
  """
142
+
120
143
  filters: Optional[dict] = {}
121
144
 
122
145
 
@@ -129,6 +152,7 @@ class QuerySchema(ModelQuerySetSchema):
129
152
  select_related (Optional[list[str]]): List of related fields for select_related optimization.
130
153
  prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
131
154
  """
155
+
132
156
  filters: Optional[dict] = {}
133
157
  getters: Optional[dict] = {}
134
158
 
@@ -140,6 +164,7 @@ class QueryUtilBaseScopesSchema(BaseModel):
140
164
  READ (str): Scope for read operations.
141
165
  QUERYSET_REQUEST (str): Scope for queryset request operations.
142
166
  """
167
+
143
168
  READ: str = "read"
144
169
  QUERYSET_REQUEST: str = "queryset_request"
145
170
 
@@ -163,6 +188,7 @@ class DecoratorsSchema(Schema):
163
188
  Consider initializing these in __init__ or using default_factory (if using pydantic/dataclasses)
164
189
  to avoid unintended side effects.
165
190
  """
191
+
166
192
  list: Optional[List] = []
167
193
  retrieve: Optional[List] = []
168
194
  create: Optional[List] = []
ninja_aio/types.py CHANGED
@@ -4,16 +4,21 @@ from joserfc import jwk
4
4
  from django.db.models import Model
5
5
  from typing import TypeAlias
6
6
 
7
- S_TYPES = Literal["read", "create", "update"]
7
+ S_TYPES = Literal["read", "detail", "create", "update"]
8
8
  F_TYPES = Literal["fields", "customs", "optionals", "excludes"]
9
- SCHEMA_TYPES = Literal["In", "Out", "Patch", "Related"]
9
+ SCHEMA_TYPES = Literal["In", "Out", "Detail", "Patch", "Related"]
10
10
  VIEW_TYPES = Literal["list", "retrieve", "create", "update", "delete", "all"]
11
11
  JwtKeys: TypeAlias = jwk.RSAKey | jwk.ECKey | jwk.OctKey
12
12
 
13
- class ModelSerializerType(type):
14
- def __repr__(self):
15
- return self.__name__
16
13
 
14
+ class SerializerMeta(type):
15
+ """Metaclass for serializers - extend with custom behavior as needed."""
16
+
17
+ def __repr__(cls):
18
+ return cls.__name__
19
+
20
+
21
+ class ModelSerializerMeta(SerializerMeta, type(Model)):
22
+ """Metaclass combining SerializerMeta with Django's ModelBase."""
17
23
 
18
- class ModelSerializerMeta(ModelSerializerType, type(Model)):
19
24
  pass
ninja_aio/views/api.py CHANGED
@@ -225,6 +225,7 @@ class APIViewSet(API):
225
225
  serializer_class: serializers.Serializer | None = None
226
226
  schema_in: Schema | None = None
227
227
  schema_out: Schema | None = None
228
+ schema_detail: Schema | None = None
228
229
  schema_update: Schema | None = None
229
230
  get_auth: list | None = NOT_SET
230
231
  post_auth: list | None = NOT_SET
@@ -262,7 +263,9 @@ class APIViewSet(API):
262
263
  if not isinstance(self.model, ModelSerializerMeta)
263
264
  else self.model.util
264
265
  )
265
- self.schema_out, self.schema_in, self.schema_update = self.get_schemas()
266
+ self.schema_out, self.schema_detail, self.schema_in, self.schema_update = (
267
+ self.get_schemas()
268
+ )
266
269
  self.path_schema = self._generate_path_schema()
267
270
  self.filters_schema = self._generate_filters_schema()
268
271
  self.model_verbose_name = (
@@ -365,22 +368,24 @@ class APIViewSet(API):
365
368
 
366
369
  def get_schemas(self):
367
370
  """
368
- Compute and return (schema_out, schema_in, schema_update).
371
+ Compute and return (schema_out, schema_detail, schema_in, schema_update).
369
372
 
370
- - If model is a ModelSerializer (ModelSerializerMeta), auto-generate read/create/update schemas.
373
+ - If model is a ModelSerializer (ModelSerializerMeta), auto-generate read/detail/create/update schemas.
371
374
  - Otherwise, use existing schemas or generate from serializer_class if provided.
372
375
  """
373
376
  # ModelSerializer case: prefer explicitly set schemas, otherwise generate from the model
374
377
  if isinstance(self.model, ModelSerializerMeta):
375
378
  return (
376
379
  self.schema_out or self.model.generate_read_s(),
380
+ self.schema_detail or self.model.generate_detail_s(),
377
381
  self.schema_in or self.model.generate_create_s(),
378
382
  self.schema_update or self.model.generate_update_s(),
379
383
  )
380
384
 
381
385
  # Non-ModelSerializer: start from provided schemas
382
- schema_out, schema_in, schema_update = (
386
+ schema_out, schema_detail, schema_in, schema_update = (
383
387
  self.schema_out,
388
+ self.schema_detail,
384
389
  self.schema_in,
385
390
  self.schema_update,
386
391
  )
@@ -389,9 +394,10 @@ class APIViewSet(API):
389
394
  if self.serializer_class:
390
395
  schema_in = schema_in or self.serializer_class.generate_create_s()
391
396
  schema_out = schema_out or self.serializer_class.generate_read_s()
397
+ schema_detail = schema_detail or self.serializer_class.generate_detail_s()
392
398
  schema_update = schema_update or self.serializer_class.generate_update_s()
393
399
 
394
- return (schema_out, schema_in, schema_update)
400
+ return (schema_out, schema_detail, schema_in, schema_update)
395
401
 
396
402
  async def query_params_handler(
397
403
  self, queryset: QuerySet[ModelSerializer], filters: dict
@@ -456,23 +462,31 @@ class APIViewSet(API):
456
462
 
457
463
  return list
458
464
 
465
+ def _get_retrieve_schema(self) -> Schema:
466
+ """
467
+ Return the schema to use for retrieve endpoint.
468
+ Uses schema_detail if available, otherwise falls back to schema_out.
469
+ """
470
+ return self.schema_detail or self.schema_out
471
+
459
472
  def retrieve_view(self):
460
473
  """
461
474
  Register retrieve endpoint.
462
475
  """
476
+ retrieve_schema = self._get_retrieve_schema()
463
477
 
464
478
  @self.router.get(
465
479
  self.get_path_retrieve,
466
480
  auth=self.get_view_auth(),
467
481
  summary=f"Retrieve {self.model_verbose_name}",
468
482
  description=self.retrieve_docs,
469
- response={200: self.schema_out, self.error_codes: GenericMessageSchema},
483
+ response={200: retrieve_schema, self.error_codes: GenericMessageSchema},
470
484
  )
471
485
  @decorate_view(unique_view(self), *self.extra_decorators.retrieve)
472
486
  async def retrieve(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
473
487
  query_data = self._get_query_data()
474
488
  return await self.model_util.read_s(
475
- self.schema_out,
489
+ retrieve_schema,
476
490
  request,
477
491
  query_data=QuerySchema(
478
492
  getters={"pk": self._get_pk(pk)}, **query_data.model_dump()