django-ninja-aio-crud 2.7.0__py3-none-any.whl → 2.9.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.

Potentially problematic release.


This version of django-ninja-aio-crud might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.7.0
3
+ Version: 2.9.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=FkPurmyPz8Yv7VAiIOZYbFEghPXZ7aeLYX5xBwFSMtk,119
1
+ ninja_aio/__init__.py,sha256=YE75I1ewNyWylWEf1tZjFFmQlFLdsdYyYiQgxSr1Jdk,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
15
- ninja_aio/helpers/query.py,sha256=lzaH-htswoJVRT-W736HGMkpMba1VmN98TBLv5cZx9Q,4549
14
+ ninja_aio/helpers/api.py,sha256=2beyexep-ehgaA_1bV5Yuh3zRDVcRCMkrW94nmfDWEA,20819
15
+ ninja_aio/helpers/query.py,sha256=NMGkS_v-ZVYKNtf1XohEUzfwca52Eq5FTcQ5lehHjus,4682
16
16
  ninja_aio/models/__init__.py,sha256=L3UQnQAlKoI3F7jinadL-Nn55hkPvnSRPYW0JtnbWFo,114
17
- ninja_aio/models/serializers.py,sha256=AlZIkwIiOt-nGBfMDoXu9tJotfzH5L13fv3Rw_4sKUg,35697
18
- ninja_aio/models/utils.py,sha256=P-YfbVyzUfxm_s1BrgSd6Zs0HIGdZ79PU1qM0Ud9-Xs,30492
17
+ ninja_aio/models/serializers.py,sha256=I7pUz_vl0FElaVsrGaogT_Lj9T4uaNG7UDMGf5VMwW4,38468
18
+ ninja_aio/models/utils.py,sha256=N7waJGOCsoFvbbO1xXy94acKeG85WRK3hjZJz7IukhU,32885
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=Vti5BfHWpxaJXj_ixZBJb34VRwhHODrlVjRlIuHh_ug,8428
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=CW74JB1tsdWfv3Y3x4K9o8yXsrMxCKWZ4QalhqJGan8,22072
25
25
  ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
26
- django_ninja_aio_crud-2.7.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
27
- django_ninja_aio_crud-2.7.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
28
- django_ninja_aio_crud-2.7.0.dist-info/METADATA,sha256=_eQi4SR0xvfnLM6ShB-yvkxffFzUsxNCB39spSHWTOU,9963
29
- django_ninja_aio_crud-2.7.0.dist-info/RECORD,,
26
+ django_ninja_aio_crud-2.9.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
27
+ django_ninja_aio_crud-2.9.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
28
+ django_ninja_aio_crud-2.9.0.dist-info/METADATA,sha256=7hbmX5K1ixoBn0RnFlkpnp3runSgZ1pi70v606cUX3Q,9963
29
+ django_ninja_aio_crud-2.9.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.7.0"
3
+ __version__ = "2.9.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
@@ -75,6 +75,9 @@ class QueryUtil:
75
75
  self.queryset_request_config: ModelQuerySetSchema = self._configs.get(
76
76
  self.SCOPES.QUERYSET_REQUEST, ModelQuerySetSchema()
77
77
  )
78
+ self.detail_config: ModelQuerySetSchema = self._configs.get(
79
+ self.SCOPES.DETAIL, ModelQuerySetSchema()
80
+ )
78
81
 
79
82
  def _get_config(self, conf_name: str) -> ModelQuerySetSchema:
80
83
  """Helper method to retrieve configuration attributes."""
@@ -1,4 +1,4 @@
1
- from typing import Any, List, Optional, Union, get_args, get_origin, ForwardRef
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,
@@ -55,6 +61,7 @@ class BaseSerializer:
55
61
  """
56
62
 
57
63
  read = ModelQuerySetSchema()
64
+ detail = ModelQuerySetSchema()
58
65
  queryset_request = ModelQuerySetSchema()
59
66
  extras: list[ModelQuerySetExtraSchema] = []
60
67
 
@@ -98,6 +105,7 @@ class BaseSerializer:
98
105
  module = sys.modules.get(module_path)
99
106
  if module is None:
100
107
  import importlib
108
+
101
109
  module = importlib.import_module(module_path)
102
110
 
103
111
  # Get the serializer class from the module
@@ -136,7 +144,9 @@ class BaseSerializer:
136
144
  return serializer_class
137
145
 
138
146
  @classmethod
139
- def _resolve_serializer_reference(cls, serializer_ref: str | type | Any) -> type | Any:
147
+ def _resolve_serializer_reference(
148
+ cls, serializer_ref: str | type | Any
149
+ ) -> type | Any:
140
150
  """
141
151
  Resolve a serializer reference that may be a string, a class, or a Union of serializers.
142
152
 
@@ -217,11 +227,11 @@ class BaseSerializer:
217
227
  Schema | Union[Schema, ...] | None
218
228
  Union of generated schemas or None if all schemas are None.
