From 34829b72abb3a099dea31559bced7c85d793cd77 Mon Sep 17 00:00:00 2001 From: Oleksandr Bulanov Date: Wed, 23 Oct 2024 13:03:57 +0300 Subject: [PATCH 1/3] [DI-2709] Ruff with ALL rules + exceptions --- .github/workflows/default.yml | 4 + .../api/conftest.py | 10 +- {{ cookiecutter.project_slug }}/api/manage.py | 7 +- .../api/pyproject.toml | 39 +++---- .../apps/accounts/api/authentication.py | 3 +- .../apps/accounts/api/permissions.py | 5 +- .../apps/accounts/api/v1/serializers/login.py | 16 ++- .../accounts/api/v1/serializers/password.py | 38 +++--- .../api/v1/serializers/registration.py | 8 +- .../apps/accounts/api/v1/views/login.py | 10 +- .../apps/accounts/api/v1/views/password.py | 7 +- .../accounts/api/v1/views/registration.py | 4 +- .../accounts/api/v1/views/user_profile.py | 5 +- .../apps/accounts/models/__init__.py | 5 +- .../apps/accounts/models/user_account.py | 17 +-- .../apps/accounts/selectors.py | 4 +- .../apps/accounts/services/login.py | 11 +- .../apps/accounts/services/password.py | 10 +- .../accounts/tests/test_api/test_login_api.py | 14 ++- .../tests/test_api/test_logout_api.py | 6 +- .../tests/test_api/test_registration_api.py | 6 +- .../tests/test_api/test_user_profile_api.py | 7 +- .../accounts/tests/test_api_permissions.py | 7 +- .../tests/test_models/test_user_model.py | 110 +++++++++--------- .../test_selectors/test_get_users_selector.py | 8 +- .../test_change_password_serializer.py | 24 ++-- .../test_confirm_reset_password_serializer.py | 9 +- .../test_serializers/test_login_serializer.py | 5 +- .../test_registration_serializer.py | 18 +-- .../test_reset_password_serializer.py | 4 +- .../tests/test_services/test_login_service.py | 6 +- .../test_services/test_password_service.py | 29 +++-- .../management/commands/generate_secretkey.py | 4 +- .../common/management/commands/startapp.py | 13 ++- .../apps/common/models/core.py | 12 +- .../apps/common/utils/redis.py | 4 +- .../fixtures/__init__.py | 2 - .../fixtures/api_client.py | 76 +++++++++--- .../fixtures/user_account.py | 12 +- .../loggers.py | 4 +- .../settings/__init__.py | 7 +- .../settings/contrib/__init__.py | 11 +- .../settings/contrib/celery.py | 4 +- .../settings/contrib/redis.py | 2 +- .../settings/contrib/rest_framework.py | 6 +- .../settings/contrib/sentry.py | 2 +- .../settings/contrib/swagger.py | 2 +- .../settings/django.py | 10 +- .../settings/environment.py | 4 +- .../{{ cookiecutter.project_slug }}.py | 2 +- .../{{ cookiecutter.project_slug }}/urls.py | 7 +- 51 files changed, 391 insertions(+), 249 deletions(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index ff6d852..a100bf1 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -43,3 +43,7 @@ jobs: cd build/awesome cp ./api/.env.example ./api/.env make api-test + - name: Ruff, Safety, migrations checks for the default project code + run: | + cd build/awesome + make api-check diff --git a/{{ cookiecutter.project_slug }}/api/conftest.py b/{{ cookiecutter.project_slug }}/api/conftest.py index 35646fc..37b69b1 100644 --- a/{{ cookiecutter.project_slug }}/api/conftest.py +++ b/{{ cookiecutter.project_slug }}/api/conftest.py @@ -1 +1,9 @@ -from {{ cookiecutter.project_slug }}.fixtures import * # noqa: W0401, W0611 +from {{ cookiecutter.project_slug }}.fixtures.api_client import api_client, unauthorized_api_client +from {{ cookiecutter.project_slug }}.fixtures.user_account import user_account + + +__all__ = [ + "api_client", + "unauthorized_api_client", + "user_account", +] diff --git a/{{ cookiecutter.project_slug }}/api/manage.py b/{{ cookiecutter.project_slug }}/api/manage.py index bea292b..b4993f2 100755 --- a/{{ cookiecutter.project_slug }}/api/manage.py +++ b/{{ cookiecutter.project_slug }}/api/manage.py @@ -3,17 +3,18 @@ import sys -def main(): +def main() -> None: """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{{ cookiecutter.project_slug }}.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: - raise ImportError( + message = ( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" - ) from exc + ) + raise ImportError(message) from exc execute_from_command_line(sys.argv) diff --git a/{{ cookiecutter.project_slug }}/api/pyproject.toml b/{{ cookiecutter.project_slug }}/api/pyproject.toml index 27417a7..4d7d054 100644 --- a/{{ cookiecutter.project_slug }}/api/pyproject.toml +++ b/{{ cookiecutter.project_slug }}/api/pyproject.toml @@ -37,13 +37,8 @@ DJANGO_SETTINGS_MODULE = "{{ cookiecutter.project_slug }}.settings" python_files = ["tests.py", "test_*.py", "*_test.py"] [tool.ruff] -# Enable pycodestyle (`E`), Pyflakes (`F`), Isort (`I`), Bandit (`S`) and flake8 Django (`DJ`) codes. -lint.select = ["E", "F", "I", "S", "DJ"] -lint.ignore = [] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -lint.fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] -lint.unfixable = [] +line-length = 120 +target-version = "py{{ cookiecutter.python_version.lower().replace('.', '') }}" # Exclude a variety of commonly ignored directories. exclude = [ @@ -69,28 +64,32 @@ exclude = [ "venv", ] -line-length = 120 - -# Allow unused variables when underscore-prefixed. -lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -target-version = "py{{ cookiecutter.python_version.lower()|replace('.', '') }}" - -[tool.ruff.lint.mccabe] -# Unlike Flake8, default to a complexity level of 10. -max-complexity = 10 +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + # Docstrings + "D", + # Assert statement + "S101", + # Unnecessary-assign before return + "RET504", + # Random for non cryptographically secure + "S311", + # Formatter related rules... + "COM812", "ISC001", +] [tool.ruff.lint.per-file-ignores] -"__init__.py" = ["F401", "F403"] -"conftest*" = ["F403"] "test_*.py" = ["S"] "tests.py" = ["S"] "*_test.py" = ["S"] +"*/migrations/*" = ["RUF012"] [tool.ruff.lint.isort] lines-between-types = 1 lines-after-imports = 2 -known-first-party=['{{ cookiecutter.project_slug }}'] -section-order= ['future', 'standard-library', 'third-party', 'django', 'first-party', 'local-folder'] +known-first-party=["{{ cookiecutter.project_slug }}"] +section-order= ["future", "standard-library", "third-party", "django", "first-party", "local-folder"] [tool.ruff.lint.isort.sections] "django" = ["django"] diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/authentication.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/authentication.py index 273bd07..63e9612 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/authentication.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/authentication.py @@ -1,8 +1,9 @@ from rest_framework.authentication import SessionAuthentication +from rest_framework.request import Request class CustomSessionAuthentication(SessionAuthentication): - def enforce_csrf(self, request): + def enforce_csrf(self, request: Request) -> None: """ Exempt CSRF for session based authentication "application/json". """ diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/permissions.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/permissions.py index a7e7bfc..5fe80bc 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/permissions.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/permissions.py @@ -1,6 +1,9 @@ from rest_framework.permissions import BasePermission +from rest_framework.request import Request + +from django.views import View class IsNotAuthenticated(BasePermission): - def has_permission(self, request, view): + def has_permission(self, request: Request, view: View) -> bool: # noqa: ARG002 return not request.user.is_authenticated diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/login.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/login.py index 1fbc172..22519bc 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/login.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/login.py @@ -4,23 +4,27 @@ from django.contrib.auth import authenticate as django_authenticate from django.utils.translation import gettext +from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount + class LoginSerializer(serializers.Serializer): email = serializers.EmailField(write_only=True, max_length=254) password = serializers.CharField(max_length=128, style={"input_type": "password"}, write_only=True) @staticmethod - def _authenticate(email, password): + def _authenticate(email: str, password: str) -> UserAccount | None: return django_authenticate(email=email, password=password) # pragma: no cover - def validate(self, attrs): + def validate(self, attrs: dict) -> dict: user = self._authenticate(attrs.get("email"), attrs.get("password")) if user: return {"user": user} raise ValidationError(gettext("Incorrect email or password.")) - def create(self, validated_data): - assert False, "Do not use update directly" # noqa: S101 + def create(self, validated_data: dict) -> None: # noqa: ARG002 + message = "Do not use create directly" + raise AssertionError(message) - def update(self, instance, validated_data): - assert False, "Do not use update directly" # noqa: S101 + def update(self, instance: dict, validated_data: dict) -> None: # noqa: ARG002 + message = "Do not use update directly" + raise AssertionError(message) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/password.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/password.py index b3bac09..66fdb34 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/password.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/password.py @@ -13,33 +13,35 @@ class ChangePasswordSerializer(serializers.Serializer): old_password = serializers.CharField(max_length=128, write_only=True, style={"input_type": "password"}) new_password = serializers.CharField(max_length=128, write_only=True, style={"input_type": "password"}) - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: # noqa: ANN002 ANN003 super().__init__(*args, **kwargs) self.request = self.context.get("request") self.user = getattr(self.request, "user", None) self.password_service = PasswordService() - def validate_old_password(self, old_password): + def validate_old_password(self, old_password: str) -> str: try: self.password_service.check_password(self.user, old_password) except WrongPasswordError as e: raise serializers.ValidationError(e.message) from e return old_password - def validate_new_password(self, new_password): + def validate_new_password(self, new_password: str) -> str: try: self.password_service.validate_password(new_password, user=self.user) except InvalidPasswordError as e: raise serializers.ValidationError(e.messages) from e return new_password - def create(self, validated_data): - assert False, "Do not use update directly" # noqa: S101 + def create(self, validated_data: dict) -> None: # noqa: ARG002 + message = "Do not use update directly" + raise AssertionError(message) - def update(self, instance, validated_data): - assert False, "Do not use update directly" # noqa: S101 + def update(self, instance: dict, validated_data: dict) -> None: # noqa: ARG002 + message = "Do not use update directly" + raise AssertionError(message) - def save(self, **kwargs): + def save(self, **_kwargs) -> None: # noqa: ANN003 self.password_service.change_password(self.user, self.validated_data["new_password"]) @@ -47,18 +49,18 @@ class ConfirmResetPasswordSerializer(serializers.Serializer): password = serializers.CharField(max_length=128, write_only=True, style={"input_type": "password"}) signature = serializers.CharField(max_length=71, write_only=True) - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: # noqa: ANN002 ANN003 super().__init__(*args, **kwargs) self.password_service = PasswordService() - def validate_password(self, password): + def validate_password(self, password: str) -> str: try: self.password_service.validate_password(password) except InvalidPasswordError as e: raise ValidationError(e) from e return password - def save(self, **kwargs): + def save(self, **_kwargs) -> None: # noqa: ANN003 new_password = self.validated_data["password"] signature = self.validated_data["signature"] try: @@ -66,20 +68,22 @@ def save(self, **kwargs): except InvalidResetPasswordSignatureError as e: raise ValidationError({"signature": e.message}) from e - def create(self, validated_data): - assert False, "Do not use update directly" # noqa: S101 + def create(self, validated_data: dict) -> None: # noqa: ARG002 + message = "Do not use create directly" + raise AssertionError(message) - def update(self, instance, validated_data): - assert False, "Do not use update directly" # noqa: S101 + def update(self, instance: dict, validated_data: dict) -> None: # noqa: ARG002 + message = "Do not use update directly" + raise AssertionError(message) class ResetPasswordSerializer(serializers.Serializer): email = serializers.EmailField(max_length=128, write_only=True) - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: # noqa: ANN002 ANN003 super().__init__(*args, **kwargs) self.password_service = PasswordService() - def save(self, **kwargs): + def save(self, **_kwargs) -> None: # noqa: ANN003 email = self.validated_data["email"] self.password_service.send_reset_password_link(email) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/registration.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/registration.py index 4310bf5..727d4c3 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/registration.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/serializers/registration.py @@ -18,23 +18,23 @@ class Meta: model = UserAccount fields = ("email", "first_name", "last_name", "password") - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: # noqa: ANN002 ANN003 super().__init__(*args, **kwargs) self.password_service = PasswordService() - def validate_email(self, email): + def validate_email(self, email: str) -> str: if UserAccount.objects.filter(email=email).exists(): raise ValidationError(gettext("Could not create account with this email.")) return super().validate(email) - def validate_password(self, new_password): + def validate_password(self, new_password: str) -> str: try: self.password_service.validate_password(new_password) except InvalidPasswordError as e: raise serializers.ValidationError(e.messages) from e return new_password - def save(self, **kwargs): + def save(self, **kwargs) -> UserAccount: # noqa: ANN003 self.instance = super().save(**kwargs) raw_password = self.validated_data.get("password") self.instance.set_password(raw_password) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/login.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/login.py index d3698cb..7fc8296 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/login.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/login.py @@ -2,6 +2,8 @@ from rest_framework import status from rest_framework.generics import GenericAPIView from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response from {{ cookiecutter.project_slug }}.apps.accounts.api.permissions import IsNotAuthenticated from {{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.login import LoginSerializer @@ -9,11 +11,11 @@ class LoginView(GenericAPIView): - permission_classes = [IsNotAuthenticated] + permission_classes = (IsNotAuthenticated,) serializer_class = LoginSerializer @extend_schema(summary="Login", tags=["Accounts"], responses={status.HTTP_204_NO_CONTENT: OpenApiResponse()}) - def post(self, request): + def post(self, request: Request) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.validated_data.get("user") @@ -22,9 +24,9 @@ def post(self, request): class LogoutView(GenericAPIView): - permission_classes = [IsAuthenticated] + permission_classes = (IsAuthenticated,) @extend_schema(summary="Log out", tags=["Accounts"], responses={status.HTTP_204_NO_CONTENT: OpenApiResponse()}) - def post(self, request): + def post(self, request: Request) -> Response: response = LoginService.logout(request) return response diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/password.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/password.py index efe7f3b..d7e6da6 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/password.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/password.py @@ -2,6 +2,7 @@ from rest_framework import status from rest_framework.generics import CreateAPIView from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request from rest_framework.response import Response from {{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.password import ( @@ -18,7 +19,7 @@ class ChangePasswordAPIView(CreateAPIView): @extend_schema( summary="Change password", tags=["Accounts"], responses={status.HTTP_204_NO_CONTENT: OpenApiResponse()} ) - def post(self, request, *args, **kwargs): # pragma: no cover + def post(self, request: Request, *args, **kwargs) -> Response: # noqa: ANN002 ANN003 super().post(request, *args, **kwargs) return Response(status=status.HTTP_204_NO_CONTENT) @@ -29,7 +30,7 @@ class ResetPasswordAPIView(CreateAPIView): @extend_schema( summary="Reset password", tags=["Accounts"], responses={status.HTTP_204_NO_CONTENT: OpenApiResponse()} ) - def post(self, request, *args, **kwargs): # pragma: no cover + def post(self, request: Request, *args, **kwargs) -> Response: # noqa: ANN002 ANN003 super().post(request, *args, **kwargs) return Response(status=status.HTTP_204_NO_CONTENT) @@ -40,6 +41,6 @@ class ConfirmResetPasswordAPIView(CreateAPIView): @extend_schema( summary="Confirm reset password", tags=["Accounts"], responses={status.HTTP_204_NO_CONTENT: OpenApiResponse()} ) - def post(self, request, *args, **kwargs): # pragma: no cover + def post(self, request: Request, *args, **kwargs) -> Response: # noqa: ANN002 ANN003 super().post(request, *args, **kwargs) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/registration.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/registration.py index 19de3a5..ff27ae1 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/registration.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/registration.py @@ -1,6 +1,8 @@ from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import status from rest_framework.generics import GenericAPIView +from rest_framework.request import Request +from rest_framework.response import Response from {{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.registration import RegistrationSerializer from {{ cookiecutter.project_slug }}.apps.accounts.services.login import LoginService @@ -10,7 +12,7 @@ class RegistrationAPIView(GenericAPIView): serializer_class = RegistrationSerializer @extend_schema(summary="Registration", tags=["Accounts"], responses={status.HTTP_204_NO_CONTENT: OpenApiResponse()}) - def post(self, request): + def post(self, request: Request) -> Response: serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/user_profile.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/user_profile.py index 6fbe444..a72ec4e 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/user_profile.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/api/v1/views/user_profile.py @@ -3,6 +3,7 @@ from rest_framework.permissions import IsAuthenticated from {{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.user_profile import UserProfileSerializer +from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount @extend_schema_view( @@ -21,7 +22,7 @@ ) class UserProfileAPIView(RetrieveUpdateAPIView): serializer_class = UserProfileSerializer - permission_classes = [IsAuthenticated] + permission_classes = (IsAuthenticated,) - def get_object(self): + def get_object(self) -> UserAccount: return self.request.user diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/models/__init__.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/models/__init__.py index 9204800..6c644fb 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/models/__init__.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/models/__init__.py @@ -1 +1,4 @@ -from .user_account import UserAccount # noqa: F401 +from .user_account import UserAccount + + +__all__ = ["UserAccount"] diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/models/user_account.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/models/user_account.py index 3a817c5..57f781a 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/models/user_account.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/models/user_account.py @@ -1,3 +1,5 @@ +from typing import ClassVar, cast + from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.db import models from django.utils import timezone @@ -8,9 +10,10 @@ class UserManager(core_models.CoreManager, BaseUserManager): - def create_user(self, email, password=None): + def create_user(self, email: str, password: str | None = None) -> "UserAccount": if not email: - raise ValueError("Users must give an email address") + message = "Users must give an email address" + raise ValueError(message) user = self.model(email=email) user.set_password(password) @@ -18,7 +21,7 @@ def create_user(self, email, password=None): return user - def create_superuser(self, email, password): + def create_superuser(self, email: str, password: str) -> "UserAccount": user = self.create_user(email, password) user.is_staff = True user.is_superuser = True @@ -48,13 +51,13 @@ class UserAccount(PermissionsMixin, CoreModel, AbstractBaseUser): objects = UserManager() USERNAME_FIELD = "email" - REQUIRED_FIELDS = [] + REQUIRED_FIELDS: ClassVar[list[str]] = [] class Meta: ordering = ("first_name", "last_name") - def __str__(self): - return self.email + def __str__(self) -> str: + return cast(str, self.email) def get_short_name(self) -> str: return str(self.email) @@ -67,7 +70,7 @@ def get_full_name(self) -> str: return full_name @property - def notification_salutation(self): + def notification_salutation(self) -> str: if self.first_name and self.last_name: salutation = f"{self.first_name} {self.last_name}" else: diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/selectors.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/selectors.py index 5e336ea..201ee97 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/selectors.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/selectors.py @@ -1,5 +1,7 @@ +from django.db.models import QuerySet + from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount -def get_all_users(): +def get_all_users() -> QuerySet[UserAccount]: return UserAccount.objects.active() diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/login.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/login.py index fe329d2..87528eb 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/login.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/login.py @@ -1,25 +1,28 @@ from rest_framework import status +from rest_framework.request import Request from rest_framework.response import Response from django.contrib.auth import login as django_login from django.contrib.auth import logout as django_logout +from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount + class LoginService: @classmethod - def login(cls, request, user): + def login(cls, request: Request, user: UserAccount) -> Response: cls._django_login(request, user) return Response(status=status.HTTP_204_NO_CONTENT) @classmethod - def logout(cls, request): + def logout(cls, request: Request) -> Response: cls._django_logout(request) return Response(status=status.HTTP_204_NO_CONTENT) @staticmethod - def _django_logout(request): # pragma: no cover + def _django_logout(request: Request) -> None: # pragma: no cover django_logout(request) @staticmethod - def _django_login(request, user): # pragma: no cover + def _django_login(request: Request, user: UserAccount) -> None: # pragma: no cover django_login(request, user) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/password.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/password.py index 3378243..d9c4c9d 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/password.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/password.py @@ -25,7 +25,7 @@ def check_password(user: UserAccount, password: str) -> None: raise WrongPasswordError(gettext("Incorrect password.")) @staticmethod - def validate_password(password, user=None) -> None: + def validate_password(password: str, user: UserAccount = None) -> None: try: password_validation.validate_password(password, user=user) except DjangoValidationError as e: @@ -52,7 +52,7 @@ def send_reset_password_link(cls, email: str) -> None: def reset_password(cls, signature: str, new_password: str) -> None: signer = TimestampSigner() try: - user_pk = signer.unsign(signature, max_age=settings.{{ cookiecutter.project_slug | upper() }}_RESET_PASSWORD_EXPIRATION_DELTA) + user_pk = signer.unsign(signature, max_age=settings.{{ cookiecutter.__env_prefix }}RESET_PASSWORD_EXPIRATION_DELTA) user = UserAccount.objects.active().get(pk=user_pk) except (SignatureExpired, BadSignature, UserAccount.DoesNotExist) as e: raise InvalidResetPasswordSignatureError(gettext("Invalid confirmation code or user does not exist")) from e @@ -60,8 +60,10 @@ def reset_password(cls, signature: str, new_password: str) -> None: cls.change_password(user, new_password) @staticmethod - def _send_notification(user_pk: str, context: dict): - # send_notification.delay(user_pk, "user-reset-password", context) # TODO: Implement notification sending + def _send_notification(user_pk: str, context: dict) -> None: + # TODO @bulya: Implement notification sending # noqa: FIX002 + # DI-2715 + # send_notification.delay(user_pk, "user-reset-password", context) # noqa: ERA001 pass @staticmethod diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_login_api.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_login_api.py index 26c6fdb..f8a464d 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_login_api.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_login_api.py @@ -1,5 +1,6 @@ import pytest +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.response import Response @@ -7,12 +8,17 @@ from django.urls import reverse from django.utils.translation import gettext +from {{ cookiecutter.project_slug }}.fixtures.api_client import ApiClientMaker, CustomAPIClient +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker + LOGIN_SERIALIZER_PATH = "{{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.login.LoginSerializer" @pytest.mark.django_db -def test_login_api_success(user_account, unauthorized_api_client, mocker): +def test_login_api_success( + user_account: UserAccountMaker, unauthorized_api_client: CustomAPIClient, mocker: MockerFixture +) -> None: user = user_account() mocked_validate = mocker.patch(f"{LOGIN_SERIALIZER_PATH}.validate", return_value={"user": user}) mocked_response = Response(status=status.HTTP_204_NO_CONTENT) @@ -30,7 +36,7 @@ def test_login_api_success(user_account, unauthorized_api_client, mocker): @pytest.mark.django_db -def test_login_api_wrong_credentials_failure(user_account, unauthorized_api_client, mocker): +def test_login_api_wrong_credentials_failure(unauthorized_api_client: CustomAPIClient, mocker: MockerFixture) -> None: mocked_validate = mocker.patch( f"{LOGIN_SERIALIZER_PATH}.validate", side_effect=[ValidationError(gettext("Incorrect email or password."))] ) @@ -46,7 +52,9 @@ def test_login_api_wrong_credentials_failure(user_account, unauthorized_api_clie @pytest.mark.django_db -def test_login_api_already_logged_in_failure(user_account, api_client, mocker): +def test_login_api_already_logged_in_failure( + user_account: UserAccountMaker, api_client: ApiClientMaker, mocker: MockerFixture +) -> None: user = user_account() client = api_client(auth_user=user) mocked_validate = mocker.patch(f"{LOGIN_SERIALIZER_PATH}.validate") diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_logout_api.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_logout_api.py index cb15cd0..e5ba3ba 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_logout_api.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_logout_api.py @@ -1,13 +1,17 @@ import pytest +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.response import Response from django.urls import reverse +from {{ cookiecutter.project_slug }}.fixtures.api_client import ApiClientMaker +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker + @pytest.mark.django_db -def test_logout_api_success(user_account, api_client, mocker): +def test_logout_api_success(user_account: UserAccountMaker, api_client: ApiClientMaker, mocker: MockerFixture) -> None: user = user_account() client = api_client(auth_user=user) mocked_logout = mocker.patch( diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_registration_api.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_registration_api.py index 6db43e7..b66fd73 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_registration_api.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_registration_api.py @@ -1,16 +1,18 @@ import pytest +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.response import Response from django.urls import reverse from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount +from {{ cookiecutter.project_slug }}.fixtures.api_client import CustomAPIClient @pytest.mark.django_db -def test_registration_api_success(unauthorized_api_client, mocker): - assert UserAccount.objects.count() == 0 +def test_registration_api_success(unauthorized_api_client: CustomAPIClient, mocker: MockerFixture) -> None: + assert not UserAccount.objects.exists() mocked_response = Response(status=status.HTTP_204_NO_CONTENT) mocked_login = mocker.patch( "{{ cookiecutter.project_slug }}.apps.accounts.services.login.LoginService.login", return_value=mocked_response diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_user_profile_api.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_user_profile_api.py index 8b24b5f..f68713c 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_user_profile_api.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_user_profile_api.py @@ -4,9 +4,12 @@ from django.urls import reverse +from {{ cookiecutter.project_slug }}.fixtures.api_client import ApiClientMaker +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker + @pytest.mark.django_db -def test_user_profile_api_get_success(user_account, api_client): +def test_user_profile_api_get_success(user_account: UserAccountMaker, api_client: ApiClientMaker) -> None: email = "john@example.com" first_name = "John" last_name = "Doe" @@ -20,7 +23,7 @@ def test_user_profile_api_get_success(user_account, api_client): @pytest.mark.django_db -def test_user_profile_api_update_success(user_account, api_client): +def test_user_profile_api_update_success(user_account: UserAccountMaker, api_client: ApiClientMaker) -> None: email = "john@example.com" old_first_name = "John" old_last_name = "Doe" diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api_permissions.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api_permissions.py index 39246bb..dcec657 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api_permissions.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api_permissions.py @@ -1,11 +1,14 @@ import pytest +from pytest_mock import MockerFixture + from django.contrib.auth.models import AnonymousUser from {{ cookiecutter.project_slug }}.apps.accounts.api.permissions import IsNotAuthenticated +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker -def test_is_not_authenticated_permission_true(mocker): +def test_is_not_authenticated_permission_true(mocker: MockerFixture) -> None: request = mocker.MagicMock() view = mocker.MagicMock() request.user = AnonymousUser() @@ -15,7 +18,7 @@ def test_is_not_authenticated_permission_true(mocker): @pytest.mark.django_db -def test_is_not_authenticated_permission_false(user_account, mocker): +def test_is_not_authenticated_permission_false(user_account: UserAccountMaker, mocker: MockerFixture) -> None: request = mocker.MagicMock() view = mocker.MagicMock() request.user = user_account() diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_models/test_user_model.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_models/test_user_model.py index fc71d26..eb48d84 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_models/test_user_model.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_models/test_user_model.py @@ -1,82 +1,77 @@ import pytest from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker @pytest.mark.django_db -def test_core_queryset_active(user_account): - assert UserAccount.objects.count() == 0 +def test_core_queryset_active(user_account: UserAccountMaker) -> None: + assert not UserAccount.objects.exists() + total_users_count = 3 active_user = user_account(is_active=True) user_account(is_active=False) user_account(is_active=False) - assert UserAccount.objects.count() == 3 - assert UserAccount.objects.active().count() == 1 - assert UserAccount.objects.active().first() == active_user + assert UserAccount.objects.count() == total_users_count + assert UserAccount.objects.active().get() == active_user @pytest.mark.django_db -def test_core_queryset_inactive(user_account): - assert UserAccount.objects.count() == 0 +def test_core_queryset_inactive(user_account: UserAccountMaker) -> None: + assert not UserAccount.objects.exists() inactive_user = user_account(is_active=False) user_account(is_active=True) user_account(is_active=True) + expected_total_users_count = 3 + expected_inactive_users_count = 1 - assert UserAccount.objects.count() == 3 - assert UserAccount.objects.inactive().count() == 1 + assert UserAccount.objects.count() == expected_total_users_count + assert UserAccount.objects.inactive().count() == expected_inactive_users_count assert UserAccount.objects.inactive().first() == inactive_user @pytest.mark.django_db -def test_core_queryset_activate(user_account): - assert UserAccount.objects.count() == 0 +def test_core_queryset_activate(user_account: UserAccountMaker) -> None: + assert not UserAccount.objects.exists() user = user_account(is_active=False) - assert UserAccount.objects.count() == 1 - assert UserAccount.objects.active().count() == 0 - assert UserAccount.objects.inactive().count() == 1 - assert UserAccount.objects.inactive().first() == user + assert not UserAccount.objects.active().exists() + assert UserAccount.objects.inactive().get() == user user.activate() - assert UserAccount.objects.count() == 1 - assert UserAccount.objects.active().count() == 1 - assert UserAccount.objects.inactive().count() == 0 - assert UserAccount.objects.active().first() == user + assert not UserAccount.objects.inactive().exists() + assert UserAccount.objects.active().get() == user @pytest.mark.django_db -def test_core_queryset_deactivate(user_account): - assert UserAccount.objects.count() == 0 +def test_core_queryset_deactivate(user_account: UserAccountMaker) -> None: + assert not UserAccount.objects.exists() user = user_account(is_active=True) - assert UserAccount.objects.count() == 1 - assert UserAccount.objects.active().count() == 1 - assert UserAccount.objects.inactive().count() == 0 - assert UserAccount.objects.active().first() == user + assert not UserAccount.objects.inactive().exists() + assert UserAccount.objects.active().get() == user user.deactivate() assert UserAccount.objects.count() == 1 - assert UserAccount.objects.active().count() == 0 - assert UserAccount.objects.inactive().count() == 1 - assert UserAccount.objects.inactive().first() == user + assert not UserAccount.objects.active().exists() + assert UserAccount.objects.inactive().get() == user @pytest.mark.django_db -def test_create_user_success(): - assert UserAccount.objects.count() == 0 +def test_create_user_success() -> None: + assert not UserAccount.objects.exists() user_email = "jane@example.com" user_password = "super-secret-password" # nosec user = UserAccount.objects.create_user(user_email, user_password) - assert UserAccount.objects.count() == 1 - assert UserAccount.objects.first() == user + assert UserAccount.objects.get() == user assert user.email == user_email assert not user.is_staff assert not user.is_superuser @@ -84,26 +79,24 @@ def test_create_user_success(): @pytest.mark.django_db -def test_create_user_no_email_failure(): - assert UserAccount.objects.count() == 0 +def test_create_user_no_email_failure() -> None: + assert not UserAccount.objects.exists() - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="Users must give an email address"): UserAccount.objects.create_user(None) - assert UserAccount.objects.count() == 0 - assert "Users must give an email address" in str(exc_info.value) + assert not UserAccount.objects.exists() @pytest.mark.django_db -def test_create_superuser_success(): - assert UserAccount.objects.count() == 0 +def test_create_superuser_success() -> None: + assert not UserAccount.objects.exists() superuser_email = "admin@example.com" superuser_password = "super-secret-password" # nosec superuser = UserAccount.objects.create_superuser(superuser_email, superuser_password) - assert UserAccount.objects.count() == 1 - assert UserAccount.objects.first() == superuser + assert UserAccount.objects.get() == superuser assert superuser.email == superuser_email assert superuser.is_staff assert superuser.is_superuser @@ -111,30 +104,28 @@ def test_create_superuser_success(): @pytest.mark.django_db -def test_create_superuser_no_email_failure(): - assert UserAccount.objects.count() == 0 +def test_create_superuser_no_email_failure() -> None: + assert not UserAccount.objects.exists() - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="Users must give an email address"): UserAccount.objects.create_superuser(None, None) - assert UserAccount.objects.count() == 0 - assert "Users must give an email address" in str(exc_info.value) + assert not UserAccount.objects.exists() @pytest.mark.parametrize("email", ["jane@example.com", "john@example.com"]) @pytest.mark.django_db -def test_get_short_name(user_account, email): - assert UserAccount.objects.count() == 0 +def test_get_short_name(user_account: UserAccountMaker, email: str) -> None: + assert not UserAccount.objects.exists() user = user_account(email=email) - assert UserAccount.objects.count() == 1 - assert UserAccount.objects.first() == user + assert UserAccount.objects.get() == user assert user.get_short_name() == user.email @pytest.mark.parametrize( - "first_name,last_name,email,expected", + ("first_name", "last_name", "email", "expected"), [ ("Jane", "Doe", "jane@example.com", "Jane Doe "), ("", "", "jane@example.com", "jane@example.com"), @@ -143,18 +134,23 @@ def test_get_short_name(user_account, email): ], ) @pytest.mark.django_db -def test_get_full_name(user_account, first_name, last_name, email, expected): - assert UserAccount.objects.count() == 0 +def test_get_full_name( + user_account: UserAccountMaker, first_name: str, last_name: str, email: str, expected: str +) -> None: + assert not UserAccount.objects.exists() user = user_account(first_name=first_name, last_name=last_name, email=email) - assert UserAccount.objects.count() == 1 - assert UserAccount.objects.first() == user + assert UserAccount.objects.get() == user assert user.get_full_name() == expected -@pytest.mark.parametrize("first_name,last_name,expected", [("Jane", "Doe", "Jane Doe"), ("", "", "Dear client")]) +@pytest.mark.parametrize( + ("first_name", "last_name", "expected"), [("Jane", "Doe", "Jane Doe"), ("", "", "Dear client")] +) @pytest.mark.django_db -def test_notification_salutation(user_account, first_name, last_name, expected): +def test_notification_salutation( + user_account: UserAccountMaker, first_name: str, last_name: str, expected: str +) -> None: user = user_account(first_name=first_name, last_name=last_name) assert user.notification_salutation == expected diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_selectors/test_get_users_selector.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_selectors/test_get_users_selector.py index 2f9f590..300a72e 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_selectors/test_get_users_selector.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_selectors/test_get_users_selector.py @@ -2,12 +2,14 @@ from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount from {{ cookiecutter.project_slug }}.apps.accounts.selectors import get_all_users +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker @pytest.mark.django_db -def test_get_all_user_selector(user_account): +def test_get_all_user_selector(user_account: UserAccountMaker) -> None: + number_of_users = 3 assert UserAccount.objects.count() == 0 user_account(_quantity=3) - assert UserAccount.objects.filter(is_active=True).count() == 3 + assert UserAccount.objects.filter(is_active=True).count() == number_of_users queryset = get_all_users() - assert queryset.count() == 3 + assert queryset.count() == number_of_users diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_change_password_serializer.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_change_password_serializer.py index 9fca382..85cbf1a 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_change_password_serializer.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_change_password_serializer.py @@ -1,19 +1,21 @@ import pytest +from pytest_mock import MockerFixture from rest_framework.exceptions import ValidationError from django.test import RequestFactory from {{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.password import ChangePasswordSerializer from {{ cookiecutter.project_slug }}.apps.accounts.exceptions import InvalidPasswordError, WrongPasswordError +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker OLD_PASSWORD = "OLD_PASSWORD" # nosec NEW_PASSWORD = "NEW_PASSWORD" # nosec -@pytest.fixture() -def change_password_serializer_request(user_account): +@pytest.fixture +def change_password_serializer_request(user_account: UserAccountMaker) -> RequestFactory: user = user_account() user.set_password(OLD_PASSWORD) user.save(update_fields=("password",)) @@ -23,7 +25,9 @@ def change_password_serializer_request(user_account): @pytest.mark.django_db -def test_validate_old_password_success(change_password_serializer_request, mocker): +def test_validate_old_password_success( + change_password_serializer_request: RequestFactory, mocker: MockerFixture +) -> None: serializer = ChangePasswordSerializer(context={"request": change_password_serializer_request}) mocked_check_password = mocker.patch.object(serializer.password_service, "check_password") @@ -34,7 +38,9 @@ def test_validate_old_password_success(change_password_serializer_request, mocke @pytest.mark.django_db -def test_validate_new_password_success(change_password_serializer_request, mocker): +def test_validate_new_password_success( + change_password_serializer_request: RequestFactory, mocker: MockerFixture +) -> None: serializer = ChangePasswordSerializer(context={"request": change_password_serializer_request}) mocked_validate_password = mocker.patch.object(serializer.password_service, "validate_password") @@ -45,7 +51,9 @@ def test_validate_new_password_success(change_password_serializer_request, mocke @pytest.mark.django_db -def test_validate_old_password_failure(change_password_serializer_request, mocker): +def test_validate_old_password_failure( + change_password_serializer_request: RequestFactory, mocker: MockerFixture +) -> None: serializer = ChangePasswordSerializer(context={"request": change_password_serializer_request}) mocked_check_password = mocker.patch.object( serializer.password_service, @@ -61,7 +69,9 @@ def test_validate_old_password_failure(change_password_serializer_request, mocke @pytest.mark.django_db -def test_validate_new_password_failure(change_password_serializer_request, mocker): +def test_validate_new_password_failure( + change_password_serializer_request: RequestFactory, mocker: MockerFixture +) -> None: serializer = ChangePasswordSerializer(context={"request": change_password_serializer_request}) mocked_validate_password = mocker.patch.object( serializer.password_service, @@ -77,7 +87,7 @@ def test_validate_new_password_failure(change_password_serializer_request, mocke @pytest.mark.django_db -def test_save(user_account, mocker): +def test_save(mocker: MockerFixture) -> None: serializer = ChangePasswordSerializer( data={"old_password": OLD_PASSWORD, "new_password": NEW_PASSWORD}, context={"request": change_password_serializer_request}, diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_confirm_reset_password_serializer.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_confirm_reset_password_serializer.py index 920f803..a94f79c 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_confirm_reset_password_serializer.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_confirm_reset_password_serializer.py @@ -1,5 +1,6 @@ import pytest +from pytest_mock import MockerFixture from rest_framework.exceptions import ValidationError from {{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.password import ConfirmResetPasswordSerializer @@ -10,7 +11,7 @@ RESET_PASSWORD_SIGNATURE = "SIGNATURE" # nosec -def test_validate_password_success(mocker): +def test_validate_password_success(mocker: MockerFixture) -> None: serializer = ConfirmResetPasswordSerializer() mocked_validate_password = mocker.patch.object(serializer.password_service, "validate_password") @@ -20,7 +21,7 @@ def test_validate_password_success(mocker): mocked_validate_password.assert_called_once_with(NEW_PASSWORD) -def test_validate_password_failure(mocker): +def test_validate_password_failure(mocker: MockerFixture) -> None: serializer = ConfirmResetPasswordSerializer() mocked_validate_password = mocker.patch.object( serializer.password_service, "validate_password", side_effect=InvalidPasswordError("message") @@ -32,7 +33,7 @@ def test_validate_password_failure(mocker): mocked_validate_password.assert_called_once_with(NEW_PASSWORD) -def test_save_success(mocker): +def test_save_success(mocker: MockerFixture) -> None: serializer = ConfirmResetPasswordSerializer(data={"password": NEW_PASSWORD, "signature": RESET_PASSWORD_SIGNATURE}) mocker.patch.object(serializer, "validate_password", return_value=NEW_PASSWORD) mocked_reset_password = mocker.patch.object(serializer.password_service, "reset_password") @@ -43,7 +44,7 @@ def test_save_success(mocker): mocked_reset_password.assert_called_once_with(RESET_PASSWORD_SIGNATURE, NEW_PASSWORD) -def test_save_failure(mocker): +def test_save_failure(mocker: MockerFixture) -> None: serializer = ConfirmResetPasswordSerializer(data={"password": NEW_PASSWORD, "signature": RESET_PASSWORD_SIGNATURE}) mocker.patch.object(serializer, "validate_password", return_value=NEW_PASSWORD) mocked_reset_password = mocker.patch.object( diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_login_serializer.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_login_serializer.py index 065f0ef..e418556 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_login_serializer.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_login_serializer.py @@ -3,10 +3,11 @@ from rest_framework.exceptions import ValidationError from {{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.login import LoginSerializer +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker @pytest.mark.django_db -def test_login_serializer_validate_success(user_account): +def test_login_serializer_validate_success(user_account: UserAccountMaker) -> None: email = "jane@example.com" password = "super_secret_password" # nosec user = user_account(email=email) @@ -19,7 +20,7 @@ def test_login_serializer_validate_success(user_account): @pytest.mark.django_db -def test_login_serializer_validate_failure(user_account): +def test_login_serializer_validate_failure(user_account: UserAccountMaker) -> None: email = "jane@example.com" password = "super_secret_password" # nosec user = user_account(email=email) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_registration_serializer.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_registration_serializer.py index dba32ed..66f810b 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_registration_serializer.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_registration_serializer.py @@ -1,14 +1,16 @@ import pytest +from pytest_mock import MockerFixture from rest_framework.exceptions import ValidationError from {{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.registration import RegistrationSerializer from {{ cookiecutter.project_slug }}.apps.accounts.exceptions import InvalidPasswordError from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker -@pytest.fixture() -def input_data(): +@pytest.fixture +def input_data() -> dict[str, str]: return { "email": "jane@example.com", "password": "test12356", # nosec @@ -17,13 +19,13 @@ def input_data(): } -def test_registration_serializer_validate_success(input_data): +def test_registration_serializer_validate_success(input_data: dict) -> None: serializer = RegistrationSerializer(data=input_data) data = serializer.validate(input_data) assert data == input_data -def test_registration_serializer_validate_password_success(mocker, input_data): +def test_registration_serializer_validate_password_success(mocker: MockerFixture, input_data: dict) -> None: serializer = RegistrationSerializer(data=input_data) mocked_validate_password = mocker.patch.object(serializer.password_service, "validate_password") @@ -33,7 +35,7 @@ def test_registration_serializer_validate_password_success(mocker, input_data): mocked_validate_password.assert_called_once_with(input_data["password"]) -def test_registration_serializer_validate_password_failure(mocker, input_data): +def test_registration_serializer_validate_password_failure(mocker: MockerFixture, input_data: dict) -> None: serializer = RegistrationSerializer(data=input_data) mocked_validate_password = mocker.patch.object( serializer.password_service, "validate_password", side_effect=[InvalidPasswordError("error")] @@ -46,7 +48,7 @@ def test_registration_serializer_validate_password_failure(mocker, input_data): @pytest.mark.django_db -def test_registration_serializer_validate_email_success(user_account, input_data): +def test_registration_serializer_validate_email_success(user_account: UserAccountMaker, input_data: dict) -> None: user_account(email="john@example.com") serializer = RegistrationSerializer(data=input_data) validated_email = serializer.validate_email(input_data["email"]) @@ -54,7 +56,7 @@ def test_registration_serializer_validate_email_success(user_account, input_data @pytest.mark.django_db -def test_registration_serializer_validate_email_failure(user_account, input_data): +def test_registration_serializer_validate_email_failure(user_account: UserAccountMaker, input_data: dict) -> None: user_account(email=input_data["email"]) serializer = RegistrationSerializer(data=input_data) with pytest.raises(ValidationError): @@ -62,7 +64,7 @@ def test_registration_serializer_validate_email_failure(user_account, input_data @pytest.mark.django_db -def test_registration_serializer_save_success(input_data): +def test_registration_serializer_save_success(input_data: dict) -> None: assert UserAccount.objects.count() == 0 serializer = RegistrationSerializer(data=input_data) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_reset_password_serializer.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_reset_password_serializer.py index 3ac6be3..8b830f4 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_reset_password_serializer.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_serializers/test_reset_password_serializer.py @@ -1,7 +1,9 @@ +from pytest_mock import MockerFixture + from {{ cookiecutter.project_slug }}.apps.accounts.api.v1.serializers.password import ResetPasswordSerializer -def test_save(mocker): +def test_save(mocker: MockerFixture) -> None: email = "jane@example.com" serializer = ResetPasswordSerializer(data={"email": email}) mocked_send_reset_password_link = mocker.patch.object(serializer.password_service, "send_reset_password_link") diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_login_service.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_login_service.py index 8a4ac9f..0fac9cc 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_login_service.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_login_service.py @@ -1,15 +1,17 @@ import pytest +from pytest_mock import MockerFixture from rest_framework import status from django.test.client import RequestFactory from django.urls import reverse from {{ cookiecutter.project_slug }}.apps.accounts.services.login import LoginService +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker @pytest.mark.django_db -def test_login_service_login(user_account, mocker): +def test_login_service_login(user_account: UserAccountMaker, mocker: MockerFixture) -> None: request = mocker.MagicMock() user = user_account() service = LoginService @@ -21,7 +23,7 @@ def test_login_service_login(user_account, mocker): mocked_django_login.assert_called_once_with(request, user) -def test_login_service_logout(user_account, mocker): +def test_login_service_logout(user_account: UserAccountMaker, mocker: MockerFixture) -> None: user = user_account() request = RequestFactory().post(reverse("api-v1-accounts:logout")) request.user = user diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_password_service.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_password_service.py index 77d28c5..02df39f 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_password_service.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_password_service.py @@ -2,6 +2,9 @@ import pytest +from pytest_mock import MockerFixture + +from django.conf import LazySettings from django.contrib.sites.models import Site from django.core.exceptions import ValidationError from django.core.signing import BadSignature, SignatureExpired, TimestampSigner @@ -13,12 +16,13 @@ ) from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount from {{ cookiecutter.project_slug }}.apps.accounts.services.password import PasswordService +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker PASSWORD_SERVICE_PATH = "{{ cookiecutter.project_slug }}.apps.accounts.services.password.PasswordService" # nosec -def test_change_password(user_account): +def test_change_password(user_account: UserAccountMaker) -> None: old_password = "old_password_1234" # nosec new_password = "new_password_5678" # nosec @@ -33,7 +37,7 @@ def test_change_password(user_account): assert user.check_password(new_password) -def test_check_password(user_account): +def test_check_password(user_account: UserAccountMaker) -> None: user = user_account() user.set_password("CORRECT_PASSWORD") user.save(update_fields=("password",)) @@ -42,21 +46,22 @@ def test_check_password(user_account): PasswordService.check_password(user, "INCORRECT_PASSWORD") -def test_validate_password(mocker): +def test_validate_password(mocker: MockerFixture) -> None: mocker.patch("django.contrib.auth.password_validation.validate_password", side_effect=ValidationError("exception")) with pytest.raises(InvalidPasswordError): PasswordService.validate_password("SOME_PASSWORD") -def test_generate_reset_password_signature(user_account): +def test_generate_reset_password_signature(user_account: UserAccountMaker) -> None: user = user_account() expected_reset_password_signature = TimestampSigner().sign(user.pk) + generated_reset_password_signature = PasswordService._generate_reset_password_signature(user) # noqa: SLF001 - assert PasswordService._generate_reset_password_signature(user) == expected_reset_password_signature + assert generated_reset_password_signature == expected_reset_password_signature -def test_reset_password_success(user_account, mocker): +def test_reset_password_success(user_account: UserAccountMaker, mocker: MockerFixture) -> None: new_password = "new_password_1234" # nosec user = user_account() reset_password_signature = TimestampSigner().sign(user.pk) @@ -69,7 +74,9 @@ def test_reset_password_success(user_account, mocker): @pytest.mark.django_db @pytest.mark.parametrize("exception", [SignatureExpired, BadSignature]) -def test_reset_password_signature_failure(mocker, settings, exception): +def test_reset_password_signature_failure( + mocker: MockerFixture, settings: LazySettings, exception: type[Exception] +) -> None: new_password = "new_password_1234" # nosec reset_password_signature = "reset_password_signature" # nosec mocker.patch("django.core.signing.TimestampSigner.unsign", side_effect=exception("Some message")) @@ -83,7 +90,7 @@ def test_reset_password_signature_failure(mocker, settings, exception): @pytest.mark.django_db -def test_reset_password_user_failure(mocker): +def test_reset_password_user_failure(mocker: MockerFixture) -> None: new_password = "new_password_1234" # nosec assert UserAccount.objects.count() == 0 reset_password_signature = TimestampSigner().sign(uuid.uuid4()) @@ -96,7 +103,9 @@ def test_reset_password_user_failure(mocker): @pytest.mark.django_db -def test_send_reset_password_link_success(settings, user_account, mocker): +def test_send_reset_password_link_success( + settings: LazySettings, user_account: UserAccountMaker, mocker: MockerFixture +) -> None: user = user_account() domain_name = "test_send_reset_password_link_success.com" signature = "signature" @@ -121,7 +130,7 @@ def test_send_reset_password_link_success(settings, user_account, mocker): @pytest.mark.django_db -def test_send_reset_password_link_failure(mocker): +def test_send_reset_password_link_failure(mocker: MockerFixture) -> None: assert UserAccount.objects.count() == 0 mocked_generate_reset_password_signature = mocker.patch( f"{PASSWORD_SERVICE_PATH}._generate_reset_password_signature" diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/management/commands/generate_secretkey.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/management/commands/generate_secretkey.py index ce3a2e5..5f8b88a 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/management/commands/generate_secretkey.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/management/commands/generate_secretkey.py @@ -4,8 +4,8 @@ class Command(BaseCommand): help = "Generates a secret key." - requires_system_checks = [] + requires_system_checks = () - def handle(self, *args, **options): + def handle(self, *_args, **_options) -> None: # noqa: ANN003 ANN002 secret_key = get_random_secret_key() self.stdout.write(self.style.SUCCESS(secret_key)) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/management/commands/startapp.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/management/commands/startapp.py index b509533..28e14e8 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/management/commands/startapp.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/management/commands/startapp.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path from django.conf import settings from django.core.management.base import CommandError @@ -6,14 +6,15 @@ class Command(StartappCommand): - def handle(self, **options): + def handle(self, **options) -> None: # noqa: ANN003 if options["directory"] is None: - app_directory = os.path.join(settings.BASE_DIR, "apps", options["name"]) + app_directory = Path(settings.BASE_DIR) / "apps" / options["name"] try: - os.makedirs(app_directory, exist_ok=True) - except FileExistsError: - raise CommandError(f"'{app_directory}' already exists") # pylint: disable=raise-missing-from + Path(app_directory).mkdir(parents=True, exist_ok=True) + except FileExistsError as exc: + message = f"'{app_directory}' already exists" + raise CommandError(message) from exc options["directory"] = app_directory diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/models/core.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/models/core.py index 0d78ac9..7428826 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/models/core.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/models/core.py @@ -5,10 +5,10 @@ class CoreQuerySet(models.QuerySet): - def active(self): + def active(self) -> models.QuerySet: return self.filter(is_active=True) - def inactive(self): + def inactive(self) -> models.QuerySet: return self.filter(is_active=False) @@ -27,18 +27,18 @@ class CoreModel(models.Model): class Meta: abstract = True - def __str__(self): + def __str__(self) -> str: return str(self.pk) - def __repr__(self): + def __repr__(self) -> str: return f"<{self.__class__.__name__} {self.pk}>" - def activate(self): + def activate(self) -> None: if not self.is_active: self.is_active = True self.save(update_fields=["is_active", "updated"] if not self._state.adding else None) - def deactivate(self): + def deactivate(self) -> None: if self.is_active: self.is_active = False self.save(update_fields=["is_active", "updated"] if not self._state.adding else None) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/utils/redis.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/utils/redis.py index 42a9e4a..87365a7 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/utils/redis.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/common/utils/redis.py @@ -2,11 +2,13 @@ import redis +from redis import Redis + from django.conf import settings @functools.lru_cache(maxsize=128) -def redis_client(redis_connection_url=settings.REDIS_URL): +def redis_client(redis_connection_url: str = settings.REDIS_URL) -> Redis: """ Redis client wrapped into LRU cache. Example: diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/__init__.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/__init__.py index bf3e4ca..e69de29 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/__init__.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/__init__.py @@ -1,2 +0,0 @@ -from .api_client import * # noqa: F401 -from .user_account import * # noqa: F401 diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/api_client.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/api_client.py index 7cbe412..6f57768 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/api_client.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/api_client.py @@ -1,13 +1,15 @@ -# pylint: skip-file - import json +from collections.abc import Callable +from typing import Any + import pytest +from rest_framework.response import Response from rest_framework.test import APIClient - -__all__ = ["api_client", "unauthorized_api_client"] +from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount +from {{ cookiecutter.project_slug }}.fixtures.user_account import UserAccountMaker class _CustomAPIClient(APIClient): @@ -20,39 +22,79 @@ class _CustomAPIClient(APIClient): `data should` properly encoded to JSON. """ - def post(self, path, data=None, format=None, content_type="application/json", follow=False, **extra): - if isinstance(data, (dict, list)): + def post( + self, + path: str, + data: Any = None, # noqa: ANN401 + format: str | None = None, # noqa: A002 + content_type: str = "application/json", + *, + follow: bool = False, + **extra, # noqa: ANN003 + ) -> Response: + if isinstance(data, dict | list): data = json.dumps(data) response = super().post(path, data=data, format=format, content_type=content_type, follow=follow, **extra) return response - def put(self, path, data=None, format=None, content_type="application/json", follow=False, **extra): - if isinstance(data, (dict, list)): + def put( + self, + path: str, + data: Any = None, # noqa: ANN401 + format: str | None = None, # noqa: A002 + content_type: str = "application/json", + *, + follow: bool = False, + **extra, # noqa: ANN003 + ) -> Response: + if isinstance(data, dict | list): data = json.dumps(data) response = super().put(path, data=data, format=format, content_type=content_type, follow=follow, **extra) return response - def patch(self, path, data=None, format=None, content_type="application/json", follow=False, **extra): - if isinstance(data, (dict, list)): + def patch( + self, + path: str, + data: Any = None, # noqa: ANN401 + format: str | None = None, # noqa: A002 + content_type: str = "application/json", + *, + follow: bool = False, + **extra, # noqa: ANN003 + ) -> Response: + if isinstance(data, dict | list): data = json.dumps(data) response = super().patch(path, data=data, format=format, content_type=content_type, follow=follow, **extra) return response - def delete(self, path, data=None, format=None, content_type="application/json", follow=False, **extra): - if isinstance(data, (dict, list)): + def delete( + self, + path: str, + data: Any = None, # noqa: ANN401 + format: str | None = None, # noqa: A002 + content_type: str = "application/json", + *, + follow: bool = False, + **extra, # noqa: ANN003 + ) -> Response: + if isinstance(data, dict | list): data = json.dumps(data) response = super().delete(path, data=data, format=format, content_type=content_type, follow=follow, **extra) return response -@pytest.fixture() -def unauthorized_api_client(): +type CustomAPIClient = _CustomAPIClient +type ApiClientMaker = Callable[..., CustomAPIClient] + + +@pytest.fixture +def unauthorized_api_client() -> CustomAPIClient: return _CustomAPIClient() -@pytest.fixture() -def api_client(user_account): - def _api_client(auth_user=None): +@pytest.fixture +def api_client(user_account: UserAccountMaker) -> ApiClientMaker: + def _api_client(auth_user: UserAccount | None) -> CustomAPIClient: if auth_user is None: auth_user = user_account() client = _CustomAPIClient() diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/user_account.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/user_account.py index 2bdaec7..8c91fc1 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/user_account.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/fixtures/user_account.py @@ -1,14 +1,18 @@ +from collections.abc import Callable + import pytest from model_bakery import baker +from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount + -__all__ = ["user_account"] +type UserAccountMaker = Callable[..., UserAccount] -@pytest.fixture() -def user_account(db): # pylint: disable=unused-argument - def _user(**kwargs): +@pytest.fixture +def user_account(db: None) -> UserAccountMaker: # noqa: ARG001 + def _user(**kwargs) -> UserAccount: # noqa: ANN003 return baker.make("accounts.UserAccount", **kwargs) return _user diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/loggers.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/loggers.py index 6999f92..5ec89f4 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/loggers.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/loggers.py @@ -4,11 +4,11 @@ import sqlparse from pygments.formatters.terminal256 import TerminalTrueColorFormatter -from pygments.lexers.sql import SqlLexer +from pygments.lexers import SqlLexer class SQLFormatter(logging.Formatter): - def format(self, record): + def format(self, record: logging.LogRecord) -> str: sql = sqlparse.format(record.sql.strip(), reindent=True) record.statement = pygments.highlight(sql, SqlLexer(), TerminalTrueColorFormatter(style="monokai")) return super().format(record) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/__init__.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/__init__.py index 6d79c04..2085059 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/__init__.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/__init__.py @@ -1,3 +1,4 @@ -from .django import * # noqa: F401 isort:skip -from .contrib import * # noqa: F401 isort:skip -from .{{ cookiecutter.project_slug }} import * # noqa: F401 isort:skip +# ruff: noqa: F403 +from .contrib import * +from .django import * +from .{{ cookiecutter.project_slug }} import * diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/__init__.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/__init__.py index 128f44d..547ad7c 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/__init__.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/__init__.py @@ -1,5 +1,6 @@ -from .celery import * # noqa: F401 -from .redis import * # noqa: F401 -from .rest_framework import * # noqa: F401 -from .sentry import * # noqa: F401 -from .swagger import * # noqa: F401 +# ruff: noqa: F403 +from .celery import * +from .redis import * +from .rest_framework import * +from .sentry import * +from .swagger import * diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/celery.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/celery.py index ac7455f..e678ee2 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/celery.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/celery.py @@ -1,5 +1,5 @@ -from ..django import TIME_ZONE as DJANGO_TIME_ZONE -from ..environment import env +from {{ cookiecutter.project_slug }}.settings.django import TIME_ZONE as DJANGO_TIME_ZONE +from {{ cookiecutter.project_slug }}.settings.environment import env CELERY_TASK_ALWAYS_EAGER = env.bool("{{ cookiecutter.__env_prefix }}CELERY_TASK_ALWAYS_EAGER", default=False) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/redis.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/redis.py index 0ab8002..8920eb2 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/redis.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/redis.py @@ -1,4 +1,4 @@ -from ..environment import env +from {{ cookiecutter.project_slug }}.settings.environment import env REDIS_URL = env.str("{{ cookiecutter.__env_prefix }}REDIS_URL", default="redis://redis:6379/2") diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/rest_framework.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/rest_framework.py index 62bfb67..320db3c 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/rest_framework.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/rest_framework.py @@ -1,9 +1,11 @@ -from ..environment import env +from {{ cookiecutter.project_slug }}.settings.environment import env REST_FRAMEWORK = { "COERCE_DECIMAL_TO_STRING": False, - "DEFAULT_AUTHENTICATION_CLASSES": ("{{ cookiecutter.project_slug }}.apps.accounts.api.authentication.CustomSessionAuthentication",), + "DEFAULT_AUTHENTICATION_CLASSES": ( + "{{ cookiecutter.project_slug }}.apps.accounts.api.authentication.CustomSessionAuthentication", + ), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", "DEFAULT_PARSER_CLASSES": ("rest_framework.parsers.JSONParser",), diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/sentry.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/sentry.py index 23a4619..b4c465f 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/sentry.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/sentry.py @@ -2,7 +2,7 @@ from sentry_sdk.integrations.django import DjangoIntegration -from ..environment import env +from {{ cookiecutter.project_slug }}.settings.environment import env USE_SENTRY = env.bool("{{ cookiecutter.__env_prefix }}USE_SENTRY", default=True) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/swagger.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/swagger.py index ff11698..3f8f76c 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/swagger.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/swagger.py @@ -1,5 +1,5 @@ SPECTACULAR_SETTINGS = { - "TITLE": "{{ cookiecutter.project_name }} API", + "TITLE": "{{ cookiecutter.project_slug }} API", "VERSION": "0.1.0", "SERVE_INCLUDE_SCHEMA": False, } diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/django.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/django.py index 6932d9e..5c50a87 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/django.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/django.py @@ -1,12 +1,12 @@ from pathlib import Path -from .environment import env +from {{ cookiecutter.project_slug }}.settings.environment import env BASE_DIR = Path(__file__).resolve().parent.parent -def rel(*path): +def rel(*path: str) -> Path: return BASE_DIR.joinpath(*path) @@ -35,7 +35,8 @@ def rel(*path): # First-party apps "{{ cookiecutter.project_slug }}.apps.common", "{{ cookiecutter.project_slug }}.apps.accounts", -] + env.list("{{ cookiecutter.__env_prefix }}DEV_INSTALLED_APPS", default=[]) + *env.list("{{ cookiecutter.__env_prefix }}DEV_INSTALLED_APPS", default=[]), +] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", @@ -45,7 +46,8 @@ def rel(*path): "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", -] + env.list("{{ cookiecutter.__env_prefix }}DEV_MIDDLEWARE", default=[]) + *env.list("{{ cookiecutter.__env_prefix }}DEV_MIDDLEWARE", default=[]), +] ROOT_URLCONF = "{{ cookiecutter.project_slug }}.urls" diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/environment.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/environment.py index a1d3139..29f7b9b 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/environment.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/environment.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path import environ @@ -9,5 +9,5 @@ site_root = current_path - 2 env_file = site_root(".env") -if os.path.exists(env_file): # pragma: no cover +if Path(env_file).exists(): environ.Env.read_env(env_file=env_file) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/{{ cookiecutter.project_slug }}.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/{{ cookiecutter.project_slug }}.py index acca1f8..caae695 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/{{ cookiecutter.project_slug }}.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/{{ cookiecutter.project_slug }}.py @@ -1,6 +1,6 @@ from datetime import timedelta -from .environment import env +from {{ cookiecutter.project_slug }}.settings.environment import env {{ cookiecutter.project_slug | upper() }}_FEATURES = env.list( diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/urls.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/urls.py index e2467cc..9d3f259 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/urls.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/urls.py @@ -20,7 +20,7 @@ ] # enable Swagger -if "SWAGGER" in settings.{{ cookiecutter.project_slug | upper() }}_FEATURES: +if "SWAGGER" in settings.{{ cookiecutter.__env_prefix }}FEATURES: from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView swagger_urlpatterns = [ @@ -51,7 +51,8 @@ from django.views.generic.base import View # pylint: disable=ungrouped-imports class ServerErrorTestView(View): - def dispatch(self, request, *args, **kwargs): - assert False, "Server error test: response with 500 HTTP status code" # noqa: S101 + def dispatch(self, *_args, **_kwargs) -> None: # noqa: ANN002 ANN003 + message = "Server error test: response with 500 HTTP status code" + raise AssertionError(message) urlpatterns += [path(f"{PLATFORM_PREFIX}/500-error-test/", ServerErrorTestView.as_view())] From f3f8502b9ebaef26c5d42c604d0dbf06cac0da98 Mon Sep 17 00:00:00 2001 From: Oleksandr Bulanov Date: Wed, 23 Oct 2024 13:26:12 +0300 Subject: [PATCH 2/3] Fix ruff formatting --- .../apps/accounts/tests/test_api/test_login_api.py | 4 +--- .../apps/accounts/tests/test_api/test_registration_api.py | 4 +--- .../api/{{ cookiecutter.project_slug }}/settings/__init__.py | 2 +- .../settings/contrib/rest_framework.py | 4 +--- 4 files changed, 4 insertions(+), 10 deletions(-) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_login_api.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_login_api.py index f8a464d..8509e36 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_login_api.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_login_api.py @@ -22,9 +22,7 @@ def test_login_api_success( user = user_account() mocked_validate = mocker.patch(f"{LOGIN_SERIALIZER_PATH}.validate", return_value={"user": user}) mocked_response = Response(status=status.HTTP_204_NO_CONTENT) - mocked_login = mocker.patch( - "{{ cookiecutter.project_slug }}.apps.accounts.services.login.LoginService.login", return_value=mocked_response - ) + mocked_login = mocker.patch("{{ cookiecutter.project_slug }}.apps.accounts.services.login.LoginService.login", return_value=mocked_response) data = {"email": "jane@example.com", "password": "super-secret-password"} # nosec response = unauthorized_api_client.post(reverse("api-v1-accounts:login"), data) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_registration_api.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_registration_api.py index b66fd73..9e086b3 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_registration_api.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_api/test_registration_api.py @@ -14,9 +14,7 @@ def test_registration_api_success(unauthorized_api_client: CustomAPIClient, mocker: MockerFixture) -> None: assert not UserAccount.objects.exists() mocked_response = Response(status=status.HTTP_204_NO_CONTENT) - mocked_login = mocker.patch( - "{{ cookiecutter.project_slug }}.apps.accounts.services.login.LoginService.login", return_value=mocked_response - ) + mocked_login = mocker.patch("{{ cookiecutter.project_slug }}.apps.accounts.services.login.LoginService.login", return_value=mocked_response) data = { "email": "jane@example.com", diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/__init__.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/__init__.py index 2085059..82d1f54 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/__init__.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/__init__.py @@ -1,4 +1,4 @@ # ruff: noqa: F403 +from .{{ cookiecutter.project_slug }} import * from .contrib import * from .django import * -from .{{ cookiecutter.project_slug }} import * diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/rest_framework.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/rest_framework.py index 320db3c..6dd7f57 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/rest_framework.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/settings/contrib/rest_framework.py @@ -3,9 +3,7 @@ REST_FRAMEWORK = { "COERCE_DECIMAL_TO_STRING": False, - "DEFAULT_AUTHENTICATION_CLASSES": ( - "{{ cookiecutter.project_slug }}.apps.accounts.api.authentication.CustomSessionAuthentication", - ), + "DEFAULT_AUTHENTICATION_CLASSES": ("{{ cookiecutter.project_slug }}.apps.accounts.api.authentication.CustomSessionAuthentication",), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", "DEFAULT_PARSER_CLASSES": ("rest_framework.parsers.JSONParser",), From 23a92bf9440e17235e9e2d953951e966cb9eb7ec Mon Sep 17 00:00:00 2001 From: Oleksandr Bulanov Date: Wed, 23 Oct 2024 14:06:58 +0300 Subject: [PATCH 3/3] ignore 70612 safety vulnerability --- {{ cookiecutter.project_slug }}/api/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{ cookiecutter.project_slug }}/api/Makefile b/{{ cookiecutter.project_slug }}/api/Makefile index 16511fe..af29c80 100644 --- a/{{ cookiecutter.project_slug }}/api/Makefile +++ b/{{ cookiecutter.project_slug }}/api/Makefile @@ -21,7 +21,7 @@ collectstatic: check: ruff format . --check \ && ruff check . \ - && safety check \ + && safety check --ignore=70612 \ && make check-migrations \ check-fix: