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.
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.github/workflows/docs.yml +1 -1
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/PKG-INFO +2 -2
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/authentication.md +1 -178
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/views/api_view.md +24 -15
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/views/api_view_set.md +77 -46
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/index.md +11 -15
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/mkdocs.yml +1 -2
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/__init__.py +1 -1
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/api.py +24 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/auth.py +3 -3
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/types.py +1 -1
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/views/api.py +99 -32
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/pyproject.toml +1 -1
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/helpers/test_many_to_many_api.py +1 -2
- django_ninja_aio_crud-2.2.0/tests/views/test_views.py +99 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/views/test_viewset.py +54 -1
- django_ninja_aio_crud-2.1.0/tests/views/test_views.py +0 -57
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.github/dependabot.yml +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.github/workflows/coverage.yml +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.github/workflows/publish.yml +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.gitignore +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/.pre-commit-config.yaml +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/LICENSE +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/README.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/CNAME +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/models/model_serializer.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/models/model_util.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/pagination.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/renderers/orjson_renderer.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/api/views/decorators.md +0 -0
- {django_ninja_aio_crud-2.1.0/docs → django_ninja_aio_crud-2.2.0/docs/api/views}/mixins.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/auth.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/contributing.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/extra.css +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/installation.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/getting_started/quick_start.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/images/bar-swagger.png +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/images/favicon.ico +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/images/foo-swagger.png +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/images/logo.png +0 -0
- {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
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/release_notes.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/requirements.txt +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/tutorial/authentication.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/tutorial/crud.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/tutorial/filtering.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/docs/tutorial/model.md +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_1/models.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_1/urls.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_1/views.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_2/auth.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_2/models.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_2/urls.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/examples/ex_2/views.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/main.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/decorators.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/exceptions.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/helpers/__init__.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/helpers/api.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/helpers/query.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/models.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/parsers.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/renders.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/__init__.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/api.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/generics.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/schemas/helpers.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/views/__init__.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/ninja_aio/views/mixins.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/requirements.dev.txt +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/run-local-coverage.sh +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/__init__.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/core/__init__.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/core/test_decorators.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/core/test_exceptions_api.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/core/test_renderer_parser.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/__init__.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/literals.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/models.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/request.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/generics/views.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/helpers/__init__.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/models/__init__.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/models/test_model_util.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/models/test_models_extra.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_app/__init__.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_app/models.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_app/schema.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_app/views.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_auth.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_decorators.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_exceptions.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_query_util.py +0 -0
- {django_ninja_aio_crud-2.1.0 → django_ninja_aio_crud-2.2.0}/tests/test_settings.py +0 -0
- {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
|
|
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.
|
|
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()
|
|
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
|
-
**
|
|
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)
|
|
130
|
-
- All views are
|
|
131
|
-
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
|
|
464
|
+
UserViewSet().add_views_to_route()
|
|
465
|
+
```
|
|
415
466
|
|
|
416
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
38
|
-
class
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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:
|
|
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:
|
|
@@ -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:
|
|
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:
|
|
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):
|