django-ninja-aio-crud 2.2.0__py3-none-any.whl → 2.3.1__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.2.0
3
+ Version: 2.3.1
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
- Requires-Python: >=3.10, <=3.14
6
+ Requires-Python: >=3.10, <3.15
7
7
  Description-Content-Type: text/markdown
8
8
  Classifier: Operating System :: OS Independent
9
9
  Classifier: Topic :: Internet
@@ -74,6 +74,7 @@ Add to your project’s dependencies and ensure Django Ninja is installed.
74
74
  ## 🚀 Quick Start
75
75
 
76
76
  models.py
77
+
77
78
  ```python
78
79
  from django.db import models
79
80
  from ninja_aio.models import ModelSerializer
@@ -93,6 +94,7 @@ class Book(ModelSerializer):
93
94
  ```
94
95
 
95
96
  views.py
97
+
96
98
  ```python
97
99
  from ninja_aio import NinjaAIO
98
100
  from ninja_aio.views import APIViewSet
@@ -100,11 +102,10 @@ from .models import Book
100
102
 
101
103
  api = NinjaAIO()
102
104
 
105
+ @api.viewset(Book)
103
106
  class BookViewSet(APIViewSet):
104
- model = Book
105
- api = api
107
+ pass
106
108
 
107
- BookViewSet().add_views_to_route()
108
109
  ```
109
110
 
110
111
  Visit `/docs` → CRUD endpoints ready.
@@ -114,9 +115,8 @@ Visit `/docs` → CRUD endpoints ready.
114
115
  ## 🔄 Query Filtering
115
116
 
116
117
  ```python
118
+ @api.viewset(Book)
117
119
  class BookViewSet(APIViewSet):
118
- model = Book
119
- api = api
120
120
  query_params = {"published": (bool, None), "title": (str, None)}
121
121
 
122
122
  async def query_params_handler(self, queryset, filters):
@@ -128,6 +128,7 @@ class BookViewSet(APIViewSet):
128
128
  ```
129
129
 
130
130
  Request:
131
+
131
132
  ```
132
133
  GET /book/?published=true&title=python
133
134
  ```
@@ -151,9 +152,8 @@ class Article(ModelSerializer):
151
152
  class ReadSerializer:
152
153
  fields = ["id", "title", "tags"]
153
154
 
155
+ @api.viewset(Article)
154
156
  class ArticleViewSet(APIViewSet):
