django-ninja-aio-crud 1.0.5__py3-none-any.whl → 2.0.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.
- {django_ninja_aio_crud-1.0.5.dist-info → django_ninja_aio_crud-2.0.0.dist-info}/METADATA +5 -3
- django_ninja_aio_crud-2.0.0.dist-info/RECORD +21 -0
- ninja_aio/__init__.py +1 -1
- ninja_aio/api.py +0 -2
- ninja_aio/auth.py +3 -4
- ninja_aio/decorators.py +76 -0
- ninja_aio/helpers/__init__.py +0 -3
- ninja_aio/helpers/api.py +117 -59
- ninja_aio/helpers/query.py +103 -0
- ninja_aio/models.py +504 -168
- ninja_aio/renders.py +8 -2
- ninja_aio/schemas/__init__.py +23 -0
- ninja_aio/schemas/api.py +24 -0
- ninja_aio/schemas/generics.py +5 -0
- ninja_aio/{schemas.py → schemas/helpers.py} +40 -32
- ninja_aio/views.py +48 -24
- django_ninja_aio_crud-1.0.5.dist-info/RECORD +0 -17
- {django_ninja_aio_crud-1.0.5.dist-info → django_ninja_aio_crud-2.0.0.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-1.0.5.dist-info → django_ninja_aio_crud-2.0.0.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:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Django Ninja AIO CRUD - Rest Framework
|
|
5
5
|
Author: Giuseppe Casillo
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -17,15 +17,17 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
22
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
23
|
Classifier: Framework :: Django
|
|
22
24
|
Classifier: Framework :: AsyncIO
|
|
23
25
|
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
24
26
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
27
|
License-File: LICENSE
|
|
26
|
-
Requires-Dist: django-ninja >=1.3.0, <=1.
|
|
28
|
+
Requires-Dist: django-ninja >=1.3.0, <=1.5.1
|
|
27
29
|
Requires-Dist: joserfc >=1.0.0, <= 1.4.1
|
|
28
|
-
Requires-Dist: orjson >= 3.10.7, <= 3.11.
|
|
30
|
+
Requires-Dist: orjson >= 3.10.7, <= 3.11.5
|
|
29
31
|
Requires-Dist: coverage ; extra == "test"
|
|
30
32
|
Project-URL: Documentation, https://django-ninja-aio.com
|
|
31
33
|
Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
ninja_aio/__init__.py,sha256=cH3S5kz6llYjarJhndCM9pmxriBwh3GJdi7HBMPj5v8,119
|
|
2
|
+
ninja_aio/api.py,sha256=SS1TYUiFkdYjfJLVy6GI90GOzvIHzPEeL-UcqWFRHkM,1684
|
|
3
|
+
ninja_aio/auth.py,sha256=8jaEp7oEJvUUB9EuyE2fOYk-khyAaekT3i80E7AbgOA,5101
|
|
4
|
+
ninja_aio/decorators.py,sha256=BHoFIiqdIVMFqSxGh-F6WeZFo1xZK4ieDw3dzKfxZIM,8147
|
|
5
|
+
ninja_aio/exceptions.py,sha256=1-iRbrloIyi0CR6Tcrn5YR4_LloA7PPohKIBaxXJ0-8,2596
|
|
6
|
+
ninja_aio/models.py,sha256=aJlo5a64O4o-fB8QESLMUJpoA5kcjRJxPBiAIMxg46k,47652
|
|
7
|
+
ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
|
|
8
|
+
ninja_aio/renders.py,sha256=VtmSliRJyZ6gjyoib8AXMVUBYF1jPNsiceCHujI_mAs,1699
|
|
9
|
+
ninja_aio/types.py,sha256=TJSGlA7bt4g9fvPhJ7gzH5tKbLagPmZUzfgttEOp4xs,468
|
|
10
|
+
ninja_aio/views.py,sha256=8vMFw-8au9O0Hpf-79jvB47PwokCzm7P8UY0DDWpN5A,16786
|
|
11
|
+
ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
ninja_aio/helpers/api.py,sha256=kgHxYPfbV6kQbp8Z4qMwQtwFiFIcRAenxI9MDowGtis,20181
|
|
13
|
+
ninja_aio/helpers/query.py,sha256=tE8RjXvSig-WB_0LRQ0LqoE4G_HMHsu0Na5QzTNIm6U,4262
|
|
14
|
+
ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
|
|
15
|
+
ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
|
|
16
|
+
ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
|
|
17
|
+
ninja_aio/schemas/helpers.py,sha256=rmE0D15lJg95Unv8PU44Hbf0VDTcErMCZZFG3D_znTo,2823
|
|
18
|
+
django_ninja_aio_crud-2.0.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
|
|
19
|
+
django_ninja_aio_crud-2.0.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
20
|
+
django_ninja_aio_crud-2.0.0.dist-info/METADATA,sha256=FKp28V4DMReIrIFlseCRVJy5hZezdESq4ZC0nw3DikA,8672
|
|
21
|
+
django_ninja_aio_crud-2.0.0.dist-info/RECORD,,
|
ninja_aio/__init__.py
CHANGED
ninja_aio/api.py
CHANGED
|
@@ -23,7 +23,6 @@ class NinjaAIO(NinjaAPI):
|
|
|
23
23
|
docs_decorator=None,
|
|
24
24
|
servers: list[dict[str, Any]] | None = None,
|
|
25
25
|
urls_namespace: str | None = None,
|
|
26
|
-
csrf: bool = False,
|
|
27
26
|
auth: Sequence[Any] | NOT_SET_TYPE = NOT_SET,
|
|
28
27
|
throttle: BaseThrottle | list[BaseThrottle] | NOT_SET_TYPE = NOT_SET,
|
|
29
28
|
default_router: Router | None = None,
|
|
@@ -39,7 +38,6 @@ class NinjaAIO(NinjaAPI):
|
|
|
39
38
|
docs_decorator=docs_decorator,
|
|
40
39
|
servers=servers,
|
|
41
40
|
urls_namespace=urls_namespace,
|
|
42
|
-
csrf=csrf,
|
|
43
41
|
auth=auth,
|
|
44
42
|
throttle=throttle,
|
|
45
43
|
default_router=default_router,
|
ninja_aio/auth.py
CHANGED
|
@@ -2,8 +2,6 @@ from joserfc import jwt, jwk, errors
|
|
|
2
2
|
from django.http.request import HttpRequest
|
|
3
3
|
from ninja.security.http import HttpBearer
|
|
4
4
|
|
|
5
|
-
from .exceptions import AuthError
|
|
6
|
-
|
|
7
5
|
|
|
8
6
|
class AsyncJwtBearer(HttpBearer):
|
|
9
7
|
"""
|
|
@@ -71,6 +69,7 @@ class AsyncJwtBearer(HttpBearer):
|
|
|
71
69
|
Return Semantics:
|
|
72
70
|
- authenticate -> user object (success) | False (failure)
|
|
73
71
|
"""
|
|
72
|
+
|
|
74
73
|
jwt_public: jwk.RSAKey
|
|
75
74
|
claims: dict[str, dict]
|
|
76
75
|
algorithms: list[str] = ["RS256"]
|
|
@@ -96,13 +95,13 @@ class AsyncJwtBearer(HttpBearer):
|
|
|
96
95
|
"""
|
|
97
96
|
try:
|
|
98
97
|
self.dcd = jwt.decode(token, self.jwt_public, algorithms=self.algorithms)
|
|
99
|
-
except ValueError
|
|
98
|
+
except ValueError:
|
|
100
99
|
# raise AuthError(", ".join(exc.args), 401)
|
|
101
100
|
return False
|
|
102
101
|
|
|
103
102
|
try:
|
|
104
103
|
self.validate_claims(self.dcd.claims)
|
|
105
|
-
except errors.JoseError
|
|
104
|
+
except errors.JoseError:
|
|
106
105
|
return False
|
|
107
106
|
|
|
108
107
|
return await self.auth_handler(request)
|
ninja_aio/decorators.py
CHANGED
|
@@ -46,10 +46,12 @@ def aatomic(func):
|
|
|
46
46
|
your async ORM / database backend.
|
|
47
47
|
- Only use on async functions.
|
|
48
48
|
"""
|
|
49
|
+
|
|
49
50
|
@wraps(func)
|
|
50
51
|
async def wrapper(*args, **kwargs):
|
|
51
52
|
async with AsyncAtomicContextManager():
|
|
52
53
|
return await func(*args, **kwargs)
|
|
54
|
+
|
|
53
55
|
return wrapper
|
|
54
56
|
|
|
55
57
|
|
|
@@ -117,6 +119,7 @@ def unique_view(self: object | str, plural: bool = False):
|
|
|
117
119
|
- Ensure that the modified name does not conflict with other functions after decoration.
|
|
118
120
|
- Use cautiously when decorators relying on original __name__ appear earlier in the chain.
|
|
119
121
|
"""
|
|
122
|
+
|
|
120
123
|
def decorator(func):
|
|
121
124
|
# Allow usage as unique_view(self_instance) or unique_view("model_name")
|
|
122
125
|
if isinstance(self, str):
|
|
@@ -140,3 +143,76 @@ def unique_view(self: object | str, plural: bool = False):
|
|
|
140
143
|
return func # Return original function (no wrapper)
|
|
141
144
|
|
|
142
145
|
return decorator
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def decorate_view(*decorators):
|
|
149
|
+
"""
|
|
150
|
+
Compose and apply multiple decorators to a view (sync or async) without adding an extra wrapper.
|
|
151
|
+
|
|
152
|
+
This utility was introduced to support class-based patterns where Django Ninja’s
|
|
153
|
+
built-in `decorate_view` does not fit well. For APIs implemented with vanilla
|
|
154
|
+
Django Ninja (function-based style), you should continue using Django Ninja’s
|
|
155
|
+
native `decorate_view`.
|
|
156
|
+
|
|
157
|
+
Behavior:
|
|
158
|
+
- Applies decorators in the same order as Python’s stacking syntax:
|
|
159
|
+
@d1
|
|
160
|
+
@d2
|
|
161
|
+
is equivalent to: view = d1(d2(view))
|
|
162
|
+
- Supports both synchronous and asynchronous views.
|
|
163
|
+
- Ignores None values, enabling conditional decoration.
|
|
164
|
+
- Does not introduce an additional wrapper; composition depends on each
|
|
165
|
+
decorator for signature/metadata preservation (e.g., using functools.wraps).
|
|
166
|
+
|
|
167
|
+
*decorators: Decorator callables to apply to the target view. Any None values
|
|
168
|
+
are skipped.
|
|
169
|
+
|
|
170
|
+
Callable: A decorator that applies the provided decorators in Python stacking order.
|
|
171
|
+
|
|
172
|
+
Method usage in class-based patterns:
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
*decorators: Decorator callables to apply to the target view. Any None
|
|
176
|
+
values are skipped.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
A decorator that applies the provided decorators in Python stacking order.
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
Basic usage:
|
|
183
|
+
class MyAPIViewSet(APIViewSet):
|
|
184
|
+
api = api
|
|
185
|
+
model = MyModel
|
|
186
|
+
|
|
187
|
+
def views(self):
|
|
188
|
+
@self.router.get('some-endpoint/')
|
|
189
|
+
@decorate_view(authenticate, log_request)
|
|
190
|
+
async def some_view(request):
|
|
191
|
+
...
|
|
192
|
+
|
|
193
|
+
Conditional decoration (skips None):
|
|
194
|
+
class MyAPIViewSet(APIViewSet):
|
|
195
|
+
api = api
|
|
196
|
+
model = MyModel
|
|
197
|
+
cache_dec = cache_page(60) if settings.ENABLE_CACHE else None
|
|
198
|
+
def views(self):
|
|
199
|
+
@self.router.get('data/')
|
|
200
|
+
@decorate_view(self.cache_dec, authenticate)
|
|
201
|
+
async def data_view(request):
|
|
202
|
+
...
|
|
203
|
+
|
|
204
|
+
Notes:
|
|
205
|
+
- Each decorator is applied in the order provided, with the first decorator
|
|
206
|
+
wrapping the result of the second, and so on.
|
|
207
|
+
- Ensure that each decorator is compatible with the view’s sync/async nature.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def _decorator(view):
|
|
211
|
+
wrapped = view
|
|
212
|
+
for dec in reversed(decorators):
|
|
213
|
+
if dec is None:
|
|
214
|
+
continue
|
|
215
|
+
wrapped = dec(wrapped)
|
|
216
|
+
return wrapped
|
|
217
|
+
|
|
218
|
+
return _decorator
|
ninja_aio/helpers/__init__.py
CHANGED
ninja_aio/helpers/api.py
CHANGED
|
@@ -6,6 +6,7 @@ from ninja import Path, Query
|
|
|
6
6
|
from ninja.pagination import paginate
|
|
7
7
|
from ninja_aio.decorators import unique_view
|
|
8
8
|
from ninja_aio.models import ModelSerializer, ModelUtil
|
|
9
|
+
from ninja_aio.schemas.helpers import ObjectsQuerySchema
|
|
9
10
|
from ninja_aio.schemas import (
|
|
10
11
|
GenericMessageSchema,
|
|
11
12
|
M2MRelationSchema,
|
|
@@ -37,27 +38,34 @@ class ManyToManyAPI:
|
|
|
37
38
|
Core behaviors:
|
|
38
39
|
- Dynamically generates per-relation filter schemas for query parameters.
|
|
39
40
|
- Supports custom per-relation query filtering handlers on the parent view set
|
|
40
|
-
|
|
41
|
+
via a `{related_name}_query_params_handler` coroutine for GET list filters.
|
|
42
|
+
- Supports custom per-relation object resolution for add/remove validation via
|
|
43
|
+
`{related_name}_query_handler(request, pk, instance)` used during POST to
|
|
44
|
+
resolve a single related object before mutation.
|
|
41
45
|
- Validates requested add/remove primary keys, producing granular success and
|
|
42
|
-
|
|
46
|
+
error feedback.
|
|
43
47
|
- Performs add/remove operations concurrently using asyncio.gather when both
|
|
44
|
-
|
|
48
|
+
types of operations are requested in the same call.
|
|
45
49
|
|
|
46
50
|
Attributes established at initialization:
|
|
47
51
|
relations: list of M2MRelationSchema defining each relation.
|
|
48
52
|
view_set: The parent APIViewSet instance from which router, pagination, model util,
|
|
49
|
-
|
|
53
|
+
and path schema are derived.
|
|
50
54
|
router: Ninja router used to register generated endpoints.
|
|
51
55
|
pagination_class: Pagination class used for GET related endpoints.
|
|
52
56
|
path_schema: Pydantic schema used to validate path parameters (e.g., primary key).
|
|
53
57
|
related_model_util: A ModelUtil instance cloned from the parent view set to access
|
|
54
|
-
|
|
58
|
+
base object retrieval helpers.
|
|
55
59
|
relations_filters_schemas: Mapping of related_name -> generated Pydantic filter schema.
|
|
56
60
|
|
|
57
61
|
Generated endpoint naming conventions:
|
|
58
62
|
GET -> get_{base_model_name}_{relation_path}
|
|
59
63
|
POST -> manage_{base_model_name}_{relation_path}
|
|
60
64
|
|
|
65
|
+
Endpoint registration details:
|
|
66
|
+
- GET: registered at `{retrieve_path}{relation_path}` with pagination; accepts Query filters.
|
|
67
|
+
- POST: registered at `{retrieve_path}{relation_path}/` (trailing slash) to manage add/remove.
|
|
68
|
+
|
|
61
69
|
All responses standardize success and error reporting for POST as:
|
|
62
70
|
{
|
|
63
71
|
"results": {"count": int, "details": [str, ...]},
|
|
@@ -65,25 +73,29 @@ class ManyToManyAPI:
|
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
Concurrency note:
|
|
68
|
-
Add and remove operations are executed concurrently when both lists are non-empty,
|
|
69
|
-
|
|
76
|
+
- Add and remove operations are executed concurrently when both lists are non-empty,
|
|
77
|
+
minimizing round-trip latency for bulk mutations.
|
|
78
|
+
- Uses related_manager.aadd(...) and related_manager.aremove(...) inside asyncio.gather.
|
|
70
79
|
|
|
71
80
|
Error semantics:
|
|
72
81
|
- Missing related objects: reported individually.
|
|
73
82
|
- Invalid operation context (e.g., removing objects not currently related or adding
|
|
74
|
-
|
|
83
|
+
objects already related) reported per primary key.
|
|
75
84
|
- Successful operations yield a corresponding success detail string per PK.
|
|
76
85
|
|
|
77
86
|
Security / auth:
|
|
78
87
|
- Each relation may optionally override auth via its schema; otherwise falls back
|
|
79
|
-
|
|
88
|
+
to a default configured on the instance (self.default_auth).
|
|
80
89
|
|
|
81
90
|
Pagination:
|
|
82
91
|
- Applied only to GET related endpoints via @paginate(self.pagination_class).
|
|
83
92
|
|
|
84
93
|
Extensibility:
|
|
85
94
|
- Provide custom query param handling by defining an async method on the parent
|
|
86
|
-
|
|
95
|
+
view set: `<related_name>_query_params_handler(self, queryset, filters_dict)`.
|
|
96
|
+
- Provide custom per-PK resolution for POST validation by defining an async method:
|
|
97
|
+
`<related_name>_query_handler(self, request, pk, instance)` returning a queryset,
|
|
98
|
+
from which .afirst() is used to resolve the single target object.
|
|
87
99
|
- Customize relation filtering schema via each relation's `filters` definition.
|
|
88
100
|
|
|
89
101
|
-----------------------------------------------------------------------
|
|
@@ -113,9 +125,9 @@ class ManyToManyAPI:
|
|
|
113
125
|
|
|
114
126
|
-----------------------------------------------------------------------
|
|
115
127
|
|
|
116
|
-
|
|
117
|
-
Retrieve an optional per-relation query handler
|
|
118
|
-
Naming convention: `<related_name
|
|
128
|
+
_get_query_params_handler(related_name)
|
|
129
|
+
Retrieve an optional per-relation query handler from the parent view set for GET list filters.
|
|
130
|
+
Naming convention: `<related_name}_query_params_handler`.
|
|
119
131
|
|
|
120
132
|
Parameters:
|
|
121
133
|
related_name (str): The relation's attribute name on the base model.
|
|
@@ -125,16 +137,32 @@ class ManyToManyAPI:
|
|
|
125
137
|
|
|
126
138
|
-----------------------------------------------------------------------
|
|
127
139
|
|
|
128
|
-
|
|
140
|
+
_get_query_handler(related_name)
|
|
141
|
+
Retrieve an optional per-relation single-object resolution handler from the parent view set
|
|
142
|
+
used during POST add/remove validation. If present, it receives `(request, pk, instance)` and
|
|
143
|
+
should return a queryset from which `.afirst()` will resolve the target object.
|
|
144
|
+
|
|
145
|
+
Parameters:
|
|
146
|
+
related_name (str): The relation's attribute name on the base model.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Coroutine | None: Handler to resolve a single related object by pk.
|
|
150
|
+
|
|
151
|
+
-----------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
_check_m2m_objs(request, objs_pks, related_model, related_manager, related_name, instance, remove=False)
|
|
129
154
|
Validate requested primary keys for add/remove operations against the current
|
|
130
155
|
relation state. Performs existence checks and logical consistency (e.g., prevents
|
|
131
|
-
adding already-related objects or removing non-related objects).
|
|
156
|
+
adding already-related objects or removing non-related objects). Uses `_get_query_handler`
|
|
157
|
+
when available; otherwise falls back to ModelUtil(...).get_objects(...) to resolve by pk.
|
|
132
158
|
|
|
133
159
|
Parameters:
|
|
134
160
|
request (HttpRequest): Incoming request context (passed to ModelUtil for access control).
|
|
135
161
|
objs_pks (list): List of primary keys to add or remove.
|
|
136
|
-
|
|
162
|
+
related_model (ModelSerializer | Model): Model class or serializer used to resolve objects.
|
|
137
163
|
related_manager (QuerySet): Related manager for the base object's M2M field.
|
|
164
|
+
related_name (str): M2M field name on the base object.
|
|
165
|
+
instance (ModelSerializer | Model): Base object instance (owner of the relation).
|
|
138
166
|
remove (bool): If True, treat operation as removal validation.
|
|
139
167
|
|
|
140
168
|
Returns:
|
|
@@ -149,7 +177,7 @@ class ManyToManyAPI:
|
|
|
149
177
|
|
|
150
178
|
-----------------------------------------------------------------------
|
|
151
179
|
|
|
152
|
-
_collect_m2m(request, pks, model, related_manager, remove=False)
|
|
180
|
+
_collect_m2m(request, pks, model, related_manager, related_name, instance, remove=False)
|
|
153
181
|
Wrapper around _check_m2m_objs that short-circuits on empty PK lists.
|
|
154
182
|
|
|
155
183
|
Parameters:
|
|
@@ -157,6 +185,8 @@ class ManyToManyAPI:
|
|
|
157
185
|
pks (list): Primary keys proposed for mutation.
|
|
158
186
|
model (ModelSerializer | Model)
|
|
159
187
|
related_manager (QuerySet)
|
|
188
|
+
related_name (str)
|
|
189
|
+
instance (ModelSerializer | Model)
|
|
160
190
|
remove (bool): Operation type flag.
|
|
161
191
|
|
|
162
192
|
Returns:
|
|
@@ -164,38 +194,30 @@ class ManyToManyAPI:
|
|
|
164
194
|
|
|
165
195
|
-----------------------------------------------------------------------
|
|
166
196
|
|
|
197
|
+
_register_get_relation_view(...)
|
|
198
|
+
Registers the GET endpoint for listing related objects. Applies optional
|
|
199
|
+
query params handler for filtering. Uses pagination and serializes via `list_read_s`.
|
|
200
|
+
|
|
201
|
+
-----------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
_register_manage_relation_view(...)
|
|
204
|
+
Registers the POST endpoint for adding/removing related objects. Validates via
|
|
205
|
+
`_collect_m2m` and executes mutations concurrently using `aadd`/`aremove` with
|
|
206
|
+
`asyncio.gather`. Aggregates per-PK results and errors into a standardized payload.
|
|
207
|
+
|
|
208
|
+
-----------------------------------------------------------------------
|
|
209
|
+
|
|
167
210
|
_build_views(relation)
|
|
168
211
|
Dynamically define and register the GET and/or POST endpoints for a single M2M
|
|
169
212
|
relation based on the relation's schema flags (get/add/remove). Builds filter
|
|
170
213
|
schemas, resolves path fragments, and binds handlers to the router with unique
|
|
171
214
|
operation IDs.
|
|
172
215
|
|
|
173
|
-
Parameters:
|
|
174
|
-
relation (M2MRelationSchema): Declarative specification for one M2M relation.
|
|
175
|
-
|
|
176
|
-
Side effects:
|
|
177
|
-
- Registers endpoints on self.router.
|
|
178
|
-
- Creates closures (get_related / manage_related) capturing relation context.
|
|
179
|
-
|
|
180
|
-
GET endpoint behavior:
|
|
181
|
-
- Retrieves base object via related_model_util.
|
|
182
|
-
- Fetches all related objects; applies optional query handler and filters.
|
|
183
|
-
- Serializes each related object with rel_util.read_s.
|
|
184
|
-
|
|
185
|
-
POST endpoint behavior:
|
|
186
|
-
- Parses add/remove PK lists.
|
|
187
|
-
- Validates objects via _collect_m2m.
|
|
188
|
-
- Performs asynchronous add/remove operations using aadd / aremove.
|
|
189
|
-
- Aggregates results and errors into standardized response payload.
|
|
190
|
-
|
|
191
216
|
-----------------------------------------------------------------------
|
|
192
217
|
|
|
193
218
|
_add_views()
|
|
194
219
|
Iterates over all declared relations and invokes _build_views to attach endpoints.
|
|
195
220
|
|
|
196
|
-
Side effects:
|
|
197
|
-
- Populates router with all required M2M endpoints.
|
|
198
|
-
|
|
199
221
|
-----------------------------------------------------------------------
|
|
200
222
|
|
|
201
223
|
Usage Example (conceptual):
|
|
@@ -240,39 +262,56 @@ class ManyToManyAPI:
|
|
|
240
262
|
for data in self.relations
|
|
241
263
|
}
|
|
242
264
|
|
|
243
|
-
def
|
|
265
|
+
def _get_query_params_handler(self, related_name: str) -> Coroutine | None:
|
|
244
266
|
return getattr(self.view_set, f"{related_name}_query_params_handler", None)
|
|
245
267
|
|
|
268
|
+
def _get_query_handler(self, related_name: str) -> Coroutine | None:
|
|
269
|
+
return getattr(self.view_set, f"{related_name}_query_handler", None)
|
|
270
|
+
|
|
246
271
|
async def _check_m2m_objs(
|
|
247
272
|
self,
|
|
248
273
|
request: HttpRequest,
|
|
249
274
|
objs_pks: list,
|
|
250
|
-
|
|
275
|
+
related_model: ModelSerializer | Model,
|
|
251
276
|
related_manager: QuerySet,
|
|
277
|
+
related_name: str,
|
|
278
|
+
instance: ModelSerializer | Model,
|
|
252
279
|
remove: bool = False,
|
|
253
280
|
):
|
|
254
281
|
"""
|
|
255
282
|
Validate requested add/remove pk list for M2M operations.
|
|
256
283
|
Returns (errors, details, objects_to_process).
|
|
284
|
+
Uses per-PK query handler if available, else falls back to ModelUtil lookup by pk.
|
|
257
285
|
"""
|
|
258
286
|
errors, objs_detail, objs = [], [], []
|
|
259
287
|
rel_objs = [rel_obj async for rel_obj in related_manager.select_related().all()]
|
|
260
|
-
rel_model_name =
|
|
288
|
+
rel_model_name = related_model._meta.verbose_name.capitalize()
|
|
261
289
|
for obj_pk in objs_pks:
|
|
262
|
-
|
|
263
|
-
await
|
|
264
|
-
|
|
290
|
+
if query_handler := self._get_query_handler(related_name):
|
|
291
|
+
rel_obj = await (
|
|
292
|
+
await query_handler(
|
|
293
|
+
request,
|
|
294
|
+
obj_pk,
|
|
295
|
+
instance,
|
|
296
|
+
)
|
|
297
|
+
).afirst()
|
|
298
|
+
else:
|
|
299
|
+
rel_obj = await (
|
|
300
|
+
await ModelUtil(related_model).get_objects(
|
|
301
|
+
request, query_data=ObjectsQuerySchema(filters={"pk": obj_pk})
|
|
302
|
+
)
|
|
303
|
+
).afirst()
|
|
265
304
|
if rel_obj is None:
|
|
266
305
|
errors.append(f"{rel_model_name} with pk {obj_pk} not found.")
|
|
267
306
|
continue
|
|
268
307
|
if remove ^ (rel_obj in rel_objs):
|
|
269
308
|
errors.append(
|
|
270
|
-
f"{rel_model_name} with
|
|
309
|
+
f"{rel_model_name} with pk {obj_pk} is {'not ' if remove else ''}in {self.related_model_util.model_name}"
|
|
271
310
|
)
|
|
272
311
|
continue
|
|
273
312
|
objs.append(rel_obj)
|
|
274
313
|
objs_detail.append(
|
|
275
|
-
f"{rel_model_name} with
|
|
314
|
+
f"{rel_model_name} with pk {obj_pk} successfully {'removed' if remove else 'added'}"
|
|
276
315
|
)
|
|
277
316
|
return errors, objs_detail, objs
|
|
278
317
|
|
|
@@ -280,14 +319,22 @@ class ManyToManyAPI:
|
|
|
280
319
|
self,
|
|
281
320
|
request: HttpRequest,
|
|
282
321
|
pks: list,
|
|
283
|
-
|
|
322
|
+
reletad_model: ModelSerializer | Model,
|
|
284
323
|
related_manager: QuerySet,
|
|
285
|
-
|
|
324
|
+
related_name: str,
|
|
325
|
+
instance: ModelSerializer | Model,
|
|
326
|
+
remove: bool = False,
|
|
286
327
|
):
|
|
287
328
|
if not pks:
|
|
288
329
|
return ([], [], [])
|
|
289
330
|
return await self._check_m2m_objs(
|
|
290
|
-
request,
|
|
331
|
+
request,
|
|
332
|
+
pks,
|
|
333
|
+
reletad_model,
|
|
334
|
+
related_manager,
|
|
335
|
+
related_name,
|
|
336
|
+
instance,
|
|
337
|
+
remove=remove,
|
|
291
338
|
)
|
|
292
339
|
|
|
293
340
|
def _register_get_relation_view(
|
|
@@ -323,14 +370,14 @@ class ManyToManyAPI:
|
|
|
323
370
|
related_manager = getattr(obj, related_name)
|
|
324
371
|
related_qs = related_manager.all()
|
|
325
372
|
|
|
326
|
-
query_handler = self.
|
|
373
|
+
query_handler = self._get_query_params_handler(related_name)
|
|
327
374
|
if filters is not None and query_handler:
|
|
328
|
-
|
|
375
|
+
if asyncio.iscoroutinefunction(query_handler):
|
|
376
|
+
related_qs = await query_handler(related_qs, filters.model_dump())
|
|
377
|
+
else:
|
|
378
|
+
related_qs = query_handler(related_qs, filters.model_dump())
|
|
329
379
|
|
|
330
|
-
return
|
|
331
|
-
await rel_util.read_s(request, rel_obj, related_schema)
|
|
332
|
-
async for rel_obj in related_qs
|
|
333
|
-
]
|
|
380
|
+
return await rel_util.list_read_s(related_schema, request, related_qs)
|
|
334
381
|
|
|
335
382
|
def _resolve_action_schema(self, add: bool, remove: bool):
|
|
336
383
|
return self.views_action_map[(add, remove)]
|
|
@@ -338,7 +385,7 @@ class ManyToManyAPI:
|
|
|
338
385
|
def _register_manage_relation_view(
|
|
339
386
|
self,
|
|
340
387
|
*,
|
|
341
|
-
|
|
388
|
+
related_model: ModelSerializer | Model,
|
|
342
389
|
related_name: str,
|
|
343
390
|
m2m_auth,
|
|
344
391
|
rel_util: ModelUtil,
|
|
@@ -375,10 +422,21 @@ class ManyToManyAPI:
|
|
|
375
422
|
remove_pks = getattr(data, "remove", []) if m2m_remove else []
|
|
376
423
|
|
|
377
424
|
add_errors, add_details, add_objs = await self._collect_m2m(
|
|
378
|
-
request,
|
|
425
|
+
request,
|
|
426
|
+
add_pks,
|
|
427
|
+
related_model,
|
|
428
|
+
related_manager,
|
|
429
|
+
related_name,
|
|
430
|
+
obj,
|
|
379
431
|
)
|
|
380
432
|
remove_errors, remove_details, remove_objs = await self._collect_m2m(
|
|
381
|
-
request,
|
|
433
|
+
request,
|
|
434
|
+
remove_pks,
|
|
435
|
+
related_model,
|
|
436
|
+
related_manager,
|
|
437
|
+
related_name,
|
|
438
|
+
obj,
|
|
439
|
+
remove=True,
|
|
382
440
|
)
|
|
383
441
|
|
|
384
442
|
tasks = []
|
|
@@ -418,7 +476,7 @@ class ManyToManyAPI:
|
|
|
418
476
|
|
|
419
477
|
if m2m_add or m2m_remove:
|
|
420
478
|
self._register_manage_relation_view(
|
|
421
|
-
|
|
479
|
+
related_model=model,
|
|
422
480
|
related_name=related_name,
|
|
423
481
|
m2m_auth=m2m_auth,
|
|
424
482
|
rel_util=rel_util,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from ninja_aio.types import ModelSerializerMeta
|
|
2
|
+
from ninja_aio.schemas.helpers import (
|
|
3
|
+
ModelQuerySetSchema,
|
|
4
|
+
QueryUtilBaseScopesSchema,
|
|
5
|
+
ModelQuerySetExtraSchema,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScopeNamespace:
|
|
10
|
+
def __init__(self, **scopes):
|
|
11
|
+
for key, value in scopes.items():
|
|
12
|
+
setattr(self, key, value)
|
|
13
|
+
|
|
14
|
+
def __iter__(self):
|
|
15
|
+
return iter(self.__dict__.values())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class QueryUtil:
|
|
19
|
+
"""
|
|
20
|
+
Helper class to manage queryset optimizations based on predefined scopes.
|
|
21
|
+
Attributes:
|
|
22
|
+
model (ModelSerializerMeta): The model serializer meta to which this utility is attached.
|
|
23
|
+
SCOPES (ScopeNamespace): An enumeration-like object containing available scopes.
|
|
24
|
+
read_config (ModelQuerySetSchema): Configuration for the 'read' scope.
|
|
25
|
+
queryset_request_config (ModelQuerySetSchema): Configuration for the 'queryset_request' scope
|
|
26
|
+
extra_configs (dict): Additional configurations for custom scopes.
|
|
27
|
+
Methods:
|
|
28
|
+
apply_queryset_optimizations(queryset, scope): Applies select_related and prefetch_related
|
|
29
|
+
optimizations to the given queryset based on the specified scope.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
query_util = QueryUtil(MyModelSerializer) or MyModel.query_util
|
|
33
|
+
qs = MyModel.objects.all()
|
|
34
|
+
optimized_qs = query_util.apply_queryset_optimizations(qs, query_util.SCOPES.READ)
|
|
35
|
+
|
|
36
|
+
# Applying optimizations for a custom scope
|
|
37
|
+
class MyModelSerializer(ModelSerializer):
|
|
38
|
+
class QuerySet:
|
|
39
|
+
extras = [
|
|
40
|
+
ModelQuerySetExtraSchema(
|
|
41
|
+
scope="custom_scope",
|
|
42
|
+
select_related=["custom_fk_field"],
|
|
43
|
+
prefetch_related=["custom_m2m_field"],
|
|
44
|
+
)
|
|
45
|
+
]
|
|
46
|
+
query_util = MyModelSerializer.query_util
|
|
47
|
+
qs = MyModelSerializer.objects.all()
|
|
48
|
+
optimized_qs_custom = query_util.apply_queryset_optimizations(qs, "custom_scope")
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
SCOPES: QueryUtilBaseScopesSchema
|
|
52
|
+
|
|
53
|
+
def __init__(self, model: ModelSerializerMeta):
|
|
54
|
+
self.model = model
|
|
55
|
+
self._configuration = getattr(self.model, "QuerySet", None)
|
|
56
|
+
self._extra_configuration: list[ModelQuerySetExtraSchema] = getattr(
|
|
57
|
+
self._configuration, "extras", []
|
|
58
|
+
)
|
|
59
|
+
self._BASE_SCOPES = QueryUtilBaseScopesSchema().model_dump()
|
|
60
|
+
self.SCOPES = ScopeNamespace(
|
|
61
|
+
**self._BASE_SCOPES,
|
|
62
|
+
**{extra.scope: extra.scope for extra in self._extra_configuration},
|
|
63
|
+
)
|
|
64
|
+
self.extra_configs = {extra.scope: extra for extra in self._extra_configuration}
|
|
65
|
+
self._configs = {
|
|
66
|
+
**{scope: self._get_config(scope) for scope in self._BASE_SCOPES.values()},
|
|
67
|
+
**self.extra_configs,
|
|
68
|
+
}
|
|
69
|
+
self.read_config: ModelQuerySetSchema = self._configs.get(self.SCOPES.READ, ModelQuerySetSchema())
|
|
70
|
+
self.queryset_request_config: ModelQuerySetSchema = self._configs.get(
|
|
71
|
+
self.SCOPES.QUERYSET_REQUEST, ModelQuerySetSchema()
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def _get_config(self, conf_name: str) -> ModelQuerySetSchema:
|
|
75
|
+
"""Helper method to retrieve configuration attributes."""
|
|
76
|
+
return getattr(self._configuration, conf_name, ModelQuerySetSchema())
|
|
77
|
+
|
|
78
|
+
def apply_queryset_optimizations(self, queryset, scope: str):
|
|
79
|
+
"""
|
|
80
|
+
Apply select_related and prefetch_related optimizations to the queryset
|
|
81
|
+
according to the specified scope.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
queryset (QuerySet): The Django queryset to optimize.
|
|
85
|
+
scope (str): The scope to apply. Must be in self.SCOPES.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
QuerySet: The optimized queryset.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If the given scope is not supported.
|
|
92
|
+
"""
|
|
93
|
+
if scope not in self._configs:
|
|
94
|
+
valid_scopes = list(self._configs.keys())
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Invalid scope '{scope}' for QueryUtil. Supported scopes: {valid_scopes}"
|
|
97
|
+
)
|
|
98
|
+
config = self._configs.get(scope, ModelQuerySetSchema())
|
|
99
|
+
if config.select_related:
|
|
100
|
+
queryset = queryset.select_related(*config.select_related)
|
|
101
|
+
if config.prefetch_related:
|
|
102
|
+
queryset = queryset.prefetch_related(*config.prefetch_related)
|
|
103
|
+
return queryset
|