django-ninja-aio-crud 2.18.0__py3-none-any.whl → 2.18.1__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.18.0
3
+ Version: 2.18.1
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
6
  Requires-Python: >=3.10, <3.15
@@ -34,11 +34,9 @@ Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
34
34
  Provides-Extra: test
35
35
 
36
36
  <p align="center">
37
- <img src="https://raw.githubusercontent.com/caspel26/django-ninja-aio-crud/main/docs/images/logo.png" alt="django-ninja-aio-crud" width="120">
37
+ <img src="https://raw.githubusercontent.com/caspel26/django-ninja-aio-crud/main/docs/images/logo-full.png" alt="django-ninja-aio-crud">
38
38
  </p>
39
39
 
40
- <h1 align="center">django-ninja-aio-crud</h1>
41
-
42
40
  <p align="center">
43
41
  <strong>Async CRUD framework for Django Ninja</strong><br>
44
42
  Automatic schema generation · Filtering · Pagination · Auth · M2M management
@@ -51,11 +49,13 @@ Provides-Extra: test
51
49
  <a href="https://pypi.org/project/django-ninja-aio-crud/"><img src="https://img.shields.io/pypi/v/django-ninja-aio-crud?color=g&logo=pypi&logoColor=white" alt="PyPI - Version"></a>
52
50
  <a href="LICENSE"><img src="https://img.shields.io/pypi/l/django-ninja-aio-crud" alt="PyPI - License"></a>
53
51
  <a href="https://github.com/astral-sh/ruff"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
52
+ <a href="https://github.com/caspel26/django-ninja-aio-crud/actions/workflows/performance.yml"><img src="https://github.com/caspel26/django-ninja-aio-crud/actions/workflows/performance.yml/badge.svg" alt="Performance"></a>
54
53
  </p>
55
54
 
56
55
  <p align="center">
57
56
  <a href="https://django-ninja-aio.com">Documentation</a> ·
58
57
  <a href="https://pypi.org/project/django-ninja-aio-crud/">PyPI</a> ·
58
+ <a href="https://caspel26.github.io/django-ninja-aio-crud/">Performance Benchmarks</a> ·
59
59
  <a href="https://github.com/caspel26/ninja-aio-blog-example">Example Project</a> ·
60
60
  <a href="https://github.com/caspel26/django-ninja-aio-crud/issues">Issues</a>
61
61
  </p>
@@ -392,7 +392,13 @@ class ReadOnlyBookViewSet(APIViewSet):
392
392
 
393
393
  ---
394
394
 
