django-ninja-aio-crud 2.0.0rc7__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.
Files changed (102) hide show
  1. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/.github/workflows/coverage.yml +1 -1
  2. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/.github/workflows/docs.yml +50 -19
  3. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/PKG-INFO +2 -2
  4. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/api/authentication.md +1 -178
  5. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/api/views/api_view.md +24 -15
  6. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/api/views/api_view_set.md +92 -10
  7. django_ninja_aio_crud-2.2.0/docs/api/views/mixins.md +147 -0
  8. django_ninja_aio_crud-2.2.0/docs/auth.md +225 -0
  9. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/index.md +11 -15
  10. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/mkdocs.yml +5 -1
  11. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/__init__.py +1 -1
  12. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/api.py +24 -0
  13. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/auth.py +121 -3
  14. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/helpers/api.py +5 -3
  15. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/types.py +3 -1
  16. django_ninja_aio_crud-2.2.0/ninja_aio/views/__init__.py +3 -0
  17. django_ninja_aio_crud-2.0.0rc7/ninja_aio/views.py → django_ninja_aio_crud-2.2.0/ninja_aio/views/api.py +144 -46
  18. django_ninja_aio_crud-2.2.0/ninja_aio/views/mixins.py +275 -0
  19. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/pyproject.toml +1 -1
  20. django_ninja_aio_crud-2.2.0/tests/__init__.py +1 -0
  21. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/helpers/test_many_to_many_api.py +1 -2
  22. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/test_app/models.py +3 -1
  23. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/test_app/views.py +53 -1
  24. django_ninja_aio_crud-2.2.0/tests/test_auth.py +124 -0
  25. django_ninja_aio_crud-2.2.0/tests/views/test_views.py +99 -0
  26. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/views/test_viewset.py +231 -1
  27. django_ninja_aio_crud-2.0.0rc7/tests/views/__init__.py +0 -0
  28. django_ninja_aio_crud-2.0.0rc7/tests/views/test_views.py +0 -57
  29. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/.github/dependabot.yml +0 -0
  30. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/.github/workflows/publish.yml +0 -0
  31. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/.gitignore +0 -0
  32. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/.pre-commit-config.yaml +0 -0
  33. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/LICENSE +0 -0
  34. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/README.md +0 -0
  35. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/CNAME +0 -0
  36. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/api/models/model_serializer.md +0 -0
  37. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/api/models/model_util.md +0 -0
  38. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/api/pagination.md +0 -0
  39. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/api/renderers/orjson_renderer.md +0 -0
  40. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/api/views/decorators.md +0 -0
  41. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/contributing.md +0 -0
  42. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/extra.css +0 -0
  43. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-create-swagger.png +0 -0
  44. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-delete-swagger.png +0 -0
  45. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-list-swagger.png +0 -0
  46. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-retrieve-swagger.png +0 -0
  47. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-swagger.png +0 -0
  48. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/getting_started/images/index/foo-index-update-swagger.png +0 -0
  49. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/getting_started/installation.md +0 -0
  50. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/getting_started/quick_start.md +0 -0
  51. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/images/bar-swagger.png +0 -0
  52. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/images/favicon.ico +0 -0
  53. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/images/foo-swagger.png +0 -0
  54. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/images/logo.png +0 -0
  55. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/images/model_util/foo-reverse-relations-swagger.png +0 -0
  56. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/release_notes.md +0 -0
  57. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/requirements.txt +0 -0
  58. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/tutorial/authentication.md +0 -0
  59. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/tutorial/crud.md +0 -0
  60. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/tutorial/filtering.md +0 -0
  61. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/docs/tutorial/model.md +0 -0
  62. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/examples/ex_1/models.py +0 -0
  63. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/examples/ex_1/urls.py +0 -0
  64. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/examples/ex_1/views.py +0 -0
  65. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/examples/ex_2/auth.py +0 -0
  66. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/examples/ex_2/models.py +0 -0
  67. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/examples/ex_2/urls.py +0 -0
  68. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/examples/ex_2/views.py +0 -0
  69. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/main.py +0 -0
  70. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/decorators.py +0 -0
  71. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/exceptions.py +0 -0
  72. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/helpers/__init__.py +0 -0
  73. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/helpers/query.py +0 -0
  74. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/models.py +0 -0
  75. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/parsers.py +0 -0
  76. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/renders.py +0 -0
  77. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/__init__.py +0 -0
  78. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/api.py +0 -0
  79. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/generics.py +0 -0
  80. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/helpers.py +0 -0
  81. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/requirements.dev.txt +0 -0
  82. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/run-local-coverage.sh +0 -0
  83. {django_ninja_aio_crud-2.0.0rc7/tests → django_ninja_aio_crud-2.2.0/tests/core}/__init__.py +0 -0
  84. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/core/test_decorators.py +0 -0
  85. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/core/test_exceptions_api.py +0 -0
  86. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/core/test_renderer_parser.py +0 -0
  87. {django_ninja_aio_crud-2.0.0rc7/tests/core → django_ninja_aio_crud-2.2.0/tests/generics}/__init__.py +0 -0
  88. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/generics/literals.py +0 -0
  89. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/generics/models.py +0 -0
  90. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/generics/request.py +0 -0
  91. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/generics/views.py +0 -0
  92. {django_ninja_aio_crud-2.0.0rc7/tests/generics → django_ninja_aio_crud-2.2.0/tests/helpers}/__init__.py +0 -0
  93. {django_ninja_aio_crud-2.0.0rc7/tests/helpers → django_ninja_aio_crud-2.2.0/tests/models}/__init__.py +0 -0
  94. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/models/test_model_util.py +0 -0
  95. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/models/test_models_extra.py +0 -0
  96. {django_ninja_aio_crud-2.0.0rc7/tests/models → django_ninja_aio_crud-2.2.0/tests/test_app}/__init__.py +0 -0
  97. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/test_app/schema.py +0 -0
  98. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/test_decorators.py +0 -0
  99. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/test_exceptions.py +0 -0
  100. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/test_query_util.py +0 -0
  101. {django_ninja_aio_crud-2.0.0rc7 → django_ninja_aio_crud-2.2.0}/tests/test_settings.py +0 -0
  102. {django_ninja_aio_crud-2.0.0rc7/tests/test_app → django_ninja_aio_crud-2.2.0/tests/views}/__init__.py +0 -0
