django-ninja-aio-crud 2.0.0rc7__py3-none-any.whl → 2.2.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.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.0.0rc7
3
+ Version: 2.2.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
- Requires-Python: >=3.10
6
+ Requires-Python: >=3.10, <=3.14
7
7
  Description-Content-Type: text/markdown
8
8
  Classifier: Operating System :: OS Independent
9
9
  Classifier: Topic :: Internet
@@ -0,0 +1,23 @@
1
+ ninja_aio/__init__.py,sha256=Jgj89rpJ3n4pkGfoSYm9NYrwBjGzwhiOeU5c6mr-J8Q,119
2
+ ninja_aio/api.py,sha256=tuC7vdvn7s1GkCnSFy9Kn1zv0glZfYptRQVvo8ZRtGQ,2429
3
+ ninja_aio/auth.py,sha256=4sWdFPjKiQgUL1d_CSGDblVjnY5ptP6LQha6XXdluJA,9157
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=nFqWEopm7eoEaHRzbi6EyA9WZ5Cneyd602ilFKypeQI,577
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=O_QQBxk-MltU-bPjxOSu5fFpHaDNP5J3Wk6E5rSBgi4,19744
19
+ ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
20
+ django_ninja_aio_crud-2.2.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
21
+ django_ninja_aio_crud-2.2.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
22
+ django_ninja_aio_crud-2.2.0.dist-info/METADATA,sha256=lJiJIHnNMaKXAM-aOwuKlcAmaJt8oV_wtiDGSFvjjJE,8680
23
+ django_ninja_aio_crud-2.2.0.dist-info/RECORD,,
ninja_aio/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.0.0-rc7"
3
+ __version__ = "2.2.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
ninja_aio/api.py CHANGED
@@ -5,10 +5,13 @@ from ninja.throttling import BaseThrottle
5
5
  from ninja import NinjaAPI
6
6
  from ninja.openapi.docs import DocsBase, Swagger
7
7
  from ninja.constants import NOT_SET, NOT_SET_TYPE
8
+ from django.db import models
8
9
 
9
10
  from .parsers import ORJSONParser
10
11
  from .renders import ORJSONRenderer
11
12
  from .exceptions import set_api_exception_handlers
13
+ from .views import APIView, APIViewSet
14
+ from .models import ModelSerializer
12
15
 
13
16
 
14
17
  class NinjaAIO(NinjaAPI):
@@ -49,3 +52,24 @@ class NinjaAIO(NinjaAPI):
49
52
  def set_default_exception_handlers(self):
50
53
  set_api_exception_handlers(self)
51
54
  super().set_default_exception_handlers()
55
+
56
+ def view(self, prefix: str, tags: list[str] = None) -> Any:
57
+ def wrapper(view: type[APIView]):
58
+ instance = view(api=self, prefix=prefix, tags=tags)
59
+ instance.add_views_to_route()
60
+ return instance
61
+
62
+ return wrapper
63
+
64
+ def viewset(
65
+ self,
66
+ model: models.Model | ModelSerializer,
67
+ prefix: str = None,
68
+ tags: list[str] = None,
69
+ ) -> None:
70
+ def wrapper(viewset: type[APIViewSet]):
71
+ instance = viewset(api=self, model=model, prefix=prefix, tags=tags)
72
+ instance.add_views_to_route()
73
+ return instance
74
+
75
+ return wrapper
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 RSA public key (JWK format) used to verify the JWT signature.
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: jwk.RSAKey
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, jwk.OctKey)):
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: JwtKeys = 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: JwtKeys = 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/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
- @unique_view(f"get_{self.related_model_util.model_name}_{rel_path}")
361
- @paginate(self.pagination_class)
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/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 | jwk.OctKey
10
12
 
11
13
  class ModelSerializerType(type):
12
14
  def __repr__(self):
@@ -0,0 +1,3 @@
1
+ from .api import APIView, APIViewSet, ReadOnlyViewSet, WriteOnlyViewSet
2
+
3
+ __all__ = ["APIView", "APIViewSet", "ReadOnlyViewSet", "WriteOnlyViewSet"]
@@ -9,28 +9,25 @@ from pydantic import create_model
9
9
 