395
- ## Performance Tips
395
+ ## Performance
396
+
397
+ View live benchmarks tracking schema generation, serialization, and CRUD throughput:
398
+
399
+ **[Live Performance Report](https://caspel26.github.io/django-ninja-aio-crud/)** — Interactive charts with historical trends
400
+
401
+ ### Performance Tips
396
402
 
397
403
  - Use `queryset_request` classmethod to `select_related` / `prefetch_related`
398
404
  - Index frequently filtered fields
@@ -1,10 +1,10 @@
1
- ninja_aio/__init__.py,sha256=CezrF0WOFMCLNAybIgP0uAtNTsUMEiOezULFLq3iluY,120
1
+ ninja_aio/__init__.py,sha256=0d83rifGl3AtqjPqmhu8uQRV_EBq6JoUIWpkaU_OSaQ,120
2
2
  ninja_aio/api.py,sha256=tuC7vdvn7s1GkCnSFy9Kn1zv0glZfYptRQVvo8ZRtGQ,2429
3
3
  ninja_aio/auth.py,sha256=f4yk45fLi36Qctu0A0zgHTFedb9yk3ewq5rOMpoPYIE,9035
4
4
  ninja_aio/exceptions.py,sha256=w8QWmVlg88iJvBrNODSDBHSsy8nNpwngaCGWRnkXoPo,3899
5
5
  ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
6
6
  ninja_aio/renders.py,sha256=CW0xDa05Xna-UvL0MZqZeDEgueEaUassV_nG7Rh1-cw,1824
7
- ninja_aio/types.py,sha256=E3yfXbNKkvLVcr8bvkHTSyIiCRZ4zumzJaXj1aiBi5U,735
7
+ ninja_aio/types.py,sha256=fSDTLLraQeZ9-bpoWSQcV5WN8vy2v4Te48D5dnnW25M,1441
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
@@ -14,17 +14,17 @@ ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU
14
14
  ninja_aio/helpers/api.py,sha256=va_HvZVBFm1KxwQhH4u09U4F1JS5JrQuRpRmPTHJt7w,21326
15
15
  ninja_aio/helpers/query.py,sha256=Lqv4nrWYr543tC5K-SEcBottLID8cb83aDc26i2Wxj4,5053
16
16
  ninja_aio/models/__init__.py,sha256=L3UQnQAlKoI3F7jinadL-Nn55hkPvnSRPYW0JtnbWFo,114
17
- ninja_aio/models/serializers.py,sha256=Obw6HCLMU9iTvAtuoGFXB2gtRELtR3g1nhn23U9McrM,60376
18
- ninja_aio/models/utils.py,sha256=lAXtc3YY7_n4f0jIacX4DSXhUOzMy7y5MsBnInNxtfk,32874
17
+ ninja_aio/models/serializers.py,sha256=pRRa0ci8ObhmJoQkzreDxV0JA6elG0Tyj8mJutRDqpo,64021
18
+ ninja_aio/models/utils.py,sha256=iZ2pmtREtTC9G1isJbHTME-PzgI_BW7r6RBxvnlQJBw,39940
19
19
  ninja_aio/schemas/__init__.py,sha256=dHILiYBKMb51lDcyQdiXRw_0nzqM7Lu81UX2hv7kEfo,837
20
20
  ninja_aio/schemas/api.py,sha256=dGUpJXR1iAf93QNR4kYj1uqIkTjiMfXultCotY6GtaQ,361
21
21
  ninja_aio/schemas/filters.py,sha256=VxzH2xSWok8cUSkyfeqtrGhRewtFVmNHQfHNvY8Aynw,2662
22
22
  ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
23
23
  ninja_aio/schemas/helpers.py,sha256=CpubwNXsZHtu8jddliyQybF1epwZ-GO60vHIuF5AR1Y,8967
24
24
  ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
25
- ninja_aio/views/api.py,sha256=AAqkj0xT8J3PmJvsbluZ33cfrmrXJHiV9ARe2BqnfQ8,22492
26
- ninja_aio/views/mixins.py,sha256=Zl6J8gbVagwT85bzDuKyJTk3iFxxFgX0YgYkjiUxZGg,17040
27
- django_ninja_aio_crud-2.18.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
28
- django_ninja_aio_crud-2.18.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
29
- django_ninja_aio_crud-2.18.0.dist-info/METADATA,sha256=2R1JT9MbqKfuAb05If2Ic1U-Cx6l_uTcXLPKHIyod5Q,12911
30
- django_ninja_aio_crud-2.18.0.dist-info/RECORD,,
25
+ ninja_aio/views/api.py,sha256=Sj4yIVLVQEVKxwFzVbT6YhiSCxXtcvdlTtWhJfccOus,26191
26
+ ninja_aio/views/mixins.py,sha256=rE4otyuenx6bfOLmnvMiSn10Kh7p0newU0-HarWDWS4,17779
27
+ django_ninja_aio_crud-2.18.1.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
28
+ django_ninja_aio_crud-2.18.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
29
+ django_ninja_aio_crud-2.18.1.dist-info/METADATA,sha256=vMfBlBuF8vq6dNp2ciGcjLSjMM5Gx0I7pqNM2dtGs7I,13404
30
+ django_ninja_aio_crud-2.18.1.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.18.0"
3
+ __version__ = "2.18.1"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -11,6 +11,8 @@ from typing import (
11
11
  )
12
12
  import warnings
13
13
  import sys
14
+ import threading
15
+ from functools import lru_cache
14
16
 
15
17
  from django.conf import settings
16
18
  from ninja import Schema
@@ -103,6 +105,56 @@ class BaseSerializer:
103
105
  queryset_request = ModelQuerySetSchema()
104
106
  extras: list[ModelQuerySetExtraSchema] = []
105
107
 
108
+ # Thread-local storage for tracking circular reference resolution
109
+ _resolution_context = threading.local()
110
+
111
+ @classmethod
112
+ def _get_resolution_stack(cls) -> list[str]:
113
+ """
114
+ Get the current resolution stack for detecting circular references.
115
+
116
+ Returns
117
+ -------
118
+ list[str]
119
+ Stack of model names currently being resolved (thread-safe).
120
+ """
121
+ if not hasattr(cls._resolution_context, 'stack'):
122
+ cls._resolution_context.stack = []
123
+ return cls._resolution_context.stack
124
+
125
+ @classmethod
126
+ def _is_circular_reference(cls, model: models.Model) -> bool:
127
+ """
128
+ Check if resolving this model would create a circular reference.
129
+
130
+ Security: Prevents infinite recursion and stack overflow attacks.
131
+
132
+ Parameters
133
+ ----------
134
+ model : models.Model
135
+ The model to check
136
+
137
+ Returns
138
+ -------
139
+ bool
140
+ True if the model is already being resolved (circular reference detected)
141
+ """
142
+ model_key = f"{model._meta.app_label}.{model._meta.model_name}"
143
+ return model_key in cls._get_resolution_stack()
144
+
145
+ @classmethod
146
+ def _push_resolution(cls, model: models.Model) -> None:
147
+ """Add a model to the resolution stack."""
148
+ model_key = f"{model._meta.app_label}.{model._meta.model_name}"
149
+ cls._get_resolution_stack().append(model_key)
150
+
151
+ @classmethod
152
+ def _pop_resolution(cls) -> None:
153
+ """Remove the most recent model from the resolution stack."""
154
+ stack = cls._get_resolution_stack()
155
+ if stack:
156
+ stack.pop()
157
+
106
158
  @classmethod
107
159
  def _collect_validators(cls, source_class) -> dict:
108
160
  """
@@ -425,29 +477,51 @@ class BaseSerializer:
425
477
  -------
426
478
  Schema | Union[Schema, ...] | None
427
479
  Generated schema, Union of schemas, or None if cannot be resolved.
428
- """
429
- # Auto-resolve ModelSerializer with readable fields
430
- if isinstance(rel_model, ModelSerializerMeta):
431
- has_readable_fields = rel_model.get_fields(
432
- "read"
433
- ) or rel_model.get_custom_fields("read")
434
- return rel_model.generate_related_s() if has_readable_fields else None
435
-
436
- # Resolve from explicit serializer mapping
437
- rel_serializers = cls._get_relations_serializers() or {}
438
- serializer_ref = rel_serializers.get(field_name)
439
480
 
440
- if not serializer_ref:
481
+ Notes
482
+ -----
483
+ Includes circular reference detection to prevent infinite recursion.
484
+ """
485
+ # Security: Check for circular references to prevent infinite recursion
486
+ if cls._is_circular_reference(rel_model):
487
+ # Circular reference detected - return None to break the cycle
488
+ warnings.warn(
489
+ f"Circular reference detected for {rel_model._meta.label} "
490
+ f"in field '{field_name}' of {cls._get_model()._meta.label}. "
491
+ f"Skipping nested schema generation to prevent infinite recursion.",
492
+ UserWarning,
493
+ stacklevel=2
494
+ )
441
495
  return None
442
496
 
443
- resolved = cls._resolve_serializer_reference(serializer_ref)
497
+ # Track this model in the resolution stack
498
+ cls._push_resolution(rel_model)
499
+ try:
500
+ # Auto-resolve ModelSerializer with readable fields
501
+ if isinstance(rel_model, ModelSerializerMeta):
502
+ has_readable_fields = rel_model.get_fields(
503
+ "read"
504
+ ) or rel_model.get_custom_fields("read")
505
+ return rel_model.generate_related_s() if has_readable_fields else None
506
+
507
+ # Resolve from explicit serializer mapping
508
+ rel_serializers = cls._get_relations_serializers() or {}
509
+ serializer_ref = rel_serializers.get(field_name)
510
+
511
+ if not serializer_ref:
512
+ return None
513
+
514
+ resolved = cls._resolve_serializer_reference(serializer_ref)
444
515
 
445
- # Handle Union of serializers
446
- if get_origin(resolved) is Union:
447
- return cls._generate_union_schema(resolved)
516
+ # Handle Union of serializers
517
+ if get_origin(resolved) is Union:
518
+ return cls._generate_union_schema(resolved)
448
519
 
449
- # Handle single serializer
450
- return resolved.generate_related_s()
520
+ # Handle single serializer
521
+ return resolved.generate_related_s()
522
+ finally:
523
+ # Always pop from resolution stack when done
524
+ cls._pop_resolution()
451
525
 
452
526
  @classmethod
453
527
  def _is_special_field(
@@ -928,64 +1002,45 @@ class BaseSerializer:
928
1002
  )
929
1003
 
930
1004
  @classmethod
931
- def _generate_model_schema(
932
- cls,
933
- schema_type: type[SCHEMA_TYPES],
934
- depth: int = None,
935
- ) -> Schema:
936
- """
937
- Core schema factory bridging serializer configuration to ``ninja.orm.create_schema``.
938
-
939
- Dispatches to the appropriate field/custom/exclude gathering logic based
940
- on the requested schema type and delegates to Django Ninja's
941
- ``create_schema`` for the actual Pydantic model construction.
942
-
943
- Parameters
944
- ----------
945
- schema_type : SCHEMA_TYPES
946
- One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
947
- depth : int, optional
948
- Nesting depth for related model schemas (used by ``Out`` and ``Detail``).
949
-
950
- Returns
951
- -------
952
- Schema | None
953
- Generated Pydantic schema, or ``None`` if no fields are configured.
954
- """
955
- model = cls._get_model()
956
- validators = cls._get_validators(schema_type)
957
-
958
- # Handle special schema types with custom logic
959
- if schema_type == "Out" or schema_type == "Detail":
960
- fields, reverse_rels, excludes, customs, optionals = (
961
- cls.get_schema_out_data(schema_type)
962
- )
963
- if not any([fields, reverse_rels, excludes, customs]):
964
- return None
965
- schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
966
- schema = create_schema(
967
- model=model,
968
- name=f"{model._meta.model_name}{schema_name}",
969
- depth=depth,
970
- fields=fields,
971
- custom_fields=reverse_rels + customs + optionals,
972
- exclude=excludes,
973
- )
974
- return cls._apply_validators(schema, validators)
1005
+ def _create_out_or_detail_schema(
1006
+ cls, schema_type: type[SCHEMA_TYPES], model, validators, depth: int = None
1007
+ ) -> Schema | None:
1008
+ """Create schema for Out or Detail types."""
1009
+ fields, reverse_rels, excludes, customs, optionals = (
1010
+ cls.get_schema_out_data(schema_type)
1011
+ )
1012
+ if not any([fields, reverse_rels, excludes, customs]):
1013
+ return None
1014
+ schema_name = "SchemaOut" if schema_type == "Out" else "DetailSchemaOut"
1015
+ schema = create_schema(
1016
+ model=model,
1017
+ name=f"{model._meta.model_name}{schema_name}",
1018
+ depth=depth,
1019
+ fields=fields,
1020
+ custom_fields=reverse_rels + customs + optionals,
1021
+ exclude=excludes,
1022
+ )
1023
+ return cls._apply_validators(schema, validators)
975
1024
 
976
- if schema_type == "Related":
977
- fields, customs = cls.get_related_schema_data()
978
- if not fields and not customs:
979
- return None
980
- schema = create_schema(
981
- model=model,
982
- name=f"{model._meta.model_name}SchemaRelated",
983
- fields=fields,
984
- custom_fields=customs,
985
- )
986
- return cls._apply_validators(schema, validators)
1025
+ @classmethod
1026
+ def _create_related_schema(cls, model, validators) -> Schema | None:
1027
+ """Create schema for Related type."""
1028
+ fields, customs = cls.get_related_schema_data()
1029
+ if not fields and not customs:
1030
+ return None
1031
+ schema = create_schema(
1032
+ model=model,
1033
+ name=f"{model._meta.model_name}SchemaRelated",
1034
+ fields=fields,
1035
+ custom_fields=customs,
1036
+ )
1037
+ return cls._apply_validators(schema, validators)
987
1038
 
988
- # Handle standard In/Patch schema types
1039
+ @classmethod
1040
+ def _create_in_or_patch_schema(
1041
+ cls, schema_type: type[SCHEMA_TYPES], model, validators
1042
+ ) -> Schema | None:
1043
+ """Create schema for In or Patch types."""
989
1044
  s_type = "create" if schema_type == "In" else "update"
990
1045
  fields = cls.get_fields(s_type)
991
1046
  optionals = cls.get_optional_fields(s_type)
@@ -1020,6 +1075,42 @@ class BaseSerializer:
1020
1075
  )
1021
1076
  return cls._apply_validators(schema, validators)
1022
1077
 
1078
+ @classmethod
1079
+ def _generate_model_schema(
1080
+ cls,
1081
+ schema_type: type[SCHEMA_TYPES],
1082
+ depth: int = None,
1083
+ ) -> Schema:
1084
+ """
1085
+ Core schema factory bridging serializer configuration to ``ninja.orm.create_schema``.
1086
+
1087
+ Dispatches to the appropriate field/custom/exclude gathering logic based
1088
+ on the requested schema type and delegates to Django Ninja's
1089
+ ``create_schema`` for the actual Pydantic model construction.
1090
+
1091
+ Parameters
1092
+ ----------
1093
+ schema_type : SCHEMA_TYPES
1094
+ One of ``"In"``, ``"Patch"``, ``"Out"``, ``"Detail"``, or ``"Related"``.
1095
+ depth : int, optional
1096
+ Nesting depth for related model schemas (used by ``Out`` and ``Detail``).
1097
+
1098
+ Returns
1099
+ -------
1100
+ Schema | None
1101
+ Generated Pydantic schema, or ``None`` if no fields are configured.
1102
+ """
1103
+ model = cls._get_model()
1104
+ validators = cls._get_validators(schema_type)
1105
+
1106
+ if schema_type in ("Out", "Detail"):
1107
+ return cls._create_out_or_detail_schema(schema_type, model, validators, depth)
1108
+
1109
+ if schema_type == "Related":
1110
+ return cls._create_related_schema(model, validators)
1111
+
1112
+ return cls._create_in_or_patch_schema(schema_type, model, validators)
1113
+
1023
1114
  @classmethod
1024
1115
  def get_related_schema_data(cls):
1025
1116
  """
@@ -1055,10 +1146,13 @@ class BaseSerializer:
1055
1146
  return non_relation_fields, customs
1056
1147
 
1057
1148
  @classmethod
1149
+ @lru_cache(maxsize=128)
1058
1150
  def generate_read_s(cls, depth: int = 1) -> Schema:
1059
1151
  """
1060
1152
  Generate the read (Out) schema for list responses.
1061
1153
 
1154
+ Performance: Results are cached per (class, depth) combination.
1155
+
1062
1156
  Parameters
1063
1157
  ----------
1064
1158
  depth : int, optional
@@ -1072,6 +1166,7 @@ class BaseSerializer:
1072
1166
  return cls._generate_model_schema("Out", depth)
1073
1167
 
1074
1168
  @classmethod
1169
+ @lru_cache(maxsize=128)
1075
1170
  def generate_detail_s(cls, depth: int = 1) -> Schema:
1076
1171
  """
1077
1172
  Generate the detail (single-object) read schema.
@@ -1079,6 +1174,8 @@ class BaseSerializer:
1079
1174
  Falls back to the standard read schema if no detail-specific
1080
1175
  configuration is defined.
1081
1176
 
1177
+ Performance: Results are cached per (class, depth) combination.
1178
+
1082
1179
  Parameters
1083
1180
  ----------
1084
1181
  depth : int, optional
@@ -1092,10 +1189,13 @@ class BaseSerializer:
1092
1189
  return cls._generate_model_schema("Detail", depth) or cls.generate_read_s(depth)
1093
1190
 
1094
1191
  @classmethod
1192
+ @lru_cache(maxsize=128)
1095
1193
  def generate_create_s(cls) -> Schema:
1096
1194
  """
1097
1195
  Generate the create (In) schema for input validation.
1098
1196
 
1197
+ Performance: Results are cached per class.
1198
+
1099
1199
  Returns
1100
1200
  -------
1101
1201
  Schema | None
@@ -1104,10 +1204,13 @@ class BaseSerializer:
1104
1204
  return cls._generate_model_schema("In")
1105
1205
 
1106
1206
  @classmethod
1207
+ @lru_cache(maxsize=128)
1107
1208
  def generate_update_s(cls) -> Schema:
1108
1209
  """
1109
1210
  Generate the update (Patch) schema for partial updates.
1110
1211
 
1212
+ Performance: Results are cached per class.
1213
+
1111
1214
  Returns
1112
1215
  -------
1113
1216
  Schema | None
@@ -1116,6 +1219,7 @@ class BaseSerializer:
1116
1219
  return cls._generate_model_schema("Patch")
1117
1220
 
1118
1221
  @classmethod
1222
+ @lru_cache(maxsize=128)
1119
1223
  def generate_related_s(cls) -> Schema:
1120
1224
  """
1121
1225
  Generate the related (nested) schema for embedding in parent schemas.
@@ -1123,6 +1227,8 @@ class BaseSerializer:
1123
1227
  Includes only non-relational model fields and custom fields, preventing
1124
1228
  infinite nesting of related objects.
1125
1229
 
1230
+ Performance: Results are cached per class.
1231
+
1126
1232
  Returns
1127
1233
  -------
1128
1234
  Schema | None
ninja_aio/models/utils.py CHANGED
@@ -94,6 +94,9 @@ class ModelUtil:
94
94
  - Stateless wrapper; safe per-request instantiation.
95
95
  """
96
96
 
97
+ # Performance: Class-level cache for relation discovery (model structure is static)
98
+ _relation_cache: dict[tuple[type, str, str], list[str]] = {}
99
+
97
100
  def __init__(
98
101
  self, model: type["ModelSerializer"] | models.Model, serializer_class=None
99
102
  ):
@@ -192,6 +195,47 @@ class ModelUtil:
192
195
  """
193
196
  return [field.name for field in self.model._meta.get_fields()]
194
197
 
198
+ def get_valid_input_fields(
199
+ self, is_serializer: bool, serializer: "ModelSerializer | None" = None
200
+ ) -> set[str]:
201
+ """
202
+ Get allowlist of valid field names for input validation.
203
+
204
+ Security: Prevents field injection by returning only fields that should
205
+ be accepted from user input.
206
+
207
+ Parameters
208
+ ----------
209
+ is_serializer : bool
210
+ Whether using a ModelSerializer
211
+ serializer : ModelSerializer, optional
212
+ Serializer instance if applicable
213
+
214
+ Returns
215
+ -------
216
+ set[str]
217
+ Set of valid field names that can be accepted in input payloads
218
+ """
219
+ valid_fields = set(self.model_fields)
220
+
221
+ # If using a serializer, also include custom fields
222
+ if is_serializer and serializer:
223
+ # Get all custom fields defined in the serializer
224
+ try:
225
+ # Custom fields are those that are not model fields but are defined
226
+ # in the serializer configuration
227
+ for schema_type in ['create', 'update', 'read', 'detail']:
228
+ try:
229
+ schema_fields = serializer.get_fields(schema_type)
230
+ if schema_fields:
231
+ valid_fields.update(schema_fields)
232
+ except (AttributeError, TypeError):
233
+ continue
234
+ except (AttributeError, TypeError):
235
+ pass
236
+
237
+ return valid_fields
238
+
195
239
  @property
196
240
  def model_name(self) -> str:
197
241
  """
@@ -518,6 +562,9 @@ class ModelUtil:
518
562
  """
519
563
  Discover reverse relation names for safe prefetching.
520
564
 
565
+ Performance: Results are cached per (model, serializer_class, is_for) tuple
566
+ since model structure is static.
567
+
521
568
  Parameters
522
569
  ----------
523
570
  is_for : Literal["read", "detail"]
@@ -528,9 +575,17 @@ class ModelUtil:
528
575
  list[str]
529
576
  Relation attribute names.
530
577
  """
578
+ # Check cache first (performance optimization)
579
+ cache_key = (self.model, str(self.serializer_class), is_for)
580
+ if cache_key in self._relation_cache:
581
+ return self._relation_cache[cache_key].copy()
582
+
531
583
  reverse_rels = self._get_read_optimizations(is_for).prefetch_related.copy()
532
584
  if reverse_rels:
585
+ # Cache and return
586
+ self._relation_cache[cache_key] = reverse_rels
533
587
  return reverse_rels
588
+
534
589
  serializable_fields = self._get_serializable_field_names(is_for)
535
590
  for f in serializable_fields:
536
591
  field_obj = getattr(self.model, f)
@@ -542,6 +597,9 @@ class ModelUtil:
542
597
  continue
543
598
  if isinstance(field_obj, ReverseOneToOneDescriptor):
544
599
  reverse_rels.append(field_obj.related.name)
600
+
601
+ # Cache the result
602
+ self._relation_cache[cache_key] = reverse_rels
545
603
  return reverse_rels
546
604
 
547
605
  def get_select_relateds(
@@ -550,6 +608,9 @@ class ModelUtil:
550
608
  """
551
609
  Discover forward relation names for safe select_related.
552
610
 
611
+ Performance: Results are cached per (model, serializer_class, is_for) tuple
612
+ since model structure is static.
613
+
553
614
  Parameters
554
615
  ----------
555
616
  is_for : Literal["read", "detail"]
@@ -560,9 +621,17 @@ class ModelUtil:
560
621
  list[str]
561
622
  Relation attribute names.
562
623
  """
624
+ # Check cache first (performance optimization)
625
+ cache_key = (self.model, str(self.serializer_class) + "_select", is_for)
626
+ if cache_key in self._relation_cache:
627
+ return self._relation_cache[cache_key].copy()
628
+
563
629
  select_rels = self._get_read_optimizations(is_for).select_related.copy()
564
630
  if select_rels:
631
+ # Cache and return
632
+ self._relation_cache[cache_key] = select_rels
565
633
  return select_rels
634
+
566
635
  serializable_fields = self._get_serializable_field_names(is_for)
567
636
  for f in serializable_fields:
568
637
  field_obj = getattr(self.model, f)
@@ -571,12 +640,17 @@ class ModelUtil:
571
640
  continue
572
641
  if isinstance(field_obj, ForwardManyToOneDescriptor):
573
642
  select_rels.append(f)
643
+
644
+ # Cache the result
645
+ self._relation_cache[cache_key] = select_rels
574
646
  return select_rels
575
647
 
576
- async def _get_field(self, k: str):
648
+ async def _get_field(self, k: str) -> models.Field:
649
+ """Get Django field object for a given field name."""
577
650
  return (await agetattr(self.model, k)).field
578
651
 
579
- def _decode_binary(self, payload: dict, k: str, v: Any, field_obj: models.Field):
652
+ def _decode_binary(self, payload: dict, k: str, v: Any, field_obj: models.Field) -> None:
653
+ """Decode base64-encoded binary field values in place."""
580
654
  if not isinstance(field_obj, models.BinaryField):
581
655
  return
582
656
  try:
@@ -591,7 +665,8 @@ class ModelUtil:
591
665
  k: str,
592
666
  v: Any,
593
667
  field_obj: models.Field,
594
- ):
668
+ ) -> None:
669
+ """Resolve foreign key ID to model instance in place."""
595
670
  if not isinstance(field_obj, models.ForeignKey):
