django-ninja-aio-crud 2.0.0rc6__py3-none-any.whl → 2.1.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-2.0.0rc6.dist-info → django_ninja_aio_crud-2.1.0.dist-info}/METADATA +1 -1
- django_ninja_aio_crud-2.1.0.dist-info/RECORD +23 -0
- ninja_aio/__init__.py +1 -1
- ninja_aio/auth.py +121 -3
- ninja_aio/decorators.py +73 -0
- ninja_aio/helpers/api.py +5 -3
- ninja_aio/schemas/helpers.py +10 -2
- ninja_aio/types.py +3 -1
- ninja_aio/views/__init__.py +3 -0
- ninja_aio/{views.py → views/api.py} +58 -23
- ninja_aio/views/mixins.py +275 -0
- django_ninja_aio_crud-2.0.0rc6.dist-info/RECORD +0 -21
- {django_ninja_aio_crud-2.0.0rc6.dist-info → django_ninja_aio_crud-2.1.0.dist-info}/WHEEL +0 -0
- {django_ninja_aio_crud-2.0.0rc6.dist-info → django_ninja_aio_crud-2.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
ninja_aio/__init__.py,sha256=YGWehZQ4d5yVFCd1ax-s61BSkND60Yh383YmUJq-LtY,119
|
|
2
|
+
ninja_aio/api.py,sha256=SS1TYUiFkdYjfJLVy6GI90GOzvIHzPEeL-UcqWFRHkM,1684
|
|
3
|
+
ninja_aio/auth.py,sha256=w9TXDQwJSZzCbncsSwemINTUe1IPZGHnLKiqWTeOoFA,9163
|
|
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=_QV0DySZhCV1otuh1zhrlHgCaFnr5fd64GQrcA-qKoc,564
|
|
10
|
+
ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
ninja_aio/helpers/api.py,sha256=BTe7OL-X7YgWYeXmka8TmN4-gA43FVZhtH7q0dRjYX0,20238
|
|
12
|
+
ninja_aio/helpers/query.py,sha256=tE8RjXvSig-WB_0LRQ0LqoE4G_HMHsu0Na5QzTNIm6U,4262
|
|
13
|
+
ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
|
|
14
|
+
ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
|
|
15
|
+
ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
|
|
16
|
+
ninja_aio/schemas/helpers.py,sha256=rmE0D15lJg95Unv8PU44Hbf0VDTcErMCZZFG3D_znTo,2823
|
|
17
|
+
ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
|
|
18
|
+
ninja_aio/views/api.py,sha256=ispbr5w6WCUdbXmn2LY94zDQ0qb2nDNeTLLNAUBFoDc,17736
|
|
19
|
+
ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
|
|
20
|
+
django_ninja_aio_crud-2.1.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
|
|
21
|
+
django_ninja_aio_crud-2.1.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
22
|
+
django_ninja_aio_crud-2.1.0.dist-info/METADATA,sha256=91kIanSvlN_aeRmhZuXZR2pIBXEzUUOuYLJkijzN7QI,8672
|
|
23
|
+
django_ninja_aio_crud-2.1.0.dist-info/RECORD,,
|
ninja_aio/__init__.py
CHANGED
ninja_aio/auth.py
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
1
4
|
from joserfc import jwt, jwk, errors
|
|
2
5
|
from django.http.request import HttpRequest
|
|
6
|
+
from django.utils import timezone
|
|
7
|
+
from django.conf import settings
|
|
3
8
|
from ninja.security.http import HttpBearer
|
|
4
9
|
|
|
10
|
+
from ninja_aio.types import JwtKeys
|
|
11
|
+
|
|
12
|
+
JWT_MANDATORY_CLAIMS = [
|
|
13
|
+
("iss", "JWT_ISSUER"),
|
|
14
|
+
("aud", "JWT_AUDIENCE"),
|
|
15
|
+
]
|
|
16
|
+
|
|
5
17
|
|
|
6
18
|
class AsyncJwtBearer(HttpBearer):
|
|
7
19
|
"""
|
|
@@ -9,8 +21,8 @@ class AsyncJwtBearer(HttpBearer):
|
|
|
9
21
|
using HTTP Bearer tokens. It decodes and validates JWTs against a configured public key
|
|
10
22
|
and claim registry, then delegates user retrieval to an overridable async handler.
|
|
11
23
|
Attributes:
|
|
12
|
-
jwt_public (jwk.RSAKey):
|
|
13
|
-
The
|
|
24
|
+
jwt_public (jwk.RSAKey | jwk.ECKey):
|
|
25
|
+
The public key (JWK format) used to verify the JWT signature.
|
|
14
26
|
Must be set externally before authentication occurs.
|
|
15
27
|
claims (dict[str, dict]):
|
|
16
28
|
A mapping defining expected JWT claims passed to jwt.JWTClaimsRegistry.
|
|
@@ -70,7 +82,7 @@ class AsyncJwtBearer(HttpBearer):
|
|
|
70
82
|
- authenticate -> user object (success) | False (failure)
|
|
71
83
|
"""
|
|
72
84
|
|
|
73
|
-
jwt_public:
|
|
85
|
+
jwt_public: JwtKeys
|
|
74
86
|
claims: dict[str, dict]
|
|
75
87
|
algorithms: list[str] = ["RS256"]
|
|
76
88
|
|
|
@@ -105,3 +117,109 @@ class AsyncJwtBearer(HttpBearer):
|
|
|
105
117
|
return False
|
|
106
118
|
|
|
107
119
|
return await self.auth_handler(request)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_key(key: Optional[JwtKeys], setting_name: str) -> JwtKeys:
|
|
123
|
+
if key is None:
|
|
124
|
+
key = getattr(settings, setting_name, None)
|
|
125
|
+
if key is None:
|
|
126
|
+
raise ValueError(f"{setting_name} is required")
|
|
127
|
+
if not isinstance(key, (jwk.RSAKey, jwk.ECKey)):
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"{setting_name} must be an instance of jwk.RSAKey or jwk.ECKey"
|
|
130
|
+
)
|
|
131
|
+
return key
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def validate_mandatory_claims(claims: dict) -> dict:
|
|
135
|
+
for claim_key, setting_name in JWT_MANDATORY_CLAIMS:
|
|
136
|
+
if claims.get(claim_key) is not None:
|
|
137
|
+
continue
|
|
138
|
+
value = getattr(settings, setting_name, None)
|
|
139
|
+
if value is None:
|
|
140
|
+
raise ValueError(f"jwt {claim_key} is required")
|
|
141
|
+
claims[claim_key] = value
|
|
142
|
+
return claims
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def encode_jwt(
|
|
146
|
+
claims: dict, duration: int, private_key: jwk.RSAKey = None, algorithm: str = None
|
|
147
|
+
) -> str:
|
|
148
|
+
"""
|
|
149
|
+
Encode and sign a JWT.
|
|
150
|
+
|
|
151
|
+
Adds time-based claims and ensures mandatory issuer/audience:
|
|
152
|
+
- iat: current time (timezone-aware)
|
|
153
|
+
- nbf: current time
|
|
154
|
+
- exp: current time + duration (seconds)
|
|
155
|
+
- iss/aud: from claims if provided; otherwise from settings.JWT_ISSUER and settings.JWT_AUDIENCE
|
|
156
|
+
|
|
157
|
+
Parameters:
|
|
158
|
+
- claims (dict): additional claims to merge into the payload (can override defaults)
|
|
159
|
+
- duration (int): token lifetime in seconds
|
|
160
|
+
- private_key (jwk.RSAKey): RSA/EC JWK for signing; defaults to settings.JWT_PRIVATE_KEY
|
|
161
|
+
- algorithm (str): JWS algorithm (default "RS256")
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
- str: JWT compact string
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
- ValueError: if private_key is missing or not jwk.RSAKey/jwk.ECKey
|
|
168
|
+
- ValueError: if mandatory claims (iss, aud) are missing and not in settings
|
|
169
|
+
|
|
170
|
+
Notes:
|
|
171
|
+
- Header includes alg, typ=JWT, and kid from the private key (if available).
|
|
172
|
+
- Uses timezone-aware timestamps from django.utils.timezone.
|
|
173
|
+
"""
|
|
174
|
+
now = timezone.now()
|
|
175
|
+
nbf = now
|
|
176
|
+
pkey = validate_key(private_key, "JWT_PRIVATE_KEY")
|
|
177
|
+
algorithm = algorithm or "RS256"
|
|
178
|
+
claims = validate_mandatory_claims(claims)
|
|
179
|
+
kid_h = {"kid": pkey.kid} if pkey.kid else {}
|
|
180
|
+
return jwt.encode(
|
|
181
|
+
header={"alg": algorithm, "typ": "JWT"} | kid_h,
|
|
182
|
+
claims={
|
|
183
|
+
"iat": now,
|
|
184
|
+
"nbf": nbf,
|
|
185
|
+
"exp": now + datetime.timedelta(seconds=duration),
|
|
186
|
+
}
|
|
187
|
+
| claims,
|
|
188
|
+
key=pkey,
|
|
189
|
+
algorithms=[algorithm],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def decode_jwt(
|
|
194
|
+
token: str,
|
|
195
|
+
public_key: jwk.RSAKey | jwk.ECKey = None,
|
|
196
|
+
algorithms: list[str] = None,
|
|
197
|
+
) -> jwt.Token:
|
|
198
|
+
"""
|
|
199
|
+
Decode and verify a JSON Web Token (JWT) using the provided RSA public key.
|
|
200
|
+
This function decodes the JWT, verifies its signature, and returns the decoded token object.
|
|
201
|
+
Parameters:
|
|
202
|
+
- token (str): The JWT string to decode.
|
|
203
|
+
- public_key (jwk.RSAKey, optional): RSA public key used to verify the token's signature.
|
|
204
|
+
If not provided, settings.JWT_PUBLIC_KEY will be used. Must be an instance of jwk.RSAKey.
|
|
205
|
+
- algorithms (list[str], optional): List of permitted algorithms for signature verification.
|
|
206
|
+
Defaults to ["RS256"] if not provided.
|
|
207
|
+
Returns:
|
|
208
|
+
- jwt.Token: The decoded JWT token object containing header and claims.
|
|
209
|
+
Raises:
|
|
210
|
+
- ValueError: If no public key is provided or if the provided key is not an instance of jwk.RSAKey.
|
|
211
|
+
- jose.errors.JoseError: If the token is invalid or fails verification.
|
|
212
|
+
Notes:
|
|
213
|
+
- The function uses the specified algorithms to restrict acceptable signing methods.
|
|
214
|
+
Example:
|
|
215
|
+
decoded_token = decode_jwt(
|
|
216
|
+
token=my_jwt,
|
|
217
|
+
public_key=my_rsa_jwk,
|
|
218
|
+
algorithms=["RS256"],
|
|
219
|
+
)
|
|
220
|
+
"""
|
|
221
|
+
return jwt.decode(
|
|
222
|
+
token,
|
|
223
|
+
validate_key(public_key, "JWT_PUBLIC_KEY"),
|
|
224
|
+
algorithms=algorithms or ["RS256"],
|
|
225
|
+
)
|
ninja_aio/decorators.py
CHANGED
|
@@ -143,3 +143,76 @@ def unique_view(self: object | str, plural: bool = False):
|
|
|
143
143
|
return func # Return original function (no wrapper)
|
|
144
144
|
|
|
145
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/api.py
CHANGED
|
@@ -4,7 +4,7 @@ from typing import Coroutine
|
|
|
4
4
|
from django.http import HttpRequest
|
|
5
5
|
from ninja import Path, Query
|
|
6
6
|
from ninja.pagination import paginate
|
|
7
|
-
from ninja_aio.decorators import unique_view
|
|
7
|
+
from ninja_aio.decorators import unique_view, decorate_view
|
|
8
8
|
from ninja_aio.models import ModelSerializer, ModelUtil
|
|
9
9
|
from ninja_aio.schemas.helpers import ObjectsQuerySchema
|
|
10
10
|
from ninja_aio.schemas import (
|
|
@@ -357,8 +357,10 @@ class ManyToManyAPI:
|
|
|
357
357
|
summary=f"Get {rel_util.model._meta.verbose_name_plural.capitalize()}",
|
|
358
358
|
description=f"Get all related {rel_util.model._meta.verbose_name_plural.capitalize()}",
|
|
359
359
|
)
|
|
360
|
-
@
|
|
361
|
-
|
|
360
|
+
@decorate_view(
|
|
361
|
+
unique_view(f"get_{self.related_model_util.model_name}_{rel_path}"),
|
|
362
|
+
paginate(self.pagination_class),
|
|
363
|
+
)
|
|
362
364
|
async def get_related(
|
|
363
365
|
request: HttpRequest,
|
|
364
366
|
pk: Path[self.path_schema], # type: ignore
|
ninja_aio/schemas/helpers.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Optional, Type
|
|
1
|
+
from typing import List, Optional, Type
|
|
2
2
|
|
|
3
3
|
from ninja import Schema
|
|
4
4
|
from ninja_aio.types import ModelSerializerMeta
|
|
@@ -79,4 +79,12 @@ class QuerySchema(ModelQuerySetSchema):
|
|
|
79
79
|
|
|
80
80
|
class QueryUtilBaseScopesSchema(BaseModel):
|
|
81
81
|
READ: str = "read"
|
|
82
|
-
QUERYSET_REQUEST: str = "queryset_request"
|
|
82
|
+
QUERYSET_REQUEST: str = "queryset_request"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DecoratorsSchema(Schema):
|
|
86
|
+
list: Optional[List] = []
|
|
87
|
+
retrieve: Optional[List] = []
|
|
88
|
+
create: Optional[List] = []
|
|
89
|
+
update: Optional[List] = []
|
|
90
|
+
delete: Optional[List] = []
|
ninja_aio/types.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
from typing import Literal
|
|
2
2
|
|
|
3
|
+
from joserfc import jwk
|
|
3
4
|
from django.db.models import Model
|
|
5
|
+
from typing import TypeAlias
|
|
4
6
|
|
|
5
7
|
S_TYPES = Literal["read", "create", "update"]
|
|
6
8
|
F_TYPES = Literal["fields", "customs", "optionals", "excludes"]
|
|
7
9
|
SCHEMA_TYPES = Literal["In", "Out", "Patch", "Related"]
|
|
8
10
|
VIEW_TYPES = Literal["list", "retrieve", "create", "update", "delete", "all"]
|
|
9
|
-
|
|
11
|
+
JwtKeys: TypeAlias = jwk.RSAKey | jwk.ECKey
|
|
10
12
|
|
|
11
13
|
class ModelSerializerType(type):
|
|
12
14
|
def __repr__(self):
|
|
@@ -7,16 +7,16 @@ from django.http import HttpRequest
|
|
|
7
7
|
from django.db.models import Model, QuerySet
|
|
8
8
|
from pydantic import create_model
|
|
9
9
|
|
|
10
|
-
from ninja_aio.schemas.helpers import ModelQuerySetSchema, QuerySchema
|
|
10
|
+
from ninja_aio.schemas.helpers import ModelQuerySetSchema, QuerySchema, DecoratorsSchema
|
|
11
11
|
|
|
12
|
-
from .models import ModelSerializer, ModelUtil
|
|
13
|
-
from .schemas import (
|
|
12
|
+
from ninja_aio.models import ModelSerializer, ModelUtil
|
|
13
|
+
from ninja_aio.schemas import (
|
|
14
14
|
GenericMessageSchema,
|
|
15
15
|
M2MRelationSchema,
|
|
16
16
|
)
|
|
17
|
-
from .helpers.api import ManyToManyAPI
|
|
18
|
-
from .types import ModelSerializerMeta, VIEW_TYPES
|
|
19
|
-
from .decorators import unique_view
|
|
17
|
+
from ninja_aio.helpers.api import ManyToManyAPI
|
|
18
|
+
from ninja_aio.types import ModelSerializerMeta, VIEW_TYPES
|
|
19
|
+
from ninja_aio.decorators import unique_view, decorate_view
|
|
20
20
|
|
|
21
21
|
ERROR_CODES = frozenset({400, 401, 404, 428})
|
|
22
22
|
|
|
@@ -103,7 +103,7 @@ class APIViewSet:
|
|
|
103
103
|
filters = { "field_name": (type, default) }
|
|
104
104
|
A dynamic Pydantic Filters schema is generated and exposed as query params
|
|
105
105
|
on the related GET endpoint: /{pk}/{related_path}?field_name=value.
|
|
106
|
-
To apply custom filter logic implement an
|
|
106
|
+
To apply custom filter logic implement an hook named:
|
|
107
107
|
<related_name>_query_params_handler(self, queryset, filters_dict)
|
|
108
108
|
It receives the initial related queryset and the validated/dumped filters
|
|
109
109
|
dict, and must return the (optionally) filtered queryset.
|
|
@@ -121,7 +121,7 @@ class APIViewSet:
|
|
|
121
121
|
)
|
|
122
122
|
]
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
def tags_query_params_handler(self, queryset, filters):
|
|
125
125
|
name_filter = filters.get("name")
|
|
126
126
|
if name_filter:
|
|
127
127
|
queryset = queryset.filter(name__icontains=name_filter)
|
|
@@ -145,7 +145,7 @@ class APIViewSet:
|
|
|
145
145
|
|
|
146
146
|
Overridable hooks:
|
|
147
147
|
views(): Register extra custom endpoints on self.router.
|
|
148
|
-
query_params_handler(queryset, filters): Async hook to apply list filters.
|
|
148
|
+
query_params_handler(queryset, filters): Sync/Async hook to apply list filters.
|
|
149
149
|
<related_name>_query_params_handler(queryset, filters): Async hook for per-M2M filtering.
|
|
150
150
|
|
|
151
151
|
Error responses:
|
|
@@ -178,6 +178,7 @@ class APIViewSet:
|
|
|
178
178
|
delete_docs = "Delete an object by its primary key."
|
|
179
179
|
m2m_relations: list[M2MRelationSchema] = []
|
|
180
180
|
m2m_auth: list | None = NOT_SET
|
|
181
|
+
extra_decorators: DecoratorsSchema = DecoratorsSchema()
|
|
181
182
|
|
|
182
183
|
def __init__(self) -> None:
|
|
183
184
|
self.error_codes = ERROR_CODES
|
|
@@ -277,15 +278,18 @@ class APIViewSet:
|
|
|
277
278
|
|
|
278
279
|
def get_schemas(self):
|
|
279
280
|
"""
|
|
280
|
-
|
|
281
|
+
Compute and return (schema_out, schema_in, schema_update).
|
|
282
|
+
|
|
283
|
+
- If model is a ModelSerializer (ModelSerializerMeta), auto-generate read/create/update schemas.
|
|
284
|
+
- Otherwise, return the schemas already set on the viewset (may be None).
|
|
281
285
|
"""
|
|
282
|
-
if isinstance(self.model, ModelSerializerMeta):
|
|
283
|
-
return
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
288
|
-
|
|
286
|
+
if not isinstance(self.model, ModelSerializerMeta):
|
|
287
|
+
return self.schema_out, self.schema_in, self.schema_update
|
|
288
|
+
return (
|
|
289
|
+
self.schema_out or self.model.generate_read_s(),
|
|
290
|
+
self.schema_in or self.model.generate_create_s(),
|
|
291
|
+
self.schema_update or self.model.generate_update_s(),
|
|
292
|
+
)
|
|
289
293
|
|
|
290
294
|
async def query_params_handler(
|
|
291
295
|
self, queryset: QuerySet[ModelSerializer], filters: dict
|
|
@@ -309,7 +313,7 @@ class APIViewSet:
|
|
|
309
313
|
description=self.create_docs,
|
|
310
314
|
response={201: self.schema_out, self.error_codes: GenericMessageSchema},
|
|
311
315
|
)
|
|
312
|
-
@unique_view(self)
|
|
316
|
+
@decorate_view(unique_view(self), *self.extra_decorators.create)
|
|
313
317
|
async def create(request: HttpRequest, data: self.schema_in): # type: ignore
|
|
314
318
|
return 201, await self.model_util.create_s(request, data, self.schema_out)
|
|
315
319
|
|
|
@@ -330,8 +334,11 @@ class APIViewSet:
|
|
|
330
334
|
self.error_codes: GenericMessageSchema,
|
|
331
335
|
},
|
|
332
336
|
)
|
|
333
|
-
@
|
|
334
|
-
|
|
337
|
+
@decorate_view(
|
|
338
|
+
paginate(self.pagination_class),
|
|
339
|
+
unique_view(self, plural=True),
|
|
340
|
+
*self.extra_decorators.list,
|
|
341
|
+
)
|
|
335
342
|
async def list(
|
|
336
343
|
request: HttpRequest,
|
|
337
344
|
filters: Query[self.filters_schema] = None, # type: ignore
|
|
@@ -359,7 +366,7 @@ class APIViewSet:
|
|
|
359
366
|
description=self.retrieve_docs,
|
|
360
367
|
response={200: self.schema_out, self.error_codes: GenericMessageSchema},
|
|
361
368
|
)
|
|
362
|
-
@unique_view(self)
|
|
369
|
+
@decorate_view(unique_view(self), *self.extra_decorators.retrieve)
|
|
363
370
|
async def retrieve(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
|
|
364
371
|
query_data = self._get_query_data()
|
|
365
372
|
return await self.model_util.read_s(
|
|
@@ -384,7 +391,7 @@ class APIViewSet:
|
|
|
384
391
|
description=self.update_docs,
|
|
385
392
|
response={200: self.schema_out, self.error_codes: GenericMessageSchema},
|
|
386
393
|
)
|
|
387
|
-
@unique_view(self)
|
|
394
|
+
@decorate_view(unique_view(self), *self.extra_decorators.update)
|
|
388
395
|
async def update(
|
|
389
396
|
request: HttpRequest,
|
|
390
397
|
data: self.schema_update, # type: ignore
|
|
@@ -408,7 +415,7 @@ class APIViewSet:
|
|
|
408
415
|
description=self.delete_docs,
|
|
409
416
|
response={204: None, self.error_codes: GenericMessageSchema},
|
|
410
417
|
)
|
|
411
|
-
@unique_view(self)
|
|
418
|
+
@decorate_view(unique_view(self), *self.extra_decorators.delete)
|
|
412
419
|
async def delete(request: HttpRequest, pk: Path[self.path_schema]): # type: ignore
|
|
413
420
|
return 204, await self.model_util.delete_s(request, self._get_pk(pk))
|
|
414
421
|
|
|
@@ -448,3 +455,31 @@ class APIViewSet:
|
|
|
448
455
|
Attach router with registered endpoints to the NinjaAPI instance.
|
|
449
456
|
"""
|
|
450
457
|
return self.api.add_router(f"{self.api_route_path}", self._add_views())
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class ReadOnlyViewSet(APIViewSet):
|
|
461
|
+
"""
|
|
462
|
+
ReadOnly viewset generating async List + Retrieve endpoints for a Django model.
|
|
463
|
+
|
|
464
|
+
Usage:
|
|
465
|
+
class MyModelReadOnlyViewSet(ReadOnlyViewSet):
|
|
466
|
+
model = MyModel
|
|
467
|
+
api = api
|
|
468
|
+
MyModelReadOnlyViewSet().add_views_to_route()
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
disable = ["create", "update", "delete"]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class WriteOnlyViewSet(APIViewSet):
|
|
475
|
+
"""
|
|
476
|
+
WriteOnly viewset generating async Create + Update + Delete endpoints for a Django model.
|
|
477
|
+
|
|
478
|
+
Usage:
|
|
479
|
+
class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
|
|
480
|
+
model = MyModel
|
|
481
|
+
api = api
|
|
482
|
+
MyModelWriteOnlyViewSet().add_views_to_route()
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
disable = ["list", "retrieve"]
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from ninja_aio.views.api import APIViewSet
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IcontainsFilterViewSetMixin(APIViewSet):
|
|
5
|
+
"""
|
|
6
|
+
Mixin providing a convenience method to apply case-insensitive substring filters
|
|
7
|
+
to a Django queryset based on request query parameters.
|
|
8
|
+
|
|
9
|
+
This mixin is intended for use with viewsets that support asynchronous handling
|
|
10
|
+
and dynamic filtering. It converts string-type filter values into Django ORM
|
|
11
|
+
`__icontains` lookups, enabling partial, case-insensitive matches.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
- Include this mixin in a viewset class that exposes a queryset and
|
|
15
|
+
passes a dictionary of filters (e.g., from request query params) to
|
|
16
|
+
`query_params_handler`.
|
|
17
|
+
- Only string values are considered; non-string values are ignored.
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
filters = {"name": "john", "email": "example.com", "age": 30}
|
|
21
|
+
# Resulting queryset filter:
|
|
22
|
+
# queryset.filter(name__icontains="john", email__icontains="example.com")
|
|
23
|
+
# Note: "age" is ignored because its value is not a string.
|
|
24
|
+
|
|
25
|
+
Apply `__icontains` filters to the provided queryset based on the given filter
|
|
26
|
+
dictionary.
|
|
27
|
+
|
|
28
|
+
Parameters:
|
|
29
|
+
queryset (django.db.models.QuerySet):
|
|
30
|
+
The base queryset to filter.
|
|
31
|
+
filters (dict[str, Any]):
|
|
32
|
+
A mapping of field names to desired filter values. Only entries with
|
|
33
|
+
string values will be transformed into `field__icontains=value`
|
|
34
|
+
filters. Non-string values are ignored.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
django.db.models.QuerySet:
|
|
38
|
+
A queryset filtered with `__icontains` lookups for all string-valued
|
|
39
|
+
keys in `filters`.
|
|
40
|
+
|
|
41
|
+
Notes:
|
|
42
|
+
- This method is asynchronous to align with async view workflows, but it
|
|
43
|
+
performs a synchronous queryset filter call typical in Django ORM usage.
|
|
44
|
+
- Ensure that the fields referenced in `filters` exist on the model
|
|
45
|
+
associated with `queryset`, otherwise a FieldError may be raised at
|
|
46
|
+
evaluation time.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
async def query_params_handler(self, queryset, filters):
|
|
50
|
+
"""
|
|
51
|
+
Apply icontains filter to the queryset based on provided filters.
|
|
52
|
+
"""
|
|
53
|
+
base_qs = await super().query_params_handler(queryset, filters)
|
|
54
|
+
return base_qs.filter(
|
|
55
|
+
**{
|
|
56
|
+
f"{key}__icontains": value
|
|
57
|
+
for key, value in filters.items()
|
|
58
|
+
if isinstance(value, str)
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BooleanFilterViewSetMixin(APIViewSet):
|
|
64
|
+
"""
|
|
65
|
+
Mixin providing boolean-based filtering for Django QuerySets.
|
|
66
|
+
|
|
67
|
+
This mixin defines a helper to apply boolean filters from a dictionary of query
|
|
68
|
+
parameters, selecting only those values that are strictly boolean (True/False)
|
|
69
|
+
and ignoring any non-boolean entries. It is intended to be used with viewsets
|
|
70
|
+
that expose queryable endpoints.
|
|
71
|
+
|
|
72
|
+
Methods:
|
|
73
|
+
query_params_handler(queryset, filters):
|
|
74
|
+
Apply boolean filters to the given queryset based on the provided
|
|
75
|
+
dictionary. Only keys with boolean values are included in the filter.
|
|
76
|
+
|
|
77
|
+
Parameters:
|
|
78
|
+
queryset (QuerySet): A Django QuerySet to be filtered.
|
|
79
|
+
filters (dict): A mapping of field names to potential filter values.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
QuerySet: A new QuerySet filtered by the boolean entries in `filters`.
|
|
83
|
+
|
|
84
|
+
Notes:
|
|
85
|
+
- Non-boolean values in `filters` are ignored.
|
|
86
|
+
- Keys should correspond to valid model fields or lookups.
|
|
87
|
+
- This method does not mutate the original queryset; it returns a
|
|
88
|
+
filtered clone.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
async def query_params_handler(self, queryset, filters):
|
|
92
|
+
"""
|
|
93
|
+
Apply boolean filter to the queryset based on provided filters.
|
|
94
|
+
"""
|
|
95
|
+
base_qs = await super().query_params_handler(queryset, filters)
|
|
96
|
+
return base_qs.filter(
|
|
97
|
+
**{key: value for key, value in filters.items() if isinstance(value, bool)}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class NumericFilterViewSetMixin(APIViewSet):
|
|
102
|
+
"""
|
|
103
|
+
Mixin providing numeric filtering for Django QuerySets.
|
|
104
|
+
|
|
105
|
+
This mixin defines a helper to apply numeric filters from a dictionary of query
|
|
106
|
+
parameters, selecting only those values that are of numeric type (int or float)
|
|
107
|
+
and ignoring any non-numeric entries. It is intended to be used with viewsets
|
|
108
|
+
that expose queryable endpoints.
|
|
109
|
+
|
|
110
|
+
Methods:
|
|
111
|
+
query_params_handler(queryset, filters):
|
|
112
|
+
Apply numeric filters to the given queryset based on the provided
|
|
113
|
+
dictionary. Only keys with numeric values are included in the filter.
|
|
114
|
+
|
|
115
|
+
Parameters:
|
|
116
|
+
queryset (QuerySet): A Django QuerySet to be filtered.
|
|
117
|
+
filters (dict): A mapping of field names to potential filter values.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
QuerySet: A new QuerySet filtered by the numeric entries in `filters`.
|
|
121
|
+
|
|
122
|
+
Notes:
|
|
123
|
+
- Non-numeric values in `filters` are ignored.
|
|
124
|
+
- Keys should correspond to valid model fields or lookups.
|
|
125
|
+
- This method does not mutate the original queryset; it returns a
|
|
126
|
+
filtered clone.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
async def query_params_handler(self, queryset, filters):
|
|
130
|
+
"""
|
|
131
|
+
Apply numeric filter to the queryset based on provided filters.
|
|
132
|
+
"""
|
|
133
|
+
base_qs = await super().query_params_handler(queryset, filters)
|
|
134
|
+
return base_qs.filter(
|
|
135
|
+
**{
|
|
136
|
+
key: value
|
|
137
|
+
for key, value in filters.items()
|
|
138
|
+
if isinstance(value, (int, float))
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class DateFilterViewSetMixin(APIViewSet):
|
|
144
|
+
"""
|
|
145
|
+
Mixin enabling date/datetime-based filtering for Django QuerySets.
|
|
146
|
+
|
|
147
|
+
Purpose:
|
|
148
|
+
- Apply dynamic date/datetime filters based on incoming query parameters.
|
|
149
|
+
- Support customizable comparison operators via `_compare_attr` (e.g., "__gt", "__lt", "__gte", "__lte").
|
|
150
|
+
|
|
151
|
+
Behavior:
|
|
152
|
+
- Filters only entries whose values implement `isoformat` (dates or datetimes).
|
|
153
|
+
- Builds lookups as "<field><_compare_attr>" with the provided value.
|
|
154
|
+
|
|
155
|
+
Attributes:
|
|
156
|
+
- _compare_attr (str): Django ORM comparison operator suffix to append to field names.
|
|
157
|
+
|
|
158
|
+
Notes:
|
|
159
|
+
- Ensure provided filter values are compatible with the target model fields.
|
|
160
|
+
- Subclasses should set `_compare_attr` to control comparison semantics.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
_compare_attr: str = ""
|
|
164
|
+
|
|
165
|
+
async def query_params_handler(self, queryset, filters):
|
|
166
|
+
"""
|
|
167
|
+
Apply date/datetime filters using `_compare_attr`.
|
|
168
|
+
|
|
169
|
+
- Delegates to `super().query_params_handler` first.
|
|
170
|
+
- Applies filters for keys whose values implement `isoformat`.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
- QuerySet filtered with lookups in the form: field<_compare_attr>=value.
|
|
174
|
+
"""
|
|
175
|
+
base_qs = await super().query_params_handler(queryset, filters)
|
|
176
|
+
return base_qs.filter(
|
|
177
|
+
**{
|
|
178
|
+
f"{key}{self._compare_attr}": value
|
|
179
|
+
for key, value in filters.items()
|
|
180
|
+
if hasattr(value, "isoformat")
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class GreaterDateFilterViewSetMixin(DateFilterViewSetMixin):
|
|
186
|
+
"""
|
|
187
|
+
Mixin that configures date filtering to return records with dates strictly greater than a given value.
|
|
188
|
+
This class extends DateFilterViewSetMixin and sets the internal comparison attribute to `__gt`,
|
|
189
|
+
ensuring that any date-based filtering uses the "greater than" operation.
|
|
190
|
+
Attributes:
|
|
191
|
+
_compare_attr (str): The Django ORM comparison operator used for filtering, set to "__gt".
|
|
192
|
+
Usage:
|
|
193
|
+
- Include this mixin in a ViewSet to filter queryset results where the specified date field is
|
|
194
|
+
greater than the provided date value.
|
|
195
|
+
- Typically used in endpoints that need to fetch items created/updated after a certain timestamp.
|
|
196
|
+
Example:
|
|
197
|
+
class MyViewSet(GreaterDateFilterViewSetMixin, ModelViewSet):
|
|
198
|
+
date_filter_field = "created_at"
|
|
199
|
+
...
|
|
200
|
+
# Filtering will apply: queryset.filter(created_at__gt=<provided_date>)
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
_compare_attr = "__gt"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class LessDateFilterViewSetMixin(DateFilterViewSetMixin):
|
|
207
|
+
"""
|
|
208
|
+
Mixin that configures date filtering to return records with dates strictly less than a given value.
|
|
209
|
+
This class extends DateFilterViewSetMixin and sets the internal comparison attribute to `__lt`,
|
|
210
|
+
ensuring that any date-based filtering uses the "less than" operation.
|
|
211
|
+
Attributes:
|
|
212
|
+
_compare_attr (str): The Django ORM comparison operator used for filtering, set to "__lt".
|
|
213
|
+
Usage:
|
|
214
|
+
- Include this mixin in a ViewSet to filter queryset results where the specified date field is
|
|
215
|
+
less than the provided date value.
|
|
216
|
+
- Typically used in endpoints that need to fetch items created/updated before a certain timestamp.
|
|
217
|
+
Example:
|
|
218
|
+
class MyViewSet(LessDateFilterViewSetMixin, ModelViewSet):
|
|
219
|
+
date_filter_field = "created_at"
|
|
220
|
+
...
|
|
221
|
+
# Filtering will apply: queryset.filter(created_at__lt=<provided_date>)
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
_compare_attr = "__lt"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class GreaterEqualDateFilterViewSetMixin(DateFilterViewSetMixin):
|
|
228
|
+
"""
|
|
229
|
+
Mixin for date-based filtering that uses a "greater than or equal to" comparison.
|
|
230
|
+
|
|
231
|
+
This mixin extends DateFilterViewSetMixin by setting the internal comparison
|
|
232
|
+
attribute to "__gte", enabling querysets to be filtered to include records whose
|
|
233
|
+
date or datetime fields are greater than or equal to the provided value.
|
|
234
|
+
|
|
235
|
+
Intended Use:
|
|
236
|
+
- Apply to Django Ninja or DRF viewsets that support date filtering.
|
|
237
|
+
- Combine with DateFilterViewSetMixin to standardize date filter behavior.
|
|
238
|
+
|
|
239
|
+
Behavior:
|
|
240
|
+
- When a date filter parameter is present, the queryset is filtered using
|
|
241
|
+
Django's "__gte" lookup, e.g., MyModel.objects.filter(created_at__gte=value).
|
|
242
|
+
|
|
243
|
+
Attributes:
|
|
244
|
+
- _compare_attr: str
|
|
245
|
+
The Django ORM comparison operator used for filtering; set to "__gte".
|
|
246
|
+
|
|
247
|
+
Notes:
|
|
248
|
+
- Ensure the target field and provided filter value are compatible (date or datetime).
|
|
249
|
+
- Timezone-aware comparisons should be handled consistently within the project settings.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
_compare_attr = "__gte"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class LessEqualDateFilterViewSetMixin(DateFilterViewSetMixin):
|
|
256
|
+
"""
|
|
257
|
+
Mixin for date-based filtering that uses a "less than or equal to" comparison.
|
|
258
|
+
This mixin extends DateFilterViewSetMixin by setting the internal comparison
|
|
259
|
+
attribute to "__lte", enabling querysets to be filtered to include records whose
|
|
260
|
+
date or datetime fields are less than or equal to the provided value.
|
|
261
|
+
Intended Use:
|
|
262
|
+
- Apply to Django Ninja or DRF viewsets that support date filtering.
|
|
263
|
+
- Combine with DateFilterViewSetMixin to standardize date filter behavior.
|
|
264
|
+
Behavior:
|
|
265
|
+
- When a date filter parameter is present, the queryset is filtered using
|
|
266
|
+
Django's "__lte" lookup, e.g., MyModel.objects.filter(created_at__lte=value).
|
|
267
|
+
Attributes:
|
|
268
|
+
- _compare_attr: str
|
|
269
|
+
The Django ORM comparison operator used for filtering; set to "__lte".
|
|
270
|
+
Notes:
|
|
271
|
+
- Ensure the target field and provided filter value are compatible (date or datetime).
|
|
272
|
+
- Timezone-aware comparisons should be handled consistently within the project settings.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
_compare_attr = "__lte"
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
ninja_aio/__init__.py,sha256=YT267RC9JIxwAbuYlHPZtMBAKcfulXoXzTmGwXu0GVU,123
|
|
2
|
-
ninja_aio/api.py,sha256=SS1TYUiFkdYjfJLVy6GI90GOzvIHzPEeL-UcqWFRHkM,1684
|
|
3
|
-
ninja_aio/auth.py,sha256=8jaEp7oEJvUUB9EuyE2fOYk-khyAaekT3i80E7AbgOA,5101
|
|
4
|
-
ninja_aio/decorators.py,sha256=gswkwl1zWSpW8VxGCe8MlgXcHMg6Y7V1f2ertey9Tjo,5522
|
|
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=h_embl52o5WOVbhbB05vkNKEGOm-s4PvdAjy81-6ISw,16424
|
|
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=al1G5-CdB9CF3eIPdZcUSE7vKRJiTU5g1ltoPhI2Onw,2622
|
|
18
|
-
django_ninja_aio_crud-2.0.0rc6.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
|
|
19
|
-
django_ninja_aio_crud-2.0.0rc6.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
20
|
-
django_ninja_aio_crud-2.0.0rc6.dist-info/METADATA,sha256=QKZ7nA93H8coEBeWBC-rSx2J4QIPsA0TjAiuc4tQnmo,8675
|
|
21
|
-
django_ninja_aio_crud-2.0.0rc6.dist-info/RECORD,,
|
|
File without changes
|
{django_ninja_aio_crud-2.0.0rc6.dist-info → django_ninja_aio_crud-2.1.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|