10
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, decorate_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
- ERROR_CODES = frozenset({400, 401, 404, 428})
21
+ ERROR_CODES = frozenset({400, 401, 404})
22
22
 
23
23
 
24
- class APIView:
25
- api: NinjaAPI
26
- router_tag: str
27
- api_route_path: str
24
+ class API:
25
+ api: NinjaAPI = None
26
+ router_tag: str = ""
27
+ router_tags: list[str] = []
28
+ api_route_path: str = ""
28
29
  auth: list | None = NOT_SET
29
30
 
30
- def __init__(self) -> None:
31
- self.router = Router(tags=[self.router_tag])
32
- self.error_codes = ERROR_CODES
33
-
34
31
  def views(self):
35
32
  """
36
33
  Override this method to add your custom views. For example:
@@ -38,7 +35,7 @@ class APIView:
38
35
  async def some_method(request, *args, **kwargs):
39
36
  pass
40
37
 
41
- You can add multilple views just doing:
38
+ You can add views just doing:
42
39
 
43
40
  @self.router.get(some_path, response=some_schema)
44
41
  async def some_method(request, *args, **kwargs):
@@ -63,20 +60,80 @@ class APIView:
63
60
  async def some_method(request, *args, **kwargs):
64
61
  pass
65
62
  """
63
+ pass
66
64
 
67
65
  def _add_views(self):
68
- self.views()
69
- return self.router
66
+ raise NotImplementedError("_add_views must be implemented in subclasses")
70
67
 
71
68
  def add_views_to_route(self):
72
69
  return self.api.add_router(f"{self.api_route_path}", self._add_views())
73
70
 
74
71
 
75
- class APIViewSet:
72
+ class APIView(API):
73
+ """
74
+ Base class to register custom, non-CRUD endpoints on a Ninja Router.
75
+
76
+ Usage:
77
+ @api.view(prefix="/custom", tags=["Custom"])
78
+ class CustomAPIView(APIView):
79
+ def views(self):
80
+ @self.router.get("/hello", response=SomeSchema)
81
+ async def hello(request):
82
+ return SomeSchema(...)
83
+
84
+ or
85
+
86
+ class CustomAPIView(APIView):
87
+ api = api
88
+ api_route_path = "/custom"
89
+ router_tags = ["Custom"]
90
+
91
+ def views(self):
92
+ @self.router.get("/hello", response=SomeSchema)
93
+ async def hello(request):
94
+ return SomeSchema(...)
95
+
96
+
97
+ CustomAPIView().add_views_to_route()
98
+
99
+ Attributes:
100
+ api: NinjaAPI instance used to mount the router.
101
+ router_tag: Single tag used if router_tags is not provided.
102
+ router_tags: List of tags assigned to the router.
103
+ api_route_path: Base path where the router is mounted.
104
+ auth: Default auth list or NOT_SET for unauthenticated endpoints.
105
+ router: Router instance where views are registered.
106
+ error_codes: Common error codes returned by endpoints.
107
+
108
+ Overridable methods:
109
+ views(): Register your endpoints using self.router.get/post/patch/delete.
110
+ """
111
+
112
+ def __init__(
113
+ self, api: NinjaAPI = None, prefix: str = None, tags: list[str] = None
114
+ ) -> None:
115
+ self.api = api or self.api
116
+ self.api_route_path = prefix or self.api_route_path
117
+ self.router_tags = tags or self.router_tags or [self.router_tag]
118
+ self.router = Router(tags=self.router_tags)
119
+ self.error_codes = ERROR_CODES
120
+
121
+ def _add_views(self):
122
+ self.views()
123
+ return self.router
124
+
125
+
126
+ class APIViewSet(API):
76
127
  """
77
128
  Base viewset generating async CRUD + optional M2M endpoints for a Django model.
78
129
 
79
130
  Usage:
131
+ @api.viewset(model=MyModel)
132
+ class MyModelViewSet(APIViewSet):
133
+ pass
134
+
135
+ or
136
+
80
137
  class MyModelViewSet(APIViewSet):
81
138
  model = MyModel
82
139
  api = api
@@ -103,14 +160,14 @@ class APIViewSet:
103
160
  filters = { "field_name": (type, default) }
104
161
  A dynamic Pydantic Filters schema is generated and exposed as query params
105
162
  on the related GET endpoint: /{pk}/{related_path}?field_name=value.
106
- To apply custom filter logic implement an async hook named:
163
+ To apply custom filter logic implement an hook named:
107
164
  <related_name>_query_params_handler(self, queryset, filters_dict)
108
165
  It receives the initial related queryset and the validated/dumped filters
109
166
  dict, and must return the (optionally) filtered queryset.
110
167
 
111
168
  Example:
169
+ @api.viewset(model=models.User)
112
170
  class UserViewSet(APIViewSet):
113
- model = models.User
114
171
  m2m_relations = [
115
172
  M2MRelationSchema(
116
173
  model=models.Tag,
@@ -121,7 +178,7 @@ class APIViewSet:
121
178
  )
122
179
  ]
123
180
 
124
- async def tags_query_params_handler(self, queryset, filters):
181
+ def tags_query_params_handler(self, queryset, filters):
125
182
  name_filter = filters.get("name")
126
183
  if name_filter:
127
184
  queryset = queryset.filter(name__icontains=name_filter)
@@ -145,11 +202,11 @@ class APIViewSet:
145
202
 
146
203
  Overridable hooks:
147
204
  views(): Register extra custom endpoints on self.router.
148
- query_params_handler(queryset, filters): Async hook to apply list filters.
205
+ query_params_handler(queryset, filters): Sync/Async hook to apply list filters.
149
206
  <related_name>_query_params_handler(queryset, filters): Async hook for per-M2M filtering.
150
207
 
151
208
  Error responses:
152
- All endpoints may return GenericMessageSchema for codes in ERROR_CODES (400,401,404,428).
209
+ All endpoints may return GenericMessageSchema for codes in ERROR_CODES (400,401,404).
153
210
 
154
211
  Internal:
155
212
  Dynamic path/filter schemas built with pydantic.create_model.
@@ -157,12 +214,9 @@ class APIViewSet:
157
214
  """