596
671
  return
597
672
  rel_util = ModelUtil(field_obj.related_model)
@@ -600,10 +675,11 @@ class ModelUtil:
600
675
 
601
676
  async def _bump_object_from_schema(
602
677
  self, obj: type["ModelSerializer"] | models.Model, schema: Schema
603
- ):
678
+ ) -> dict:
679
+ """Convert model instance to dict using Pydantic schema."""
604
680
  return (await sync_to_async(schema.from_orm)(obj)).model_dump()
605
681
 
606
- def _validate_read_params(self, request: HttpRequest, query_data: QuerySchema):
682
+ def _validate_read_params(self, request: HttpRequest, query_data: QuerySchema) -> None:
607
683
  """Validate required parameters for read operations."""
608
684
  if request is None:
609
685
  raise SerializeError(
@@ -667,12 +743,152 @@ class ModelUtil:
667
743
  obj = await self.get_object(request, query_data=query_data, is_for=is_for)
668
744
  return await self._bump_object_from_schema(obj, obj_schema)
669
745
 
746
+ def _validate_input_fields(
747
+ self, payload: dict, is_serializer: bool, serializer
748
+ ) -> None:
749
+ """
750
+ Validate non-custom payload keys against model fields.
751
+
752
+ Parameters
753
+ ----------
754
+ payload : dict
755
+ Input payload to validate.
756
+ is_serializer : bool
757
+ Whether using a ModelSerializer.
758
+ serializer : ModelSerializer | Serializer
759
+ Serializer instance if applicable.
760
+
761
+ Raises
762
+ ------
763
+ SerializeError
764
+ If invalid field names are found in payload.
765
+ """
766
+ invalid_fields = []
767
+ for key in payload.keys():
768
+ # Skip custom fields - they're validated by Pydantic schema
769
+ if is_serializer and serializer.is_custom(key):
770
+ continue
771
+ # Validate non-custom fields exist on the model
772
+ if key not in self.model_fields:
773
+ invalid_fields.append(key)
774
+
775
+ if invalid_fields:
776
+ raise SerializeError(
777
+ {
778
+ "detail": f"Invalid field names in payload: {', '.join(sorted(invalid_fields))}",
779
+ "invalid_fields": sorted(invalid_fields),
780
+ },
781
+ 400,
782
+ )
783
+
784
+ def _collect_custom_and_optional_fields(
785
+ self, payload: dict, is_serializer: bool, serializer
786
+ ) -> tuple[dict[str, Any], list[str]]:
787
+ """
788
+ Collect custom and optional fields from payload.
789
+
790
+ Parameters
791
+ ----------
792
+ payload : dict
793
+ Input payload.
794
+ is_serializer : bool
795
+ Whether using a ModelSerializer.
796
+ serializer : ModelSerializer | Serializer
797
+ Serializer instance if applicable.
798
+
799
+ Returns
800
+ -------
801
+ tuple[dict[str, Any], list[str]]
802
+ (custom_fields_dict, optional_field_names)
803
+ """
804
+ customs: dict[str, Any] = {}
805
+ optionals: list[str] = []
806
+
807
+ if not is_serializer:
808
+ return customs, optionals
809
+
810
+ customs = {
811
+ k: v
812
+ for k, v in payload.items()
813
+ if serializer.is_custom(k) and k not in self.model_fields
814
+ }
815
+ optionals = [
816
+ k for k, v in payload.items() if serializer.is_optional(k) and v is None
817
+ ]
818
+
819
+ return customs, optionals
820
+
821
+ def _determine_skip_keys(
822
+ self, payload: dict, is_serializer: bool, serializer
823
+ ) -> set[str]:
824
+ """
825
+ Determine which keys to skip during model field processing.
826
+
827
+ Parameters
828
+ ----------
829
+ payload : dict
830
+ Input payload.
831
+ is_serializer : bool
832
+ Whether using a ModelSerializer.
833
+ serializer : ModelSerializer | Serializer
834
+ Serializer instance if applicable.
835
+
836
+ Returns
837
+ -------
838
+ set[str]
839
+ Set of keys to skip.
840
+ """
841
+ if not is_serializer:
842
+ return set()
843
+
844
+ skip_keys = {
845
+ k
846
+ for k, v in payload.items()
847
+ if (serializer.is_custom(k) and k not in self.model_fields)
848
+ or (serializer.is_optional(k) and v is None)
849
+ }
850
+ return skip_keys
851
+
852
+ async def _process_payload_fields(
853
+ self, request: HttpRequest, payload: dict, fields_to_process: list[tuple[str, Any]]
854
+ ) -> None:
855
+ """
856
+ Process payload fields: decode binary and resolve foreign keys.
857
+
858
+ Parameters
859
+ ----------
860
+ request : HttpRequest
861
+ HTTP request object.
862
+ payload : dict
863
+ Payload dict to modify in place.
864
+ fields_to_process : list[tuple[str, Any]]
865
+ List of (field_name, field_value) tuples to process.
866
+ """
867
+ if not fields_to_process:
868
+ return
869
+
870
+ # Fetch all field objects in parallel
871
+ field_tasks = [self._get_field(k) for k, _ in fields_to_process]
872
+ field_objs = await asyncio.gather(*field_tasks)
873
+
874
+ # Decode binary fields (synchronous, must be sequential)
875
+ for (k, v), field_obj in zip(fields_to_process, field_objs):
876
+ self._decode_binary(payload, k, v, field_obj)
877
+
878
+ # Resolve all FK fields in parallel
879
+ fk_tasks = [
880
+ self._resolve_fk(request, payload, k, v, field_obj)
881
+ for (k, v), field_obj in zip(fields_to_process, field_objs)
882
+ ]
883
+ await asyncio.gather(*fk_tasks)
884
+
670
885
  async def parse_input_data(self, request: HttpRequest, data: Schema):
671
886
  """
672
887
  Transform inbound schema data to a model-ready payload.
673
888
 
674
889
  Steps
675
890
  -----
891
+ - Validate fields against allowlist (security).
676
892
  - Strip custom fields (retain separately).
677
893
  - Drop optional fields with None (ModelSerializer only).
678
894
  - Decode BinaryField base64 values.
@@ -692,7 +908,7 @@ class ModelUtil:
692
908
  Raises
693
909
  ------
694
910
  SerializeError
695
- On base64 decoding failure.
911
+ On base64 decoding failure or invalid field names.
696
912
  """
697
913
  payload = data.model_dump(mode="json")
698
914
 
@@ -701,36 +917,20 @@ class ModelUtil:
701
917
  )
