django-ninja-aio-crud 2.8.0__py3-none-any.whl → 2.10.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.8.0
3
+ Version: 2.10.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -1,4 +1,4 @@
1
- ninja_aio/__init__.py,sha256=s2uQ_vbFbWOcD6laB1hG40Mv67xvXbSehMTUbkWCcrA,119
1
+ ninja_aio/__init__.py,sha256=FZKyvJwguGusKjYpVUXSYB45Fssox9yEFHg47WX0BaE,120
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
@@ -12,18 +12,18 @@ ninja_aio/factory/__init__.py,sha256=IdH2z1ZZpv_IqonaDfVo7IsMzkgop6lHqz42RphUYBU
12
12
  ninja_aio/factory/operations.py,sha256=OgWGqq4WJ4arSQrH9FGAby9kx-HTdS7MOITxHdYMk18,12051
13
13
  ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  ninja_aio/helpers/api.py,sha256=2beyexep-ehgaA_1bV5Yuh3zRDVcRCMkrW94nmfDWEA,20819
15
- ninja_aio/helpers/query.py,sha256=lzaH-htswoJVRT-W736HGMkpMba1VmN98TBLv5cZx9Q,4549
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=uviKndPf9HRySup-0t_nULJs-vDLCeGFLtkkQGZvi2E,38142
18
- ninja_aio/models/utils.py,sha256=P-YfbVyzUfxm_s1BrgSd6Zs0HIGdZ79PU1qM0Ud9-Xs,30492
19
- ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
20
- ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
17
+ ninja_aio/models/serializers.py,sha256=I7pUz_vl0FElaVsrGaogT_Lj9T4uaNG7UDMGf5VMwW4,38468
18
+ ninja_aio/models/utils.py,sha256=dxzwqE25b0lFZ--30XxeIJB-y4xbIDtc624vGaa_wOg,32885
19
+ ninja_aio/schemas/__init__.py,sha256=_a092xZezlLc9QWCPWrybeklByCs39jbvf33zwdnrys,603
20
+ ninja_aio/schemas/api.py,sha256=InzZgIFU4Zxkgj9u_zZzAMYs_vdaPD5eu12gG7xAFoQ,1047
21
21
  ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
22
- ninja_aio/schemas/helpers.py,sha256=KkbDgT7DwvdeBHZ__wurQQ9A1AIy-toCIL9dCzkTFhM,8350
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=l7z-Cg_BUPRi3TAGpO2EppVA2tUAWUftQAn_qOn6XlM,21963
25
- ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
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,,
24
+ ninja_aio/views/api.py,sha256=CW74JB1tsdWfv3Y3x4K9o8yXsrMxCKWZ4QalhqJGan8,22072
25
+ ninja_aio/views/mixins.py,sha256=01kEGMmpD3eTd8R5mX8sNr7KoizOfN0wvbV5y180IdE,13835
26
+ django_ninja_aio_crud-2.10.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
27
+ django_ninja_aio_crud-2.10.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
28
+ django_ninja_aio_crud-2.10.0.dist-info/METADATA,sha256=-cSLMUj-s7djNL2RxRhLNkErv0yM49NK8CA8-zp-4zw,9964
29
+ django_ninja_aio_crud-2.10.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.8.0"
3
+ __version__ = "2.10.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -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."""
@@ -61,6 +61,7 @@ class BaseSerializer:
61
61
  """
62
62
 
63
63
  read = ModelQuerySetSchema()
64
+ detail = ModelQuerySetSchema()
64
65
  queryset_request = ModelQuerySetSchema()
65
66
  extras: list[ModelQuerySetExtraSchema] = []
66
67
 
@@ -325,14 +326,18 @@ class BaseSerializer:
325
326
  ]
326
327
 
327
328
  @classmethod
328
- def get_excluded_fields(cls, s_type: type[S_TYPES]):
329
+ def get_excluded_fields(cls, s_type: S_TYPES):
329
330
  """Return excluded field names for the serializer type."""
330
331
  return cls._get_fields(s_type, "excludes")
331
332
 
332
333
  @classmethod
333
- def get_fields(cls, s_type: type[S_TYPES]):
334
+ def get_fields(cls, s_type: S_TYPES):
334
335
  """Return explicit declared fields for the serializer type."""
335
- 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
336
341
 
337
342
  @classmethod
338
343
  def is_custom(cls, field: str) -> bool:
@@ -406,7 +411,11 @@ class BaseSerializer:
406
411
  """Check if field is a reverse relation (M2M, reverse FK, reverse O2O)."""
407
412
  return isinstance(
408
413
  field_obj,
409
- (ManyToManyDescriptor, ReverseManyToOneDescriptor, ReverseOneToOneDescriptor),
414
+ (
415
+ ManyToManyDescriptor,
416
+ ReverseManyToOneDescriptor,
417
+ ReverseOneToOneDescriptor,
418
+ ),
410
419
  )
411
420
 
412
421
  @classmethod
@@ -419,9 +428,8 @@ class BaseSerializer:
419
428
  @classmethod
420
429
  def _warn_missing_relation_serializer(cls, field_name: str, model) -> None:
421
430
  """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)