@@ -27,6 +27,6 @@ jobs:
27
27
  coverage xml
28
28
 
29
29
  - name: Coverage
30
- uses: codecov/codecov-action@v5.5.1
30
+ uses: codecov/codecov-action@v5.5.2
31
31
  with:
32
32
  token: ${{ secrets.CODECOV_TOKEN }}
@@ -7,7 +7,7 @@ on:
7
7
  description: 'Docs version to deploy (e.g. v1.0.0)'
8
8
  required: true
9
9
  default: 'dev'
10
- type: choice # ← SonarQube compliant: predefined safe options
10
+ type: choice
11
11
  options:
12
12
  - dev
13
13
  - stable
@@ -16,12 +16,23 @@ on:
16
16
  make_latest:
17
17
  description: 'Set as "latest" and default?'
18
18
  type: boolean
19
- default: true
19
+ default: false
20
20
 
21
21
  delete_version:
22
- description: 'Version to DELETE (leave empty to skip)'
23
- required: false
24
- default: ''
22
+ description: 'Docs version to delete'
23
+ required: false
24
+ default: ''
25
+ type: choice
26
+ options:
27
+ - ''
28
+ - dev
29
+ - stable
30
+ - "1.0"
31
+ - "2.0"
32
+ delete_confirm:
33
+ description: 'Confirm deletion of the selected version'
34
+ type: boolean
35
+ default: false
25
36
 
26
37
  permissions:
27
38
  contents: write
@@ -29,8 +40,6 @@ permissions:
29
40
  jobs:
30
41
  deploy:
31
42
  runs-on: ubuntu-latest
32
-
33
- # Only trusted events (SonarQube safe)
34
43
  if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
35
44
 
36
45
  steps:
@@ -52,18 +61,15 @@ jobs:
52
61
  - name: Compute VERSION and MAKE_LATEST
53
62
  id: vars
54
63
  env:
55
- # Assign untrusted input to env var FIRST (SonarQube compliant)
56
64
  INPUT_VERSION: ${{ inputs.docs_version }}
57
65
  shell: bash
58
66
  run: |
59
- # Now use safe shell variable $INPUT_VERSION
60
67
  if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "$INPUT_VERSION" ]; then
61
68
  VERSION="$INPUT_VERSION"
62
69
  else
63
70
  VERSION="dev"
64
71
  fi
65
72
 
66
- # Boolean input is always safe ("true"/"false")
67
73
  if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
68
74
  MAKE_LATEST="${{ inputs.make_latest }}"
69
75
  else
@@ -82,29 +88,54 @@ jobs:
82
88
  git fetch --all --tags
83
89
  git fetch origin gh-pages --depth=1 || true
84
90
 
85
- # Idempotent worktree setup
86
91
  if git worktree list | grep -q " gh-pages "; then
87
92
  git worktree remove gh-pages --force
88
93
  fi
89
94
  git worktree add gh-pages gh-pages 2>/dev/null || git worktree add gh-pages origin/gh-pages
90
95
 
91
96
  if [ "$MAKE_LATEST" = "true" ]; then
92
- echo "🚀 Deploying $VERSION as latest and default"
97
+ echo "Deploying $VERSION as latest and default"
93
98
  mike deploy --push --update-aliases "$VERSION" latest --ignore-remote-status
94
- mike set-default "$VERSION"
99
+ mike set-default --push latest
95
100
  else
96
- echo "📦 Deploying $VERSION (non-latest)"
101
+ echo "Deploying $VERSION (non-latest)"
97
102
  mike deploy --push "$VERSION" --ignore-remote-status
98
103
  fi
99
104
 
100
105
  git worktree remove gh-pages --force
101
106
 