158
215
 
159
216
  model: ModelSerializer | Model
160
- api: NinjaAPI
161
- router_tag: str = ""
162
217
  schema_in: Schema | None = None
163
218
  schema_out: Schema | None = None
164
219
  schema_update: Schema | None = None
165
- auth: list | None = NOT_SET
166
220
  get_auth: list | None = NOT_SET
167
221
  post_auth: list | None = NOT_SET
168
222
  patch_auth: list | None = NOT_SET
@@ -170,7 +224,6 @@ class APIViewSet:
170
224
  pagination_class: type[AsyncPaginationBase] = PageNumberPagination
171
225
  query_params: dict[str, tuple[type, ...]] = {}
172
226
  disable: list[type[VIEW_TYPES]] = []
173
- api_route_path: str = ""
174
227
  list_docs = "List all objects."
175
228
  create_docs = "Create a new object."
176
229
  retrieve_docs = "Retrieve a specific object by its primary key."
@@ -180,8 +233,16 @@ class APIViewSet:
180
233
  m2m_auth: list | None = NOT_SET
181
234
  extra_decorators: DecoratorsSchema = DecoratorsSchema()
182
235
 
183
- def __init__(self) -> None:
236
+ def __init__(
237
+ self,
238
+ api: NinjaAPI = None,
239
+ model: Model | ModelSerializer = None,
240
+ prefix: str = None,
241
+ tags: list[str] = None,
242
+ ) -> None:
243
+ self.api = api or self.api
184
244
  self.error_codes = ERROR_CODES