431
+ if not isinstance(model, ModelSerializerMeta) and not getattr(
432
+ settings, "NINJA_AIO_TESTING", False
425
433
  ):
426
434
  warnings.warn(
427
435
  f"{cls.__name__}: reverse relation '{field_name}' is listed in read fields "
@@ -469,7 +477,9 @@ class BaseSerializer:
469
477
  tuple: (fields, reverse_rels, excludes, customs_with_forward_rels, optionals)
470
478
  """
471
479
  if schema_type not in ("Out", "Detail"):
472
- raise ValueError("get_schema_out_data only supports 'Out' or 'Detail' types")
480
+ raise ValueError(
481
+ "get_schema_out_data only supports 'Out' or 'Detail' types"
482
+ )
473
483
 
474
484
  fields_type = "read" if schema_type == "Out" else "detail"
475
485
  model = cls._get_model()
@@ -602,7 +612,7 @@ class BaseSerializer:
602
612
  @classmethod
603
613
  def generate_detail_s(cls, depth: int = 1) -> Schema:
604
614
  """Generate detail (single object Out) schema."""
605
- return cls._generate_model_schema("Detail", depth)
615
+ return cls._generate_model_schema("Detail", depth) or cls.generate_read_s(depth)
606
616
 
607
617
  @classmethod
608
618
  def generate_create_s(cls) -> Schema:
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,24 +544,32 @@ 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
- if isinstance(field_obj, ForwardManyToOneDescriptor):
569
+ if isinstance(field_obj, ForwardOneToOneDescriptor):
517
570
  select_rels.append(f)
518
571
  continue
519
- if isinstance(field_obj, ForwardOneToOneDescriptor):
572
+ if isinstance(field_obj, ForwardManyToOneDescriptor):
520
573
  select_rels.append(f)
521
574
  return select_rels
522
575
 
@@ -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(
@@ -5,6 +5,7 @@ from .api import (
5
5
  M2MAddSchemaIn,
6
6
  M2MRemoveSchemaIn,
7
7
  M2MSchemaIn,
8
+ RelationFilterSchema,
8
9
  )
9
10
  from .helpers import M2MRelationSchema, QuerySchema, ModelQuerySetSchema, ObjectQuerySchema, ObjectsQuerySchema
10
11
 
@@ -20,4 +21,5 @@ __all__ = [
20
21
  "ModelQuerySetSchema",
21
22
  "ObjectQuerySchema",
22
23
  "ObjectsQuerySchema",
24
+ "RelationFilterSchema",
23
25
  ]
ninja_aio/schemas/api.py CHANGED
@@ -1,3 +1,5 @@
1
+ from typing import Any
2
+
1
3
  from ninja import Schema
2
4
 
3
5
 
@@ -21,4 +23,21 @@ class M2MRemoveSchemaIn(Schema):
21
23
 
22
24
  class M2MSchemaIn(Schema):
23
25
  add: list = []
24
- remove: list = []
26
+ remove: list = []
27
+
28
+
29
+ class RelationFilterSchema(Schema):
30
+ """
31
+ Schema for configuring relation-based filters in RelationFilterViewSetMixin.
32
+
33
+ Attributes:
34
+ filter_type: A tuple of (type, default_value) used to generate the query parameter
35
+ field in the filters schema. Example: (int, None) for optional integer filter.
36
+ query_param: The name of the query parameter exposed in the API endpoint.
37
+ This is what clients will use in requests (e.g., ?author_id=5).
38
+ query_filter: The Django ORM lookup to apply (e.g., "author__id", "category__slug").
39
+ """
40
+
41
+ filter_type: tuple[type, Any]
42
+ query_param: str
43
+ query_filter: str
@@ -162,10 +162,12 @@ class QueryUtilBaseScopesSchema(BaseModel):
162
162
  Schema defining base scopes for query utilities.
163
163
  Attributes:
164
164
  READ (str): Scope for read operations.
165
+ DETAIL (str): Scope for detail operations.
165
166
  QUERYSET_REQUEST (str): Scope for queryset request operations.
166
167
  """
167
168
 
168
169
  READ: str = "read"
170
+ DETAIL: str = "detail"
169
171
  QUERYSET_REQUEST: str = "queryset_request"
170
172
 
171
173
 
ninja_aio/views/api.py CHANGED
@@ -454,11 +454,13 @@ class APIViewSet(API):
454
454
  qs = await self.model_util.get_objects(
455
455
  request,
456
456
  query_data=self._get_query_data(),
457
- is_for_read=True,
457
+ is_for="read",
458
458
  )
459
459
  if filters is not None:
460
460
  qs = await self.query_params_handler(qs, filters.model_dump())
461
- 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
+ )
462
464
 