102
- - name: Delete version
103
- if: inputs.delete_version != ''
107
+ - name: Delete version (safe)
108
+ if: github.event_name == 'workflow_dispatch' && inputs.delete_version != '' && inputs.delete_confirm == true
109
+ env:
110
+ DELETE_VERSION: ${{ inputs.delete_version }}
111
+ shell: bash
104
112
  run: |
105
- echo "Deleting version: ${{ inputs.delete_version }}"
106
- mike delete "${{ inputs.delete_version }}" --push
107
- echo "Version ${{ inputs.delete_version }} deleted successfully"
113
+ set -euo pipefail
114
+
115
+ echo "Requested delete: $DELETE_VERSION"
116
+
117
+ # Protect aliases and default
118
+ if [ "$DELETE_VERSION" = "latest" ] || [ "$DELETE_VERSION" = "stable" ]; then
119
+ echo "Refusing to delete protected alias: $DELETE_VERSION"
120
+ exit 1
121
+ fi
122
+
123
+ # Load current default; fail if attempting to delete it
124
+ CURRENT_DEFAULT="$(mike default || true)"
125
+ if [ -n "$CURRENT_DEFAULT" ] && [ "$DELETE_VERSION" = "$CURRENT_DEFAULT" ]; then
126
+ echo "Refusing to delete current default docs version: $DELETE_VERSION"
127
+ exit 1
128
+ fi
129
+
130
+ # Ensure version exists before deleting
131
+ if ! mike list | awk '{print $1}' | grep -Fxq "$DELETE_VERSION"; then
132
+ echo "Version '$DELETE_VERSION' not found. Nothing to delete."
133
+ exit 0
134
+ fi
135
+
136
+ echo "Deleting version: $DELETE_VERSION"
137
+ mike delete "$DELETE_VERSION" --push
138
+ echo "Version '$DELETE_VERSION' deleted successfully"
108
139
 
109
140
  - name: List versions
110
141
  run: mike list
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-ninja-aio-crud
3
- Version: 2.0.0rc7
3
+ Version: 2.2.0
4
4
  Summary: Django Ninja AIO CRUD - Rest Framework
5
5
  Author: Giuseppe Casillo
6
- Requires-Python: >=3.10
6
+ Requires-Python: >=3.10, <=3.14
7
7
  Description-Content-Type: text/markdown
8
8
  Classifier: Operating System :: OS Independent
9
9
  Classifier: Topic :: Internet
@@ -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
+
332
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
+
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,41 @@ class User(ModelSerializer):
371
402
  class ReadSerializer:
372
403
  fields = ["id", "username", "tags"]
373
404
 
405
+ @api.viewset(model=User)
406
+ class UserViewSet(APIViewSet):
407
+ query_params = {"search": (str, None)}
408
+ m2m_relations = [
409
+ M2MRelationSchema(
410
+ model=Tag,
411
+ related_name="tags",
412
+ filters={"name": (str, "")},
413
+ add=True,
414
+ remove=True,
415
+ get=True,
416
+ )
417
+ ]
418
+
419
+ async def query_params_handler(self, queryset, filters):
420
+ if filters.get("search"):
421
+ from django.db.models import Q
422
+ s = filters["search"]
423
+ return queryset.filter(Q(username__icontains=s))
424
+ return queryset
425
+
426
+ async def tags_query_params_handler(self, queryset, filters):
427
+ name_filter = filters.get("name")
428
+ if name_filter:
429
+ queryset = queryset.filter(name__icontains=name_filter)
430
+ return queryset
431
+ ```
432
+
433
+ Alternative implementation:
434
+
435
+ ```python
374
436
  class UserViewSet(APIViewSet):
375
437
  model = User
376
438
  api = api
377
- query_params = {
378
- "search": (str, None)
379
- }
439
+ query_params = {"search": (str, None)}
380
440
  m2m_relations = [
381
441
  M2MRelationSchema(
382
442
  model=Tag,
@@ -384,7 +444,7 @@ class UserViewSet(APIViewSet):
384
444
  filters={"name": (str, "")},
385
445
  add=True,
386
446
  remove=True,
387
- get=True
447
+ get=True,
388
448
  )
389
449
  ]
390
450
 
@@ -400,6 +460,28 @@ class UserViewSet(APIViewSet):
400
460
  if name_filter:
401
461
  queryset = queryset.filter(name__icontains=name_filter)
402
462
  return queryset
463
+
464
+ UserViewSet().add_views_to_route()
465
+ ```
466
+
467
+ ## ReadOnlyViewSet
468
+
469
+ ReadOnlyViewSet enables only list and retrieve endpoints.
470
+
471
+ ```python
472
+ @api.viewset(model=MyModel)
473
+ class MyModelReadOnlyViewSet(ReadOnlyViewSet):
474
+ pass
475
+ ```
476
+
477
+ ## WriteOnlyViewSet
478
+
479
+ WriteOnlyViewSet enables only create, update, and delete endpoints.
480
+
481
+ ```python
482
+ @api.viewset(model=MyModel)
483
+ class MyModelWriteOnlyViewSet(WriteOnlyViewSet):
484
+ pass
403
485
  ```
404
486
 
405
487
  ## See Also