Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate to django-ninja from DRF #1

Merged
merged 2 commits into from
Mar 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

in example project for including django-ninja to cookiecutter-django. reproduces DRF endpoints in cookiecutter-django template.

## For more information to include django-ninja in cookiecutter-django

1. see the [discussion in cookiecutter-django issue:](https://github.com/cookiecutter/cookiecutter-django/issues/4923)
and feel free to contribute to the issue.

2. check [which files are changed in this project to include django-ninja in cookiecutter-django.](https://github.com/TGoddessana/cookiecutter_django_ninja_example/pull/1/files#diff-ae8f68a11766be0eaa8ff041845c8381ac4a3a8b881c226c63bb36eaad2ac19b)

[![Built with Cookiecutter Django](https://img.shields.io/badge/built%20with-Cookiecutter%20Django-ff69b4.svg?logo=cookiecutter)](https://github.com/cookiecutter/cookiecutter-django/)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)

Expand Down
3 changes: 2 additions & 1 deletion config/ninja_api.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from django.contrib.admin.views.decorators import staff_member_required
from ninja import NinjaAPI

from cookiecutter_django_ninja_example.users.api.views import users_router

# Overrides to maintain consistency with the DRF url namespace in cookiecutter-django.
api = NinjaAPI(urls_namespace="api")
api = NinjaAPI(urls_namespace="api", docs_decorator=staff_member_required)

api.add_router("/users/", users_router)
40 changes: 11 additions & 29 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,7 @@
"allauth.account",
"allauth.mfa",
"allauth.socialaccount",
"rest_framework",
"rest_framework.authtoken",
"corsheaders",
"drf_spectacular",
]

LOCAL_APPS = [
Expand All @@ -99,7 +96,9 @@
# MIGRATIONS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules
MIGRATION_MODULES = {"sites": "cookiecutter_django_ninja_example.contrib.sites.migrations"}
MIGRATION_MODULES = {
"sites": "cookiecutter_django_ninja_example.contrib.sites.migrations",
}

# AUTHENTICATION
# ------------------------------------------------------------------------------
Expand Down Expand Up @@ -279,34 +278,17 @@
# https://docs.allauth.org/en/latest/account/configuration.html
ACCOUNT_ADAPTER = "cookiecutter_django_ninja_example.users.adapters.AccountAdapter"
# https://docs.allauth.org/en/latest/account/forms.html
ACCOUNT_FORMS = {"signup": "cookiecutter_django_ninja_example.users.forms.UserSignupForm"}
ACCOUNT_FORMS = {
"signup": "cookiecutter_django_ninja_example.users.forms.UserSignupForm",
}
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
SOCIALACCOUNT_ADAPTER = "cookiecutter_django_ninja_example.users.adapters.SocialAccountAdapter"
SOCIALACCOUNT_ADAPTER = (
"cookiecutter_django_ninja_example.users.adapters.SocialAccountAdapter"
)
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
SOCIALACCOUNT_FORMS = {"signup": "cookiecutter_django_ninja_example.users.forms.UserSocialSignupForm"}

# django-rest-framework
# -------------------------------------------------------------------------------
# django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
SOCIALACCOUNT_FORMS = {
"signup": "cookiecutter_django_ninja_example.users.forms.UserSocialSignupForm",
}

# django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup
CORS_URLS_REGEX = r"^/api/.*$"

# By Default swagger ui is available only to admin user(s). You can change permission classes to change that
# See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings
SPECTACULAR_SETTINGS = {
"TITLE": "cookiecutter-django-ninja-example API",
"DESCRIPTION": "Documentation of API endpoints of cookiecutter-django-ninja-example",
"VERSION": "1.0.0",
"SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"],
}
# Your stuff...
# ------------------------------------------------------------------------------
19 changes: 6 additions & 13 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
from django.urls import path
from django.views import defaults as default_views
from django.views.generic import TemplateView
from drf_spectacular.views import SpectacularAPIView
from drf_spectacular.views import SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token
from config.ninja_api import api

urlpatterns = [
path("", TemplateView.as_view(template_name="pages/home.html"), name="home"),
Expand All @@ -20,7 +18,10 @@
# Django Admin, use {% url 'admin:index' %}
path(settings.ADMIN_URL, admin.site.urls),
# User management
path("users/", include("cookiecutter_django_ninja_example.users.urls", namespace="users")),
path(
"users/",
include("cookiecutter_django_ninja_example.users.urls", namespace="users"),
),
path("accounts/", include("allauth.urls")),
# Your stuff: custom urls includes go here
# ...
Expand All @@ -31,15 +32,7 @@
# API URLS
urlpatterns += [
# API base url
path("api/", include("config.api_router")),
# DRF auth token
path("auth-token/", obtain_auth_token),
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
path(
"api/docs/",
SpectacularSwaggerView.as_view(url_name="api-schema"),
name="api-docs",
),
path("api/", api.urls),
]

if settings.DEBUG:
Expand Down
25 changes: 25 additions & 0 deletions cookiecutter_django_ninja_example/users/api/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from django.urls import reverse
from ninja import ModelSchema

from cookiecutter_django_ninja_example.users.models import User


class UserUpdateSchema(ModelSchema):
class Meta:
model = User
fields = ["username", "name"]


class UserSchema(ModelSchema):
url: str

@staticmethod
def resolve_url(obj: User, context):
request = context["request"]
return request.build_absolute_uri(
reverse("api:user-detail", kwargs={"username": obj.username}),
)

class Meta:
model = User
fields = ["username", "name"]
13 changes: 0 additions & 13 deletions cookiecutter_django_ninja_example/users/api/serializers.py

This file was deleted.

70 changes: 51 additions & 19 deletions cookiecutter_django_ninja_example/users/api/views.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,58 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.mixins import ListModelMixin
from rest_framework.mixins import RetrieveModelMixin
from rest_framework.mixins import UpdateModelMixin
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from ninja import Router
from ninja.errors import HttpError

from cookiecutter_django_ninja_example.users.models import User

from .serializers import UserSerializer
from .schemas import UserSchema
from .schemas import UserUpdateSchema

users_router = Router(tags=["users"])

class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet):
serializer_class = UserSerializer
queryset = User.objects.all()
lookup_field = "username"

def get_queryset(self, *args, **kwargs):
assert isinstance(self.request.user.id, int)
return self.queryset.filter(id=self.request.user.id)
# error handling in django-ninja is different from DRF
# see https://django-ninja.dev/guides/errors/
# do we need to add a custom error handler function in template?

@action(detail=False)
def me(self, request):
serializer = UserSerializer(request.user, context={"request": request})
return Response(status=status.HTTP_200_OK, data=serializer.data)

@users_router.get("me/", response=UserSchema, url_name="user-me")
def user_me(request):
return request.user


@users_router.get("/", response=list[UserSchema], url_name="user-list")
def user_list(request):
return User.objects.all()


@users_router.get("{username}/", response=UserSchema, url_name="user-detail")
def user_detail(request, username: str):
try:
return User.objects.get(username=username)
except User.DoesNotExist as exc:
raise HttpError(404, "User not found") from exc
Comment on lines +29 to +32

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be simplified with get_object_or_404(User, username=username)



@users_router.put("{username}/", response=UserSchema, url_name="user-update")
def update_user(request, username: str, payload: UserUpdateSchema):
try:
user = User.objects.get(username=username)
user.username = payload.username
user.name = payload.name
user.save()
except User.DoesNotExist as exc:
raise HttpError(404, "User not found") from exc

return user


@users_router.patch("{username}/", response=UserSchema, url_name="user-partial-update")
def partial_update_user(request, username: str, payload: UserUpdateSchema):
try:
user = User.objects.get(username=username)
user.username = payload.username
user.name = payload.name
user.save()
except User.DoesNotExist as exc:
raise HttpError(404, "User not found") from exc

return user
22 changes: 22 additions & 0 deletions cookiecutter_django_ninja_example/users/tests/test_api_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from ninja.testing import TestClient

from cookiecutter_django_ninja_example.users.api.views import users_router
from cookiecutter_django_ninja_example.users.models import User


class TestUserAPI:
def test_user_me(self, user: User):
client = TestClient(users_router)
response = client.get("/me/", user=user)

# there is no `status` in django-ninja,
# do we need to add enum for status codes?
assert response.status_code == 200 # noqa: PLR2004

assert response.json() == {
"username": user.username,
# note that drf creates urls with `testserver`,
# but django-ninja uses `testlocation`
"url": f"http://testlocation/api/users/{user.username}/",
"name": user.name,
}
35 changes: 0 additions & 35 deletions cookiecutter_django_ninja_example/users/tests/test_drf_views.py

This file was deleted.

13 changes: 9 additions & 4 deletions cookiecutter_django_ninja_example/users/tests/test_swagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@


def test_swagger_accessible_by_admin(admin_client):
url = reverse("api-docs")
url = reverse("api:openapi-view")
response = admin_client.get(url)
assert response.status_code == HTTPStatus.OK


@pytest.mark.django_db()
def test_swagger_ui_not_accessible_by_normal_user(client):
url = reverse("api-docs")
url = reverse("api:openapi-view")
response = client.get(url)
assert response.status_code == HTTPStatus.FORBIDDEN
# Not like DRF, Ninja does not return a 403 Forbidden status code.
# we can define custom decorators, to pass like this:
# `api = NinjaAPI(urls_namespace="api", docs_decorator=staff_member_required)`
# do we have to define custom decorators for Ninja, to reproduce DRF-like behavior?
# see: https://django-ninja.dev/guides/api-docs/
assert response.status_code == HTTPStatus.FOUND


def test_api_schema_generated_successfully(admin_client):
url = reverse("api-schema")
url = reverse("api:openapi-json")
response = admin_client.get(url)
assert response.status_code == HTTPStatus.OK