219
229
  """
220
- # Generate schemas for each serializer in the Union
230
+ # Generate schemas for each serializer in the Union (single call per serializer)
221
231
  schemas = tuple(
222
- serializer_type.generate_related_s()
232
+ schema
223
233
  for serializer_type in get_args(resolved_union)
224
- if serializer_type.generate_related_s() is not None
234
+ if (schema := serializer_type.generate_related_s()) is not None
225
235
  )
226
236
 
227
237
  if not schemas:
@@ -316,14 +326,18 @@ class BaseSerializer:
316
326
  ]
317
327
 
318
328
  @classmethod
319
- def get_excluded_fields(cls, s_type: type[S_TYPES]):
329
+ def get_excluded_fields(cls, s_type: S_TYPES):
320
330
  """Return excluded field names for the serializer type."""
321
331
  return cls._get_fields(s_type, "excludes")
322
332
 
323
333
  @classmethod
324
- def get_fields(cls, s_type: type[S_TYPES]):
334
+ def get_fields(cls, s_type: S_TYPES):
325
335
  """Return explicit declared fields for the serializer type."""
326
- return cls._get_fields(s_type, "fields")
336
+ fields = cls._get_fields(s_type, "fields")
337
+ # Detail schema falls back to read fields if none declared
338
+ if not fields and s_type == "detail":
339
+ return cls._get_fields("read", "fields")
340
+ return fields
327
341
 
328
342
  @classmethod
329
343
  def is_custom(cls, field: str) -> bool:
@@ -393,72 +407,105 @@ class BaseSerializer:
393
407
  return (field_name, schema | None, None)
394
408
 
395
409
  @classmethod
396
- def get_schema_out_data(cls):
410
+ def _is_reverse_relation(cls, field_obj) -> bool:
411
+ """Check if field is a reverse relation (M2M, reverse FK, reverse O2O)."""
412
+ return isinstance(
413
+ field_obj,
414
+ (
415
+ ManyToManyDescriptor,
416
+ ReverseManyToOneDescriptor,
417
+ ReverseOneToOneDescriptor,
418
+ ),
419
+ )
420
+
421
+ @classmethod
422
+ def _is_forward_relation(cls, field_obj) -> bool:
423
+ """Check if field is a forward relation (FK, O2O)."""
424
+ return isinstance(
425
+ field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
426
+ )
427
+
428
+ @classmethod
429
+ def _warn_missing_relation_serializer(cls, field_name: str, model) -> None:
430
+ """Emit warning for reverse relations without explicit serializer mapping."""
431
+ if not isinstance(model, ModelSerializerMeta) and not getattr(
432
+ settings, "NINJA_AIO_TESTING", False
433
+ ):
434
+ warnings.warn(
435
+ f"{cls.__name__}: reverse relation '{field_name}' is listed in read fields "
436
+ "but has no entry in relations_serializers; it will be auto-resolved only "
437
+ "for ModelSerializer relations, otherwise skipped.",
438
+ UserWarning,
439
+ stacklevel=3,
440
+ )
441
+
442
+ @classmethod
443
+ def _process_field(
444
+ cls,
445
+ field_name: str,
446
+ model,
447
+ relations_serializers: dict,
448
+ ) -> tuple[str | None, tuple | None, tuple | None]:
397
449
  """
