django-ninja-aio-crud 2.1.0__py3-none-any.whl → 2.3.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.

Potentially problematic release.


This version of django-ninja-aio-crud might be problematic. Click here for more details.

@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.1.0
3
+ Version: 2.3.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
@@ -100,11 +100,10 @@ from .models import Book
100
100
 
101
101
  api = NinjaAIO()
102
102
 
103
+ @api.viewset(Book)
103
104
  class BookViewSet(APIViewSet):
104
- model = Book
105
- api = api
105
+ pass
106
106
 
107
- BookViewSet().add_views_to_route()
108
107
  ```
109
108
 
110
109
  Visit `/docs` → CRUD endpoints ready.
@@ -114,9 +113,8 @@ Visit `/docs` → CRUD endpoints ready.
114
113
  ## 🔄 Query Filtering
115
114
 
116
115
  ```python
116
+ @api.viewset(Book)
117
117
  class BookViewSet(APIViewSet):
118
- model = Book
119
- api = api
120
118
  query_params = {"published": (bool, None), "title": (str, None)}
121
119
 
122
120
  async def query_params_handler(self, queryset, filters):
@@ -151,9 +149,8 @@ class Article(ModelSerializer):
151
149
  class ReadSerializer:
152
150
  fields = ["id", "title", "tags"]
153
151
 
152
+ @api.viewset(Article)
154
153
  class ArticleViewSet(APIViewSet):