245
+ self.model = model or self.model
185
246
  self.model_util = (
186
247
  ModelUtil(self.model)
187
248
  if not isinstance(self.model, ModelSerializerMeta)
@@ -191,16 +252,17 @@ class APIViewSet:
191
252
  self.path_schema = self._generate_path_schema()
192
253
  self.filters_schema = self._generate_filters_schema()
193
254
  self.model_verbose_name = self.model._meta.verbose_name.capitalize()
194
- self.router_tag = (
195
- self.model_verbose_name if not self.router_tag else self.router_tag
196
- )
197
- self.router = Router(tags=[self.router_tag])
255
+ self.router_tag = self.router_tag or self.model_verbose_name
256
+ self.router_tags = self.router_tags or tags or [self.router_tag]
257
+ self.router = Router(tags=self.router_tags)
198
258
  self.path = "/"
199
259
  self.get_path = ""
200
260
  self.path_retrieve = f"{{{self.model_util.model_pk_name}}}/"
201
261
  self.get_path_retrieve = f"{{{self.model_util.model_pk_name}}}"
202
262
  self.api_route_path = (
203
- self.api_route_path or self.model_util.verbose_name_path_resolver()
263
+ self.api_route_path
264
+ or prefix
265
+ or self.model_util.verbose_name_path_resolver()
204
266
  )
205
267
  self.m2m_api = (
206
268
  None
@@ -278,15 +340,18 @@ class APIViewSet:
278
340
 
279
341
  def get_schemas(self):
280
342
  """
281
- Return (schema_out, schema_in, schema_update), generating them if model is a ModelSerializer.
343
+ Compute and return (schema_out, schema_in, schema_update).
344
+
345
+ - If model is a ModelSerializer (ModelSerializerMeta), auto-generate read/create/update schemas.
346
+ - Otherwise, return the schemas already set on the viewset (may be None).
282
347
  """
283
- if isinstance(self.model, ModelSerializerMeta):
284
- return (
285
- self.model.generate_read_s(),
286
- self.model.generate_create_s(),
287
- self.model.generate_update_s(),
288
- )
289
- return self.schema_out, self.schema_in, self.schema_update
348
+ if not isinstance(self.model, ModelSerializerMeta):
349
+ return self.schema_out, self.schema_in, self.schema_update
350
+ return (
351
+ self.schema_out or self.model.generate_read_s(),
352
+ self.schema_in or self.model.generate_create_s(),
353
+ self.schema_update or self.model.generate_update_s(),
354
+ )
290
355
 
291
356
  async def query_params_handler(
292
357
  self, queryset: QuerySet[ModelSerializer], filters: dict
@@ -447,8 +512,41 @@ class APIViewSet:
447
512
 
448
513
  return self._set_additional_views()
449
514
 
450
- def add_views_to_route(self):
451
- """
452
- Attach router with registered endpoints to the NinjaAPI instance.
453
- """
454
- return self.api.add_router(f"{self.api_route_path}", self._add_views())
515
+
516
+ class ReadOnlyViewSet(APIViewSet):
517
+ """
518
+ ReadOnly viewset generating async List + Retrieve endpoints for a Django model.
519
+
520
+ Usage:
521
+ @api.viewset(model=MyModel)
522
+ class MyModelReadOnlyViewSet(ReadOnlyViewSet):
523
+ pass
524
+
525
+ or
526
+
527
+ class MyModelReadOnlyViewSet(ReadOnlyViewSet):
528
+ model = MyModel
529
+ api = api
530
+ MyModelReadOnlyViewSet().add_views_to_route()
531
+ """
532
+
533
+ disable = ["create", "update", "delete"]
534
+
535
+
536
+ class WriteOnlyViewSet(APIViewSet):
537
+ """
538
+ WriteOnly viewset generating async Create + Update + Delete endpoints for a Django model.
539
+
540
+ Usage:
541
+ @api.viewset(model=MyModel)
542
+ class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
543
+ pass
544
+
545
+ or
546
+
547
+ class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
548
+ model = MyModel
549
+ api = api
550
+ """
551
+
552
+ 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=b0K4gr4xA4QngOZ7PrtTuDcxgyk3yFTNwNxsCObFqug,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=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.0rc7.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
19
- django_ninja_aio_crud-2.0.0rc7.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
20
- django_ninja_aio_crud-2.0.0rc7.dist-info/METADATA,sha256=bxFgTP1QMN1oSLPw4fhV3Nu9-U90pNN92FoiVqJq6hY,8675
21
- django_ninja_aio_crud-2.0.0rc7.dist-info/RECORD,,