398
- Collect components for 'Out' read schema generation.
399
- Returns (fields, reverse_rel_descriptors, excludes, custom_fields_with_forward_relations, optionals).
400
- Enforces relation serializers only when provided by subclass via _get_relations_serializers.
450
+ Process a single field and determine its classification.
451
+
452
+ Returns:
453
+ (plain_field, reverse_rel, forward_rel) - only one will be non-None
401
454
  """
402
- fields: list[str] = []
403
- reverse_rels: list[tuple] = []
404
- rels: list[tuple] = []
405
- relations_serializers = cls._get_relations_serializers() or {}
406
- model = cls._get_model()
455
+ field_obj = getattr(model, field_name)
407
456
 
408
- for f in cls.get_fields("read"):
409
- field_obj = getattr(model, f)
410
- is_reverse = isinstance(
411
- field_obj,
412
- (
413
- ManyToManyDescriptor,
414
- ReverseManyToOneDescriptor,
415
- ReverseOneToOneDescriptor,
416
- ),
417
- )
418
- is_forward = isinstance(
419
- field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
457
+ if cls._is_reverse_relation(field_obj):
458
+ if field_name not in relations_serializers:
459
+ cls._warn_missing_relation_serializer(field_name, model)
460
+ rel_tuple = cls._build_schema_reverse_rel(field_name, field_obj)
461
+ return (None, rel_tuple, None)
462
+
463
+ if cls._is_forward_relation(field_obj):
464
+ rel_tuple = cls._build_schema_forward_rel(field_name, field_obj)
465
+ if rel_tuple is True:
466
+ return (field_name, None, None)
467
+ return (None, None, rel_tuple)
468
+
469
+ return (field_name, None, None)
470
+
471
+ @classmethod
472
+ def get_schema_out_data(cls, schema_type: Literal["Out", "Detail"] = "Out"):
473
+ """
474
+ Collect components for output schema generation (Out or Detail).
475
+
476
+ Returns:
477
+ tuple: (fields, reverse_rels, excludes, customs_with_forward_rels, optionals)
478
+ """
479
+ if schema_type not in ("Out", "Detail"):
480
+ raise ValueError(
481
+ "get_schema_out_data only supports 'Out' or 'Detail' types"
420
482
  )
421
483
 
422
- # If explicit relation serializers are declared, require mapping presence.
423
- if (
424
- is_reverse
425
- and not isinstance(model, ModelSerializerMeta)
426
- and f not in relations_serializers
427
- and not getattr(settings, "NINJA_AIO_TESTING", False)
428
- ):
429
- warnings.warn(
430
- f"{cls.__name__}: reverse relation '{f}' is listed in read fields but has no entry in relations_serializers; "
431
- "it will be auto-resolved only for ModelSerializer relations, otherwise skipped.",
432
- UserWarning,
433
- stacklevel=2,
434
- )
484
+ fields_type = "read" if schema_type == "Out" else "detail"
485
+ model = cls._get_model()
486
+ relations_serializers = cls._get_relations_serializers() or {}
487
+
488
+ fields: list[str] = []
489
+ reverse_rels: list[tuple] = []
490
+ forward_rels: list[tuple] = []
435
491
 
436
- # Reverse relations
437
- if is_reverse:
438
- rel_tuple = cls._build_schema_reverse_rel(f, field_obj)
439
- if rel_tuple:
440
- reverse_rels.append(rel_tuple)
441
- continue
442
-
443
- # Forward relations
444
- if is_forward:
445
- rel_tuple = cls._build_schema_forward_rel(f, field_obj)
446
- if rel_tuple is True:
447
- fields.append(f)
448
- elif rel_tuple:
449
- rels.append(rel_tuple)
450
- # None -> skip entirely
451
- continue
452
-
453
- # Plain field
454
- fields.append(f)
492
+ for field_name in cls.get_fields(fields_type):
493
+ plain, reverse, forward = cls._process_field(
494
+ field_name, model, relations_serializers
495
+ )
496
+ if plain:
497
+ fields.append(plain)
498
+ if reverse:
499
+ reverse_rels.append(reverse)
500
+ if forward:
501
+ forward_rels.append(forward)
455
502
 
456
503
  return (
457
504
  fields,
458
505
  reverse_rels,
459
- cls.get_excluded_fields("read"),
460
- cls.get_custom_fields("read") + rels,
461
- cls.get_optional_fields("read"),
506
+ cls.get_excluded_fields(fields_type),
507
+ cls.get_custom_fields(fields_type) + forward_rels,
508
+ cls.get_optional_fields(fields_type),
462
509
  )
463
510
 
464
511
  @classmethod
@@ -474,15 +521,16 @@ class BaseSerializer:
474
521
  model = cls._get_model()
475
522
 
476
523
  # Handle special schema types with custom logic
477
- if schema_type == "Out":
524
+ if schema_type == "Out" or schema_type == "Detail":
478
525
  fields, reverse_rels, excludes, customs, optionals = (
479
- cls.get_schema_out_data()
526
+ cls.get_schema_out_data(schema_type)
480
527
  )
481
528
  if not any([fields, reverse_rels, excludes, customs]):
482
529
  return None
530
+ schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
483
531
  return create_schema(
484
532
  model=model,
485
- name=f"{model._meta.model_name}SchemaOut",
533
+ name=f"{model._meta.model_name}{schema_name}",
486
534
  depth=depth,
487
535
  fields=fields,
488
536
  custom_fields=reverse_rels + customs + optionals,
@@ -561,6 +609,11 @@ class BaseSerializer:
561
609
  """Generate read (Out) schema."""
562
610
  return cls._generate_model_schema("Out", depth)
563
611
 
612
+ @classmethod
613
+ def generate_detail_s(cls, depth: int = 1) -> Schema:
614
+ """Generate detail (single object Out) schema."""
615
+ return cls._generate_model_schema("Detail", depth) or cls.generate_read_s(depth)
616
+
564
617
  @classmethod
565
618
  def generate_create_s(cls) -> Schema:
566
619
  """Generate create (In) schema."""
@@ -659,6 +712,26 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
659
712
  optionals: list[tuple[str, type]] = []
660
713
  excludes: list[str] = []
661
714
 
715
+ class DetailSerializer:
716
+ """Configuration describing detail (single object) read schema.
717
+
718
+ Attributes
719
+ ----------
720
+ fields : list[str]
721
+ Explicit model fields to include.
722
+ excludes : list[str]
723
+ Fields to force exclude (safety).
724
+ customs : list[tuple[str, type, Any]]
725
+ Computed / synthetic output attributes.
726
+ optionals : list[tuple[str, type]]
727
+ Optional output fields.
728
+ """
729
+
730
+ fields: list[str] = []
731
+ customs: list[tuple[str, type, Any]] = []
732
+ optionals: list[tuple[str, type]] = []
733
+ excludes: list[str] = []
734
+
662
735
  class UpdateSerializer:
663
736
  """Configuration describing update (PATCH/PUT) schema.
