django-ninja-aio-crud 2.1.0__tar.gz → 2.2.0__tar.gz

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.

Files changed (101) hide show
  1. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.github/workflows/docs.yml +1 -1
  2. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/PKG-INFO +2 -2
  3. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/authentication.md +1 -178
  4. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/views/api_view.md +24 -15
  5. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/views/api_view_set.md +77 -46
  6. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/index.md +11 -15
  7. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/mkdocs.yml +1 -2
  8. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/__init__.py +1 -1
  9. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/api.py +24 -0
  10. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/auth.py +3 -3
  11. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/types.py +1 -1
  12. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/views/api.py +99 -32
  13. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/pyproject.toml +1 -1
  14. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/helpers/test_many_to_many_api.py +1 -2
  15. django_ninja_aio_crud-2.2.0/tests/views/test_views.py +99 -0
  16. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/views/test_viewset.py +54 -1
  17. django_ninja_aio_crud-2.1.0/tests/views/test_views.py +0 -57
  18. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.github/dependabot.yml +0 -0
  19. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.github/workflows/coverage.yml +0 -0
  20. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.github/workflows/publish.yml +0 -0
  21. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.gitignore +0 -0
  22. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.pre-commit-config.yaml +0 -0
  23. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/LICENSE +0 -0
  24. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/README.md +0 -0
  25. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/CNAME +0 -0
  26. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/models/model_serializer.md +0 -0
  27. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/models/model_util.md +0 -0
  28. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/pagination.md +0 -0
  29. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/renderers/orjson_renderer.md +0 -0
  30. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/views/decorators.md +0 -0
  31. {django_ninja_aio_crud-2.1.0/docs → django_ninja_aio_crud-2.2.0/docs/api/views}/mixins.md +0 -0
  32. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/auth.md +0 -0
  33. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/contributing.md +0 -0
  34. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/extra.css +0 -0
  35. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-create-swagger.png +0 -0
  36. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-delete-swagger.png +0 -0
  37. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-list-swagger.png +0 -0
  38. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-retrieve-swagger.png +0 -0
  39. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-swagger.png +0 -0
  40. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-update-swagger.png +0 -0
  41. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/installation.md +0 -0
  42. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/quick_start.md +0 -0
  43. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/images/bar-swagger.png +0 -0
  44. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/images/favicon.ico +0 -0
  45. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/images/foo-swagger.png +0 -0
  46. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/images/logo.png +0 -0
  47. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/images/model_util/foo-reverse-relations-swagger.png +0 -0
  48. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/release_notes.md +0 -0
  49. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/requirements.txt +0 -0
  50. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/tutorial/authentication.md +0 -0
  51. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/tutorial/crud.md +0 -0
  52. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/tutorial/filtering.md +0 -0
  53. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/tutorial/model.md +0 -0
  54. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_1/models.py +0 -0
  55. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_1/urls.py +0 -0
  56. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_1/views.py +0 -0
  57. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_2/auth.py +0 -0
  58. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_2/models.py +0 -0
  59. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_2/urls.py +0 -0
  60. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_2/views.py +0 -0
  61. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/main.py +0 -0
  62. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/decorators.py +0 -0
  63. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/exceptions.py +0 -0
  64. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/helpers/__init__.py +0 -0
  65. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/helpers/api.py +0 -0
  66. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/helpers/query.py +0 -0
  67. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/models.py +0 -0
  68. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/parsers.py +0 -0
  69. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/renders.py +0 -0
  70. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/__init__.py +0 -0
  71. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/api.py +0 -0
  72. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/generics.py +0 -0
  73. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/helpers.py +0 -0
  74. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/views/__init__.py +0 -0
  75. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/views/mixins.py +0 -0
  76. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/requirements.dev.txt +0 -0
  77. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/run-local-coverage.sh +0 -0
  78. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/__init__.py +0 -0
  79. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/core/__init__.py +0 -0
  80. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/core/test_decorators.py +0 -0
  81. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/core/test_exceptions_api.py +0 -0
  82. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/core/test_renderer_parser.py +0 -0
  83. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/__init__.py +0 -0
  84. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/literals.py +0 -0
  85. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/models.py +0 -0
  86. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/request.py +0 -0
  87. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/views.py +0 -0
  88. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/helpers/__init__.py +0 -0
  89. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/models/__init__.py +0 -0
  90. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/models/test_model_util.py +0 -0
  91. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/models/test_models_extra.py +0 -0
  92. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_app/__init__.py +0 -0
  93. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_app/models.py +0 -0
  94. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_app/schema.py +0 -0
  95. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_app/views.py +0 -0
  96. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_auth.py +0 -0
  97. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_decorators.py +0 -0
  98. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_exceptions.py +0 -0
  99. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_query_util.py +0 -0
  100. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_settings.py +0 -0
  101. {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/views/__init__.py +0 -0
@@ -96,7 +96,7 @@ jobs:
96
96
  if [ "$MAKE_LATEST" = "true" ]; then
97
97
  echo "Deploying $VERSION as latest and default"
98
98
  mike deploy --push --update-aliases "$VERSION" latest --ignore-remote-status
99
- mike set-default "$VERSION"
99
+ mike set-default --push latest
100
100
  else
101
101
  echo "Deploying $VERSION (non-latest)"
102
102
  mike deploy --push "$VERSION" --ignore-remote-status
@@ -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.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
@@ -614,188 +614,11 @@ Support both JWT and API Key:
614
614
  class ArticleViewSet(APIViewSet):
615
615
  model = Article
616
616
  api = api
617
- auth = [JWTAuth() | APIKeyAuth()] # Either JWT or API Key
617
+ auth = [JWTAuth(), APIKeyAuth()] # Either JWT or API Key
618
618
  ```
619
619
 
620
620
  Django Ninja will try both methods; if either succeeds, the request is authenticated.
621
621
 
622
- ## Token Validation
623
-
624
- ### Expiration Validation
625
-
626
- JWT tokens include `exp` claim (expiration timestamp):
627
-
628
- ```python
629
- # Token payload
630
- {
631
- "sub": "123",
632
- "exp": 1704067200, # Unix timestamp
633
- "iat": 1704063600
634
- }
635
- ```
636
-
637
- AsyncJwtBearer automatically validates expiration:
638
-
639
- ```python
640
- class JWTAuth(AsyncJwtBearer):
641
- jwt_public = jwk.RSAKey.import_key(PUBLIC_KEY)
642
- # Expiration checked automatically
643
- ```
644
-
645
- **Error Response:**
646
-
647
- ```json
648
- {
649
- "detail": "Token has expired"
650
- }
651
- ```
652
-
653
- ### Not Before Validation
654
-
655
- Use `nbf` claim for tokens that become valid in the future:
656
-
657
- ```python
658
- # Token payload
659
- {
660
- "sub": "123",
661
- "nbf": 1704063600, # Not valid before this time
662
- "exp": 1704067200
663
- }
664
- ```
665
-
666
- Automatically validated by AsyncJwtBearer.
667
-
668
- ### Custom Validation
669
-
670
- ```python
671
- class StrictAuth(AsyncJwtBearer):
672
- jwt_public = jwk.RSAKey.import_key(PUBLIC_KEY)
673
-
674
- async def auth_handler(self, request):
675
- # Check token type
676
- token_type = self.dcd.claims.get("typ")
677
- if token_type != "access":
678
- return False
679
-
680
- # Check IP whitelist
681
- allowed_ips = self.dcd.claims.get("allowed_ips", [])
682
- client_ip = request.META.get('REMOTE_ADDR')
683
- if allowed_ips and client_ip not in allowed_ips:
684
- return False
685
-
686
- # Continue with normal auth
687
- user_id = self.dcd.claims.get("sub")
688
- return await User.objects.aget(id=user_id)
689
- ```
690
-
691
- ## Testing Authentication
692
-
693
- ### Unit Tests
694
-
695
- ```python
696
- import pytest
697
- from ninja.testing import TestAsyncClient
698
- from myapp.views import api
699
- from myapp.auth import JWTAuth
700
- import jwt
701
- from datetime import datetime, timedelta
702
-
703
-
704
- def create_token(user_id: int, **claims) -> str:
705
- payload = {
706
- "sub": str(user_id),
707
- "exp": datetime.utcnow() + timedelta(hours=1),
708
- "iat": datetime.utcnow(),
709
- **claims
710
- }
711
- return jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")
712
-
713
-
714
- @pytest.mark.asyncio
715
- async def test_authenticated_request():
716
- client = TestAsyncClient(api)
717
-
718
- # Create user
719
- user = await User.objects.acreate(username="testuser")
720
-
721
- # Create token
722
- token = create_token(user.id)
723
-
724
- # Make authenticated request
725
- response = await client.get(
726
- "/article/",
727
- headers={"Authorization": f"Bearer {token}"}
728
- )
729
-
730
- assert response.status_code == 200
731
-
732
-
733
- @pytest.mark.asyncio
734
- async def test_missing_token():
735
- client = TestAsyncClient(api)
736
-
737
- response = await client.get("/article/")
738
- assert response.status_code == 401
739
- assert "detail" in response.json()
740
-
741
-
742
- @pytest.mark.asyncio
743
- async def test_expired_token():
744
- client = TestAsyncClient(api)
745
-
746
- # Create expired token
747
- payload = {
748
- "sub": "123",
749
- "exp": datetime.utcnow() - timedelta(hours=1), # Expired
750
- "iat": datetime.utcnow() - timedelta(hours=2)
751
- }
752
- token = jwt.encode(payload, PRIVATE_KEY, algorithm="RS256")
753
-
754
- response = await client.get(
755
- "/article/",
756
- headers={"Authorization": f"Bearer {token}"}
757
- )
758
-
759
- assert response.status_code == 401
760
-
761
-
762
- @pytest.mark.asyncio
763
- async def test_invalid_signature():
764
- client = TestAsyncClient(api)
765
-
766
- # Create token with wrong key
767
- payload = {"sub": "123", "exp": datetime.utcnow() + timedelta(hours=1)}
768
- token = jwt.encode(payload, "wrong-secret", algorithm="HS256")
769
-
770
- response = await client.get(
771
- "/article/",
772
- headers={"Authorization": f"Bearer {token}"}
773
- )
774
-
775
- assert response.status_code == 401
776
- ```
777
-
778
- ### Mock Authentication
779
-
780
- For testing without real tokens:
781
-
782
- ```python
783
- from unittest.mock import AsyncMock, patch
784
-
785
-
786
- @pytest.mark.asyncio
787
- @patch('myapp.auth.JWTAuth.auth_handler')
788
- async def test_with_mock_auth(mock_auth):
789
- # Mock auth to return test user
790
- user = await User.objects.acreate(username="testuser")
791
- mock_auth.return_value = user
792
-
793
- client = TestAsyncClient(api)
794
- response = await client.get("/article/")
795
-
796
- assert response.status_code == 200
797
- ```
798
-
799
622
  ## Best Practices
800
623
 
801
624
  1. **Use RSA (asymmetric) keys for production:**
@@ -81,15 +81,12 @@ Registers all defined views to the API instance.
81
81
 
82
82
  **Returns:** The router instance
83
83
 
84
- **Usage:**
85
-
86
- ```python
87
- view = UserAPIView()
88
- view.add_views_to_route()
89
- ```
84
+ **Note:** When using `@api.view(prefix="/path", tags=[...])`, manual registration via `add_views_to_route()` is not required; the router is mounted automatically.
90
85
 
91
86
  ## Complete Example
92
87
 
88
+ **Recommended:**
89
+
93
90
  ```python
94
91
  from ninja_aio import NinjaAIO
95
92
  from ninja_aio.views import APIView
@@ -101,6 +98,23 @@ class StatsSchema(Schema):
101
98
  total: int
102
99
  active: int
103
100
 
101
+ @api.view(prefix="/analytics", tags=["Analytics"])
102
+ class AnalyticsView(APIView):
103
+ def views(self):
104
+ @self.router.get("/dashboard", response=StatsSchema)
105
+ async def dashboard(request):
106
+ return {"total": 1000, "active": 750}
107
+
108
+ @self.router.post("/track")
109
+ async def track_event(request, event: str):
110
+ return {"tracked": event}
111
+ ```
112
+
113
+ **Alternative implementation:**
114
+
115
+ ```python
116
+ api = NinjaAIO(title="My API")
117
+
104
118
  class AnalyticsView(APIView):
105
119
  api = api
106
120
  router_tag = "Analytics"
@@ -109,26 +123,21 @@ class AnalyticsView(APIView):
109
123
  def views(self):
110
124
  @self.router.get("/dashboard", response=StatsSchema)
111
125
  async def dashboard(request):
112
- return {
113
- "total": 1000,
114
- "active": 750
115
- }
126
+ return {"total": 1000, "active": 750}
116
127
 
117
128
  @self.router.post("/track")
118
129
  async def track_event(request, event: str):
119
- # tracking logic
120
130
  return {"tracked": event}
121
131
 
122
- # Register views
123
132
  AnalyticsView().add_views_to_route()
124
133
  ```
125
134
 
126
135
  ## Notes
127
136
 
128
137
  - Use `APIView` for simple, non-CRUD endpoints
129
- - For CRUD operations, use [`APIViewSet`](api_view_set.md) instead
130
- - All views are automatically async-compatible
131
- - Error codes `{400, 401, 404, 428}` are available via `self.error_codes`
138
+ - For CRUD operations, use [`APIViewSet`](api_view_set.md)
139
+ - All views are async-compatible
140
+ - Standard error codes are available via `self.error_codes`
132
141
 
133
142
  Note:
134
143
 
@@ -16,7 +16,7 @@ Notes:
16
16
 
17
17
  - Retrieve path has no trailing slash; update/delete include a trailing slash.
18
18
  - `{base}` auto-resolves from model verbose name plural (lowercase) unless `api_route_path` is provided.
19
- - Error responses may use a unified generic schema for codes: 400, 401, 404, 428.
19
+ - Error responses may use a unified generic schema for codes: 400, 401, 404.
20
20
 
21
21
  ## Core Attributes
22
22
 
@@ -317,7 +317,7 @@ All generated handlers are decorated with `@unique_view(...)` to ensure stable u
317
317
 
318
318
  ## Error Handling
319
319
 
320
- All CRUD and M2M endpoints may respond with `GenericMessageSchema` for error codes: 400 (validation), 401 (auth), 404 (not found), 428 (precondition required).
320
+ All CRUD and M2M endpoints may respond with `GenericMessageSchema` for error codes: 400 (validation), 401 (auth), 404 (not found).
321
321
 
322
322
  ## Performance Tips
323
323
 
@@ -329,7 +329,31 @@ All CRUD and M2M endpoints may respond with `GenericMessageSchema` for error cod
329
329
 
330
330
  ## Minimal Usage
331
331
 
332
+ Recommended:
333
+
334
+ ```python
335
+ from ninja_aio import NinjaAIO
336
+ from ninja_aio.views import APIViewSet
337
+ from .models import User
338
+
339
+ api = NinjaAIO(title="My API")
340
+
341
+ @api.viewset(model=User)
342
+ class UserViewSet(APIViewSet):
343
+ pass
344
+ ```
345
+
346
+ Note: prefix and tags are optional. If omitted, the base path is inferred from the model verbose name plural and tags default to the model verbose name.
347
+
348
+ Alternative implementation:
349
+
332
350
  ```python
351
+ from ninja_aio import NinjaAIO
352
+ from ninja_aio.views import APIViewSet
353
+ from .models import User
354
+
355
+ api = NinjaAIO(title="My API")
356
+
333
357
  class UserViewSet(APIViewSet):
334
358
  model = User
335
359
  api = api
@@ -340,18 +364,16 @@ UserViewSet().add_views_to_route()
340
364
  ## Disable Selected Views
341
365
 
342
366
  ```python
367
+ @api.viewset(model=User)
343
368
  class ReadOnlyUserViewSet(APIViewSet):
344
- model = User
345
- api = api
346
369
  disable = ["create", "update", "delete"]
347
370
  ```
348
371
 
349
372
  ## Authentication Example
350
373
 
351
374
  ```python
375
+ @api.viewset(model=User)
352
376
  class UserViewSet(APIViewSet):
353
- model = User
354
- api = api
355
377
  auth = [JWTAuth()] # global fallback
356
378
  get_auth = None # list/retrieve public
357
379
  delete_auth = [AdminAuth()] # delete restricted
@@ -359,7 +381,16 @@ class UserViewSet(APIViewSet):
359
381
 
360
382
  ## Complete M2M + Filters Example
361
383
 
384
+ Recommended:
385
+
362
386
  ```python
387
+ from ninja_aio import NinjaAIO
388
+ from ninja_aio.views import APIViewSet
389
+ from ninja_aio.models import ModelSerializer
390
+ from django.db import models
391
+
392
+ api = NinjaAIO(title="My API")
393
+
363
394
  class Tag(ModelSerializer):
364
395
  name = models.CharField(max_length=100)
365
396
  class ReadSerializer:
@@ -371,12 +402,9 @@ class User(ModelSerializer):
371
402
  class ReadSerializer:
372
403
  fields = ["id", "username", "tags"]
373
404
 
405
+ @api.viewset(model=User)
374
406
  class UserViewSet(APIViewSet):
375
- model = User
376
- api = api
377
- query_params = {
378
- "search": (str, None)
379
- }
407
+ query_params = {"search": (str, None)}
380
408
  m2m_relations = [
381
409
  M2MRelationSchema(
382
410
  model=Tag,
@@ -384,7 +412,7 @@ class UserViewSet(APIViewSet):
384
412
  filters={"name": (str, "")},
385
413
  add=True,
386
414
  remove=True,
387
- get=True
415
+ get=True,
388
416
  )
389
417
  ]
390
418
 
@@ -402,57 +430,60 @@ class UserViewSet(APIViewSet):
402
430
  return queryset
403
431
  ```
404
432
 
405
- ---
433
+ Alternative implementation:
406
434
 
407
- ## ReadOnlyViewSet
435
+ ```python
436
+ class UserViewSet(APIViewSet):
437
+ model = User
438
+ api = api
439
+ query_params = {"search": (str, None)}
440
+ m2m_relations = [
441
+ M2MRelationSchema(
442
+ model=Tag,
443
+ related_name="tags",
444
+ filters={"name": (str, "")},
445
+ add=True,
446
+ remove=True,
447
+ get=True,
448
+ )
449
+ ]
408
450
 
409
- ReadOnlyViewSet is a convenience subclass of APIViewSet that enables only list and retrieve endpoints. It is equivalent to setting `disable = ["create", "update", "delete"]`.
451
+ async def query_params_handler(self, queryset, filters):
452
+ if filters.get("search"):
453
+ from django.db.models import Q
454
+ s = filters["search"]
455
+ return queryset.filter(Q(username__icontains=s))
456
+ return queryset
410
457
 
411
- Generated endpoints:
458
+ async def tags_query_params_handler(self, queryset, filters):
459
+ name_filter = filters.get("name")
460
+ if name_filter:
461
+ queryset = queryset.filter(name__icontains=name_filter)
462
+ return queryset
412
463
 
413
- - GET `/{base}/` -> List
414
- - GET `/{base}/{pk}` -> Retrieve
464
+ UserViewSet().add_views_to_route()
465
+ ```
415
466
 
416
- Minimal usage:
467
+ ## ReadOnlyViewSet
468
+
469
+ ReadOnlyViewSet enables only list and retrieve endpoints.
417
470
 
418
471
  ```python
472
+ @api.viewset(model=MyModel)
419
473
  class MyModelReadOnlyViewSet(ReadOnlyViewSet):
420
- model = MyModel
421
- api = api
422
-
423
- MyModelReadOnlyViewSet().add_views_to_route()
474
+ pass
424
475
  ```
425
476
 
426
- Notes:
427
-
428
- - Supports all features of APIViewSet relevant to read operations (pagination, list filters, auth per verb, dynamic schema generation when using ModelSerializer).
429
- - M2M endpoints can still be added via `m2m_relations` if desired.
430
-
431
477
  ## WriteOnlyViewSet
432
478
 
433
- WriteOnlyViewSet is a convenience subclass of APIViewSet that enables only create, update, and delete endpoints. It is equivalent to setting `disable = ["list", "retrieve"]`.
434
-
435
- Generated endpoints:
436
-
437
- - POST `/{base}/` -> Create
438
- - PATCH `/{base}/{pk}/` -> Update
439
- - DELETE `/{base}/{pk}/` -> Delete
440
-
441
- Minimal usage:
479
+ WriteOnlyViewSet enables only create, update, and delete endpoints.
442
480
 
443
481
  ```python
482
+ @api.viewset(model=MyModel)
444
483
  class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
445
- model = MyModel
446
- api = api
447
-
448
- MyModelWriteOnlyViewSet().add_views_to_route()
484
+ pass
449
485
  ```
450
486
 
451
- Notes:
452
-
453
- - Supports auth per verb and dynamic schema generation for write operations when using ModelSerializer.
454
- - M2M endpoints can still be added via `m2m_relations` if desired.
455
-
456
487
  ## See Also
457
488
 
458
489
  - [ModelSerializer](../models/model_serializer.md)
@@ -34,31 +34,27 @@ Traditional Django REST development requires:
34
34
 
35
35
  === "Traditional Approach"
36
36
  ```python
37
- # serializers.py
38
- class UserSerializer(serializers.ModelSerializer):
37
+ # schema.py
38
+ class UserSchemaOut(ModelSchema)
39
39
  class Meta:
40
40
  model = User
41
41
  fields = ['id', 'username', 'email']
42
42
 
43
- class UserCreateSerializer(serializers.ModelSerializer):
43
+ class UserSchemaIn(ModelSchema):
44
44
  class Meta:
45
45
  model = User
46
46
  fields = ['username', 'email', 'password']
47
47
 
48
48
  # views.py
49
- class UserListView(APIView):
50
- async def get(self, request):
51
- users = await sync_to_async(list)(User.objects.all())
52
- serializer = UserSerializer(users, many=True)
53
- return Response(serializer.data)
49
+ @api.get("/users", response={200: list[UserSchemaOut]})
50
+ async def list_users(request):
51
+ return [user async for user in User.objects.select_related().all()]
54
52
 
55
- class UserCreateView(APIView):
56
- async def post(self, request):
57
- serializer = UserCreateSerializer(data=request.data)
58
- if serializer.is_valid():
59
- user = await sync_to_async(serializer.save)()
60
- return Response(UserSerializer(user).data)
61
- return Response(serializer.errors, status=400)
53
+ @api.post("/users/", response={201: UserSchemaOut})
54
+ async def create_user(request, data: UserSchemaIn):
55
+ user = await User.objects.select_related().acreate(**data.model_dump())
56
+ return 201, user
57
+
62
58
 
63
59
  # ... more views for retrieve, update, delete
64
60
  ```
@@ -20,7 +20,7 @@ nav:
20
20
  - APIView: api/views/api_view.md
21
21
  - APIViewSet: api/views/api_view_set.md
22
22
  - Decorators: api/views/decorators.md
23
- - Mixins: docs/mixins.md
23
+ - Mixins: api/views/mixins.md
24
24
  - Models:
25
25
  - ModelSerializer: api/models/model_serializer.md
26
26
  - ModelUtil: api/models/model_util.md
@@ -97,7 +97,6 @@ extra:
97
97
  version:
98
98
  provider: mike
99
99
  repo: caspel26/django-ninja-aio-crud
100
- stable: true
101
100
  default: latest
102
101
 
103
102
  plugins:
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "2.1.0"
3
+ __version__ = "2.2.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
@@ -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
@@ -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
  """
@@ -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):