diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5cbec4fe..ec7a996968 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Basic args: "ci_tool=Gitlab" - name: Celery & DRF - args: "use_celery=y use_drf=y" + args: "use_celery=y rest_api=DRF" - name: Gulp args: "frontend_pipeline=Gulp" - name: Webpack @@ -72,6 +72,12 @@ jobs: args: "frontend_pipeline=Webpack use_heroku=y" - name: Email Username args: "username_type=email ci_tool=Github project_name='Something superduper long - the great amazing project' project_slug=my_awesome_project" + - name: Email username & DRF + args: "username_type=email rest_api=DRF ci_tool=Gitlab" + - name: Async & Django-Ninja + args: "use_async=y rest_api='Django Ninja'" + - name: Async, email username & Django-Ninja + args: "use_async=y username_type=email rest_api='Django Ninja'" name: "Bare metal ${{ matrix.script.name }}" runs-on: ubuntu-latest diff --git a/README.md b/README.md index beeb5084f1..7fc890fa17 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,12 @@ Answer the prompts with your own desired [options](http://cookiecutter-django.re 8 - SparkPost 9 - Other SMTP Choose from 1, 2, 3, 4, 5, 6, 7, 8, 9 [1]: 1 + Select rest_api [n]: + 1 - None + 2 - DRF + 3 - Django Ninja + Choose from 1, 2, 3 [1]: 1 use_async [n]: n - use_drf [n]: y Select frontend_pipeline: 1 - None 2 - Django Compressor diff --git a/cookiecutter.json b/cookiecutter.json index d4dbc126a8..e989e0744a 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -31,8 +31,8 @@ "SparkPost", "Other SMTP" ], + "rest_api": ["None", "DRF", "Django Ninja"], "use_async": "n", - "use_drf": "n", "frontend_pipeline": ["None", "Django Compressor", "Gulp", "Webpack"], "use_celery": "n", "use_mailpit": "n", diff --git a/docs/1-getting-started/project-generation-options.rst b/docs/1-getting-started/project-generation-options.rst index fe6fa29cce..142b37b2ab 100644 --- a/docs/1-getting-started/project-generation-options.rst +++ b/docs/1-getting-started/project-generation-options.rst @@ -94,12 +94,16 @@ mail_service: 8. SparkPost_ 9. `Other SMTP`_ +rest_api: + Select a REST API framework to use. The choices are: + + 1. None + 2. `Django Rest Framework`_ + 3. `Django Ninja`_ + use_async: Indicates whether the project should use web sockets with Uvicorn + Gunicorn. -use_drf: - Indicates whether the project should be configured to use `Django Rest Framework`_. - frontend_pipeline: Select a pipeline to compile and optimise frontend assets (JS, CSS, ...): @@ -178,6 +182,7 @@ debug: .. _Other SMTP: https://anymail.readthedocs.io/en/stable/ .. _Django Rest Framework: https://github.com/encode/django-rest-framework/ +.. _Django Ninja: https://github.com/vitalik/django-ninja .. _Django Compressor: https://github.com/django-compressor/django-compressor diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index f08825d381..ad7fecd9be 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -408,6 +408,17 @@ def remove_aws_dockerfile(): def remove_drf_starter_files(): Path("config", "api_router.py").unlink() + Path("{{cookiecutter.project_slug}}", "users", "api", "serializers.py").unlink() + + +def remove_ninja_starter_files(): + Path("config", "api.py").unlink() + Path("{{cookiecutter.project_slug}}", "users", "api", "schema.py").unlink() + + +def remove_rest_api_files(): + remove_drf_starter_files() + remove_ninja_starter_files() shutil.rmtree(Path("{{cookiecutter.project_slug}}", "users", "api")) shutil.rmtree(Path("{{cookiecutter.project_slug}}", "users", "tests", "api")) @@ -499,8 +510,12 @@ def main(): # noqa: C901, PLR0912, PLR0915 if "{{ cookiecutter.ci_tool }}" != "Drone": remove_dotdrone_file() - if "{{ cookiecutter.use_drf }}".lower() == "n": + if "{{ cookiecutter.rest_api }}" == "DRF": + remove_ninja_starter_files() + elif "{{ cookiecutter.rest_api }}" == "Django Ninja": remove_drf_starter_files() + else: + remove_rest_api_files() if "{{ cookiecutter.use_async }}".lower() == "n": remove_async_files() diff --git a/tests/test_cookiecutter_generation.py b/tests/test_cookiecutter_generation.py index 75e4d4346e..5f10acba67 100755 --- a/tests/test_cookiecutter_generation.py +++ b/tests/test_cookiecutter_generation.py @@ -106,10 +106,11 @@ def context(): {"cloud_provider": "Azure", "mail_service": "Other SMTP"}, # Note: cloud_providers GCP, Azure, and None # with mail_service Amazon SES is not supported + {"rest_api": "None"}, + {"rest_api": "DRF"}, + {"rest_api": "Django Ninja"}, {"use_async": "y"}, {"use_async": "n"}, - {"use_drf": "y"}, - {"use_drf": "n"}, {"frontend_pipeline": "None"}, {"frontend_pipeline": "Django Compressor"}, {"frontend_pipeline": "Gulp"}, diff --git a/{{cookiecutter.project_slug}}/config/api.py b/{{cookiecutter.project_slug}}/config/api.py new file mode 100644 index 0000000000..00d235e562 --- /dev/null +++ b/{{cookiecutter.project_slug}}/config/api.py @@ -0,0 +1,11 @@ +from django.contrib.admin.views.decorators import staff_member_required +from ninja import NinjaAPI +from ninja.security import SessionAuth + +api = NinjaAPI( + urls_namespace="api", + auth=SessionAuth(), + docs_decorator=staff_member_required, +) + +api.add_router("/users/", "{{ cookiecutter.project_slug }}.users.api.views.router") diff --git a/{{cookiecutter.project_slug}}/config/settings/base.py b/{{cookiecutter.project_slug}}/config/settings/base.py index ba0a7ce463..e82f9ffa2d 100644 --- a/{{cookiecutter.project_slug}}/config/settings/base.py +++ b/{{cookiecutter.project_slug}}/config/settings/base.py @@ -1,7 +1,6 @@ # ruff: noqa: ERA001, E501 """Base settings to build other settings files upon.""" - -{% if cookiecutter.use_celery == 'y' -%} +{% if cookiecutter.use_celery == 'y' %} import ssl {%- endif %} from pathlib import Path @@ -92,11 +91,13 @@ {%- if cookiecutter.use_celery == 'y' %} "django_celery_beat", {%- endif %} -{%- if cookiecutter.use_drf == "y" %} +{%- if cookiecutter.rest_api == 'DRF' %} "rest_framework", "rest_framework.authtoken", "corsheaders", "drf_spectacular", +{%- elif cookiecutter.rest_api == 'Django Ninja' %} + "corsheaders", {%- endif %} {%- if cookiecutter.frontend_pipeline == 'Webpack' %} "webpack_loader", @@ -154,7 +155,7 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#middleware MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", -{%- if cookiecutter.use_drf == 'y' %} +{%- if cookiecutter.rest_api != 'None' %} "corsheaders.middleware.CorsMiddleware", {%- endif %} {%- if cookiecutter.use_whitenoise == 'y' %} @@ -361,7 +362,7 @@ INSTALLED_APPS += ["compressor"] STATICFILES_FINDERS += ["compressor.finders.CompressorFinder"] {%- endif %} -{% if cookiecutter.use_drf == "y" -%} +{% if cookiecutter.rest_api == 'DRF' -%} # django-rest-framework # ------------------------------------------------------------------------------- # django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/ diff --git a/{{cookiecutter.project_slug}}/config/settings/local.py b/{{cookiecutter.project_slug}}/config/settings/local.py index 37772f5888..5b78b5bf18 100644 --- a/{{cookiecutter.project_slug}}/config/settings/local.py +++ b/{{cookiecutter.project_slug}}/config/settings/local.py @@ -43,7 +43,8 @@ {%- else -%} # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend EMAIL_BACKEND = env( - "DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend", + "DJANGO_EMAIL_BACKEND", + default="django.core.mail.backends.console.EmailBackend", ) {%- endif %} diff --git a/{{cookiecutter.project_slug}}/config/settings/production.py b/{{cookiecutter.project_slug}}/config/settings/production.py index 1981be38f9..1ef8403aca 100644 --- a/{{cookiecutter.project_slug}}/config/settings/production.py +++ b/{{cookiecutter.project_slug}}/config/settings/production.py @@ -17,7 +17,7 @@ from .base import DATABASES from .base import INSTALLED_APPS from .base import REDIS_URL -{%- if cookiecutter.use_drf == "y" %} +{%- if cookiecutter.rest_api == 'DRF' %} from .base import SPECTACULAR_SETTINGS {%- endif %} from .base import env @@ -436,7 +436,7 @@ traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0), ) {% endif %} -{% if cookiecutter.use_drf == "y" -%} +{% if cookiecutter.rest_api == 'DRF' -%} # django-rest-framework # ------------------------------------------------------------------------------- diff --git a/{{cookiecutter.project_slug}}/config/urls.py b/{{cookiecutter.project_slug}}/config/urls.py index c693581807..576d06fe7f 100644 --- a/{{cookiecutter.project_slug}}/config/urls.py +++ b/{{cookiecutter.project_slug}}/config/urls.py @@ -8,10 +8,13 @@ from django.urls import path from django.views import defaults as default_views from django.views.generic import TemplateView -{%- if cookiecutter.use_drf == 'y' %} +{%- if cookiecutter.rest_api == 'DRF' %} from drf_spectacular.views import SpectacularAPIView from drf_spectacular.views import SpectacularSwaggerView from rest_framework.authtoken.views import obtain_auth_token +{%- elif cookiecutter.rest_api == 'Django Ninja' %} + +from .api import api {%- endif %} urlpatterns = [ @@ -36,7 +39,7 @@ # Static file serving when using Gunicorn + Uvicorn for local web socket development urlpatterns += staticfiles_urlpatterns() {%- endif %} -{% if cookiecutter.use_drf == 'y' %} +{% if cookiecutter.rest_api == 'DRF' %} # API URLS urlpatterns += [ # API base url @@ -50,6 +53,13 @@ name="api-docs", ), ] +{%- elif cookiecutter.rest_api == 'Django Ninja' %} + +# API URLS +urlpatterns += [ + # API base url + path("api/", api.urls), +] {%- endif %} if settings.DEBUG: diff --git a/{{cookiecutter.project_slug}}/manage.py b/{{cookiecutter.project_slug}}/manage.py index 0332cc26e0..8cb02c411e 100755 --- a/{{cookiecutter.project_slug}}/manage.py +++ b/{{cookiecutter.project_slug}}/manage.py @@ -1,5 +1,6 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys from pathlib import Path diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index fbf8aff048..4d466fdb0b 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -26,7 +26,7 @@ warn_redundant_casts = true warn_unused_configs = true plugins = [ "mypy_django_plugin.main", - {%- if cookiecutter.use_drf == "y" %} + {%- if cookiecutter.rest_api == 'DRF' %} "mypy_drf_plugin.main", {%- endif %} ] diff --git a/{{cookiecutter.project_slug}}/requirements/base.txt b/{{cookiecutter.project_slug}}/requirements/base.txt index e9e0352cc5..60cef6f04a 100644 --- a/{{cookiecutter.project_slug}}/requirements/base.txt +++ b/{{cookiecutter.project_slug}}/requirements/base.txt @@ -39,12 +39,16 @@ crispy-bootstrap5==2025.6 # https://github.com/django-crispy-forms/crispy-boots django-compressor==4.5.1 # https://github.com/django-compressor/django-compressor {%- endif %} django-redis==6.0.0 # https://github.com/jazzband/django-redis -{%- if cookiecutter.use_drf == 'y' %} +{%- if cookiecutter.rest_api == 'DRF' %} # Django REST Framework djangorestframework==3.16.1 # https://github.com/encode/django-rest-framework django-cors-headers==4.9.0 # https://github.com/adamchainz/django-cors-headers # DRF-spectacular for api documentation drf-spectacular==0.28.0 # https://github.com/tfranzel/drf-spectacular +{%- elif cookiecutter.rest_api == 'Django Ninja' %} +# Django Ninja +django-ninja==1.4.3 # https://github.com/vitalik/django-ninja +django-cors-headers==4.9.0 # https://github.com/adamchainz/django-cors-headers {%- endif %} {%- if cookiecutter.frontend_pipeline == 'Webpack' %} django-webpack-loader==3.2.1 # https://github.com/django-webpack/django-webpack-loader diff --git a/{{cookiecutter.project_slug}}/requirements/local.txt b/{{cookiecutter.project_slug}}/requirements/local.txt index bf43ce0a42..90740080c9 100644 --- a/{{cookiecutter.project_slug}}/requirements/local.txt +++ b/{{cookiecutter.project_slug}}/requirements/local.txt @@ -15,7 +15,7 @@ mypy==1.18.2 # https://github.com/python/mypy django-stubs[compatible-mypy]==5.2.5 # https://github.com/typeddjango/django-stubs pytest==8.4.2 # https://github.com/pytest-dev/pytest pytest-sugar==1.1.1 # https://github.com/Teemu/pytest-sugar -{%- if cookiecutter.use_drf == "y" %} +{%- if cookiecutter.rest_api == 'DRF' %} djangorestframework-stubs==3.16.4 # https://github.com/typeddjango/djangorestframework-stubs {%- endif %} diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/schema.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/schema.py new file mode 100644 index 0000000000..976e1b0da2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/schema.py @@ -0,0 +1,34 @@ +from django.urls.base import reverse +from ninja import ModelSchema + +from {{ cookiecutter.project_slug }}.users.models import User + + +class UpdateUserSchema(ModelSchema): + class Meta: + model = User + {%- if cookiecutter.username_type == "email" %} + fields = ["name"] + {%- else %} + fields = ["username", "name"] + {%- endif %} + + +class UserSchema(ModelSchema): + url: str + + class Meta: + model = User + {%- if cookiecutter.username_type == "email" %} + fields = ["email", "name"] + {%- else %} + fields = ["username", "email", "name"] + {%- endif %} + + @staticmethod + def resolve_url(obj: User): + {%- if cookiecutter.username_type == "email" %} + return reverse("api:retrieve_user", kwargs={"pk": obj.pk}) + {%- else %} + return reverse("api:retrieve_user", kwargs={"username": obj.username}) + {%- endif %} diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py index 7a521cdfe4..91a8eb9546 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/api/views.py @@ -1,3 +1,4 @@ +{% if cookiecutter.rest_api == 'DRF' -%} from rest_framework import status from rest_framework.decorators import action from rest_framework.mixins import ListModelMixin @@ -28,3 +29,63 @@ def get_queryset(self, *args, **kwargs): def me(self, request): serializer = UserSerializer(request.user, context={"request": request}) return Response(status=status.HTTP_200_OK, data=serializer.data) +{%- elif cookiecutter.rest_api == 'Django Ninja' -%} +from django.db.models import QuerySet +from django.shortcuts import get_object_or_404 +from ninja import Router + +from {{ cookiecutter.project_slug }}.users.api.schema import UpdateUserSchema +from {{ cookiecutter.project_slug }}.users.api.schema import UserSchema +from {{ cookiecutter.project_slug }}.users.models import User + +router = Router(tags=["users"]) + + +def _get_users_queryset(request) -> QuerySet[User]: + return User.objects.filter(pk=request.user.pk) + + +@router.get("/", response=list[UserSchema]) +def list_users(request): + return _get_users_queryset(request) +{%- if cookiecutter.username_type == "email" %} + + +@router.get("/{pk}/", response=UserSchema) +def retrieve_user(request, pk: str): + if pk == "me": + return request.user + users_qs = _get_users_queryset(request) + return get_object_or_404(users_qs, pk=pk) +{%- else %} + + +@router.get("/{username}/", response=UserSchema) +def retrieve_user(request, username: str): + if username == "me": + return request.user + users_qs = _get_users_queryset(request) + return get_object_or_404(users_qs, username=username) +{%- endif %} +{%- if cookiecutter.username_type == "email" %} + + +@router.patch("/{pk}/", response=UserSchema) +def update_user(request, pk: str, data: UpdateUserSchema): + users_qs = _get_users_queryset(request) + user = get_object_or_404(users_qs, pk=pk) + user.name = data.name + user.save() + return user +{%- else %} + + +@router.patch("/{username}/", response=UserSchema) +def update_user(request, username: str, data: UpdateUserSchema): + users_qs = _get_users_queryset(request) + user = get_object_or_404(users_qs, username=username) + user.name = data.name + user.save() + return user +{%- endif %} +{%- endif %} diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_openapi.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_openapi.py index cb3f19d3da..ffd8ee6d0e 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_openapi.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_openapi.py @@ -5,19 +5,34 @@ def test_api_docs_accessible_by_admin(admin_client): + {%- if cookiecutter.rest_api == 'DRF' %} url = reverse("api-docs") + {%- elif cookiecutter.rest_api == 'Django Ninja' %} + url = reverse("api:openapi-view") + {%- endif %} response = admin_client.get(url) assert response.status_code == HTTPStatus.OK @pytest.mark.django_db def test_api_docs_not_accessible_by_anonymous_users(client): + {%- if cookiecutter.rest_api == 'DRF' %} url = reverse("api-docs") response = client.get(url) assert response.status_code == HTTPStatus.FORBIDDEN + {%- elif cookiecutter.rest_api == 'Django Ninja' %} + url = reverse("api:openapi-view") + response = client.get(url) + assert response.status_code == HTTPStatus.FOUND + assert response.url == "/admin/login/?next=/api/docs" + {%- endif %} def test_api_schema_generated_successfully(admin_client): + {%- if cookiecutter.rest_api == 'DRF' %} url = reverse("api-schema") + {%- elif cookiecutter.rest_api == 'Django Ninja' %} + url = reverse("api:openapi-json") + {%- endif %} response = admin_client.get(url) assert response.status_code == HTTPStatus.OK diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_urls.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_urls.py index b445b611d6..cbd28480ca 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_urls.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_urls.py @@ -2,6 +2,7 @@ from django.urls import reverse from {{ cookiecutter.project_slug }}.users.models import User +{%- if cookiecutter.rest_api == 'DRF' %} def test_user_detail(user: User): @@ -27,3 +28,45 @@ def test_user_list(): def test_user_me(): assert reverse("api:user-me") == "/api/users/me/" assert resolve("/api/users/me/").view_name == "api:user-me" +{%- elif cookiecutter.rest_api == 'Django Ninja' %} + + +def test_user_detail(user: User): + {%- if cookiecutter.username_type == "email" %} + assert ( + reverse("api:retrieve_user", kwargs={"pk": user.pk}) == f"/api/users/{user.pk}/" + ) + assert resolve(f"/api/users/{user.pk}/").view_name == "api:retrieve_user" + {%- else %} + assert ( + reverse("api:retrieve_user", kwargs={"username": user.username}) + == f"/api/users/{user.username}/" + ) + assert resolve(f"/api/users/{user.username}/").view_name == "api:retrieve_user" + {%- endif %} + + +def test_user_list(): + assert reverse("api:list_users") == "/api/users/" + assert resolve("/api/users/").view_name == "api:list_users" + + +def test_user_me(): + {%- if cookiecutter.username_type == "email" %} + assert reverse("api:retrieve_user", kwargs={"pk": "me"}) == "/api/users/me/" + assert resolve("/api/users/me/").view_name == "api:retrieve_user" + {%- else %} + assert reverse("api:retrieve_user", kwargs={"username": "me"}) == "/api/users/me/" + assert resolve("/api/users/me/").view_name == "api:retrieve_user" + {%- endif %} + + +def test_update_user(): + {%- if cookiecutter.username_type == "email" %} + assert reverse("api:update_user", kwargs={"pk": "me"}) == "/api/users/me/" + assert resolve("/api/users/me/").view_name == "api:retrieve_user" + {%- else %} + assert reverse("api:update_user", kwargs={"username": "me"}) == "/api/users/me/" + assert resolve("/api/users/me/").view_name == "api:retrieve_user" + {%- endif %} +{%- endif %} diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_views.py index 0198b1309d..5b37118762 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/tests/api/test_views.py @@ -1,3 +1,4 @@ +{% if cookiecutter.rest_api == 'DRF' -%} import pytest from rest_framework.test import APIRequestFactory @@ -37,3 +38,143 @@ def test_me(self, user: User, api_rf: APIRequestFactory): {%- endif %} "name": user.name, } +{%- elif cookiecutter.rest_api == 'Django Ninja' -%} +from http import HTTPStatus + +import pytest +from django.test import Client +from django.urls import reverse + +from {{ cookiecutter.project_slug }}.users.models import User +from {{ cookiecutter.project_slug }}.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def user(): + return UserFactory() + + +def test_list_users_as_anonymous_user(client: Client): + response = client.get(reverse("api:list_users")) + + assert response.status_code == HTTPStatus.UNAUTHORIZED + + +def test_list_users_as_authenticated_user(client: Client, user: User): + client.force_login(user) + # Another user, excluded from the response + UserFactory() + + response = client.get(reverse("api:list_users")) + + assert response.status_code == HTTPStatus.OK + assert response.json() == [ + { + "email": user.email, + "name": user.name, + {%- if cookiecutter.username_type == "email" %} + "url": f"/api/users/{user.pk}/", + {%- else %} + "url": f"/api/users/{user.username}/", + "username": user.username, + {%- endif %} + }, + ] +{%- if cookiecutter.username_type == "email" %} + + +@pytest.mark.parametrize("user_pk", [None, "me"]) +def test_retrieve_user(client: Client, user: User, user_pk: str | None): + client.force_login(user) + user_pk = user_pk or user.pk + + response = client.get( + reverse("api:retrieve_user", kwargs={"pk": user_pk}), + ) + + assert response.status_code == HTTPStatus.OK + assert response.json() == { + "email": user.email, + "name": user.name, + "url": f"/api/users/{user.pk}/", + } +{%- else %} + + +@pytest.mark.parametrize("username", [None, "me"]) +def test_retrieve_user(client: Client, user: User, username: str | None): + client.force_login(user) + username = username or user.username + + response = client.get( + reverse("api:retrieve_user", kwargs={"username": username}), + ) + + assert response.status_code == HTTPStatus.OK + assert response.json() == { + "email": user.email, + "name": user.name, + "url": f"/api/users/{user.username}/", + "username": user.username, + } +{%- endif %} + + +def test_retrieve_another_user(client: Client, user: User): + client.force_login(user) + user_2 = UserFactory() + + response = client.get( + {%- if cookiecutter.username_type == "email" %} + reverse("api:retrieve_user", kwargs={"pk": user_2.pk}), + {%- else %} + reverse("api:retrieve_user", kwargs={"username": user_2.username}), + {%- endif %} + ) + + assert response.status_code == HTTPStatus.NOT_FOUND + assert response.json() == {"detail": "Not Found"} + +{%- if cookiecutter.username_type == "email" %} + + +def test_update_user(client: Client): + user = UserFactory(name="Old") + client.force_login(user) + + response = client.patch( + reverse("api:update_user", kwargs={"pk": user.pk}), + data='{"name": "New Name"}', + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.OK, response.json() + assert response.json() == { + "email": user.email, + "name": "New Name", + "url": f"/api/users/{user.pk}/", + } +{%- else %} + + +def test_update_user(client: Client): + user = UserFactory(name="Old", username="old") + client.force_login(user) + + response = client.patch( + reverse("api:update_user", kwargs={"username": "old"}), + data='{"name": "New Name", "username": "old"}', + content_type="application/json", + ) + + assert response.status_code == HTTPStatus.OK, response.json() + assert response.json() == { + "email": user.email, + "name": "New Name", + "url": "/api/users/old/", + "username": "old", + } +{%- endif %} +{%- endif %} diff --git a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py index e7655f1234..f4c9e75179 100644 --- a/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py +++ b/{{cookiecutter.project_slug}}/{{cookiecutter.project_slug}}/users/views.py @@ -33,7 +33,7 @@ def get_success_url(self) -> str: assert self.request.user.is_authenticated # type guard return self.request.user.get_absolute_url() - def get_object(self, queryset: QuerySet | None=None) -> User: + def get_object(self, queryset: QuerySet | None = None) -> User: assert self.request.user.is_authenticated # type guard return self.request.user