664
737
 
@@ -684,6 +757,7 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
684
757
  "create": "CreateSerializer",
685
758
  "update": "UpdateSerializer",
686
759
  "read": "ReadSerializer",
760
+ "detail": "DetailSerializer",
687
761
  }
688
762
 
689
763
  @classmethod
@@ -852,7 +926,7 @@ class SchemaModelConfig(Schema):
852
926
  customs: Optional[List[tuple[str, type, Any]]] = None
853
927
 
854
928
 
855
- class Serializer(BaseSerializer):
929
+ class Serializer(BaseSerializer, metaclass=SerializerMeta):
856
930
  """
857
931
  Serializer
858
932
  ----------
@@ -867,6 +941,7 @@ class Serializer(BaseSerializer):
867
941
  "create": "in",
868
942
  "update": "update",
869
943
  "read": "out",
944
+ "detail": "detail",
870
945
  }
871
946
 
872
947
  def __init_subclass__(cls, **kwargs):
@@ -884,6 +959,7 @@ class Serializer(BaseSerializer):
884
959
  schema_in: Optional[SchemaModelConfig] = None
885
960
  schema_out: Optional[SchemaModelConfig] = None
886
961
  schema_update: Optional[SchemaModelConfig] = None
962
+ schema_detail: Optional[SchemaModelConfig] = None
887
963
  relations_serializers: dict[str, "Serializer"] = {}
888
964
 
889
965
  @classmethod
@@ -908,6 +984,8 @@ class Serializer(BaseSerializer):
908
984
  return cls._get_meta_data("schema_out")
909
985
  case "update":
910
986
  return cls._get_meta_data("schema_update")
987
+ case "detail":
988
+ return cls._get_meta_data("schema_detail")
911
989
  case _:
912
990
  return None
913
991
 
@@ -1027,7 +1105,12 @@ class Serializer(BaseSerializer):
1027
1105
  dict
1028
1106
  Serialized data.
1029
1107
  """
1030
- return await self.util.read_s(schema=self.generate_read_s(), instance=instance)
1108
+ schema = (
1109
+ self.generate_read_s()
1110
+ if self.generate_detail_s() is None
1111
+ else self.generate_detail_s()
1112
+ )
1113
+ return await self.util.read_s(schema=schema, instance=instance)
1031
1114
 