702
918
  serializer = self.serializer if self.with_serializer else self.model
703
919
 
704
- # Collect custom and optional fields (only if ModelSerializerMeta)
705
- customs: dict[str, Any] = {}
706
- optionals: list[str] = []
707
- if is_serializer:
708
- customs = {
709
- k: v
710
- for k, v in payload.items()
711
- if serializer.is_custom(k) and k not in self.model_fields
712
- }
713
- optionals = [
714
- k for k, v in payload.items() if serializer.is_optional(k) and v is None
715
- ]
920
+ # Security: Validate non-custom payload keys against model fields
921
+ self._validate_input_fields(payload, is_serializer, serializer)
716
922
 
717
- skip_keys = set()
718
- if is_serializer:
719
- # Keys to skip during model field processing
720
- skip_keys = {
721
- k
722
- for k, v in payload.items()
723
- if (serializer.is_custom(k) and k not in self.model_fields)
724
- or (serializer.is_optional(k) and v is None)
725
- }
726
-
727
- # Process payload fields
728
- for k, v in payload.items():
729
- if k in skip_keys:
730
- continue
731
- field_obj = await self._get_field(k)
732
- self._decode_binary(payload, k, v, field_obj)
733
- await self._resolve_fk(request, payload, k, v, field_obj)
923
+ # Collect custom and optional fields
924
+ customs, optionals = self._collect_custom_and_optional_fields(
925
+ payload, is_serializer, serializer
926
+ )
927
+
928
+ # Determine which keys to skip during model field processing
929
+ skip_keys = self._determine_skip_keys(payload, is_serializer, serializer)
930
+
931
+ # Process payload fields - gather field objects in parallel for better performance
932
+ fields_to_process = [(k, v) for k, v in payload.items() if k not in skip_keys]
933
+ await self._process_payload_fields(request, payload, fields_to_process)
734
934
 
