diff --git a/.circleci/config.yml b/.circleci/config.yml index 4169dd8d..beef2d54 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,11 +1,11 @@ version: 2.1 orbs: - docker: circleci/docker@1.7.0 + docker: circleci/docker@2.4.0 jobs: build: docker: - - image: cimg/python:3.10 + - image: cimg/python:3.11 steps: - checkout @@ -34,7 +34,7 @@ jobs: coverage: docker: - - image: cimg/python:3.10 + - image: cimg/python:3.11 steps: - checkout - attach_workspace: @@ -73,5 +73,6 @@ workflows: image: f213/django path: testproject/django docker-context: testproject/django + extra_build_args: '--build-arg PYTHON_VERSION=3.11' deploy: false attach-at: . diff --git a/{{cookiecutter.project_slug}}/.python-version b/{{cookiecutter.project_slug}}/.python-version index 36435ac6..375f5cab 100644 --- a/{{cookiecutter.project_slug}}/.python-version +++ b/{{cookiecutter.project_slug}}/.python-version @@ -1 +1 @@ -3.10.8 +3.11.6 diff --git a/{{cookiecutter.project_slug}}/Dockerfile b/{{cookiecutter.project_slug}}/Dockerfile index 3152c119..6b277cb1 100644 --- a/{{cookiecutter.project_slug}}/Dockerfile +++ b/{{cookiecutter.project_slug}}/Dockerfile @@ -1,5 +1,5 @@ ARG PYTHON_VERSION -FROM python:${PYTHON_VERSION}-slim-bullseye +FROM python:${PYTHON_VERSION}-slim-bookworm LABEL maintainer="fedor@borshev.com" LABEL com.datadoghq.ad.logs='[{"source": "uwsgi", "service": "django"}]' @@ -9,9 +9,9 @@ ENV DEBIAN_FRONTEND noninteractive ENV STATIC_ROOT /static -ENV _UWSGI_VERSION 2.0.20 +ENV _UWSGI_VERSION 2.0.23 -RUN echo deb http://deb.debian.org/debian bullseye contrib non-free > /etc/apt/sources.list.d/debian-contrib.list \ +RUN echo deb http://deb.debian.org/debian bookworm contrib non-free > /etc/apt/sources.list.d/debian-contrib.list \ && apt update \ && apt --no-install-recommends install -y gettext locales-all wget \ imagemagick tzdata wait-for-it build-essential \ diff --git a/{{cookiecutter.project_slug}}/Makefile b/{{cookiecutter.project_slug}}/Makefile index 2de07f68..0e04bbac 100644 --- a/{{cookiecutter.project_slug}}/Makefile +++ b/{{cookiecutter.project_slug}}/Makefile @@ -27,3 +27,5 @@ test: cd src && ./manage.py compilemessages cd src && pytest --dead-fixtures cd src && pytest -x + +pr: fmt lint test diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index 45b3221a..8d7f7da3 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -2,7 +2,7 @@ name = "{{cookiecutter.project_slug}}" version = "{{cookiecutter.project_version}}" dependencies = [ - "Django<3.3", + "Django<4.3", "bcrypt", "django-behaviors", "django-environ", diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/api/serializers.py-tpl b/{{cookiecutter.project_slug}}/src/.django-app-template/api/serializers.py-tpl deleted file mode 100644 index 058c2956..00000000 --- a/{{cookiecutter.project_slug}}/src/.django-app-template/api/serializers.py-tpl +++ /dev/null @@ -1,3 +0,0 @@ -from rest_framework import serializers - -# Create your DRF serializers here. diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/api/serializers/__init__.py b/{{cookiecutter.project_slug}}/src/.django-app-template/api/serializers/__init__.py new file mode 100644 index 00000000..44f0b885 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/.django-app-template/api/serializers/__init__.py @@ -0,0 +1,3 @@ +__all__ = [ + "", +] diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/api/serializers/app_name.py-tpl b/{{cookiecutter.project_slug}}/src/.django-app-template/api/serializers/app_name.py-tpl new file mode 100644 index 00000000..3cfd0eef --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/.django-app-template/api/serializers/app_name.py-tpl @@ -0,0 +1,3 @@ +from rest_framework import serializers + +# Rename this file to singular form of your entity, e.g. "orders.py -> order.py". Add your class to __init__.py. diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/urls.py-tpl b/{{cookiecutter.project_slug}}/src/.django-app-template/api/urls.py-tpl similarity index 100% rename from {{cookiecutter.project_slug}}/src/.django-app-template/urls.py-tpl rename to {{cookiecutter.project_slug}}/src/.django-app-template/api/urls.py-tpl diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/api/views/__init__.py b/{{cookiecutter.project_slug}}/src/.django-app-template/api/views/__init__.py new file mode 100644 index 00000000..44f0b885 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/.django-app-template/api/views/__init__.py @@ -0,0 +1,3 @@ +__all__ = [ + "", +] diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/api/views/app_name.py-tpl b/{{cookiecutter.project_slug}}/src/.django-app-template/api/views/app_name.py-tpl new file mode 100644 index 00000000..999e02e2 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/.django-app-template/api/views/app_name.py-tpl @@ -0,0 +1,3 @@ +from app.api.viewsets import DefaultModelViewSet + +# Rename this file to singular form of your entity, e.g. "orders.py -> order.py". Add your class to __init__.py. diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/api/viewsets.py-tpl b/{{cookiecutter.project_slug}}/src/.django-app-template/api/viewsets.py-tpl deleted file mode 100644 index 18a66958..00000000 --- a/{{cookiecutter.project_slug}}/src/.django-app-template/api/viewsets.py-tpl +++ /dev/null @@ -1,4 +0,0 @@ -from {{ app_name }}.api import serializers -from app.api.viewsets import DefaultModelViewSet - -# Create your API views and viewsets here. diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/models.py-tpl b/{{cookiecutter.project_slug}}/src/.django-app-template/models.py-tpl deleted file mode 100644 index 1ed72cc7..00000000 --- a/{{cookiecutter.project_slug}}/src/.django-app-template/models.py-tpl +++ /dev/null @@ -1,5 +0,0 @@ -from django.db import models - -from app.models import DefaultModel - -# Create your models here. diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/models/__init__.py b/{{cookiecutter.project_slug}}/src/.django-app-template/models/__init__.py new file mode 100644 index 00000000..44f0b885 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/.django-app-template/models/__init__.py @@ -0,0 +1,3 @@ +__all__ = [ + "", +] diff --git a/{{cookiecutter.project_slug}}/src/.django-app-template/models/app_name.py-tpl b/{{cookiecutter.project_slug}}/src/.django-app-template/models/app_name.py-tpl new file mode 100644 index 00000000..0ca24cdc --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/.django-app-template/models/app_name.py-tpl @@ -0,0 +1,5 @@ +from django.db import models + +from app.models import DefaultModel, TimestampedModel + +# Rename this file to singular form of your entity, e.g. "orders.py -> order.py". Add your class to __init__.py. diff --git a/{{cookiecutter.project_slug}}/src/a12n/urls.py b/{{cookiecutter.project_slug}}/src/a12n/api/urls.py similarity index 100% rename from {{cookiecutter.project_slug}}/src/a12n/urls.py rename to {{cookiecutter.project_slug}}/src/a12n/api/urls.py diff --git a/{{cookiecutter.project_slug}}/src/app/api/viewsets.py b/{{cookiecutter.project_slug}}/src/app/api/viewsets.py index e69de29b..0303f45e 100644 --- a/{{cookiecutter.project_slug}}/src/app/api/viewsets.py +++ b/{{cookiecutter.project_slug}}/src/app/api/viewsets.py @@ -0,0 +1,177 @@ +from typing import Any, Optional, Protocol, Type + +from rest_framework import mixins +from rest_framework import status +from rest_framework.mixins import CreateModelMixin +from rest_framework.mixins import UpdateModelMixin +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import BaseSerializer +from rest_framework.viewsets import GenericViewSet + +__all__ = ["DefaultModelViewSet"] + + +class BaseGenericViewSet(Protocol): + def get_serializer(self, *args: Any, **kwargs: Any) -> Any: + ... + + def get_response(self, *args: Any, **kwargs: Any) -> Any: + ... + + def perform_create(self, *args: Any, **kwargs: Any) -> Any: + ... + + def perform_update(self, *args: Any, **kwargs: Any) -> Any: + ... + + def get_success_headers(self, *args: Any, **kwargs: Any) -> Any: + ... + + def get_serializer_class(self, *args: Any, **kwargs: Any) -> Any: + ... + + def get_object(self, *args: Any, **kwargs: Any) -> Any: + ... + + +class DefaultCreateModelMixin(CreateModelMixin): + """Return detail-serialized created instance""" + + def create(self: BaseGenericViewSet, request: Request, *args: Any, **kwargs: Any) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + instance = self.perform_create(serializer) # No getting created instance in original DRF + headers = self.get_success_headers(serializer.data) + return self.get_response(instance, status.HTTP_201_CREATED, headers) + + def perform_create(self: BaseGenericViewSet, serializer: Any) -> Any: + return serializer.save() # No returning created instance in original DRF + + +class DefaultUpdateModelMixin(UpdateModelMixin): + """Return detail-serialized updated instance""" + + def update(self: BaseGenericViewSet, request: Request, *args: Any, **kwargs: Any) -> Response: + partial = kwargs.pop("partial", False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + instance = self.perform_update(serializer) # No getting updated instance in original DRF + + if getattr(instance, "_prefetched_objects_cache", None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} + + return self.get_response(instance, status.HTTP_200_OK) + + def perform_update(self: BaseGenericViewSet, serializer: Any) -> Any: + return serializer.save() # No returning updated instance in original DRF + + +class ResponseWithRetrieveSerializerMixin: + """ + Always response with 'retrieve' serializer or fallback to `serializer_class`. + Usage: + + class MyViewSet(DefaultModelViewSet): + serializer_class = MyDefaultSerializer + serializer_action_classes = { + 'list': MyListSerializer, + 'my_action': MyActionSerializer, + } + @action + def my_action: + ... + + 'my_action' request will be validated with MyActionSerializer, + but response will be serialized with MyDefaultSerializer + (or 'retrieve' if provided). + + Thanks gonz: http://stackoverflow.com/a/22922156/11440 + + """ + + def get_response( + self: BaseGenericViewSet, + instance: Any, + status: Any, + headers: Any = None, + ) -> Response: + retrieve_serializer_class = self.get_serializer_class(action="retrieve") + context = self.get_serializer_context() # type: ignore + retrieve_serializer = retrieve_serializer_class(instance, context=context) + return Response( + retrieve_serializer.data, + status=status, + headers=headers, + ) + + def get_serializer_class( + self: BaseGenericViewSet, + action: Optional[str] = None, + ) -> Type[BaseSerializer]: + if action is None: + action = self.action # type: ignore + + try: + return self.serializer_action_classes[action] # type: ignore + except (KeyError, AttributeError): + return super().get_serializer_class() # type: ignore + + +class DefaultModelViewSet( + DefaultCreateModelMixin, # Create response is overriden + mixins.RetrieveModelMixin, + DefaultUpdateModelMixin, # Update response is overriden + mixins.DestroyModelMixin, + mixins.ListModelMixin, + ResponseWithRetrieveSerializerMixin, # Response with retrieve or default serializer + GenericViewSet, +): + pass + + +class ReadonlyModelViewSet( + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + ResponseWithRetrieveSerializerMixin, # Response with retrieve or default serializer + GenericViewSet, +): + pass + + +class ListOnlyModelViewSet( + mixins.ListModelMixin, + ResponseWithRetrieveSerializerMixin, # Response with retrieve or default serializer + GenericViewSet, +): + pass + + +class UpdateOnlyModelViewSet( + DefaultUpdateModelMixin, + ResponseWithRetrieveSerializerMixin, + GenericViewSet, +): + pass + + +class DefaultRetrieveDestroyListViewSet( + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + ResponseWithRetrieveSerializerMixin, # Response with retrieve or default serializer + GenericViewSet, +): + pass + + +class ListUpdateModelViewSet( + DefaultUpdateModelMixin, + mixins.ListModelMixin, + ResponseWithRetrieveSerializerMixin, + GenericViewSet, +): + pass diff --git a/{{cookiecutter.project_slug}}/src/app/urls/v1.py b/{{cookiecutter.project_slug}}/src/app/urls/v1.py index dad4b295..c49cc038 100644 --- a/{{cookiecutter.project_slug}}/src/app/urls/v1.py +++ b/{{cookiecutter.project_slug}}/src/app/urls/v1.py @@ -6,8 +6,8 @@ app_name = "api_v1" urlpatterns = [ - path("auth/", include("a12n.urls")), - path("users/", include("users.urls")), + path("auth/", include("a12n.api.urls")), + path("users/", include("users.api.urls")), path("healthchecks/", include("django_healthchecks.urls")), path("docs/schema/", SpectacularAPIView.as_view(), name="schema"), path("docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema")), diff --git a/{{cookiecutter.project_slug}}/src/users/urls.py b/{{cookiecutter.project_slug}}/src/users/api/urls.py similarity index 100% rename from {{cookiecutter.project_slug}}/src/users/urls.py rename to {{cookiecutter.project_slug}}/src/users/api/urls.py