1032
1115
  async def models_dump(
1033
1116
  self, instances: models.QuerySet[models.Model]
ninja_aio/models/utils.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
  import base64
3
- from typing import Any
3
+ from typing import Any, Literal
4
4
 
5
5
  from ninja import Schema
6
6
  from ninja.orm import fields
@@ -167,9 +167,19 @@ class ModelUtil:
167
167
  list[str]
168
168
  Explicit read fields if ModelSerializerMeta, otherwise all model fields.
169
169
  """
170
- if isinstance(self.model, ModelSerializerMeta):
171
- return self.model.get_fields("read")
172
- return self.model_fields
170
+ return self._get_serializable_field_names("read")
171
+
172
+ @property
173
+ def serializable_detail_fields(self):
174
+ """
175
+ List of fields considered serializable for detail operations.
176
+
177
+ Returns
178
+ -------
179
+ list[str]
180
+ Explicit detail fields if ModelSerializerMeta, otherwise all model fields.
181
+ """
182
+ return self._get_serializable_field_names("detail")
173
183
 
174
184
  @property
175
185
  def model_fields(self):
@@ -235,12 +245,29 @@ class ModelUtil:
235
245
  """
236
246
  return self.model_verbose_name_plural.replace(" ", "")
237
247
 
248
+ def _get_serializable_field_names(
249
+ self, fields_type: Literal["read", "detail"]
250
+ ) -> list[str]:
251
+ """
252
+ Get serializable field names for the model.
253
+
254
+ Returns
255
+ -------
256
+ list[str]
257
+ List of serializable field names.
258
+ """
259
+ if isinstance(self.model, ModelSerializerMeta):
260
+ return self.model.get_fields(fields_type)
261
+ if self.with_serializer:
262
+ return self.serializer_class.get_fields(fields_type)
263
+ return self.model_fields
264
+
238
265
  async def _get_base_queryset(
239
266
  self,
240
267
  request: HttpRequest,
241
268
  query_data: QuerySchema,
242
269
  with_qs_request: bool,
243
- is_for_read: bool,
270
+ is_for: Literal["read", "detail"] | None = None,
244
271
  ) -> models.QuerySet[type["ModelSerializer"] | models.Model]:
245
272
  """
246
273
  Build base queryset with optimizations and filters.
@@ -253,8 +280,9 @@ class ModelUtil:
253
280
  Query configuration with filters and optimizations.
254
281
  with_qs_request : bool
255
282
  Whether to apply queryset_request hook.
256
- is_for_read : bool
257
- Whether this is a read operation.
283
+ is_for : Literal["read", "detail"] | None
284
+ Purpose of the query, determines which serializable fields to use.
285
+ If None, only query_data optimizations are applied.
258
286
 
259
287
  Returns
260
288
  -------
@@ -269,7 +297,7 @@ class ModelUtil:
269
297
  )
270
298
 
271
299
  # Apply query optimizations
272
- obj_qs = self._apply_query_optimizations(obj_qs, query_data, is_for_read)
300
+ obj_qs = self._apply_query_optimizations(obj_qs, query_data, is_for)
273
301
 
274
302
  # Apply queryset_request hook if available
275
303
  if isinstance(self.model, ModelSerializerMeta) and with_qs_request:
@@ -286,7 +314,7 @@ class ModelUtil:
286
314
  request: HttpRequest,
287
315
  query_data: ObjectsQuerySchema = None,
288
316
  with_qs_request=True,
289
- is_for_read: bool = False,
317
+ is_for: Literal["read", "detail"] | None = None,
290
318
  ) -> models.QuerySet[type["ModelSerializer"] | models.Model]:
291
319
  """
292
320
  Retrieve a queryset with optimized database queries.
@@ -305,9 +333,9 @@ class ModelUtil:
305
333
  with_qs_request : bool, optional
306
334
  Whether to apply the model's queryset_request hook if available.
307
335
  Defaults to True.
308
- is_for_read : bool, optional
309
- Flag indicating if the query is for read operations, which may affect
310
- query optimization strategies. Defaults to False.
336
+ is_for : Literal["read", "detail"] | None, optional
337
+ Purpose of the query, determines which serializable fields to use.
338
+ If None, only query_data optimizations are applied.
311
339
 
312
340
  Returns
313
341
  -------
@@ -323,7 +351,7 @@ class ModelUtil:
323
351
  query_data = ObjectsQuerySchema()
324
352
 
325
353
  return await self._get_base_queryset(
326
- request, query_data, with_qs_request, is_for_read
354
+ request, query_data, with_qs_request, is_for
327
355
  )
328
356
 
329
357
  async def get_object(
@@ -332,7 +360,7 @@ class ModelUtil:
332
360
  pk: int | str = None,
333
361
  query_data: ObjectQuerySchema = None,
334
362
  with_qs_request=True,
335
- is_for_read: bool = False,
363
+ is_for: Literal["read", "detail"] | None = None,
336
364
  ) -> type["ModelSerializer"] | models.Model:
337
365
  """
338
366
  Retrieve a single object with optimized database queries.
@@ -353,9 +381,9 @@ class ModelUtil:
353
381
  with_qs_request : bool, optional
354
382
  Whether to apply the model's queryset_request hook if available.
355
383
  Defaults to True.
356
- is_for_read : bool, optional
357
- Flag indicating if the query is for read operations, which may affect
358
- query optimization strategies. Defaults to False.
384
+ is_for : Literal["read", "detail"] | None, optional
385
+ Purpose of the query, determines which serializable fields to use.
386
+ If None, only query_data optimizations are applied.
359
387
 
360
388
  Returns
361
389
  -------
@@ -385,7 +413,7 @@ class ModelUtil:
385
413
  # Build lookup query and get optimized queryset
386
414
  get_q = self._build_lookup_query(pk, query_data.getters)
387
415
  obj_qs = await self._get_base_queryset(
388
- request, query_data, with_qs_request, is_for_read
416
+ request, query_data, with_qs_request, is_for
389
417
  )
390
418
 
391
419
  # Perform lookup
@@ -421,7 +449,7 @@ class ModelUtil:
421
449
  self,
422
450
  queryset: models.QuerySet,
423
451
  query_data: QuerySchema,
424
- is_for_read: bool,
452
+ is_for: Literal["read", "detail"] | None = None,
425
453
  ) -> models.QuerySet:
426
454
  """
427
455
  Apply select_related and prefetch_related optimizations to queryset.
@@ -432,8 +460,9 @@ class ModelUtil:
432
460
  Base queryset to optimize.
433
461
  query_data : ModelQuerySchema
434
462
  Query configuration with select_related/prefetch_related lists.
435
- is_for_read : bool
436
- Whether to include model-level relation discovery.
463
+ is_for : Literal["read", "detail"] | None
464
+ Purpose of the query, determines which serializable fields to use.
465
+ If None, only query_data optimizations are applied.
437
466
 
438
467
  Returns
439
468
  -------
@@ -441,13 +470,13 @@ class ModelUtil:
441
470
  Optimized queryset.
442
471
  """
443
472
  select_related = (
444
- query_data.select_related + self.get_select_relateds()
445
- if is_for_read
473
+ query_data.select_related + self.get_select_relateds(is_for)
474
+ if is_for
446
475
  else query_data.select_related
447
476
  )
448
477
  prefetch_related = (
449
- query_data.prefetch_related + self.get_reverse_relations()
450
- if is_for_read
478
+ query_data.prefetch_related + self.get_reverse_relations(is_for)
479
+ if is_for
451
480
  else query_data.prefetch_related
452
481
  )
453
482
 
@@ -458,36 +487,52 @@ class ModelUtil:
458
487
 
459
488
  return queryset
460
489
 
461
- def _get_read_optimizations(self) -> ModelQuerySetSchema:
490
+ def _get_read_optimizations(
491
+ self, is_for: Literal["read", "detail"] = "read"
492
+ ) -> ModelQuerySetSchema:
462
493
  """
463
494
  Retrieve read optimizations from model or serializer class.
464
495
 
496
+ When is_for="detail" and no detail config exists, falls back to read config.
497
+
465
498
  Returns
466
499
  -------
467
500
  ModelQuerySetSchema
468
501
  Read optimization configuration.
469
502
  """
470
503
  if isinstance(self.model, ModelSerializerMeta):
471
- return getattr(self.model.QuerySet, "read", ModelQuerySetSchema())
504
+ result = getattr(self.model.QuerySet, is_for, None)
505
+ if result is None and is_for == "detail":
506
+ result = getattr(self.model.QuerySet, "read", None)
507
+ return result or ModelQuerySetSchema()
472
508
  if self.with_serializer:
473
- return getattr(
474
- self.serializer_class.QuerySet, "read", ModelQuerySetSchema()
475
- )
509
+ result = getattr(self.serializer_class.QuerySet, is_for, None)
510
+ if result is None and is_for == "detail":
511
+ result = getattr(self.serializer_class.QuerySet, "read", None)
512
+ return result or ModelQuerySetSchema()
476
513
  return ModelQuerySetSchema()
477
514
 
478
- def get_reverse_relations(self) -> list[str]:
515
+ def get_reverse_relations(
516
+ self, is_for: Literal["read", "detail"] = "read"
517
+ ) -> list[str]:
479
518
  """
480
519
  Discover reverse relation names for safe prefetching.
481
520
 
521
+ Parameters
522
+ ----------
523
+ is_for : Literal["read", "detail"]
524
+ Purpose of the query, determines which serializable fields to use.
525
+
482
526
  Returns
483
527
  -------
484
528
  list[str]
485
529
  Relation attribute names.
486
530
  """
487
- reverse_rels = self._get_read_optimizations().prefetch_related.copy()
531
+ reverse_rels = self._get_read_optimizations(is_for).prefetch_related.copy()
488
532
  if reverse_rels:
489
533
  return reverse_rels
490
- for f in self.serializable_fields:
534
+ serializable_fields = self._get_serializable_field_names(is_for)
535
+ for f in serializable_fields:
491
536
  field_obj = getattr(self.model, f)
492
537
  if isinstance(field_obj, ManyToManyDescriptor):
493
538
  reverse_rels.append(f)
@@ -499,19 +544,27 @@ class ModelUtil:
499
544
  reverse_rels.append(field_obj.related.name)
500
545
  return reverse_rels
501
546
 
502
- def get_select_relateds(self) -> list[str]:
547
+ def get_select_relateds(
548
+ self, is_for: Literal["read", "detail"] = "read"
549
+ ) -> list[str]:
503
550
  """
504
551
  Discover forward relation names for safe select_related.
505
552
 
553
+ Parameters
554
+ ----------
555
+ is_for : Literal["read", "detail"]
556
+ Purpose of the query, determines which serializable fields to use.
557
+
506
558
  Returns
507
559
  -------
508
560
  list[str]
509
561
  Relation attribute names.
510
562
  """
511
- select_rels = self._get_read_optimizations().select_related.copy()
563
+ select_rels = self._get_read_optimizations(is_for).select_related.copy()
512
564
  if select_rels:
513
565
  return select_rels
514
- for f in self.serializable_fields:
566
+ serializable_fields = self._get_serializable_field_names(is_for)
567
+ for f in serializable_fields:
515
568
  field_obj = getattr(self.model, f)
516
569
  if isinstance(field_obj, ForwardManyToOneDescriptor):
517
570
  select_rels.append(f)
@@ -577,17 +630,15 @@ class ModelUtil:
577
630
  request: HttpRequest,
578
631
  query_data: QuerySchema,
579
632
  schema: Schema,
580
- is_for_read: bool,
633
+ is_for: Literal["read", "detail"] | None = None,
581
634
  ):
582
635
  """Handle different query modes (filters vs getters)."""
583
636
  if hasattr(query_data, "filters") and query_data.filters:
584
- return await self._serialize_queryset(
585
- request, query_data, schema, is_for_read
586
- )
637
+ return await self._serialize_queryset(request, query_data, schema, is_for)
587
638
 
588
639
  if hasattr(query_data, "getters") and query_data.getters:
589
640
  return await self._serialize_single_object(
590
- request, query_data, schema, is_for_read
641
+ request, query_data, schema, is_for
591
642
  )
592
643
 
593
644
  raise SerializeError(
@@ -599,12 +650,10 @@ class ModelUtil:
599
650
  request: HttpRequest,
600
651
  query_data: QuerySchema,
601
652
  schema: Schema,
602
- is_for_read: bool,
653
+ is_for: Literal["read", "detail"] | None = None,
603
654
  ):
604
655
  """Serialize a queryset of objects."""
605
- objs = await self.get_objects(
606
- request, query_data=query_data, is_for_read=is_for_read
607
- )
656
+ objs = await self.get_objects(request, query_data=query_data, is_for=is_for)
608
657
  return [await self._bump_object_from_schema(obj, schema) async for obj in objs]
609
658
 
610
659
  async def _serialize_single_object(
@@ -612,12 +661,10 @@ class ModelUtil:
612
661
  request: HttpRequest,
613
662
  query_data: QuerySchema,
614
663
  obj_schema: Schema,
615
- is_for_read: bool,
664
+ is_for: Literal["read", "detail"] | None = None,
616
665
  ):
617
666
  """Serialize a single object."""
618
- obj = await self.get_object(
619
- request, query_data=query_data, is_for_read=is_for_read
620
- )
667
+ obj = await self.get_object(request, query_data=query_data, is_for=is_for)
621
668
  return await self._bump_object_from_schema(obj, obj_schema)
622
669
 
623
670
  async def parse_input_data(self, request: HttpRequest, data: Schema):
@@ -649,7 +696,9 @@ class ModelUtil:
649
696
  """
650
697
  payload = data.model_dump(mode="json")
651
698
 
652
- is_serializer = isinstance(self.model, ModelSerializerMeta) or self.with_serializer
699
+ is_serializer = (
700
+ isinstance(self.model, ModelSerializerMeta) or self.with_serializer
701
+ )
653
702
  serializer = self.serializer if self.with_serializer else self.model
654
703
 
655
704
  # Collect custom and optional fields (only if ModelSerializerMeta)
@@ -732,7 +781,7 @@ class ModelUtil:
732
781
  | type["ModelSerializer"]
733
782
  | models.Model = None,
734
783
  query_data: QuerySchema = None,
735
- is_for_read: bool = False,
784
+ is_for: Literal["read", "detail"] | None = None,
736
785
  ):