735
935
  # Preserve original exclusion semantics (customs if present else optionals)
736
936
  exclude_keys = customs.keys() or optionals
ninja_aio/types.py CHANGED
@@ -10,6 +10,44 @@ 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
+ # Django ORM field lookup suffixes for QuerySet filtering
14
+ # See: https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups
15
+ DjangoLookup = Literal[
16
+ "exact",
17
+ "iexact",
18
+ "contains",
19
+ "icontains",
20
+ "in",
21
+ "gt",
22
+ "gte",
23
+ "lt",
24
+ "lte",
25
+ "startswith",
26
+ "istartswith",
27
+ "endswith",
28
+ "iendswith",
29
+ "range",
30
+ "date",
31
+ "year",
32
+ "iso_year",
33
+ "month",
34
+ "day",
35
+ "week",
36
+ "week_day",
37
+ "iso_week_day",
38
+ "quarter",
39
+ "time",
40
+ "hour",
41
+ "minute",
42
+ "second",
43
+ "isnull",
44
+ "regex",
45
+ "iregex",
46
+ ]
47
+
48
+ # Set of valid Django lookup suffixes for runtime validation
49
+ VALID_DJANGO_LOOKUPS: set[str] = set(DjangoLookup.__args__)
50
+
13
51
 
14
52
  class SerializerMeta(type):
