django-ninja-aio-crud 2.18.0__py3-none-any.whl → 2.18.2__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.
- {django_ninja_aio_crud-2.18.0.dist-info → django_ninja_aio_crud-2.18.2.dist-info}/METADATA +11 -5
- {django_ninja_aio_crud-2.18.0.dist-info → django_ninja_aio_crud-2.18.2.dist-info}/RECORD +10 -10
- ninja_aio/__init__.py +1 -1
- ninja_aio/models/serializers.py +180 -74
- ninja_aio/models/utils.py +158 -35
- ninja_aio/types.py +38 -0
- ninja_aio/views/api.py +108 -9
- ninja_aio/views/mixins.py +25 -9
- {django_ninja_aio_crud-2.18.0.dist-info → django_ninja_aio_crud-2.18.2.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-2.18.0.dist-info → django_ninja_aio_crud-2.18.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-ninja-aio-crud
|
|
3
|
-
Version: 2.18.
|
|
3
|
+
Version: 2.18.2
|
|
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"
|
|
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
|
|
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=
|
|
1
|
+
ninja_aio/__init__.py,sha256=GG9VIprfl_xsxe8mkdQQOZKg-SjYn-uPExDck4VTOSg,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=
|
|
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=
|
|
18
|
-
ninja_aio/models/utils.py,sha256=
|
|
17
|
+
ninja_aio/models/serializers.py,sha256=pRRa0ci8ObhmJoQkzreDxV0JA6elG0Tyj8mJutRDqpo,64021
|
|
18
|
+
ninja_aio/models/utils.py,sha256=uIjQz6-89H7Myires79_v9xSMU5A6JVBBmMzlDLZwp4,37261
|
|
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=
|
|
26
|
-
ninja_aio/views/mixins.py,sha256=
|
|
27
|
-
django_ninja_aio_crud-2.18.
|
|
28
|
-
django_ninja_aio_crud-2.18.
|
|
29
|
-
django_ninja_aio_crud-2.18.
|
|
30
|
-
django_ninja_aio_crud-2.18.
|
|
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.2.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
|
|
28
|
+
django_ninja_aio_crud-2.18.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
29
|
+
django_ninja_aio_crud-2.18.2.dist-info/METADATA,sha256=uRj6qJ8HaV6vM5nhqSTs6FYwd1Bex_gfCosWXtlJDbw,13404
|
|
30
|
+
django_ninja_aio_crud-2.18.2.dist-info/RECORD,,
|
ninja_aio/__init__.py
CHANGED
ninja_aio/models/serializers.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
516
|
+
# Handle Union of serializers
|
|
517
|
+
if get_origin(resolved) is Union:
|
|
518
|
+
return cls._generate_union_schema(resolved)
|
|
448
519
|
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
932
|
-
cls,
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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,7 @@ class ModelUtil:
|
|
|
192
195
|
"""
|
|
193
196
|
return [field.name for field in self.model._meta.get_fields()]
|
|
194
197
|
|
|
198
|
+
|
|
195
199
|
@property
|
|
196
200
|
def model_name(self) -> str:
|
|
197
201
|
"""
|
|
@@ -518,6 +522,9 @@ class ModelUtil:
|
|
|
518
522
|
"""
|
|
519
523
|
Discover reverse relation names for safe prefetching.
|
|
520
524
|
|
|
525
|
+
Performance: Results are cached per (model, serializer_class, is_for) tuple
|
|
526
|
+
since model structure is static.
|
|
527
|
+
|
|
521
528
|
Parameters
|
|
522
529
|
----------
|
|
523
530
|
is_for : Literal["read", "detail"]
|
|
@@ -528,9 +535,17 @@ class ModelUtil:
|
|
|
528
535
|
list[str]
|
|
529
536
|
Relation attribute names.
|
|
530
537
|
"""
|
|
538
|
+
# Check cache first (performance optimization)
|
|
539
|
+
cache_key = (self.model, str(self.serializer_class), is_for)
|
|
540
|
+
if cache_key in self._relation_cache:
|
|
541
|
+
return self._relation_cache[cache_key].copy()
|
|
542
|
+
|
|
531
543
|
reverse_rels = self._get_read_optimizations(is_for).prefetch_related.copy()
|
|
532
544
|
if reverse_rels:
|
|
545
|
+
# Cache and return
|
|
546
|
+
self._relation_cache[cache_key] = reverse_rels
|
|
533
547
|
return reverse_rels
|
|
548
|
+
|
|
534
549
|
serializable_fields = self._get_serializable_field_names(is_for)
|
|
535
550
|
for f in serializable_fields:
|
|
536
551
|
field_obj = getattr(self.model, f)
|
|
@@ -542,6 +557,9 @@ class ModelUtil:
|
|
|
542
557
|
continue
|
|
543
558
|
if isinstance(field_obj, ReverseOneToOneDescriptor):
|
|
544
559
|
reverse_rels.append(field_obj.related.name)
|
|
560
|
+
|
|
561
|
+
# Cache the result
|
|
562
|
+
self._relation_cache[cache_key] = reverse_rels
|
|
545
563
|
return reverse_rels
|
|
546
564
|
|
|
547
565
|
def get_select_relateds(
|
|
@@ -550,6 +568,9 @@ class ModelUtil:
|
|
|
550
568
|
"""
|
|
551
569
|
Discover forward relation names for safe select_related.
|
|
552
570
|
|
|
571
|
+
Performance: Results are cached per (model, serializer_class, is_for) tuple
|
|
572
|
+
since model structure is static.
|
|
573
|
+
|
|
553
574
|
Parameters
|
|
554
575
|
----------
|
|
555
576
|
is_for : Literal["read", "detail"]
|
|
@@ -560,9 +581,17 @@ class ModelUtil:
|
|
|
560
581
|
list[str]
|
|
561
582
|
Relation attribute names.
|
|
562
583
|
"""
|
|
584
|
+
# Check cache first (performance optimization)
|
|
585
|
+
cache_key = (self.model, str(self.serializer_class) + "_select", is_for)
|
|
586
|
+
if cache_key in self._relation_cache:
|
|
587
|
+
return self._relation_cache[cache_key].copy()
|
|
588
|
+
|
|
563
589
|
select_rels = self._get_read_optimizations(is_for).select_related.copy()
|
|
564
590
|
if select_rels:
|
|
591
|
+
# Cache and return
|
|
592
|
+
self._relation_cache[cache_key] = select_rels
|
|
565
593
|
return select_rels
|
|
594
|
+
|
|
566
595
|
serializable_fields = self._get_serializable_field_names(is_for)
|
|
567
596
|
for f in serializable_fields:
|
|
568
597
|
field_obj = getattr(self.model, f)
|
|
@@ -571,12 +600,17 @@ class ModelUtil:
|
|
|
571
600
|
continue
|
|
572
601
|
if isinstance(field_obj, ForwardManyToOneDescriptor):
|
|
573
602
|
select_rels.append(f)
|
|
603
|
+
|
|
604
|
+
# Cache the result
|
|
605
|
+
self._relation_cache[cache_key] = select_rels
|
|
574
606
|
return select_rels
|
|
575
607
|
|
|
576
|
-
async def _get_field(self, k: str):
|
|
608
|
+
async def _get_field(self, k: str) -> models.Field:
|
|
609
|
+
"""Get Django field object for a given field name."""
|
|
577
610
|
return (await agetattr(self.model, k)).field
|
|
578
611
|
|
|
579
|
-
def _decode_binary(self, payload: dict, k: str, v: Any, field_obj: models.Field):
|
|
612
|
+
def _decode_binary(self, payload: dict, k: str, v: Any, field_obj: models.Field) -> None:
|
|
613
|
+
"""Decode base64-encoded binary field values in place."""
|
|
580
614
|
if not isinstance(field_obj, models.BinaryField):
|
|
581
615
|
return
|
|
582
616
|
try:
|
|
@@ -591,7 +625,8 @@ class ModelUtil:
|
|
|
591
625
|
k: str,
|
|
592
626
|
v: Any,
|
|
593
627
|
field_obj: models.Field,
|
|
594
|
-
):
|
|
628
|
+
) -> None:
|
|
629
|
+
"""Resolve foreign key ID to model instance in place."""
|
|
595
630
|
if not isinstance(field_obj, models.ForeignKey):
|
|
596
631
|
return
|
|
597
632
|
rel_util = ModelUtil(field_obj.related_model)
|
|
@@ -600,10 +635,11 @@ class ModelUtil:
|
|
|
600
635
|
|
|
601
636
|
async def _bump_object_from_schema(
|
|
602
637
|
self, obj: type["ModelSerializer"] | models.Model, schema: Schema
|
|
603
|
-
):
|
|
638
|
+
) -> dict:
|
|
639
|
+
"""Convert model instance to dict using Pydantic schema."""
|
|
604
640
|
return (await sync_to_async(schema.from_orm)(obj)).model_dump()
|
|
605
641
|
|
|
606
|
-
def _validate_read_params(self, request: HttpRequest, query_data: QuerySchema):
|
|
642
|
+
def _validate_read_params(self, request: HttpRequest, query_data: QuerySchema) -> None:
|
|
607
643
|
"""Validate required parameters for read operations."""
|
|
608
644
|
if request is None:
|
|
609
645
|
raise SerializeError(
|
|
@@ -667,12 +703,115 @@ class ModelUtil:
|
|
|
667
703
|
obj = await self.get_object(request, query_data=query_data, is_for=is_for)
|
|
668
704
|
return await self._bump_object_from_schema(obj, obj_schema)
|
|
669
705
|
|
|
706
|
+
|
|
707
|
+
def _collect_custom_and_optional_fields(
|
|
708
|
+
self, payload: dict, is_serializer: bool, serializer
|
|
709
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
710
|
+
"""
|
|
711
|
+
Collect custom and optional fields from payload.
|
|
712
|
+
|
|
713
|
+
Parameters
|
|
714
|
+
----------
|
|
715
|
+
payload : dict
|
|
716
|
+
Input payload.
|
|
717
|
+
is_serializer : bool
|
|
718
|
+
Whether using a ModelSerializer.
|
|
719
|
+
serializer : ModelSerializer | Serializer
|
|
720
|
+
Serializer instance if applicable.
|
|
721
|
+
|
|
722
|
+
Returns
|
|
723
|
+
-------
|
|
724
|
+
tuple[dict[str, Any], list[str]]
|
|
725
|
+
(custom_fields_dict, optional_field_names)
|
|
726
|
+
"""
|
|
727
|
+
customs: dict[str, Any] = {}
|
|
728
|
+
optionals: list[str] = []
|
|
729
|
+
|
|
730
|
+
if not is_serializer:
|
|
731
|
+
return customs, optionals
|
|
732
|
+
|
|
733
|
+
customs = {
|
|
734
|
+
k: v
|
|
735
|
+
for k, v in payload.items()
|
|
736
|
+
if serializer.is_custom(k) and k not in self.model_fields
|
|
737
|
+
}
|
|
738
|
+
optionals = [
|
|
739
|
+
k for k, v in payload.items() if serializer.is_optional(k) and v is None
|
|
740
|
+
]
|
|
741
|
+
|
|
742
|
+
return customs, optionals
|
|
743
|
+
|
|
744
|
+
def _determine_skip_keys(
|
|
745
|
+
self, payload: dict, is_serializer: bool, serializer
|
|
746
|
+
) -> set[str]:
|
|
747
|
+
"""
|
|
748
|
+
Determine which keys to skip during model field processing.
|
|
749
|
+
|
|
750
|
+
Parameters
|
|
751
|
+
----------
|
|
752
|
+
payload : dict
|
|
753
|
+
Input payload.
|
|
754
|
+
is_serializer : bool
|
|
755
|
+
Whether using a ModelSerializer.
|
|
756
|
+
serializer : ModelSerializer | Serializer
|
|
757
|
+
Serializer instance if applicable.
|
|
758
|
+
|
|
759
|
+
Returns
|
|
760
|
+
-------
|
|
761
|
+
set[str]
|
|
762
|
+
Set of keys to skip.
|
|
763
|
+
"""
|
|
764
|
+
if not is_serializer:
|
|
765
|
+
return set()
|
|
766
|
+
|
|
767
|
+
skip_keys = {
|
|
768
|
+
k
|
|
769
|
+
for k, v in payload.items()
|
|
770
|
+
if (serializer.is_custom(k) and k not in self.model_fields)
|
|
771
|
+
or (serializer.is_optional(k) and v is None)
|
|
772
|
+
}
|
|
773
|
+
return skip_keys
|
|
774
|
+
|
|
775
|
+
async def _process_payload_fields(
|
|
776
|
+
self, request: HttpRequest, payload: dict, fields_to_process: list[tuple[str, Any]]
|
|
777
|
+
) -> None:
|
|
778
|
+
"""
|
|
779
|
+
Process payload fields: decode binary and resolve foreign keys.
|
|
780
|
+
|
|
781
|
+
Parameters
|
|
782
|
+
----------
|
|
783
|
+
request : HttpRequest
|
|
784
|
+
HTTP request object.
|
|
785
|
+
payload : dict
|
|
786
|
+
Payload dict to modify in place.
|
|
787
|
+
fields_to_process : list[tuple[str, Any]]
|
|
788
|
+
List of (field_name, field_value) tuples to process.
|
|
789
|
+
"""
|
|
790
|
+
if not fields_to_process:
|
|
791
|
+
return
|
|
792
|
+
|
|
793
|
+
# Fetch all field objects in parallel
|
|
794
|
+
field_tasks = [self._get_field(k) for k, _ in fields_to_process]
|
|
795
|
+
field_objs = await asyncio.gather(*field_tasks)
|
|
796
|
+
|
|
797
|
+
# Decode binary fields (synchronous, must be sequential)
|
|
798
|
+
for (k, v), field_obj in zip(fields_to_process, field_objs):
|
|
799
|
+
self._decode_binary(payload, k, v, field_obj)
|
|
800
|
+
|
|
801
|
+
# Resolve all FK fields in parallel
|
|
802
|
+
fk_tasks = [
|
|
803
|
+
self._resolve_fk(request, payload, k, v, field_obj)
|
|
804
|
+
for (k, v), field_obj in zip(fields_to_process, field_objs)
|
|
805
|
+
]
|
|
806
|
+
await asyncio.gather(*fk_tasks)
|
|
807
|
+
|
|
670
808
|
async def parse_input_data(self, request: HttpRequest, data: Schema):
|
|
671
809
|
"""
|
|
672
810
|
Transform inbound schema data to a model-ready payload.
|
|
673
811
|
|
|
674
812
|
Steps
|
|
675
813
|
-----
|
|
814
|
+
- Validate fields against schema (including aliases and custom fields).
|
|
676
815
|
- Strip custom fields (retain separately).
|
|
677
816
|
- Drop optional fields with None (ModelSerializer only).
|
|
678
817
|
- Decode BinaryField base64 values.
|
|
@@ -692,7 +831,7 @@ class ModelUtil:
|
|
|
692
831
|
Raises
|
|
693
832
|
------
|
|
694
833
|
SerializeError
|
|
695
|
-
On base64 decoding failure.
|
|
834
|
+
On base64 decoding failure or invalid field names.
|
|
696
835
|
"""
|
|
697
836
|
payload = data.model_dump(mode="json")
|
|
698
837
|
|
|
@@ -701,36 +840,20 @@ class ModelUtil:
|
|
|
701
840
|
)
|
|
702
841
|
serializer = self.serializer if self.with_serializer else self.model
|
|
703
842
|
|
|
704
|
-
#
|
|
705
|
-
|
|
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
|
-
]
|
|
843
|
+
# Note: Field validation is handled by Pydantic during schema deserialization
|
|
844
|
+
# No additional validation needed here since data is already a validated Schema instance
|
|
716
845
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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)
|
|
846
|
+
# Collect custom and optional fields
|
|
847
|
+
customs, optionals = self._collect_custom_and_optional_fields(
|
|
848
|
+
payload, is_serializer, serializer
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
# Determine which keys to skip during model field processing
|
|
852
|
+
skip_keys = self._determine_skip_keys(payload, is_serializer, serializer)
|
|
853
|
+
|
|
854
|
+
# Process payload fields - gather field objects in parallel for better performance
|
|
855
|
+
fields_to_process = [(k, v) for k, v in payload.items() if k not in skip_keys]
|
|
856
|
+
await self._process_payload_fields(request, payload, fields_to_process)
|
|
734
857
|
|
|
735
858
|
# Preserve original exclusion semantics (customs if present else optionals)
|
|
736
859
|
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)
|
|
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)
|
|
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))
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
File without changes
|
{django_ninja_aio_crud-2.18.0.dist-info → django_ninja_aio_crud-2.18.2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|