737
786
  """
738
787
  Internal serialization method handling both single instances and querysets.
@@ -747,8 +796,8 @@ class ModelUtil:
747
796
  Instance(s) to serialize. If None, fetches based on query_data.
748
797
  query_data : QuerySchema, optional
749
798
  Query parameters for fetching objects when instance is None.
750
- is_for_read : bool, optional
751
- Whether to apply read-specific query optimizations.
799
+ is_for : Literal["read", "detail"] | None, optional
800
+ Purpose of the query, determines which serializable fields to use.
752
801
 
753
802
  Returns
754
803
  -------
@@ -772,7 +821,7 @@ class ModelUtil:
772
821
  return await self._bump_object_from_schema(instance, schema)
773
822
 
774
823
  self._validate_read_params(request, query_data)
775
- return await self._handle_query_mode(request, query_data, schema, is_for_read)
824
+ return await self._handle_query_mode(request, query_data, schema, is_for)
776
825
 
777
826
  async def read_s(
778
827
  self,
@@ -780,7 +829,7 @@ class ModelUtil:
780
829
  request: HttpRequest = None,
781
830
  instance: type["ModelSerializer"] = None,
782
831
  query_data: ObjectQuerySchema = None,
783
- is_for_read: bool = False,
832
+ is_for: Literal["read", "detail"] | None = None,
784
833
  ) -> dict:
785
834
  """