15
53
  """Metaclass for serializers - extend with custom behavior as needed."""
ninja_aio/views/api.py CHANGED
@@ -6,6 +6,7 @@ from ninja.pagination import paginate, AsyncPaginationBase, PageNumberPagination
6
6
  from django.http import HttpRequest
7
7
  from django.db.models import Model, QuerySet
8
8
  from django.conf import settings
9
+ from django.core.exceptions import FieldDoesNotExist
9
10
  from pydantic import create_model
10
11
 
11
12
  from ninja_aio.schemas.helpers import ModelQuerySetSchema, QuerySchema, DecoratorsSchema
@@ -16,7 +17,7 @@ from ninja_aio.schemas import (
16
17
  M2MRelationSchema,
17
18
  )
18
19
  from ninja_aio.helpers.api import ManyToManyAPI
19
- from ninja_aio.types import ModelSerializerMeta, VIEW_TYPES
20
+ from ninja_aio.types import ModelSerializerMeta, VIEW_TYPES, VALID_DJANGO_LOOKUPS
20
21
  from ninja_aio.decorators import unique_view, decorate_view, aatomic
21
22
  from ninja_aio.models import serializers
22
23
 
@@ -31,7 +32,7 @@ class API:
31
32
  auth: list | None = NOT_SET
32
33
  router: Router = None
