django-ninja-aio-crud 2.7.0__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.7.0
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=FkPurmyPz8Yv7VAiIOZYbFEghPXZ7aeLYX5xBwFSMtk,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=AlZIkwIiOt-nGBfMDoXu9tJotfzH5L13fv3Rw_4sKUg,35697
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.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.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.7.0"
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, 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,
@@ -98,6 +104,7 @@ class BaseSerializer:
98
104
  module = sys.modules.get(module_path)
99
105
  if module is None:
100
106
  import importlib
107
+
101
108
  module = importlib.import_module(module_path)
102
109
 
103
110
  # Get the serializer class from the module
@@ -136,7 +143,9 @@ class BaseSerializer:
136
143
  return serializer_class
137
144
 
138
145
  @classmethod
139
- def _resolve_serializer_reference(cls, serializer_ref: str | type | Any) -> type | Any:
146
+ def _resolve_serializer_reference(
147
+ cls, serializer_ref: str | type | Any
148
+ ) -> type | Any:
140
149
  """
141
150
  Resolve a serializer reference that may be a string, a class, or a Union of serializers.
142
151
 
@@ -217,11 +226,11 @@ class BaseSerializer:
217
226
  Schema | Union[Schema, ...] | None
218
227
  Union of generated schemas or None if all schemas are None.
219
228
  """
220
- # Generate schemas for each serializer in the Union
229
+ # Generate schemas for each serializer in the Union (single call per serializer)
221
230
  schemas = tuple(
222
- serializer_type.generate_related_s()
231
+ schema
223
232
  for serializer_type in get_args(resolved_union)
224
- if serializer_type.generate_related_s() is not None
233
+ if (schema := serializer_type.generate_related_s()) is not None
225
234
  )
226
235
 
227
236
  if not schemas:
@@ -393,72 +402,100 @@ class BaseSerializer:
393
402
  return (field_name, schema | None, None)
394
403
 
395
404
  @classmethod
396
- 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"):
397
465
  """
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.
466
+ Collect components for output schema generation (Out or Detail).
467
+
468
+ Returns:
469
+ tuple: (fields, reverse_rels, excludes, customs_with_forward_rels, optionals)
401
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
+
402
478
  fields: list[str] = []
403
479
  reverse_rels: list[tuple] = []
404
- rels: list[tuple] = []
405
- relations_serializers = cls._get_relations_serializers() or {}
406
- model = cls._get_model()
480
+ forward_rels: list[tuple] = []
407
481
 
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
- ),
482
+ for field_name in cls.get_fields(fields_type):
483
+ plain, reverse, forward = cls._process_field(
484
+ field_name, model, relations_serializers
417
485
  )
418
- is_forward = isinstance(
419
- field_obj, (ForwardOneToOneDescriptor, ForwardManyToOneDescriptor)
420
- )
421
-
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
- )
435
-
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)
486
+ if plain:
487
+ fields.append(plain)
488
+ if reverse:
489
+ reverse_rels.append(reverse)
490
+ if forward:
491
+ forward_rels.append(forward)
455
492
 
456
493
  return (
457
494
  fields,
458
495
  reverse_rels,
459
- cls.get_excluded_fields("read"),
460
- cls.get_custom_fields("read") + rels,
461
- 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),
462
499
  )
463
500
 
464
501
  @classmethod
@@ -474,15 +511,16 @@ class BaseSerializer:
474
511
  model = cls._get_model()
475
512
 
476
513
  # Handle special schema types with custom logic
477
- if schema_type == "Out":
514
+ if schema_type == "Out" or schema_type == "Detail":
478
515
  fields, reverse_rels, excludes, customs, optionals = (
479
- cls.get_schema_out_data()
516
+ cls.get_schema_out_data(schema_type)
480
517
  )
481
518
  if not any([fields, reverse_rels, excludes, customs]):
482
519
  return None
520
+ schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
483
521
  return create_schema(
484
522
  model=model,
485
- name=f"{model._meta.model_name}SchemaOut",
523
+ name=f"{model._meta.model_name}{schema_name}",
486
524
  depth=depth,
487
525
  fields=fields,
488
526
  custom_fields=reverse_rels + customs + optionals,
@@ -561,6 +599,11 @@ class BaseSerializer:
561
599
  """Generate read (Out) schema."""
562
600
  return cls._generate_model_schema("Out", depth)
563
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
+
564
607
  @classmethod
565
608
  def generate_create_s(cls) -> Schema:
566
609
  """Generate create (In) schema."""
@@ -659,6 +702,26 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
659
702
  optionals: list[tuple[str, type]] = []
660
703
  excludes: list[str] = []
661
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
+
662
725
  class UpdateSerializer:
663
726
  """Configuration describing update (PATCH/PUT) schema.
664
727
 
@@ -684,6 +747,7 @@ class ModelSerializer(models.Model, BaseSerializer, metaclass=ModelSerializerMet
684
747
  "create": "CreateSerializer",
685
748
  "update": "UpdateSerializer",
686
749
  "read": "ReadSerializer",
750
+ "detail": "DetailSerializer",
687
751
  }
688
752
 
689
753
  @classmethod
@@ -852,7 +916,7 @@ class SchemaModelConfig(Schema):
852
916
  customs: Optional[List[tuple[str, type, Any]]] = None
853
917
 
854
918
 
855
- class Serializer(BaseSerializer):
919
+ class Serializer(BaseSerializer, metaclass=SerializerMeta):
856
920
  """
857
921
  Serializer
858
922
  ----------
@@ -867,6 +931,7 @@ class Serializer(BaseSerializer):
867
931
  "create": "in",
868
932
  "update": "update",
869
933
  "read": "out",
934
+ "detail": "detail",
870
935
  }
871
936
 
872
937
  def __init_subclass__(cls, **kwargs):
@@ -884,6 +949,7 @@ class Serializer(BaseSerializer):
884
949
  schema_in: Optional[SchemaModelConfig] = None
885
950
  schema_out: Optional[SchemaModelConfig] = None
886
951
  schema_update: Optional[SchemaModelConfig] = None
952
+ schema_detail: Optional[SchemaModelConfig] = None
887
953
  relations_serializers: dict[str, "Serializer"] = {}
888
954
 
889
955
  @classmethod
@@ -908,6 +974,8 @@ class Serializer(BaseSerializer):
908
974
  return cls._get_meta_data("schema_out")
909
975
  case "update":
910
976
  return cls._get_meta_data("schema_update")
977
+ case "detail":
978
+ return cls._get_meta_data("schema_detail")
911
979
  case _:
912
980
  return None
913
981
 
@@ -1027,7 +1095,12 @@ class Serializer(BaseSerializer):
1027
1095
  dict
1028
1096
  Serialized data.
1029
1097
  """
1030
- 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)
1031
1104
 
1032
1105
  async def models_dump(
1033
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()