786
835
  Serialize a single model instance or fetch and serialize using query parameters.
@@ -799,8 +848,9 @@ class ModelUtil:
799
848
  query_data : ObjectQuerySchema, optional
800
849
  Query parameters with getters for single object lookup.
801
850
  Required when instance is None.
802
- is_for_read : bool, optional
803
- Whether to apply read-specific query optimizations. Defaults to False.
851
+ is_for : Literal["read", "detail"] | None, optional
852
+ Purpose of the query, determines which serializable fields to use.
853
+ Defaults to None.
804
854
 
805
855
  Returns
806
856
  -------
@@ -820,14 +870,14 @@ class ModelUtil:
820
870
  -----
821
871
  - Uses Pydantic's from_orm() with mode="json" for serialization
822
872
  - When instance is provided, request and query_data are ignored
823
- - Query optimizations applied when is_for_read=True
873
+ - Query optimizations applied when is_for is specified
824
874
  """
825
875
  return await self._read_s(
826
876
  schema,
827
877
  request,
828
878
  instance,
829
879
  query_data,
830
- is_for_read,
880
+ is_for,
831
881
  )
832
882
 
833
883
  async def list_read_s(
@@ -836,7 +886,7 @@ class ModelUtil:
836
886
  request: HttpRequest = None,
837
887
  instances: models.QuerySet[type["ModelSerializer"] | models.Model] = None,
838
888
  query_data: ObjectsQuerySchema = None,
839
- is_for_read: bool = False,
889
+ is_for: Literal["read", "detail"] | None = None,
840
890
  ) -> list[dict]:
841
891
  """