33
34
 
34
- def views(self):
35
+ def views(self) -> None:
35
36
  """
36
37
  Override this method to add your custom views. For example:
37
38
  @self.router.get(some_path, response=some_schema)
@@ -65,13 +66,15 @@ class API:
65
66
  """
66
67
  pass
67
68
 
68
- def _add_views(self):
69
+ def _add_views(self) -> Router:
70
+ """Register views decorated with @api_register."""
69
71
  for name in dir(self.__class__):
70
72
  method = getattr(self.__class__, name)
71
73
  if hasattr(method, "_api_register"):
72
74
  method._api_register(self)
75
+ return self.router
73
76
 
74
- def add_views_to_route(self):
77
+ def add_views_to_route(self) -> Router:
75
78
  return self.api.add_router(f"{self.api_route_path}", self._add_views())
76
79
 
77
80
 
@@ -322,6 +325,98 @@ class APIViewSet(API):
322
325
  filter
323
326
  )
324
327
 
328
+ def _is_lookup_suffix(self, part: str) -> bool:
329
+ """
330
+ Check if a part is a valid Django lookup suffix.
331
+
332
+ Args:
333
+ part: The part to check
334
+
335
+ Returns:
336
+ bool: True if the part is a valid lookup suffix
337
+ """
338
+ return part in VALID_DJANGO_LOOKUPS
339
+
340
+ def _get_related_model(self, field):
341
+ """
342
+ Extract the related model from a field if it exists.
343
+
344
+ Args:
345
+ field: The Django field object
346
+
347
+ Returns:
348
+ Model class or None
349
+ """
350
+ if hasattr(field, 'related_model') and field.related_model:
351
+ return field.related_model
352
+ if hasattr(field, 'remote_field') and field.remote_field and hasattr(field.remote_field, 'model'):
353
+ return field.remote_field.model
354
+ return None
355
+
356
+ def _validate_non_relation_field(self, parts: list[str], i: int) -> bool:
357
+ """
358
+ Validate a non-relation field that appears before the end of the path.
359
+
360
+ Args:
361
+ parts: List of field path parts
362
+ i: Current index in parts
363
+
364
+ Returns:
365
+ bool: True if valid, False otherwise
366
+ """
367
+ if i >= len(parts) - 1:
368
+ return True
369
+ next_part = parts[i + 1]
370
+ return self._is_lookup_suffix(next_part)
371
+
372
+ def _validate_filter_field(self, field_path: str) -> bool:
373
+ """
374
+ Validate that a filter field path corresponds to valid model fields.
375
+
376
+ Security: Prevents field injection attacks by ensuring only valid model
377
+ fields can be used in filters.
378
+
379
+ Args:
380
+ field_path: The field path to validate (e.g., "name", "author__name")
381
+
382
+ Returns:
383
+ bool: True if the field path is valid, False otherwise
384
+
385
+ Examples:
386
+ "name" -> validates against direct model field
387
+ "author__name" -> validates author is a relation, then name on related model
388
+ "created_at__gte" -> validates created_at field, lookup suffix is allowed
389
+ """
390
+ if not field_path:
391
+ return False
392
+
393
+ parts = field_path.split('__')
394
+ current_model = self.model
395
+
396
+ # Iterate through the path, validating each part
397
+ for i, part in enumerate(parts):
398
+ # Check if this is the last part and might be a lookup suffix
399
+ is_last_part = i == len(parts) - 1
400
+ if is_last_part and self._is_lookup_suffix(part):
401
+ return True
402
+
403
+ try:
404
+ field = current_model._meta.get_field(part)
405
+ except (FieldDoesNotExist, AttributeError):
406
+ # Field doesn't exist on this model
407
+ return False
408
+
409
+ # If this is a relation field and not the last part, traverse to related model
410
+ related_model = self._get_related_model(field)
411
+ if related_model and not is_last_part:
412
+ current_model = related_model
413
+ elif not is_last_part:
414
+ # Non-relation field in the middle - must be followed by a lookup suffix
415
+ if not self._validate_non_relation_field(parts, i):
416
+ return False
417
+
418
+ return True
419
+
325
420
  def _auth_view(self, view_type: str):