463
465
  return list
464
466
 
@@ -491,6 +493,7 @@ class APIViewSet(API):
491
493
  query_data=QuerySchema(
492
494
  getters={"pk": self._get_pk(pk)}, **query_data.model_dump()
493
495
  ),
496
+ is_for="detail" if self.schema_detail else "read",
494
497
  )
495
498
 
496
499
  return retrieve
ninja_aio/views/mixins.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from ninja_aio.views.api import APIViewSet
2
+ from ninja_aio.schemas import RelationFilterSchema
2
3
 
3
4
 
4
5
  class IcontainsFilterViewSetMixin(APIViewSet):
@@ -273,3 +274,65 @@ class LessEqualDateFilterViewSetMixin(DateFilterViewSetMixin):
273
274
  """
274
275
 
275
276
  _compare_attr = "__lte"
277
+
278
+
279
+ class RelationFilterViewSetMixin(APIViewSet):
280
+ """
281
+ Mixin providing filtering for related fields in Django QuerySets.
282
+
283
+ This mixin applies filters on related fields based on configured RelationFilterSchema
284
+ entries. Each entry maps a query parameter name to a Django ORM lookup path.
285
+
286
+ Attributes:
287
+ relations_filters: List of RelationFilterSchema defining the relation filters.
288
+ Each schema specifies:
289
+ - query_param: The API query parameter name (e.g., "author_id")
290
+ - query_filter: The Django ORM lookup (e.g., "author__id")
291
+ - filter_type: Tuple of (type, default) for schema generation
292
+
293
+ Example:
294
+ class BookViewSet(RelationFilterViewSetMixin, APIViewSet):
295
+ relations_filters = [
296
+ RelationFilterSchema(
297
+ query_param="author_id",
298
+ query_filter="author__id",
299
+ filter_type=(int, None),
300
+ ),
301
+ RelationFilterSchema(
302
+ query_param="category_slug",
303
+ query_filter="category__slug",
304
+ filter_type=(str, None),
305
+ ),
306
+ ]
307
+
308
+ # GET /books?author_id=5 -> queryset.filter(author__id=5)
309
+ # GET /books?category_slug=fiction -> queryset.filter(category__slug="fiction")
310
+
311
+ Notes:
312
+ - Filter values that are None or falsy are skipped.
313
+ - This mixin automatically registers query_params from relations_filters.
314
+ """
315
+
316
+ relations_filters: list[RelationFilterSchema] = []
317
+
318
+ def __init_subclass__(cls, **kwargs):
319
+ super().__init_subclass__(**kwargs)
320
+ cls.query_params = {
321
+ **cls.query_params,
322
+ **{
323
+ rel_filter.query_param: rel_filter.filter_type
324
+ for rel_filter in cls.relations_filters
325
+ },
326
+ }
327
+
328
+ async def query_params_handler(self, queryset, filters):
329
+ """
330
+ Apply relation filters to the queryset based on configured relations_filters.
331
+ """
332
+ base_qs = await super().query_params_handler(queryset, filters)
333
+ rel_filters = {}
334
+ for rel_filter in self.relations_filters:
335
+ value = filters.get(rel_filter.query_param)
336
+ if value is not None:
337
+ rel_filters[rel_filter.query_filter] = value
338
+ return base_qs.filter(**rel_filters) if rel_filters else base_qs