842
892
  Serialize multiple model instances or fetch and serialize using query parameters.
@@ -855,8 +905,9 @@ class ModelUtil:
855
905
  query_data : ObjectsQuerySchema, optional
856
906
  Query parameters with filters for multiple object lookup.
857
907
  Required when instances is None.
858
- is_for_read : bool, optional
859
- Whether to apply read-specific query optimizations. Defaults to False.
908
+ is_for : Literal["read", "detail"] | None, optional
909
+ Purpose of the query, determines which serializable fields to use.
910
+ Defaults to None.
860
911
 
861
912
  Returns
862
913
  -------
@@ -874,7 +925,7 @@ class ModelUtil:
874
925
  -----
875
926
  - Uses Pydantic's from_orm() with mode="json" for serialization
876
927
  - When instances is provided, request and query_data are ignored
877
- - Query optimizations applied when is_for_read=True
928
+ - Query optimizations applied when is_for is specified
878
929
  - Processes queryset asynchronously for efficiency
879
930
  """
880
931
  return await self._read_s(
@@ -882,7 +933,7 @@ class ModelUtil:
882
933
  request,
883
934
  instances,
884
935
  query_data,
885
- is_for_read,
936
+ is_for,
886
937
  )
887
938
 
888
939
  async def update_s(
@@ -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
 
@@ -138,9 +162,12 @@ class QueryUtilBaseScopesSchema(BaseModel):
138
162
  Schema defining base scopes for query utilities.
139
163
  Attributes:
140
164
  READ (str): Scope for read operations.
165
+ DETAIL (str): Scope for detail operations.
141
166
  QUERYSET_REQUEST (str): Scope for queryset request operations.
142
167
  """
168
+
143
169
  READ: str = "read"
170
+ DETAIL: str = "detail"
144
171
  QUERYSET_REQUEST: str = "queryset_request"
145
172
 
146
173
 
@@ -163,6 +190,7 @@ class DecoratorsSchema(Schema):
163
190
  Consider initializing these in __init__ or using default_factory (if using pydantic/dataclasses)
164
191
  to avoid unintended side effects.
165
192
  """
193
+
166
194
  list: Optional[List] = []
167
195
  retrieve: Optional[List] = []
168
196
  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
@@ -448,35 +454,46 @@ class APIViewSet(API):
448
454
  qs = await self.model_util.get_objects(
449
455
  request,
450
456
  query_data=self._get_query_data(),
451
- is_for_read=True,
457
+ is_for="read",
452
458
  )
453
459
  if filters is not None:
454
460
  qs = await self.query_params_handler(qs, filters.model_dump())
455
- return await self.model_util.list_read_s(self.schema_out, request, qs)
461
+ return await self.model_util.list_read_s(
462
+ self.schema_out, request, qs, is_for="read"
463
+ )
456
464
 
457
465
  return list
458
466
 
467
+ def _get_retrieve_schema(self) -> Schema:
468
+ """
469
+ Return the schema to use for retrieve endpoint.
470
+ Uses schema_detail if available, otherwise falls back to schema_out.
471
+ """
472
+ return self.schema_detail or self.schema_out
473
+
459
474
  def retrieve_view(self):
460
475
  """
461
476
  Register retrieve endpoint.
462
477
  """
478
+ retrieve_schema = self._get_retrieve_schema()
463
479
 
464
480
  @self.router.get(
465
481
  self.get_path_retrieve,
466
482
  auth=self.get_view_auth(),
467
483
  summary=f"Retrieve {self.model_verbose_name}",
468
484
  description=self.retrieve_docs,
469
- response={200: self.schema_out, self.error_codes: GenericMessageSchema},
485
+ response={200: retrieve_schema, self.error_codes: GenericMessageSchema},
470
486
  )
471
487
  @decorate_view(unique_view(self), *self.extra_decorators.retrieve)
472
488
  async def retrieve(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
473
489
  query_data = self._get_query_data()
474
490
  return await self.model_util.read_s(
475
- self.schema_out,
491
+ retrieve_schema,
476
492
  request,
477
493
  query_data=QuerySchema(
478
494
  getters={"pk": self._get_pk(pk)}, **query_data.model_dump()
479
495
  ),
496
+ is_for="detail" if self.schema_detail else "read",
480
497
  )
481
498
 
482
499
  return retrieve