326
421
  """
327
422
  Resolve auth for a specific HTTP verb; falls back to self.auth if NOT_SET.
@@ -329,16 +424,20 @@ class APIViewSet(API):
329
424
  auth = getattr(self, f"{view_type}_auth", None)
330
425
  return auth if auth is not NOT_SET else self.auth
331
426
 
332
- def get_view_auth(self):
427
+ def get_view_auth(self) -> list | None:
428
+ """Get authentication configuration for GET endpoints."""
333
429
  return self._auth_view("get")
334
430
 
335
- def post_view_auth(self):
431
+ def post_view_auth(self) -> list | None:
432
+ """Get authentication configuration for POST endpoints."""
336
433
  return self._auth_view("post")
337
434
 
338
- def patch_view_auth(self):
435
+ def patch_view_auth(self) -> list | None:
436
+ """Get authentication configuration for PATCH endpoints."""
339
437
  return self._auth_view("patch")
340
438
 
341
- def delete_view_auth(self):
439
+ def delete_view_auth(self) -> list | None:
440
+ """Get authentication configuration for DELETE endpoints."""
342
441
  return self._auth_view("delete")
343
442
 
344
443
  def _generate_schema(self, fields: dict, name: str) -> Schema:
@@ -347,7 +446,7 @@ class APIViewSet(API):
347
446
  """
348
447
  return create_model(f"{self.model_util.model_name}{name}", **fields)
349
448
 
350
- def _generate_path_schema(self):
449
+ def _generate_path_schema(self) -> Schema:
351
450
  """
352
451
  Schema containing only the primary key field for path resolution.
353
452
  """
ninja_aio/views/mixins.py CHANGED
@@ -56,7 +56,9 @@ class IcontainsFilterViewSetMixin(APIViewSet):
56
56
  **{
57
57
  f"{key}__icontains": value
58
58
  for key, value in filters.items()
59
- if isinstance(value, str) and not self._is_special_filter(key)
59
+ if isinstance(value, str)
60
+ and not self._is_special_filter(key)
61
+ and self._validate_filter_field(key)
60
62
  }
61
63
  )
62
64
 
@@ -98,7 +100,9 @@ class BooleanFilterViewSetMixin(APIViewSet):
98
100
  **{
99
101
  key: value
100
102
  for key, value in filters.items()
101
- if isinstance(value, bool) and not self._is_special_filter(key)
103
+ if isinstance(value, bool)
104
+ and not self._is_special_filter(key)
105
+ and self._validate_filter_field(key)
102
106
  }
103
107
  )
104
108
 
@@ -140,7 +144,9 @@ class NumericFilterViewSetMixin(APIViewSet):
140
144
  **{
141
145
  key: value
142
146
  for key, value in filters.items()
143
- if isinstance(value, (int, float)) and not self._is_special_filter(key)
147
+ if isinstance(value, (int, float))
148
+ and not self._is_special_filter(key)
149
+ and self._validate_filter_field(key)
144
150
  }
145
151
  )
146
152
 
@@ -182,7 +188,9 @@ class DateFilterViewSetMixin(APIViewSet):
182
188
  **{
183
189
  f"{key}{self._compare_attr}": value
184
190
  for key, value in filters.items()
185
- if hasattr(value, "isoformat") and not self._is_special_filter(key)
191
+ if hasattr(value, "isoformat")
192
+ and not self._is_special_filter(key)
193
+ and self._validate_filter_field(key)
186
194
  }
187
195
  )
188
196
 
@@ -341,7 +349,8 @@ class RelationFilterViewSetMixin(APIViewSet):
341
349
  rel_filters = {}
342
350
  for rel_filter in self.relations_filters:
343
351
  value = filters.get(rel_filter.query_param)
344
- if value is not None:
352
+ # Validate the configured query_filter path for security
353
+ if value is not None and self._validate_filter_field(rel_filter.query_filter):
345
354
  rel_filters[rel_filter.query_filter] = value
346
355
  return base_qs.filter(**rel_filters) if rel_filters else base_qs
347
356
 
@@ -406,8 +415,15 @@ class MatchCaseFilterViewSetMixin(APIViewSet):
406
415
  filter_match.cases.true if value else filter_match.cases.false
407
416
  )
408
417
  lookup = case_filter.query_filter
409
- if case_filter.include:
410
- base_qs = base_qs.filter(**lookup)
411
- else:
412
- base_qs = base_qs.exclude(**lookup)
418
+ # Validate all filter fields in the lookup dictionary for security
419
+ validated_lookup = {
420
+ k: v
421
+ for k, v in lookup.items()
422
+ if self._validate_filter_field(k)
423
+ }
424
+ if validated_lookup:
425
+ if case_filter.include:
426
+ base_qs = base_qs.filter(**validated_lookup)
427
+ else:
428
+ base_qs = base_qs.exclude(**validated_lookup)
413
429
  return base_qs