155
- model = Article
156
- api = api
157
154
  m2m_relations = [
158
155
  M2MRelationSchema(
159
156
  model=Tag,
@@ -168,7 +165,6 @@ class ArticleViewSet(APIViewSet):
168
165
  queryset = queryset.filter(name__icontains=n)
169
166
  return queryset
170
167
 
171
- ArticleViewSet().add_views_to_route()
172
168
  ```
173
169
 
174
170
  Endpoints:
@@ -195,9 +191,8 @@ class JWTAuth(AsyncJwtBearer):
195
191
  book_id = self.dcd.claims.get("sub")
196
192
  return await Book.objects.aget(id=book_id)
197
193
 
194
+ @api.viewset(Book)
198
195
  class SecureBookViewSet(APIViewSet):
199
- model = Book
200
- api = api
201
196
  auth = [JWTAuth()]
202
197
  get_auth = None # list/retrieve public
203
198
  ```
@@ -220,10 +215,21 @@ Available on every save/delete:
220
215
  ## 🧩 Adding Custom Endpoints
221
216
 
222
217
  ```python
218
+ from ninja_aio.decorators import api_get
219
+
220
+ @api.viewset(Book)
223
221
  class BookViewSet(APIViewSet):
224
- model = Book
225
- api = api
222
+ @api_get("/stats/")
223
+ async def stats(self, request):
224
+ total = await Book.objects.acount()
225
+ return {"total": total}
226
+ ```
227
+
228
+ Or
226
229
 
230
+ ```python
231
+ @api.viewset(Book)
232
+ class BookViewSet(APIViewSet):
227
233
  def views(self):
228
234
  @self.router.get("/stats/")
229
235
  async def stats(request):
@@ -288,9 +294,8 @@ count = await Book.objects.acount()
288
294
  ## 🚫 Disable Operations
289
295
 
290
296
  ```python
297
+ @api.viewset(Book)
291
298
  class ReadOnlyBookViewSet(APIViewSet):
292
- model = Book
293
- api = api
294
299
  disable = ["update", "delete"]
295
300
  ```
296
301
 
@@ -0,0 +1,27 @@
1
+ ninja_aio/__init__.py,sha256=E3q86wZJvkl9LIkWh-fNxr_uH_7pT-x4KJC-WMIjMY0,119
2
+ ninja_aio/api.py,sha256=tuC7vdvn7s1GkCnSFy9Kn1zv0glZfYptRQVvo8ZRtGQ,2429
3
+ ninja_aio/auth.py,sha256=4sWdFPjKiQgUL1d_CSGDblVjnY5ptP6LQha6XXdluJA,9157
4
+ ninja_aio/exceptions.py,sha256=_3xFqfFCOfrrMhSA0xbMqgXy8R0UQjhXaExrFvaDAjY,3891
5
+ ninja_aio/models.py,sha256=aJlo5a64O4o-fB8QESLMUJpoA5kcjRJxPBiAIMxg46k,47652
6
+ ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
7
+ ninja_aio/renders.py,sha256=VtmSliRJyZ6gjyoib8AXMVUBYF1jPNsiceCHujI_mAs,1699
8
+ ninja_aio/types.py,sha256=nFqWEopm7eoEaHRzbi6EyA9WZ5Cneyd602ilFKypeQI,577
9
+ ninja_aio/decorators/__init__.py,sha256=cDDHD_9EI4CP7c5eL1m2mGNl9bR24i8FAkQsT3_RNGM,371
10
+ ninja_aio/decorators/operations.py,sha256=L9yt2ku5oo4CpOLixCADmkcFjLGsWAn-cg-sDcjFhMA,343
11
+ ninja_aio/decorators/views.py,sha256=0RVU4XaM1HvTQ-BOt_NwUtbhwfHau06lh-O8El1LqQk,8139
12
+ ninja_aio/factory/__init__.py,sha256=IdH2z1ZZpv_IqonaDfVo7IsMzkgop6lHqz42RphUYBU,72
13
+ ninja_aio/factory/operations.py,sha256=OgWGqq4WJ4arSQrH9FGAby9kx-HTdS7MOITxHdYMk18,12051
14
+ ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ ninja_aio/helpers/api.py,sha256=YMzuZ4-ZpUrJBQIabE26gb_GYwsH2rVosWRE95YfdPQ,20775
16
+ ninja_aio/helpers/query.py,sha256=YJMdEonCuqx1XjmszCK74mg5hcUPh84ynXrsuoSQdNA,4519
17
+ ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
18
+ ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
19
+ ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
20
+ ninja_aio/schemas/helpers.py,sha256=W6IeHi5Tmbjh3FXwDYqjqlLBTVj5uTYq3_JVkNUWayo,7355
21
+ ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
22
+ ninja_aio/views/api.py,sha256=jJ0Awl9ynuvM6rWetgp9KHTKlnwREyjl2cxCobY4I_4,20158
23
+ ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
24
+ django_ninja_aio_crud-2.3.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
25
+ django_ninja_aio_crud-2.3.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
26
+ django_ninja_aio_crud-2.3.0.dist-info/METADATA,sha256=6NxeKxjsDeOO3Zp7E-keTspm6tEzzSvXlcpthS0OVYI,8790
27
+ django_ninja_aio_crud-2.3.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.1.0"
3
+ __version__ = "2.3.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
@@ -124,7 +124,7 @@ def validate_key(key: Optional[JwtKeys], setting_name: str) -> JwtKeys:
124
124
  key = getattr(settings, setting_name, None)
125
125
  if key is None:
126
126
  raise ValueError(f"{setting_name} is required")
127
- if not isinstance(key, (jwk.RSAKey, jwk.ECKey)):
127
+ if not isinstance(key, (jwk.RSAKey, jwk.ECKey, jwk.OctKey)):
128
128
  raise ValueError(
129
129
  f"{setting_name} must be an instance of jwk.RSAKey or jwk.ECKey"
130
130
  )
@@ -143,7 +143,7 @@ def validate_mandatory_claims(claims: dict) -> dict:
143
143
 
144
144
 
145
145
  def encode_jwt(
146
- claims: dict, duration: int, private_key: jwk.RSAKey = None, algorithm: str = None
146
+ claims: dict, duration: int, private_key: JwtKeys = None, algorithm: str = None
147
147
  ) -> str:
148
148
  """
149
149
  Encode and sign a JWT.
@@ -192,7 +192,7 @@ def encode_jwt(
192
192
 
193
193
  def decode_jwt(
194
194
  token: str,
195
- public_key: jwk.RSAKey | jwk.ECKey = None,
195
+ public_key: JwtKeys = None,
196
196
  algorithms: list[str] = None,
197
197
  ) -> jwt.Token:
198
198
  """
@@ -0,0 +1,23 @@
1
+ from .views import decorate_view, aatomic, unique_view
2
+ from .operations import (
3
+ api_get,
4
+ api_post,
5
+ api_put,
6
+ api_delete,
7
+ api_patch,
8
+ api_options,
9
+ api_head,
10
+ )
11
+
12
+ __all__ = [
13
+ "decorate_view",
14
+ "aatomic",
15
+ "unique_view",
16
+ "api_get",
17
+ "api_post",
18
+ "api_put",
19
+ "api_delete",
20
+ "api_patch",
21
+ "api_options",
22
+ "api_head",
23
+ ]
@@ -0,0 +1,9 @@
1
+ from ninja_aio.factory import ApiMethodFactory
2
+
3
+ api_get = ApiMethodFactory.make("get")
4
+ api_post = ApiMethodFactory.make("post")
5
+ api_put = ApiMethodFactory.make("put")
6
+ api_patch = ApiMethodFactory.make("patch")
7
+ api_delete = ApiMethodFactory.make("delete")
8
+ api_options = ApiMethodFactory.make("options")
9
+ api_head = ApiMethodFactory.make("head")
@@ -1,5 +1,6 @@
1
- from django.db.transaction import Atomic
2
1
  from functools import wraps
2
+
3
+ from django.db.transaction import Atomic
3
4
  from asgiref.sync import sync_to_async
4
5
 
5
6
 
@@ -189,7 +190,7 @@ def decorate_view(*decorators):
189
190
  @decorate_view(authenticate, log_request)
190
191
  async def some_view(request):
191
192
  ...
192
-
193
+
193
194
  Conditional decoration (skips None):
194
195
  class MyAPIViewSet(APIViewSet):
195
196
  api = api
@@ -215,4 +216,4 @@ def decorate_view(*decorators):
215
216
  wrapped = dec(wrapped)
216
217
  return wrapped
217
218
 
218
- return _decorator
219
+ return _decorator
ninja_aio/exceptions.py CHANGED
@@ -1,5 +1,4 @@
1
1
  from functools import partial
2
-
3
2
  from joserfc.errors import JoseError
4
3
  from ninja import NinjaAPI
5
4
  from django.http import HttpRequest, HttpResponse
@@ -8,6 +7,8 @@ from django.db.models import Model
8
7
 
9
8
 
10
9
  class BaseException(Exception):
10
+ """Base application exception carrying a serializable error payload and status code."""
11
+
11
12
  error: str | dict = ""
12
13
  status_code: int = 400
13
14
 
@@ -17,6 +18,11 @@ class BaseException(Exception):
17
18
  status_code: int | None = None,
18
19
  details: str | None = None,
19
20
  ) -> None:
21
+ """Initialize the exception with error content, optional HTTP status, and details.
22
+
23
+ If `error` is a string, it is wrapped into a dict under the `error` key.
24
+ If `error` is a dict, it is used directly. Optional `details` are merged.
25
+ """
20
26
  if isinstance(error, str):
21
27
  self.error = {"error": error}
22
28
  if isinstance(error, dict):
@@ -25,22 +31,30 @@ class BaseException(Exception):
25
31
  self.status_code = status_code or self.status_code
26
32
 
27
33
  def get_error(self):
34
+ """Return the error body and HTTP status code tuple for response creation."""
28
35
  return self.error, self.status_code
29
36
 
30
37
 
31
38
  class SerializeError(BaseException):
39
+ """Raised when serialization to or from request/response payloads fails."""
40
+
32
41
  pass
33
42
 
34
43
 
35
44
  class AuthError(BaseException):
45
+ """Raised when authentication or authorization fails."""
46
+
36
47
  pass
37
48
 
38
49
 
39
50
  class NotFoundError(BaseException):
51
+ """Raised when a requested model instance cannot be found."""
52
+
40
53
  status_code = 404
41
54
  error = "not found"
42
55
 
43
56
  def __init__(self, model: Model, details=None):
57
+ """Build a not-found error referencing the model's verbose name."""
44
58
  super().__init__(
45
59
  error={model._meta.verbose_name.replace(" ", "_"): self.error},
46
60
  status_code=self.status_code,
@@ -49,19 +63,24 @@ class NotFoundError(BaseException):
49
63
 
50
64
 
51
65
  class PydanticValidationError(BaseException):
66
+ """Wrapper for pydantic ValidationError to normalize the API error response."""
67
+
52
68
  def __init__(self, details=None):
69
+ """Create a validation error with 400 status and provided details list."""
53
70
  super().__init__("Validation Error", 400, details)
54
71
 
55
72
 
56
73
  def _default_error(
57
74
  request: HttpRequest, exc: BaseException, api: type[NinjaAPI]
58
75
  ) -> HttpResponse:
76
+ """Default handler: convert BaseException to an API response."""
59
77
  return api.create_response(request, exc.error, status=exc.status_code)
60
78
 
61
79
 
62
80
  def _pydantic_validation_error(
63
81
  request: HttpRequest, exc: ValidationError, api: type[NinjaAPI]
64
82
  ) -> HttpResponse:
83
+ """Translate a pydantic ValidationError into a normalized API error response."""
65
84
  error = PydanticValidationError(exc.errors(include_input=False))
66
85
  return api.create_response(request, error.error, status=error.status_code)
67
86
 
@@ -69,11 +88,13 @@ def _pydantic_validation_error(
69
88
  def _jose_error(
70
89
  request: HttpRequest, exc: JoseError, api: type[NinjaAPI]
71
90
  ) -> HttpResponse:
91
+ """Translate a JOSE library error into an unauthorized API response."""
72
92
  error = BaseException(**parse_jose_error(exc), status_code=401)
73
93
  return api.create_response(request, error.error, status=error.status_code)
74
94
 
75
95
 
76
96
  def set_api_exception_handlers(api: type[NinjaAPI]) -> None:
97
+ """Register exception handlers for common error types on the NinjaAPI instance."""
77
98
  api.add_exception_handler(BaseException, partial(_default_error, api=api))
78
99
  api.add_exception_handler(JoseError, partial(_jose_error, api=api))
79
100
  api.add_exception_handler(
@@ -82,6 +103,7 @@ def set_api_exception_handlers(api: type[NinjaAPI]) -> None:
82
103
 
83
104
 
84
105
  def parse_jose_error(jose_exc: JoseError) -> dict:
106
+ """Extract error and optional description from a JoseError into a dict."""
85
107
  error_msg = {"error": jose_exc.error}
86
108
  return (
87
109
  error_msg | {"details": jose_exc.description}
@@ -0,0 +1,3 @@
1
+ from .operations import ApiMethodFactory
2
+
3
+ __all__ = ["ApiMethodFactory"]
@@ -0,0 +1,296 @@
1
+ import asyncio
2
+ from typing import (
3
+ Callable,
4
+ Dict,
5
+ List,
6
+ Optional,
7
+ Union,
8
+ Any,
9
+ )
10
+ import inspect
11
+
12
+ from ninja.constants import NOT_SET, NOT_SET_TYPE
13
+ from ninja.throttling import BaseThrottle
14
+ from ninja import Router
15
+
16
+
17
+ class ApiMethodFactory:
18
+ """
19
+ Factory for creating class-bound API method decorators that register endpoints
20
+ on a Ninja Router from instance methods.
21
+
22
+ This class enables defining API handlers as instance methods while ensuring
23
+ the resulting callables exposed to Ninja are free of `self`/`cls` in their
24
+ OpenAPI signatures, preventing them from being interpreted as query params.
25
+
26
+ Typical usage:
27
+ - Use ApiMethodFactory.make("get" | "post" | "put" | "delete" | ...) to produce
28
+ a decorator that can be applied to an instance method on a view class.
29
+ - When the owning instance (e.g., a subclass of ninja_aio.views.api.API) is
30
+ created, the method is lazily registered on its `router` with the provided
31
+ configuration (path, auth, response, tags, etc.).
32
+
33
+ The factory supports both sync and async methods. It wraps the original method
34
+ with a handler whose first argument is `request` (as expected by Ninja),
35
+ internally binding `self` from the instance so you can still write methods
36
+ naturally.
37
+
38
+ Attributes:
39
+ - method_name: The HTTP method name used to select the corresponding Router
40
+ adder (e.g., "get", "post", etc.).
41
+
42
+ __init__(method_name: str)
43
+ Initialize the factory for a specific HTTP method.
44
+
45
+ Parameters:
46
+ - method_name: The name of the Router method to call (e.g., "get", "post").
47
+ This determines which endpoint registration function is used on the router.
48
+
49
+ _build_handler(view_instance, original)
50
+ Build a callable that Ninja can use as the endpoint handler, correctly
51
+ binding `self` and presenting a `request`-first signature.
52
+
53
+ Behavior:
54
+ - If the original method is async, return an async wrapper that awaits it.
55
+ - If the original method is sync, return a sync wrapper that calls it.
56
+ - The wrapper passes (view_instance, request, *args, **kwargs) to the
57
+ original method, ensuring instance binding while exposing a clean handler
58
+ to Ninja.
59
+
60
+ Parameters:
61
+ - view_instance: The object instance that owns the router and the method.
62
+ - original: The original instance method to be wrapped.
63
+
64
+ Returns:
65
+ - A callable suitable for Ninja route registration (sync or async).
66
+
67
+ _apply_metadata(clean_handler, original)
68
+ Copy relevant metadata from the original method to the wrapped handler to
69
+ improve OpenAPI generation and introspection.
70
+
71
+ Behavior:
72
+ - Preserve the function name where possible.
73
+ - Replace the __signature__ to exclude the first parameter if it is
74
+ `self` or `cls`, ensuring Ninja does not treat them as parameters.
75
+ - Copy annotations while removing `self` to avoid unwanted schema entries.
76
+
77
+ Parameters:
78
+ - clean_handler: The wrapped function produced by _build_handler.
79
+ - original: The original method from which metadata will be copied.
80
+
81
+ build_decorator(
82
+ auth=NOT_SET,
83
+ throttle=NOT_SET,
84
+ response=NOT_SET,
85
+ Create and return a decorator that can be applied to an instance method to
86
+ lazily register it as an endpoint when the instance is initialized.
87
+
88
+ How it works:
89
+ - The decorator attaches an `_api_register` callable to the method.
90
+ - When invoked with an API view instance, `_api_register` resolves the
91
+ instance’s `router`, wraps the method via _build_handler, applies metadata
92
+ via _apply_metadata, and registers the handler using the router’s method
93
+ corresponding to `method_name` (e.g., router.get).
94
+
95
+ Parameters mirror Ninja Router endpoint registration and control OpenAPI
96
+ generation and request handling:
97
+ - path: Route path for the endpoint.
98
+ - auth: Authentication configuration or NOT_SET.
99
+ - throttle: Throttle configuration(s) or NOT_SET.
100
+ - response: Response schema/model or NOT_SET.
101
+ - operation_id: Optional OpenAPI operation identifier.
102
+ - summary: Short summary for OpenAPI.
103
+ - description: Detailed description for OpenAPI.
104
+ - tags: Grouping tags for OpenAPI.
105
+ - deprecated: Mark endpoint as deprecated in OpenAPI.
106
+ - by_alias, exclude_unset, exclude_defaults, exclude_none: Pydantic-related
107
+ serialization options for response models.
108
+ - url_name: Optional Django URL name.
109
+ - include_in_schema: Whether to include this endpoint in OpenAPI schema.
110
+ - openapi_extra: Additional raw OpenAPI metadata.
111
+
112
+ Returns:
113
+ - A decorator to apply to sync/async instance methods.
114
+
115
+ make(method_name: str)
116
+ Class method that returns a ready-to-use decorator function for the given
117
+ HTTP method, suitable for direct use on instance methods.
118
+
119
+ Example:
120
+ api_get = ApiMethodFactory.make("get")
121
+
122
+ class MyView(API):
123
+ router = Router()
124
+
125
+ @api_get("/items")
126
+ async def list_items(self, request):
127
+ ...
128
+
129
+ Parameters:
130
+ - method_name: The HTTP method name to bind (e.g., "get", "post", "put").
131
+
132
+ Returns:
133
+ - A function that mirrors build_decorator’s signature, named
134
+ "api_{method_name}", with a docstring indicating it registers the
135
+ corresponding HTTP endpoint on the instance router.
136
+ """
137
+
138
+ def __init__(self, method_name: str):
139
+ self.method_name = method_name
140
+
141
+ def _build_handler(self, view_instance, original):
142
+ is_async = asyncio.iscoroutinefunction(original)
143
+
144
+ if is_async:
145
+
146
+ async def clean_handler(request, *args, **kwargs):
147
+ return await original(view_instance, request, *args, **kwargs)
148
+ else:
149
+
150
+ def clean_handler(request, *args, **kwargs):
151
+ return original(view_instance, request, *args, **kwargs)
152
+
153
+ return clean_handler
154
+
155
+ def _apply_metadata(self, clean_handler, original):
156
+ # name
157
+ try:
158
+ clean_handler.__name__ = getattr(
159
+ original, "__name__", clean_handler.__name__
160
+ )
161
+ except Exception:
162
+ pass
163
+
164
+ # signature and annotations without self/cls
165
+ try:
166
+ sig = inspect.signature(original)
167
+ params = sig.parameters
168
+ params_list = list(params.values())
169
+ if params_list and params_list[0].name in {"self", "cls"}:
170
+ params_list = params_list[1:]
171
+ clean_handler.__signature__ = sig.replace(parameters=params_list) # type: ignore[attr-defined]
172
+
173
+ anns = dict(getattr(original, "__annotations__", {}))
174
+ anns.pop("self", None)
175
+ clean_handler.__annotations__ = anns
176
+ except Exception:
177
+ pass
178
+
179
+ def build_decorator(
180
+ self,
181
+ path: str,
182
+ *,
183
+ auth: Any = NOT_SET,
184
+ throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
185
+ response: Any = NOT_SET,
186
+ operation_id: Optional[str] = None,
187
+ summary: Optional[str] = None,
188
+ description: Optional[str] = None,
189
+ tags: Optional[List[str]] = None,
190
+ deprecated: Optional[bool] = None,
191
+ by_alias: Optional[bool] = None,
192
+ exclude_unset: Optional[bool] = None,
193
+ exclude_defaults: Optional[bool] = None,
194
+ exclude_none: Optional[bool] = None,
195
+ url_name: Optional[str] = None,
196
+ include_in_schema: bool = True,
197
+ openapi_extra: Optional[Dict[str, Any]] = None,
198
+ decorators: Optional[List[Callable]] = None, # es. [paginate(...)]
199
+ ):
200
+ """
201
+ Returns a decorator that can be applied to an async or sync instance method.
202
+ When the instance is created and owns a `router`, the wrapped method is
203
+ registered on that router using the provided configuration.
204
+ """
205
+
206
+ def decorator(func):
207
+ from ninja_aio.views.api import API
208
+
209
+ def register_on_instance(view_instance: API):
210
+ router: Router = getattr(view_instance, "router", None)
211
+ if router is None:
212
+ raise RuntimeError("The view instance does not have a router")
213
+
214
+ clean_handler = self._build_handler(view_instance, func)
215
+ self._apply_metadata(clean_handler, func)
216
+
217
+ # Apply additional decorators if any
218
+ if decorators:
219
+ for dec in reversed(decorators):
220
+ clean_handler = dec(clean_handler)
221
+
222
+ route_adder = getattr(router, self.method_name)
223
+ route_adder(
224
+ path=path,
225
+ auth=auth,
226
+ throttle=throttle,
227
+ response=response,
228
+ operation_id=operation_id,
229
+ summary=summary,
230
+ description=description,
231
+ tags=tags,
232
+ deprecated=deprecated,
233
+ by_alias=by_alias,
234
+ exclude_unset=exclude_unset,
235
+ exclude_defaults=exclude_defaults,
236
+ exclude_none=exclude_none,
237
+ url_name=url_name,
238
+ include_in_schema=include_in_schema,
239
+ openapi_extra=openapi_extra,
240
+ )(clean_handler)
241
+
242
+ setattr(func, "_api_register", register_on_instance)
243
+ return func
244
+
245
+ return decorator
246
+
247
+ @classmethod
248
+ def make(cls, method_name: str):
249
+ """Factory returning a decorator function for the given HTTP method."""
250
+
251
+ def wrapper(
252
+ path: str,
253
+ *,
254
+ auth: Any = NOT_SET,
255
+ throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
256
+ response: Any = NOT_SET,
257
+ operation_id: Optional[str] = None,
258
+ summary: Optional[str] = None,
259
+ description: Optional[str] = None,
260
+ tags: Optional[List[str]] = None,
261
+ deprecated: Optional[bool] = None,
262
+ by_alias: Optional[bool] = None,
263
+ exclude_unset: Optional[bool] = None,
264
+ exclude_defaults: Optional[bool] = None,
265
+ exclude_none: Optional[bool] = None,
266
+ url_name: Optional[str] = None,
267
+ include_in_schema: bool = True,
268
+ openapi_extra: Optional[Dict[str, Any]] = None,
269
+ decorators: Optional[List[Callable]] = None, # es. [paginate(...)]
270
+ ):
271
+ return cls(method_name).build_decorator(
272
+ path,
273
+ auth=auth,
274
+ throttle=throttle,
275
+ response=response,
276
+ operation_id=operation_id,
277
+ summary=summary,
278
+ description=description,
279
+ tags=tags,
280
+ deprecated=deprecated,
281
+ by_alias=by_alias,
282
+ exclude_unset=exclude_unset,
283
+ exclude_defaults=exclude_defaults,
284
+ exclude_none=exclude_none,
285
+ url_name=url_name,
286
+ include_in_schema=include_in_schema,
287
+ openapi_extra=openapi_extra,
288
+ decorators=decorators,
289
+ )
290
+
291
+ wrapper.__name__ = f"api_{method_name}"
292
+ wrapper.__doc__ = (
293
+ f"Class method decorator that lazily registers a {method_name.upper()} endpoint on the instance router.\n\n"
294
+ f"Parameters mirror api_get."
295
+ )
296
+ return wrapper
ninja_aio/helpers/api.py CHANGED
@@ -337,6 +337,17 @@ class ManyToManyAPI:
337
337
  remove=remove,
338
338
  )
339
339
 
340
+ def _get_api_path(self, rel_path: str, append_slash: bool = None) -> str:
341
+ append_slash = append_slash if append_slash is not None else True
342
+ path = (
343
+ f"{self.view_set.path_retrieve}{rel_path}/"
344
+ if rel_path.startswith("/")
345
+ else f"{self.view_set.path_retrieve}/{rel_path}/"
346
+ )
347
+ if not append_slash:
348
+ path = path.rstrip("/")
349
+ return path
350
+
340
351
  def _register_get_relation_view(
341
352
  self,
342
353
  *,
@@ -346,9 +357,10 @@ class ManyToManyAPI:
346
357
  rel_path: str,
347
358
  related_schema,
348
359
  filters_schema,
360
+ append_slash: bool,
349
361
  ):
350
362
  @self.router.get(
351
- f"{self.view_set.path_retrieve}{rel_path}",
363
+ self._get_api_path(rel_path, append_slash=append_slash),
352
364
  response={
353
365
  200: list[related_schema],
354
366
  self.view_set.error_codes: GenericMessageSchema,
@@ -400,7 +412,7 @@ class ManyToManyAPI:
400
412
  summary = f"{action} {plural}"
401
413
 
402
414
  @self.router.post(
403
- f"{self.view_set.path_retrieve}{rel_path}/",
415
+ self._get_api_path(rel_path),
404
416
  response={
405
417
  200: M2MSchemaOut,
406
418
  self.view_set.error_codes: GenericMessageSchema,
@@ -465,6 +477,7 @@ class ManyToManyAPI:
465
477
  related_schema = relation.related_schema
466
478
  m2m_add, m2m_remove, m2m_get = relation.add, relation.remove, relation.get
467
479
  filters_schema = self.relations_filters_schemas.get(related_name)
480
+ append_slash = relation.append_slash
468
481
 
469
482
  if m2m_get:
470
483
  self._register_get_relation_view(
@@ -474,6 +487,7 @@ class ManyToManyAPI:
474
487
  rel_path=rel_path,
475
488
  related_schema=related_schema,
476
489
  filters_schema=filters_schema,
490
+ append_slash=append_slash,
477
491
  )
478
492
 
479
493
  if m2m_add or m2m_remove:
@@ -8,10 +8,12 @@ from ninja_aio.schemas.helpers import (
8
8
 
9
9
  class ScopeNamespace:
10
10
  def __init__(self, **scopes):
11
+ """Create a simple namespace where each provided scope becomes an attribute."""
11
12
  for key, value in scopes.items():
12
13
  setattr(self, key, value)
13
14
 
14
15
  def __iter__(self):
16
+ """Iterate over the stored scope values."""
15
17
  return iter(self.__dict__.values())
16
18
 
17
19
 
@@ -51,6 +53,7 @@ class QueryUtil:
51
53
  SCOPES: QueryUtilBaseScopesSchema
52
54
 
53
55
  def __init__(self, model: ModelSerializerMeta):
56
+ """Initialize QueryUtil, resolving base and extra scope configurations for a model."""
54
57
  self.model = model
55
58
  self._configuration = getattr(self.model, "QuerySet", None)
56
59
  self._extra_configuration: list[ModelQuerySetExtraSchema] = getattr(
@@ -66,7 +69,9 @@ class QueryUtil:
66
69
  **{scope: self._get_config(scope) for scope in self._BASE_SCOPES.values()},
67
70
  **self.extra_configs,
68
71
  }
69
- self.read_config: ModelQuerySetSchema = self._configs.get(self.SCOPES.READ, ModelQuerySetSchema())
72
+ self.read_config: ModelQuerySetSchema = self._configs.get(
73
+ self.SCOPES.READ, ModelQuerySetSchema()
74
+ )
70
75
  self.queryset_request_config: ModelQuerySetSchema = self._configs.get(
71
76
  self.SCOPES.QUERYSET_REQUEST, ModelQuerySetSchema()
72
77
  )
@@ -8,24 +8,50 @@ from pydantic import BaseModel, ConfigDict, model_validator
8
8
 
9
9
  class M2MRelationSchema(BaseModel):
10
10
  """
11
- Configuration schema for declaring a Many-to-Many relation in the API.
12
-
13
- Attributes:
14
- model (Type[ModelSerializer] | Type[Model]): Target model class or its serializer.
15
- related_name (str): Name of the relationship field on the Django model.
16
- add (bool): Enable adding related objects (default True).
17
- remove (bool): Enable removing related objects (default True).
18
- get (bool): Enable retrieving related objects (default True).
19
- path (str | None): Optional custom URL path segment (None/"" => auto-generated).
20
- auth (list | None): Optional list of authentication backends for the endpoints.
21
- filters (dict[str, tuple] | None): Field name -> (type, default) pairs for query filtering.
22
-
23
- Example:
24
- M2MRelationSchema(
25
- model=BookSerializer,
26
- related_name="authors",
27
- filters={"country": ("str", '')}
28
- )
11
+ Configuration schema for declaring and controlling a Many-to-Many (M2M) relation in the API.
12
+
13
+ This schema is used to describe an M2M relationship between a primary resource and its related
14
+ objects, and to automatically provision CRUD-like endpoints for managing that relation
15
+ (add, remove, and get). It supports both direct Django model classes and model serializers,
16
+ and can optionally expose a custom schema for the related output.
17
+
18
+ model (ModelSerializerMeta | Type[Model]):
19
+ The target related entity, provided either as a ModelSerializer (preferred) or a Django model.
20
+ If a plain model is supplied, you must also provide `related_schema`.
21
+ related_name (str):
22
+ The name of the M2M field on the Django model that links to the related objects.
23
+ add (bool):
24
+ Whether to enable an endpoint for adding related objects. Defaults to True.
25
+ remove (bool):
26
+ Whether to enable an endpoint for removing related objects. Defaults to True.
27
+ get (bool):
28
+ Whether to enable an endpoint for listing/retrieving related objects. Defaults to True.
29
+ path (str | None):
30
+ Optional custom URL path segment for the relation endpoints. If empty or None, a path
31
+ is auto-generated based on `related_name`.
32
+ auth (list | None):
33
+ Optional list of authentication backends to protect the relation endpoints.
34
+ filters (dict[str, tuple] | None):
35
+ Optional mapping of queryable filter fields for the GET endpoint, defined as:
36
+ field_name -> (type, default). Example: {"country": ("str", "")}.
37
+ related_schema (Type[Schema] | None):
38
+ Optional explicit schema to represent related objects in responses.
39
+ If `model` is a ModelSerializerMeta, this is auto-derived via `model.generate_related_s()`.
40
+ If `model` is a plain Django model, this must be provided.
41
+ append_slash (bool):
42
+ Whether to append a trailing slash to the generated GET endpoint path. Defaults to False for backward compatibility.
43
+
44
+ Validation:
45
+ - If `model` is not a ModelSerializerMeta, `related_schema` is required.
46
+ - When `model` is a ModelSerializerMeta and `related_schema` is not provided, it will be
47
+ automatically generated.
48
+
49
+ Usage example:
50
+ filters={"country": ("str", "")},
51
+ auth=[AuthBackend],
52
+ add=True,
53
+ remove=True,
54
+ get=True,
29
55
  """
30
56
 
31
57
  model: ModelSerializerMeta | Type[Model]
@@ -37,6 +63,7 @@ class M2MRelationSchema(BaseModel):
37
63
  auth: Optional[list] = None
38
64
  filters: Optional[dict[str, tuple]] = None
39
65
  related_schema: Optional[Type[Schema]] = None
66
+ append_slash: bool = False
40
67
 
41
68
  model_config = ConfigDict(arbitrary_types_allowed=True)
42
69
 
@@ -61,28 +88,81 @@ class ModelQuerySetSchema(BaseModel):
61
88
 
62
89
 
63
90
  class ModelQuerySetExtraSchema(ModelQuerySetSchema):
91
+ """
92
+ Schema defining extra query parameters for model queryset operations in API endpoints.
93
+ Attributes:
94
+ scope (str): The scope defining the level of access for the queryset operation.
95
+ select_related (Optional[list[str]]): List of related fields for select_related optimization.
96
+ prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
97
+ """
64
98
  scope: str
65
99
 
66
100
 
67
101
  class ObjectQuerySchema(ModelQuerySetSchema):
102
+ """
103
+ Schema defining query parameters for single object retrieval in API endpoints.
104
+ Attributes:
105
+ getters (Optional[dict]): A dictionary of getters to apply to the query.
106
+ select_related (Optional[list[str]]): List of related fields for select_related optimization.
107
+ prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
108
+ """
68
109
  getters: Optional[dict] = {}
69
110
 
70
111
 
71
112
  class ObjectsQuerySchema(ModelQuerySetSchema):
113
+ """
114
+ Schema defining query parameters for multiple object retrieval in API endpoints.
115
+ Attributes:
116
+ filters (Optional[dict]): A dictionary of filters to apply to the query.
117
+ select_related (Optional[list[str]]): List of related fields for select_related optimization.
118
+ prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
119
+ """
72
120
  filters: Optional[dict] = {}
73
121
 
74
122
 
75
123
  class QuerySchema(ModelQuerySetSchema):
124
+ """
125
+ Schema defining query parameters for API endpoints.
126
+ Attributes:
127
+ filters (Optional[dict]): A dictionary of filters to apply to the query.
128
+ getters (Optional[dict]): A dictionary of getters to apply to the query.
129
+ select_related (Optional[list[str]]): List of related fields for select_related optimization.
130
+ prefetch_related (Optional[list[str]]): List of related fields for prefetch_related optimization
131
+ """
76
132
  filters: Optional[dict] = {}
77
133
  getters: Optional[dict] = {}
78
134
 
79
135
 
80
136
  class QueryUtilBaseScopesSchema(BaseModel):
137
+ """
138
+ Schema defining base scopes for query utilities.
139
+ Attributes:
140
+ READ (str): Scope for read operations.
141
+ QUERYSET_REQUEST (str): Scope for queryset request operations.
142
+ """
81
143
  READ: str = "read"
82
144
  QUERYSET_REQUEST: str = "queryset_request"
83
145
 
84
146
 
85
147
  class DecoratorsSchema(Schema):
148
+ """
149
+ Schema defining optional decorator lists for CRUD operations.
150
+
151
+ Attributes:
152
+ list (Optional[List]): Decorators applied to the list endpoint.
153
+ retrieve (Optional[List]): Decorators applied to the retrieve endpoint.
154
+ create (Optional[List]): Decorators applied to the create endpoint.
155
+ update (Optional[List]): Decorators applied to the update endpoint.
156
+ delete (Optional[List]): Decorators applied to the delete endpoint.
157
+
158
+ Notes:
159
+ - Each attribute holds an ordered collection of decorators (callables or decorator references)
160
+ to be applied to the corresponding endpoint.
161
+ - Defaults are empty lists, meaning no decorators are applied unless explicitly provided.
162
+ - Using mutable defaults (empty lists) at the class level may lead to shared state between instances.
163
+ Consider initializing these in __init__ or using default_factory (if using pydantic/dataclasses)
164
+ to avoid unintended side effects.
165
+ """
86
166
  list: Optional[List] = []
87
167
  retrieve: Optional[List] = []
88
168
  create: Optional[List] = []
ninja_aio/types.py CHANGED
@@ -8,7 +8,7 @@ S_TYPES = Literal["read", "create", "update"]
8
8
  F_TYPES = Literal["fields", "customs", "optionals", "excludes"]
9
9
  SCHEMA_TYPES = Literal["In", "Out", "Patch", "Related"]
10
10
  VIEW_TYPES = Literal["list", "retrieve", "create", "update", "delete", "all"]
11
- JwtKeys: TypeAlias = jwk.RSAKey | jwk.ECKey
11
+ JwtKeys: TypeAlias = jwk.RSAKey | jwk.ECKey | jwk.OctKey
12
12
 
13
13
  class ModelSerializerType(type):
14
14
  def __repr__(self):
ninja_aio/views/api.py CHANGED
@@ -5,6 +5,7 @@ from ninja.constants import NOT_SET
5
5
  from ninja.pagination import paginate, AsyncPaginationBase, PageNumberPagination
6
6
  from django.http import HttpRequest
7
7
  from django.db.models import Model, QuerySet
8
+ from django.conf import settings
8
9
  from pydantic import create_model
9
10
 
10
11
  from ninja_aio.schemas.helpers import ModelQuerySetSchema, QuerySchema, DecoratorsSchema
@@ -18,18 +19,16 @@ from ninja_aio.helpers.api import ManyToManyAPI
18
19
  from ninja_aio.types import ModelSerializerMeta, VIEW_TYPES
19
20
  from ninja_aio.decorators import unique_view, decorate_view
20
21
 
21
- ERROR_CODES = frozenset({400, 401, 404, 428})
22
+ ERROR_CODES = frozenset({400, 401, 404})
22
23
 
23
24
 
24
- class APIView:
25
- api: NinjaAPI
26
- router_tag: str
27
- api_route_path: str
25
+ class API:
26
+ api: NinjaAPI = None
27
+ router_tag: str = ""
28
+ router_tags: list[str] = []
29
+ api_route_path: str = ""
28
30
  auth: list | None = NOT_SET
29
-
30
- def __init__(self) -> None:
31
- self.router = Router(tags=[self.router_tag])
32
- self.error_codes = ERROR_CODES
31
+ router: Router = None
33
32
 
34
33
  def views(self):
35
34
  """
@@ -38,7 +37,7 @@ class APIView:
38
37
  async def some_method(request, *args, **kwargs):
39
38
  pass
40
39
 
41
- You can add multilple views just doing:
40
+ You can add views just doing:
42
41
 
43
42
  @self.router.get(some_path, response=some_schema)
44
43
  async def some_method(request, *args, **kwargs):
@@ -63,20 +62,85 @@ class APIView:
63
62
  async def some_method(request, *args, **kwargs):
64
63
  pass
65
64
  """
65
+ pass
66
66
 
67
67
  def _add_views(self):
68
- self.views()
69
- return self.router
68
+ for name in dir(self.__class__):
69
+ method = getattr(self.__class__, name)
70
+ if hasattr(method, "_api_register"):
71
+ method._api_register(self)
70
72
 
71
73
  def add_views_to_route(self):
72
74
  return self.api.add_router(f"{self.api_route_path}", self._add_views())
73
75
 
74
76
 
75
- class APIViewSet:
77
+ class APIView(API):
78
+ """
79
+ Base class to register custom, non-CRUD endpoints on a Ninja Router.
80
+
81
+ Usage:
82
+ from ninja_aio.decorations import api_get
83
+
84
+ @api.view(prefix="/custom", tags=["Custom"])
85
+ class CustomAPIView(APIView):
86
+ @api_get("/hello", response=SomeSchema)
87
+ async def hello(request):
88
+ return SomeSchema(...)
89
+
90
+ or
91
+
92
+ class CustomAPIView(APIView):
93
+ api = api
94
+ api_route_path = "/custom"
95
+ router_tags = ["Custom"]
96
+
97
+ def views(self):
98
+ @self.router.get("/hello", response=SomeSchema)
99
+ async def hello(request):
100
+ return SomeSchema(...)
101
+
102
+
103
+ CustomAPIView().add_views_to_route()
104
+
105
+ Attributes:
106
+ api: NinjaAPI instance used to mount the router.
107
+ router_tag: Single tag used if router_tags is not provided.
108
+ router_tags: List of tags assigned to the router.
109
+ api_route_path: Base path where the router is mounted.
110
+ auth: Default auth list or NOT_SET for unauthenticated endpoints.
111
+ router: Router instance where views are registered.
112
+ error_codes: Common error codes returned by endpoints.
113
+
114
+ Overridable methods:
115
+ views(): Register your endpoints using self.router.get/post/patch/delete.
116
+ """
117
+
118
+ def __init__(
119
+ self, api: NinjaAPI = None, prefix: str = None, tags: list[str] = None
120
+ ) -> None:
121
+ self.api = api or self.api
122
+ self.api_route_path = prefix or self.api_route_path
123
+ self.router_tags = tags or self.router_tags or [self.router_tag]
124
+ self.router = Router(tags=self.router_tags)
125
+ self.error_codes = ERROR_CODES
126
+
127
+ def _add_views(self):
128
+ super()._add_views()
129
+ self.views()
130
+ return self.router
131
+
132
+
133
+ class APIViewSet(API):
76
134
  """
77
135
  Base viewset generating async CRUD + optional M2M endpoints for a Django model.
78
136
 
79
137
  Usage:
138
+ @api.viewset(model=MyModel)
139
+ class MyModelViewSet(APIViewSet):
140
+ pass
141
+
142
+ or
143
+
80
144
  class MyModelViewSet(APIViewSet):
81
145
  model = MyModel
82
146
  api = api
@@ -109,8 +173,8 @@ class APIViewSet:
109
173
  dict, and must return the (optionally) filtered queryset.
110
174
 
111
175
  Example:
176
+ @api.viewset(model=models.User)
112
177
  class UserViewSet(APIViewSet):
113
- model = models.User
114
178
  m2m_relations = [
115
179
  M2MRelationSchema(
116
180
  model=models.Tag,
@@ -149,7 +213,7 @@ class APIViewSet:
149
213
  <related_name>_query_params_handler(queryset, filters): Async hook for per-M2M filtering.
150
214
 
151
215
  Error responses:
152
- All endpoints may return GenericMessageSchema for codes in ERROR_CODES (400,401,404,428).
216
+ All endpoints may return GenericMessageSchema for codes in ERROR_CODES (400,401,404).
153
217
 
154
218
  Internal:
155
219
  Dynamic path/filter schemas built with pydantic.create_model.
@@ -157,12 +221,9 @@ class APIViewSet:
157
221
  """
158
222
 
159
223
  model: ModelSerializer | Model
160
- api: NinjaAPI
161
- router_tag: str = ""
162
224
  schema_in: Schema | None = None
163
225
  schema_out: Schema | None = None
164
226
  schema_update: Schema | None = None
165
- auth: list | None = NOT_SET
166
227
  get_auth: list | None = NOT_SET
167
228
  post_auth: list | None = NOT_SET
168
229
  patch_auth: list | None = NOT_SET
@@ -170,7 +231,6 @@ class APIViewSet:
170
231
  pagination_class: type[AsyncPaginationBase] = PageNumberPagination
171
232
  query_params: dict[str, tuple[type, ...]] = {}
172
233
  disable: list[type[VIEW_TYPES]] = []
173
- api_route_path: str = ""
174
234
  list_docs = "List all objects."
175
235
  create_docs = "Create a new object."
176
236
  retrieve_docs = "Retrieve a specific object by its primary key."
@@ -180,8 +240,16 @@ class APIViewSet:
180
240
  m2m_auth: list | None = NOT_SET
181
241
  extra_decorators: DecoratorsSchema = DecoratorsSchema()
182
242
 
183
- def __init__(self) -> None:
243
+ def __init__(
244
+ self,
245
+ api: NinjaAPI = None,
246
+ model: Model | ModelSerializer = None,
247
+ prefix: str = None,
248
+ tags: list[str] = None,
249
+ ) -> None:
250
+ self.api = api or self.api
184
251
  self.error_codes = ERROR_CODES
252
+ self.model = model or self.model
185
253
  self.model_util = (
186
254
  ModelUtil(self.model)
187
255
  if not isinstance(self.model, ModelSerializerMeta)
@@ -191,16 +259,22 @@ class APIViewSet:
191
259
  self.path_schema = self._generate_path_schema()
192
260
  self.filters_schema = self._generate_filters_schema()
193
261
  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])
198
- self.path = "/"
262
+ self.router_tag = self.router_tag or self.model_verbose_name
263
+ self.router_tags = self.router_tags or tags or [self.router_tag]
264
+ self.router = Router(tags=self.router_tags)
265
+ self.append_slash = getattr(settings, "NINJA_AIO_APPEND_SLASH", True)
266
+ self.path = "/" if self.append_slash else ""
199
267
  self.get_path = ""
200
- self.path_retrieve = f"{{{self.model_util.model_pk_name}}}/"
201
268
  self.get_path_retrieve = f"{{{self.model_util.model_pk_name}}}"
269
+ self.path_retrieve = (
270
+ f"{self.get_path_retrieve}/"
271
+ if self.append_slash
272
+ else self.get_path_retrieve
273
+ )
202
274
  self.api_route_path = (
203
- self.api_route_path or self.model_util.verbose_name_path_resolver()
275
+ self.api_route_path
276
+ or prefix
277
+ or self.model_util.verbose_name_path_resolver()
204
278
  )
205
279
  self.m2m_api = (
206
280
  None
@@ -439,6 +513,7 @@ class APIViewSet:
439
513
  Register CRUD (unless disabled), custom views, and M2M endpoints.
440
514
  If 'all' in disable only CRUD is skipped; M2M + custom still added.
441
515
  """
516
+ super()._add_views()
442
517
  if "all" in self.disable:
443
518
  return self._set_additional_views()
444
519
 
@@ -450,18 +525,18 @@ class APIViewSet:
450
525
 
451
526
  return self._set_additional_views()
452
527
 
453
- def add_views_to_route(self):
454
- """
455
- Attach router with registered endpoints to the NinjaAPI instance.
456
- """
457
- return self.api.add_router(f"{self.api_route_path}", self._add_views())
458
-
459
528
 
460
529
  class ReadOnlyViewSet(APIViewSet):
461
530
  """
462
531
  ReadOnly viewset generating async List + Retrieve endpoints for a Django model.
463
532
 
464
533
  Usage:
534
+ @api.viewset(model=MyModel)
535
+ class MyModelReadOnlyViewSet(ReadOnlyViewSet):
536
+ pass
537
+
538
+ or
539
+
465
540
  class MyModelReadOnlyViewSet(ReadOnlyViewSet):
466
541
  model = MyModel
467
542
  api = api
@@ -476,10 +551,15 @@ class WriteOnlyViewSet(APIViewSet):
476
551
  WriteOnly viewset generating async Create + Update + Delete endpoints for a Django model.
477
552
 
478
553
  Usage:
554
+ @api.viewset(model=MyModel)
555
+ class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
556
+ pass
557
+
558
+ or
559
+
479
560
  class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
480
561
  model = MyModel
481
562
  api = api
482
- MyModelWriteOnlyViewSet().add_views_to_route()
483
563
  """
484
564
 
485
565
  disable = ["list", "retrieve"]
@@ -1,23 +0,0 @@
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,,