155
- model = Article
156
- api = api
157
157
  m2m_relations = [
158
158
  M2MRelationSchema(
159
159
  model=Tag,
@@ -168,10 +168,10 @@ class ArticleViewSet(APIViewSet):
168
168
  queryset = queryset.filter(name__icontains=n)
169
169
  return queryset
170
170
 
171
- ArticleViewSet().add_views_to_route()
172
171
  ```
173
172
 
174
173
  Endpoints:
174
+
175
175
  - `GET /article/{pk}/tag?name=dev`
176
176
  - `POST /article/{pk}/tag/` body: `{"add":[1,2],"remove":[3]}`
177
177
 
@@ -195,9 +195,8 @@ class JWTAuth(AsyncJwtBearer):
195
195
  book_id = self.dcd.claims.get("sub")
196
196
  return await Book.objects.aget(id=book_id)
197
197
 
198
+ @api.viewset(Book)
198
199
  class SecureBookViewSet(APIViewSet):
199
- model = Book
200
- api = api
201
200
  auth = [JWTAuth()]
202
201
  get_auth = None # list/retrieve public
203
202
  ```
@@ -207,6 +206,7 @@ class SecureBookViewSet(APIViewSet):
207
206
  ## 📑 Lifecycle Hooks (ModelSerializer)
208
207
 
209
208
  Available on every save/delete:
209
+
210
210
  - `on_create_before_save`
211
211
  - `on_create_after_save`
212
212
  - `before_save`
@@ -220,10 +220,21 @@ Available on every save/delete:
220
220
  ## 🧩 Adding Custom Endpoints
221
221
 
222
222
  ```python
223
+ from ninja_aio.decorators import api_get
224
+
225
+ @api.viewset(Book)
223
226
  class BookViewSet(APIViewSet):
224
- model = Book
225
- api = api
227
+ @api_get("/stats/")
228
+ async def stats(self, request):
229
+ total = await Book.objects.acount()
230
+ return {"total": total}
231
+ ```
226
232
 
233
+ Or
234
+
235
+ ```python
236
+ @api.viewset(Book)
237
+ class BookViewSet(APIViewSet):
227
238
  def views(self):
228
239
  @self.router.get("/stats/")
229
240
  async def stats(request):
@@ -244,9 +255,8 @@ class LargePagination(PageNumberPagination):
244
255
  page_size = 50
245
256
  max_page_size = 200
246
257
 
258
+ @api.viewset(Book)
247
259
  class BookViewSet(APIViewSet):
248
- model = Book
249
- api = api
250
260
  pagination_class = LargePagination
251
261
  ```
252
262
 
@@ -255,6 +265,7 @@ class BookViewSet(APIViewSet):
255
265
  ## 🛠 Project Structure & Docs
256
266
 
257
267
  Documentation (MkDocs + Material):
268
+
258
269
  ```
259
270
  docs/
260
271
  getting_started/
@@ -267,17 +278,19 @@ docs/
267
278
  ```
268
279
 
269
280
  Browse full reference:
281
+
270
282
  - APIViewSet: `docs/api/views/api_view_set.md`
271
283
  - APIView: `docs/api/views/api_view.md`
272
284
  - ModelSerializer: `docs/api/models/model_serializer.md`
273
285
  - Authentication: `docs/api/authentication.md`
274
- - Pagination: `docs/api/pagination.md`
286
+ - Example repository: https://github.com/caspel26/ninja-aio-blog-example
275
287
 
276
288
  ---
277
289
 
278
290
  ## 🧪 Tests
279
291
 
280
292
  Use Django test runner + async ORM patterns. Example async pattern:
293
+
281
294
  ```python
282
295
  obj = await Book.objects.acreate(title="T1", published=True)
283
296
  count = await Book.objects.acount()
@@ -288,9 +301,8 @@ count = await Book.objects.acount()
288
301
  ## 🚫 Disable Operations
289
302
 
290
303
  ```python
304
+ @api.viewset(Book)
291
305
  class ReadOnlyBookViewSet(APIViewSet):
292
- model = Book
293
- api = api
294
306
  disable = ["update", "delete"]
295
307
  ```
296
308
 
@@ -318,6 +330,7 @@ class ReadOnlyBookViewSet(APIViewSet):
318
330
  ## ⭐ Support
319
331
 
320
332
  Star the repo or donate:
333
+
321
334
  - [Buy me a coffee](https://buymeacoffee.com/caspel26)
322
335
 
323
336
  ---
@@ -330,11 +343,12 @@ MIT License. See [LICENSE](LICENSE).
330
343
 
331
344
  ## 🔗 Quick Links
332
345
 
333
- | Item | Link |
334
- |------|------|
335
- | PyPI | https://pypi.org/project/django-ninja-aio-crud/ |
336
- | Docs | https://django-ninja-aio.com
337
- | Issues | https://github.com/caspel26/django-ninja-aio-crud/issues |
346
+ | Item | Link |
347
+ | ------- | -------------------------------------------------------- |
348
+ | PyPI | https://pypi.org/project/django-ninja-aio-crud/ |
349
+ | Docs | https://django-ninja-aio.com |
350
+ | Issues | https://github.com/caspel26/django-ninja-aio-crud/issues |
351
+ | Example | https://github.com/caspel26/ninja-aio-blog-example |
338
352
 
339
353
  ---
340
354
 
@@ -0,0 +1,27 @@
1
+ ninja_aio/__init__.py,sha256=gBYKYp-SDpKV643SKBBLVlAZrsk3j6ndnV-oOP-Z56U,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.1.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
25
+ django_ninja_aio_crud-2.3.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
26
+ django_ninja_aio_crud-2.3.1.dist-info/METADATA,sha256=74hdMdbaOSFTfpFXJVgZ_0bydVBGNsO8rnZHOBDTvZg,9047
27
+ django_ninja_aio_crud-2.3.1.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.2.0"
3
+ __version__ = "2.3.1"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -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/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
@@ -27,6 +28,7 @@ class API:
27
28
  router_tags: list[str] = []
28
29
  api_route_path: str = ""
29
30
  auth: list | None = NOT_SET
31
+ router: Router = None
30
32
 
31
33
  def views(self):
32
34
  """
@@ -63,7 +65,10 @@ class API:
63
65
  pass
64
66
 
65
67
  def _add_views(self):
66
- raise NotImplementedError("_add_views must be implemented in subclasses")
68
+ for name in dir(self.__class__):
69
+ method = getattr(self.__class__, name)
70
+ if hasattr(method, "_api_register"):
71
+ method._api_register(self)
67
72
 
68
73
  def add_views_to_route(self):
69
74
  return self.api.add_router(f"{self.api_route_path}", self._add_views())
@@ -74,12 +79,13 @@ class APIView(API):
74
79
  Base class to register custom, non-CRUD endpoints on a Ninja Router.
75
80
 
76
81
  Usage:
82
+ from ninja_aio.decorations import api_get
83
+
77
84
  @api.view(prefix="/custom", tags=["Custom"])
78
85
  class CustomAPIView(APIView):
79
- def views(self):
80
- @self.router.get("/hello", response=SomeSchema)
81
- async def hello(request):
82
- return SomeSchema(...)
86
+ @api_get("/hello", response=SomeSchema)
87
+ async def hello(request):
88
+ return SomeSchema(...)
83
89
 
84
90
  or
85
91
 
@@ -119,6 +125,7 @@ class APIView(API):
119
125
  self.error_codes = ERROR_CODES
120
126
 
121
127
  def _add_views(self):
128
+ super()._add_views()
122
129
  self.views()
123
130
  return self.router
124
131
 
@@ -255,10 +262,15 @@ class APIViewSet(API):
255
262
  self.router_tag = self.router_tag or self.model_verbose_name
256
263
  self.router_tags = self.router_tags or tags or [self.router_tag]
257
264
  self.router = Router(tags=self.router_tags)
258
- self.path = "/"
265
+ self.append_slash = getattr(settings, "NINJA_AIO_APPEND_SLASH", True)
266
+ self.path = "/" if self.append_slash else ""
259
267
  self.get_path = ""
260
- self.path_retrieve = f"{{{self.model_util.model_pk_name}}}/"
261
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
+ )
262
274
  self.api_route_path = (
263
275
  self.api_route_path
264
276
  or prefix
@@ -501,6 +513,7 @@ class APIViewSet(API):
501
513
  Register CRUD (unless disabled), custom views, and M2M endpoints.
502
514
  If 'all' in disable only CRUD is skipped; M2M + custom still added.
503
515
  """
516
+ super()._add_views()
504
517
  if "all" in self.disable:
505
518
  return self._set_additional_views()
506
519
 
@@ -1,23 +0,0 @@
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,,