diff --git a/.coveragerc b/.coveragerc index 37aa52d..5e979e2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -38,3 +38,5 @@ exclude_lines = @(abc\.)?abstractmethod from abc * class .*\b\(ABC\): + if typing.TYPE_CHECKING: + if TYPE_CHECKING: diff --git a/.vscode/launch.json b/.vscode/launch.json index b2d674c..484efc4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,7 +13,10 @@ "-x", "-vv" ], - "envFile": "${workspaceFolder}/.env.test" + "envFile": "${workspaceFolder}/.env.test", + "env": { + "USE_DEBUG_TOOLBAR": "0" + } }, { "name": "Django Debug", diff --git a/pyproject.toml b/pyproject.toml index d093d77..a79d6cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ server.cmd = "python manage.py runserver" createsuperuser.cmd = "python manage.py createsuperuser" makemessages.shell = "cd src && pdm run python manage.py makemessages" compilemessages.shell = "cd src && pdm run python manage.py compilemessages" +fixtures = { env_file = ".env.test", shell = "pytest --fixtures" } tests = { env_file = ".env.test", shell = "coverage run --source=src/ --rcfile=.coveragerc -m pytest tests/ -x -vv && coverage html && google-chrome htmlcov/index.html"} [tool.pdm.dev-dependencies] diff --git a/src/api/urls.py b/src/api/urls.py index 34790cd..9cfc77c 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -7,6 +7,6 @@ urlpatterns = [ path("v1/", include((v1.urls, "api"), namespace="v1")), path("schema/", drf_views.SpectacularAPIView.as_view(), name="schema"), - path("docs/", drf_views.SpectacularSwaggerView.as_view(url_name="schema"), name="docs"), - path("redoc/", drf_views.SpectacularRedocView.as_view(url_name="schema"), name="redoc"), + path("docs/", drf_views.SpectacularSwaggerView.as_view(url_name="api:schema"), name="docs"), + path("redoc/", drf_views.SpectacularRedocView.as_view(url_name="api:schema"), name="redoc"), ] diff --git a/src/api/v1/auth/__init__.py b/src/api/v1/auth/__init__.py new file mode 100644 index 0000000..fa599e4 --- /dev/null +++ b/src/api/v1/auth/__init__.py @@ -0,0 +1 @@ +from . import urls diff --git a/src/api/v1/auth/schemas.py b/src/api/v1/auth/schemas.py new file mode 100644 index 0000000..305355b --- /dev/null +++ b/src/api/v1/auth/schemas.py @@ -0,0 +1,51 @@ +from rest_framework import serializers + +from app.consts.i18n import Language, TimeZoneName +from app.drf.fields import create_choice_human_field +from users.models import User + + +class AuthenticationInputSchema(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField() + + +class StaticTokenSchema(serializers.Serializer): + type = serializers.CharField(default="Token") + access = serializers.CharField() + refresh = serializers.CharField(default=None) + + +class JwtTokenSchema(serializers.Serializer): + type = serializers.CharField(default="Bearer") + access = serializers.CharField() + refresh = serializers.CharField() + + +LanguageChoiceField = create_choice_human_field(Language) +TimeZoneNameChoiceField = create_choice_human_field(TimeZoneName) + + +class _AuthenticationOutputSchema(serializers.ModelSerializer): + language_code = LanguageChoiceField() + time_zone = TimeZoneNameChoiceField() + token = serializers.CharField(default=None) + + class Meta: + model = User + fields = ( + "id", + "email", + "full_name", + "language_code", + "time_zone", + "token", + ) + + +class TokenAuthenticationOutputSchema(_AuthenticationOutputSchema): + token = StaticTokenSchema() + + +class JwtTokenAuthenticationOutputSchema(_AuthenticationOutputSchema): + token = JwtTokenSchema() diff --git a/src/api/v1/auth/urls.py b/src/api/v1/auth/urls.py new file mode 100644 index 0000000..0addd5d --- /dev/null +++ b/src/api/v1/auth/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import SimpleRouter + +from . import views + +router = SimpleRouter(trailing_slash=False) +router.register("", views.AuthenticationViewSet, basename="auth") + +urlpatterns = router.urls diff --git a/src/api/v1/auth/views.py b/src/api/v1/auth/views.py new file mode 100644 index 0000000..6890258 --- /dev/null +++ b/src/api/v1/auth/views.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass +from typing import Literal + +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework_simplejwt import serializers as jwt_schemas + +from app.consts.http import HttpStatusCode +from app.drf.openapi import openapi_schema +from app.drf.viewsets import AppViewSet +from users.services import auth + +from . import schemas + + +@dataclass +class TokenDTO: + type: Literal["Token", "Bearer"] + access: str + refresh: str | None = None + + +class AuthenticationViewSet(AppViewSet): + permission_classes = [AllowAny] + + @openapi_schema( + summary="Token authentication", + description="Authenticates and returns a static Token that can be " + "used to access protected endpoints", + request=schemas.AuthenticationInputSchema, + responses={HttpStatusCode.HTTP_200_OK: schemas.TokenAuthenticationOutputSchema}, + tags=["auth"], + operation_id="authentication:token", + add_bad_request_response=True, + add_unauthorized_response=True, + raises=[auth.InactiveOrInexistentAccount, auth.InvalidCredentials], + ) + @action(methods=["POST"], detail=False, url_path="token") + def token(self, request: Request): + data = self.get_valid_data(srlzr_class=schemas.AuthenticationInputSchema) + user, token = auth.token_authenticate(**data) + user.token = TokenDTO(type="Token", access=token.key, refresh=None) # type: ignore + srlzr = schemas.TokenAuthenticationOutputSchema(instance=user) + return Response(data=srlzr.data, status=200) + + @openapi_schema( + summary="JWT authentication", + description="Authenticates and returns a JWT Token that can be " + "used to access protected endpoints", + request=schemas.AuthenticationInputSchema, + responses={HttpStatusCode.HTTP_200_OK: schemas.JwtTokenAuthenticationOutputSchema}, + tags=["auth"], + operation_id="authentication:jwt-token", + add_bad_request_response=True, + add_unauthorized_response=True, + raises=[auth.InactiveOrInexistentAccount, auth.InvalidCredentials], + ) + @action(methods=["POST"], detail=False, url_path="jwt") + def jwt_token(self, request: Request): + data = self.get_valid_data(srlzr_class=schemas.AuthenticationInputSchema) + user = auth.authenticate(**data) + + refresh = jwt_schemas.TokenObtainPairSerializer().get_token(user) + token = TokenDTO(type="Bearer", access=str(refresh.access_token), refresh=str(refresh)) + user.token = token # type: ignore + srlzr = schemas.JwtTokenAuthenticationOutputSchema(instance=user) + return Response(data=srlzr.data, status=200) + + @openapi_schema( + summary="JWT Refresh", + description="Refreshes the given access token", + request=jwt_schemas.TokenRefreshSerializer, + responses={HttpStatusCode.HTTP_200_OK: jwt_schemas.TokenRefreshSerializer}, + tags=["auth"], + operation_id="authentication:jwt-refresh", + add_bad_request_response=True, + ) + @action(methods=["POST"], detail=False, url_path="jwt/refresh") + def jwt_refresh(self, request: Request): + return Response( + data=self.get_valid_data(srlzr_class=jwt_schemas.TokenRefreshSerializer), + status=200, + ) diff --git a/src/api/v1/push_notifications/schemas.py b/src/api/v1/push_notifications/schemas.py index 0714af2..c19a1de 100644 --- a/src/api/v1/push_notifications/schemas.py +++ b/src/api/v1/push_notifications/schemas.py @@ -51,3 +51,7 @@ class PushNotificationReadManyInputSchema(serializers.Serializer): class PushNotificationReadManyOutputSchema(serializers.Serializer): read = serializers.IntegerField() + + +class PushNotificationSetTokenInputSchema(serializers.Serializer): + notification_token = serializers.CharField() diff --git a/src/api/v1/push_notifications/urls.py b/src/api/v1/push_notifications/urls.py index acba305..8c25496 100644 --- a/src/api/v1/push_notifications/urls.py +++ b/src/api/v1/push_notifications/urls.py @@ -3,6 +3,6 @@ from . import views router = SimpleRouter(trailing_slash=False) -router.register("notifications", views.PushNotificationViewSet, basename="push_notifications") +router.register("", views.PushNotificationViewSet, basename="notifications") urlpatterns = router.urls diff --git a/src/api/v1/push_notifications/views.py b/src/api/v1/push_notifications/views.py index 4ca7638..6704f8a 100644 --- a/src/api/v1/push_notifications/views.py +++ b/src/api/v1/push_notifications/views.py @@ -4,9 +4,10 @@ from rest_framework.request import Request from rest_framework.response import Response +from app.consts.http import HttpStatusCode from app.drf.openapi import limit_offset_openapi_schema, openapi_schema from app.drf.viewsets import AppViewSet -from push_notifications import selectors, services +from push_notifications import exc, selectors, services from . import schemas @@ -35,7 +36,7 @@ def list(self, request: Request) -> Response: summary="Read many notifications", description="Reads a list of notifications", request=schemas.PushNotificationReadManyInputSchema, - responses={200: schemas.PushNotificationReadManyOutputSchema}, + responses={HttpStatusCode.HTTP_200_OK: schemas.PushNotificationReadManyOutputSchema}, tags=["push notifications"], operation_id="push-notifications-read", add_bad_request_response=True, @@ -51,7 +52,7 @@ def read_many(self, request: Request) -> Response: summary="Read notification", description="Reads a single notification", request=None, - responses={200: schemas.PushNotificationOutputSchema}, + responses={HttpStatusCode.HTTP_200_OK: schemas.PushNotificationOutputSchema}, tags=["push notifications"], operation_id="push-notification-read", add_not_found_response=True, @@ -59,11 +60,42 @@ def read_many(self, request: Request) -> Response: add_unauthorized_response=True, ) @action(methods=["PATCH"], detail=True) - def read(self, request: Request, id: int) -> Response: + def read(self, request: Request, pk: int) -> Response: notification = get_object_or_404( selectors.push_notification_get_viewable_qs(user=request.user), - pk=id, + pk=pk, ) notification = services.push_notification_read(push_notification=notification) out_srlzr = schemas.PushNotificationOutputSchema(instance=notification) return Response(data=out_srlzr.data) + + @openapi_schema( + summary="Set Notification token", + description="Defines the notification for the current user", + request=schemas.PushNotificationSetTokenInputSchema, + responses={HttpStatusCode.HTTP_200_OK: None}, + tags=["push notifications"], + operation_id="push-notification-set-token", + add_unauthorized_response=True, + add_bad_request_response=True, + raises=[exc.InvalidNotificationToken], + ) + @action(methods=["PUT"], detail=False) + def token(self, request: Request) -> Response: + data = self.get_valid_data(schemas.PushNotificationSetTokenInputSchema) + services.push_notification_set_token(user=request.user, **data) + return Response(status=HttpStatusCode.HTTP_200_OK) + + @openapi_schema( + summary="Delete Notification token", + description="Removes the notification token from the current user (Opt-out)", + request=None, + responses={HttpStatusCode.HTTP_200_OK: None}, + tags=["push notifications"], + operation_id="push-notification-delete-token", + add_unauthorized_response=True, + ) + @token.mapping.delete + def delete_token(self, request: Request) -> Response: + services.push_notification_set_token(user=request.user, notification_token=None) + return Response(status=HttpStatusCode.HTTP_200_OK) diff --git a/src/api/v1/urls.py b/src/api/v1/urls.py index aaca614..80e7ec8 100644 --- a/src/api/v1/urls.py +++ b/src/api/v1/urls.py @@ -1,10 +1,12 @@ from django.urls import include, path -from . import push_notifications +from . import auth, push_notifications, users urlpatterns = [ + path("auth/", include((auth.urls, "auth"), namespace="auth")), path( "notifications/", include((push_notifications.urls, "push_notifications"), namespace="push_notifications"), ), + path("users/", include((users.urls, "users"), namespace="users")), ] diff --git a/src/api/v1/users/__init__.py b/src/api/v1/users/__init__.py new file mode 100644 index 0000000..fa599e4 --- /dev/null +++ b/src/api/v1/users/__init__.py @@ -0,0 +1 @@ +from . import urls diff --git a/src/api/v1/users/schemas.py b/src/api/v1/users/schemas.py new file mode 100644 index 0000000..d46a7f4 --- /dev/null +++ b/src/api/v1/users/schemas.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from api.v1.auth.schemas import LanguageChoiceField, TimeZoneNameChoiceField +from users.models import User + + +class CurrentUserOutputSchema(serializers.ModelSerializer): + language_code = LanguageChoiceField() + time_zone = TimeZoneNameChoiceField() + + class Meta: + model = User + fields = ( + "id", + "email", + "full_name", + "notification_token", + "language_code", + "time_zone", + "date_joined", + "is_staff", + "is_superuser", + ) diff --git a/src/api/v1/users/urls.py b/src/api/v1/users/urls.py new file mode 100644 index 0000000..2fd1081 --- /dev/null +++ b/src/api/v1/users/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import SimpleRouter + +from . import views + +router = SimpleRouter(trailing_slash=False) +router.register("me", views.CurrentUserViewSet, basename="me") + +urlpatterns = router.urls diff --git a/src/api/v1/users/views.py b/src/api/v1/users/views.py new file mode 100644 index 0000000..44690f8 --- /dev/null +++ b/src/api/v1/users/views.py @@ -0,0 +1,39 @@ +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response + +from app.consts.http import HttpStatusCode +from app.drf.openapi import openapi_schema +from app.drf.viewsets import AppViewSet +from users.services import account + +from . import schemas + + +class CurrentUserViewSet(AppViewSet): + @openapi_schema( + summary="Current user", + description="Returns details about the current user", + request=None, + responses={HttpStatusCode.HTTP_200_OK: schemas.CurrentUserOutputSchema}, + tags=["users:me"], + operation_id="users-me", + add_unauthorized_response=True, + ) + def list(self, request: Request) -> Response: + srlzr = schemas.CurrentUserOutputSchema(instance=request.user) + return Response(srlzr.data) + + @openapi_schema( + summary="Delete account", + description="Deletes the account for the current user", + request=None, + responses={HttpStatusCode.HTTP_204_NO_CONTENT: None}, + tags=["users:me"], + operation_id="users-me-delete-account", + add_unauthorized_response=True, + ) + @action(methods=["DELETE"], detail=False, url_name="delete-account", url_path="account") + def delete_account(self, request: Request) -> Response: + account.user_delete_account(user=request.user) + return Response(status=204) diff --git a/src/app/apps.py b/src/app/apps.py index 314267a..2c5b812 100644 --- a/src/app/apps.py +++ b/src/app/apps.py @@ -4,3 +4,7 @@ class RootAppConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "app" + + def ready(self) -> None: + # Load openapi extensions + from app.drf import extensions diff --git a/src/app/consts/http.py b/src/app/consts/http.py new file mode 100644 index 0000000..0424d30 --- /dev/null +++ b/src/app/consts/http.py @@ -0,0 +1,133 @@ +class HttpStatusCode: + HTTP_100_CONTINUE = 100 + HTTP_101_SWITCHING_PROTOCOLS = 101 + HTTP_102_PROCESSING = 102 + HTTP_103_EARLY_HINTS = 103 + HTTP_200_OK = 200 + HTTP_201_CREATED = 201 + HTTP_202_ACCEPTED = 202 + HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203 + HTTP_204_NO_CONTENT = 204 + HTTP_205_RESET_CONTENT = 205 + HTTP_206_PARTIAL_CONTENT = 206 + HTTP_207_MULTI_STATUS = 207 + HTTP_208_ALREADY_REPORTED = 208 + HTTP_226_IM_USED = 226 + HTTP_300_MULTIPLE_CHOICES = 300 + HTTP_301_MOVED_PERMANENTLY = 301 + HTTP_302_FOUND = 302 + HTTP_303_SEE_OTHER = 303 + HTTP_304_NOT_MODIFIED = 304 + HTTP_305_USE_PROXY = 305 + HTTP_306_RESERVED = 306 + HTTP_307_TEMPORARY_REDIRECT = 307 + HTTP_308_PERMANENT_REDIRECT = 308 + HTTP_400_BAD_REQUEST = 400 + HTTP_401_UNAUTHORIZED = 401 + HTTP_402_PAYMENT_REQUIRED = 402 + HTTP_403_FORBIDDEN = 403 + HTTP_404_NOT_FOUND = 404 + HTTP_405_METHOD_NOT_ALLOWED = 405 + HTTP_406_NOT_ACCEPTABLE = 406 + HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407 + HTTP_408_REQUEST_TIMEOUT = 408 + HTTP_409_CONFLICT = 409 + HTTP_410_GONE = 410 + HTTP_411_LENGTH_REQUIRED = 411 + HTTP_412_PRECONDITION_FAILED = 412 + HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413 + HTTP_414_REQUEST_URI_TOO_LONG = 414 + HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 + HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 + HTTP_417_EXPECTATION_FAILED = 417 + HTTP_418_IM_A_TEAPOT = 418 + HTTP_421_MISDIRECTED_REQUEST = 421 + HTTP_422_UNPROCESSABLE_ENTITY = 422 + HTTP_423_LOCKED = 423 + HTTP_424_FAILED_DEPENDENCY = 424 + HTTP_425_TOO_EARLY = 425 + HTTP_426_UPGRADE_REQUIRED = 426 + HTTP_428_PRECONDITION_REQUIRED = 428 + HTTP_429_TOO_MANY_REQUESTS = 429 + HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 + HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS = 451 + HTTP_500_INTERNAL_SERVER_ERROR = 500 + HTTP_501_NOT_IMPLEMENTED = 501 + HTTP_502_BAD_GATEWAY = 502 + HTTP_503_SERVICE_UNAVAILABLE = 503 + HTTP_504_GATEWAY_TIMEOUT = 504 + HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505 + HTTP_506_VARIANT_ALSO_NEGOTIATES = 506 + HTTP_507_INSUFFICIENT_STORAGE = 507 + HTTP_508_LOOP_DETECTED = 508 + HTTP_509_BANDWIDTH_LIMIT_EXCEEDED = 509 + HTTP_510_NOT_EXTENDED = 510 + HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511 + + +HTTP_STATUS_NAME = { + HttpStatusCode.HTTP_100_CONTINUE: "Continue", + HttpStatusCode.HTTP_101_SWITCHING_PROTOCOLS: "Switching Protocols", + HttpStatusCode.HTTP_102_PROCESSING: "Processing", + HttpStatusCode.HTTP_103_EARLY_HINTS: "Early Hints", + HttpStatusCode.HTTP_200_OK: "Ok", + HttpStatusCode.HTTP_201_CREATED: "Created", + HttpStatusCode.HTTP_202_ACCEPTED: "Accepted", + HttpStatusCode.HTTP_203_NON_AUTHORITATIVE_INFORMATION: "Non Authoritative Information", + HttpStatusCode.HTTP_204_NO_CONTENT: "No Content", + HttpStatusCode.HTTP_205_RESET_CONTENT: "Reset Content", + HttpStatusCode.HTTP_206_PARTIAL_CONTENT: "Partial Content", + HttpStatusCode.HTTP_207_MULTI_STATUS: "Multi Status", + HttpStatusCode.HTTP_208_ALREADY_REPORTED: "Already Reported", + HttpStatusCode.HTTP_226_IM_USED: "Im Used", + HttpStatusCode.HTTP_300_MULTIPLE_CHOICES: "Multiple Choices", + HttpStatusCode.HTTP_301_MOVED_PERMANENTLY: "Moved Permanently", + HttpStatusCode.HTTP_302_FOUND: "Found", + HttpStatusCode.HTTP_303_SEE_OTHER: "See Other", + HttpStatusCode.HTTP_304_NOT_MODIFIED: "Not Modified", + HttpStatusCode.HTTP_305_USE_PROXY: "Use Proxy", + HttpStatusCode.HTTP_306_RESERVED: "Reserved", + HttpStatusCode.HTTP_307_TEMPORARY_REDIRECT: "Temporary Redirect", + HttpStatusCode.HTTP_308_PERMANENT_REDIRECT: "Permanent Redirect", + HttpStatusCode.HTTP_400_BAD_REQUEST: "Bad Request", + HttpStatusCode.HTTP_401_UNAUTHORIZED: "Unauthorized", + HttpStatusCode.HTTP_402_PAYMENT_REQUIRED: "Payment Required", + HttpStatusCode.HTTP_403_FORBIDDEN: "Forbidden", + HttpStatusCode.HTTP_404_NOT_FOUND: "Not Found", + HttpStatusCode.HTTP_405_METHOD_NOT_ALLOWED: "Method Not Allowed", + HttpStatusCode.HTTP_406_NOT_ACCEPTABLE: "Not Acceptable", + HttpStatusCode.HTTP_407_PROXY_AUTHENTICATION_REQUIRED: "Proxy Authentication Required", + HttpStatusCode.HTTP_408_REQUEST_TIMEOUT: "Request Timeout", + HttpStatusCode.HTTP_409_CONFLICT: "Conflict", + HttpStatusCode.HTTP_410_GONE: "Gone", + HttpStatusCode.HTTP_411_LENGTH_REQUIRED: "Length Required", + HttpStatusCode.HTTP_412_PRECONDITION_FAILED: "Precondition Failed", + HttpStatusCode.HTTP_413_REQUEST_ENTITY_TOO_LARGE: "Request Entity Too Large", + HttpStatusCode.HTTP_414_REQUEST_URI_TOO_LONG: "Request Uri Too Long", + HttpStatusCode.HTTP_415_UNSUPPORTED_MEDIA_TYPE: "Unsupported Media Type", + HttpStatusCode.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE: "Requested Range Not Satisfiable", + HttpStatusCode.HTTP_417_EXPECTATION_FAILED: "Expectation Failed", + HttpStatusCode.HTTP_418_IM_A_TEAPOT: "Im A Teapot", + HttpStatusCode.HTTP_421_MISDIRECTED_REQUEST: "Misdirected Request", + HttpStatusCode.HTTP_422_UNPROCESSABLE_ENTITY: "Unprocessable Entity", + HttpStatusCode.HTTP_423_LOCKED: "Locked", + HttpStatusCode.HTTP_424_FAILED_DEPENDENCY: "Failed Dependency", + HttpStatusCode.HTTP_425_TOO_EARLY: "Too Early", + HttpStatusCode.HTTP_426_UPGRADE_REQUIRED: "Upgrade Required", + HttpStatusCode.HTTP_428_PRECONDITION_REQUIRED: "Precondition Required", + HttpStatusCode.HTTP_429_TOO_MANY_REQUESTS: "Too Many Requests", + HttpStatusCode.HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE: "Request Header Fields Too Large", + HttpStatusCode.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS: "Unavailable For Legal Reasons", + HttpStatusCode.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error", + HttpStatusCode.HTTP_501_NOT_IMPLEMENTED: "Not Implemented", + HttpStatusCode.HTTP_502_BAD_GATEWAY: "Bad Gateway", + HttpStatusCode.HTTP_503_SERVICE_UNAVAILABLE: "Service Unavailable", + HttpStatusCode.HTTP_504_GATEWAY_TIMEOUT: "Gateway Timeout", + HttpStatusCode.HTTP_505_HTTP_VERSION_NOT_SUPPORTED: "Ion Not Supported", + HttpStatusCode.HTTP_506_VARIANT_ALSO_NEGOTIATES: "Variant Also Negotiates", + HttpStatusCode.HTTP_507_INSUFFICIENT_STORAGE: "Insufficient Storage", + HttpStatusCode.HTTP_508_LOOP_DETECTED: "Loop Detected", + HttpStatusCode.HTTP_509_BANDWIDTH_LIMIT_EXCEEDED: "Bandwidth Limit Exceeded", + HttpStatusCode.HTTP_510_NOT_EXTENDED: "Not Extended", + HttpStatusCode.HTTP_511_NETWORK_AUTHENTICATION_REQUIRED: "Network Authentication Required", +} diff --git a/src/app/drf/authentication.py b/src/app/drf/authentication.py new file mode 100644 index 0000000..583ca92 --- /dev/null +++ b/src/app/drf/authentication.py @@ -0,0 +1,39 @@ +from typing import Any + +from rest_framework.authentication import TokenAuthentication as DrfTokenAuthentication +from rest_framework.authtoken.models import Token +from rest_framework_simplejwt.authentication import ( + JWTAuthentication as DrfJwtAuthentication, +) + +from users.models import User +from users.services.auth import user_update_last_login_on_succesfull_api_authentication + +AuthResult = tuple[User, Any] | None + + +def after_authenticate(auth_result: AuthResult) -> AuthResult: + if auth_result is None: + return None + user, token = auth_result + user_update_last_login_on_succesfull_api_authentication(user=user) + return auth_result + + +class LastLoginAwareTokenAuthentication(DrfTokenAuthentication): + """Normally the `TokenAuthentication` from rest framework does not updates + the `last_login` field, this class does not changes any behavior of the rest + framework. + + Only after the authentication is succesful check if the `last_login` + was never updated/was not updated on the current date.""" + + def authenticate(self, request): + auth_result: tuple[User, Token] | None = super().authenticate(request) + return after_authenticate(auth_result) + + +class LastLoginAwareJwtAuthentication(DrfJwtAuthentication): + def authenticate(self, request): + auth_result: tuple[User, str] | None = super().authenticate(request) + return after_authenticate(auth_result) diff --git a/src/app/drf/extensions.py b/src/app/drf/extensions.py new file mode 100644 index 0000000..69a2a34 --- /dev/null +++ b/src/app/drf/extensions.py @@ -0,0 +1,22 @@ +from drf_spectacular.contrib.rest_framework_simplejwt import SimpleJWTScheme +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from drf_spectacular.openapi import AutoSchema + + +class LoginAwareSimpleJWTScheme(SimpleJWTScheme): + target_class = "app.drf.authentication.LastLoginAwareJwtAuthentication" + name = "JWT Authentication" + + +class LoginAwareTokenScheme(OpenApiAuthenticationExtension): + target_class = "app.drf.authentication.LastLoginAwareTokenAuthentication" + name = "Token Authentication" + + def get_security_definition(self, auto_schema: AutoSchema): + return { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "bearerFormat": "Token", + "description": "Token-based authentication, requires `Token` to be prefixed", + } diff --git a/src/app/drf/metadata.py b/src/app/drf/metadata.py new file mode 100644 index 0000000..bf2dba6 --- /dev/null +++ b/src/app/drf/metadata.py @@ -0,0 +1,144 @@ +from collections import OrderedDict + +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.utils.encoding import force_str +from rest_framework import exceptions, serializers +from rest_framework.metadata import BaseMetadata +from rest_framework.request import clone_request +from rest_framework.utils.field_mapping import ClassLookupDict + + +class OpenApiMetadata(BaseMetadata): + """This is a rewrite of the SimpleMetadata class. + We use the `openapi_schema` to generate more details about the view.""" + + label_lookup = ClassLookupDict( + { + serializers.Field: "field", + serializers.BooleanField: "boolean", + serializers.CharField: "string", + serializers.UUIDField: "string", + serializers.URLField: "url", + serializers.EmailField: "email", + serializers.RegexField: "regex", + serializers.SlugField: "slug", + serializers.IntegerField: "integer", + serializers.FloatField: "float", + serializers.DecimalField: "decimal", + serializers.DateField: "date", + serializers.DateTimeField: "datetime", + serializers.TimeField: "time", + serializers.ChoiceField: "choice", + serializers.MultipleChoiceField: "multiple choice", + serializers.FileField: "file upload", + serializers.ImageField: "image upload", + serializers.ListField: "list", + serializers.DictField: "nested object", + serializers.Serializer: "nested object", + } + ) + + def determine_metadata(self, request, view): + metadata = OrderedDict() + metadata["name"] = view.get_view_name() + metadata["description"] = view.get_view_description() + metadata["renders"] = [renderer.media_type for renderer in view.renderer_classes] + metadata["parses"] = [parser.media_type for parser in view.parser_classes] + actions = self.determine_actions(request, view) + if actions: + metadata["actions"] = actions + return metadata + + def determine_actions(self, request, view): + actions = {} + for method in {"PUT", "POST", "PATCH"} & set(view.allowed_methods): + view.request = clone_request(request, method) + try: + # Test global permissions + if hasattr(view, "check_permissions"): + view.check_permissions(view.request) + # Test object permissions + if method == "PUT" and hasattr(view, "get_object"): + view.get_object() + except (exceptions.APIException, PermissionDenied, Http404): + pass + else: + # If user has appropriate permissions for the view, include + # appropriate metadata about the fields that should be supplied. + schema_class = getattr(view, "schema", None) + get_serializer = getattr(view, "get_serializer", None) + if schema_class is not None: + schema = schema_class() + serializer_class = schema.get_request_serializer() + if serializer_class is None: + continue + serializer = serializer_class() + elif get_serializer is not None: + serializer = view.get_serializer() + else: + continue + actions[method] = self.get_serializer_info(serializer) + finally: + view.request = request + + return actions + + def get_serializer_info(self, serializer): + """ + Given an instance of a serializer, return a dictionary of metadata + about its fields. + """ + if hasattr(serializer, "child"): + # If this is a `ListSerializer` then we want to examine the + # underlying child serializer instance instead. + serializer = serializer.child + return OrderedDict( + [ + (field_name, self.get_field_info(field)) + for field_name, field in serializer.fields.items() + if not isinstance(field, serializers.HiddenField) + ] + ) + + def get_field_info(self, field): + """ + Given an instance of a serializer field, return a dictionary + of metadata about it. + """ + field_info = OrderedDict() + field_info["type"] = self.label_lookup[field] + field_info["required"] = getattr(field, "required", False) + + attrs = [ + "read_only", + "label", + "help_text", + "min_length", + "max_length", + "min_value", + "max_value", + ] + # TODO: Extend attrs with specific fields + + for attr in attrs: + value = getattr(field, attr, None) + if value is not None and value != "": + field_info[attr] = force_str(value, strings_only=True) + + if getattr(field, "child", None): + field_info["child"] = self.get_field_info(field.child) + elif getattr(field, "fields", None): + field_info["children"] = self.get_serializer_info(field) + + if ( + not field_info.get("read_only") + and not isinstance(field, (serializers.RelatedField, serializers.ManyRelatedField)) + and hasattr(field, "choices") + ): + field_info["choices"] = [ + {"value": choice_value, "display_name": force_str(choice_name, strings_only=True)} + for choice_value, choice_name in field.choices.items() + ] + + return field_info diff --git a/src/app/drf/openapi.py b/src/app/drf/openapi.py index 89ea022..ed60122 100644 --- a/src/app/drf/openapi.py +++ b/src/app/drf/openapi.py @@ -6,6 +6,7 @@ from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import serializers, status +from app.consts.http import HTTP_STATUS_NAME from app.exceptions import ApplicationError @@ -138,9 +139,17 @@ def openapi_schema( kwargs["parameters"] = parameters if raises: - description += "This endpoint may raise the following errors: " + description += "
*This endpoint may raise the following errors:*
" for exc in raises: - description += f"
HTTP {exc.http_status_code} - `{exc.__name__}`: {exc.error_message}" + http_code = exc.http_status_code + if http_code not in responses: + raise TypeError( + f"Operation ID: {operation_id} ({summary}). " + "A exception defined inside the `raises` clause has a status code " + f"({http_code}) that is not documented on the `responses`" + "Maybe you forgot to pass a `True` value to some of the `add__response` flags?" + ) + description += f"
**{http_code} - {HTTP_STATUS_NAME[http_code]}** - `{exc.__name__}`: {exc.error_message}" return extend_schema( operation_id=operation_id, diff --git a/src/app/settings/conf.py b/src/app/settings/conf.py index 0b4ef58..dabffdf 100644 --- a/src/app/settings/conf.py +++ b/src/app/settings/conf.py @@ -39,7 +39,7 @@ # Security - CSRF CSRF_TRUSTED_ORIGINS = env.list( - "CSRF_TRUSTED_ORIGINS", default=["http://*.com", "https://*.ngrok.io"] + "CSRF_TRUSTED_ORIGINS", default=["http://*.com", "https://*.ngrok-free.app"] ) # Password Validation diff --git a/src/app/settings/third_party/drf.py b/src/app/settings/third_party/drf.py index 97e7033..7021a76 100644 --- a/src/app/settings/third_party/drf.py +++ b/src/app/settings/third_party/drf.py @@ -6,10 +6,11 @@ "rest_framework.permissions.IsAuthenticated", ], "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_simplejwt.authentication.JWTAuthentication", + "app.drf.authentication.LastLoginAwareJwtAuthentication", + "app.drf.authentication.LastLoginAwareTokenAuthentication", "rest_framework.authentication.SessionAuthentication", - "rest_framework.authentication.BasicAuthentication", ), + "DEFAULT_METADATA_CLASS": "app.drf.metadata.OpenApiMetadata", "EXCEPTION_HANDLER": "app.drf.exc_handler.custom_exception_handler", } API_PAGINATION_DEFAULT_LIMIT = env.int("API_PAGINATION_DEFAULT_LIMIT", 50) diff --git a/src/app/settings/third_party/drf_simple_jwt.py b/src/app/settings/third_party/drf_simple_jwt.py index dbbc08d..a22688b 100644 --- a/src/app/settings/third_party/drf_simple_jwt.py +++ b/src/app/settings/third_party/drf_simple_jwt.py @@ -10,6 +10,8 @@ SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=ACCESS_TOKEN_LIFETIME_MINUTES), "REFRESH_TOKEN_LIFETIME": timedelta(REFRESH_TOKEN_LIFETIME_DAYS), - "UPDATE_LAST_LOGIN": True, + "UPDATE_LAST_LOGIN": False, + # We don't want that this is controlled by the serializers of the drf-simple-jwt, + # we take care of it "ROTATE_REFRESH_TOKENS": True, } diff --git a/src/app/urls.py b/src/app/urls.py index 2f2edca..e946aed 100644 --- a/src/app/urls.py +++ b/src/app/urls.py @@ -1,16 +1,9 @@ from django.conf import settings from django.contrib import admin -from django.contrib.auth import decorators as auth_decorators from django.contrib.auth import views as auth_views from django.http import HttpResponse from django.shortcuts import redirect from django.urls import include, path, reverse -from drf_spectacular import views as drf_views - -schema_view = drf_views.SpectacularAPIView.as_view() -swagger_view = drf_views.SpectacularSwaggerView.as_view(url_name="schema") -redoc_view = drf_views.SpectacularRedocView.as_view(url_name="schema") - urlpatterns = [ path("health-check", lambda r: HttpResponse("Ok")), @@ -37,8 +30,5 @@ name="password_reset_complete", ), path("i18n/", include("django.conf.urls.i18n")), - path("api/schema/", auth_decorators.login_required(schema_view), name="schema"), - path("api/docs/", auth_decorators.login_required(swagger_view), name="docs"), - path("api/redoc/", auth_decorators.login_required(redoc_view), name="redoc"), path("api/", include("api.urls", namespace="api")), ] diff --git a/src/push_notifications/exc.py b/src/push_notifications/exc.py index 141a7ab..b54cd22 100644 --- a/src/push_notifications/exc.py +++ b/src/push_notifications/exc.py @@ -1,5 +1,11 @@ +from django.utils.translation import gettext_lazy as _ + from app.exceptions import ApplicationError class UnableToSendPushNotification(ApplicationError): pass + + +class InvalidNotificationToken(ApplicationError): + error_message = _("Invalid notification token, {reason}") diff --git a/src/push_notifications/models.py b/src/push_notifications/models.py index ea864b6..389cd9e 100644 --- a/src/push_notifications/models.py +++ b/src/push_notifications/models.py @@ -115,8 +115,8 @@ def __str__(self): def enriched_data(self): return { "id": self.id, - "createdAt": str(self.created_at), - "readAt": str(self.read_at), + "createdAt": self.created_at.isoformat(), + "readAt": self.read_at.isoformat() if self.read_at else None, "timeSinceCreated": timesince(self.created_at), "kind": self.kind, "meta": self.data, diff --git a/src/push_notifications/services.py b/src/push_notifications/services.py index 51733ab..f8f3c49 100644 --- a/src/push_notifications/services.py +++ b/src/push_notifications/services.py @@ -1,7 +1,9 @@ from datetime import datetime, timedelta from typing import Any, Sequence +from django.core.exceptions import ValidationError from django.utils import timezone +from django.utils.translation import gettext as _ from app import consts from app.ext import di @@ -14,7 +16,7 @@ from app.models import BaseModel from users.models import User -from . import models, selectors +from . import exc, models, selectors def push_notification_create( @@ -239,3 +241,21 @@ def push_notification_read_many(*, reader: User, ids: Sequence[int]) -> int: if ids: qs = qs.filter(pk__in=ids) return qs.update(read_at=timezone.now(), status=consts.push_notification.Status.READ) + + +def push_notification_set_token(*, user: User, notification_token: str | None) -> None: + # We aren't calling full_clean here on User so we don't raise any other errors that + # aren't from this specific field + + if notification_token is not None: + if not notification_token: + # Because we have blank=True, and we accept None values, raise if someone attempt + # to send a blank string "" + raise exc.InvalidNotificationToken( + message_format_kwargs={"reason": _("it may not be blank")} + ) + max_length = User._meta.get_field("notification_token").max_length or 64 + if len(notification_token) > max_length: + raise exc.InvalidNotificationToken(message_format_kwargs={"reason": _("value too big")}) + user.notification_token = notification_token + user.save() diff --git a/src/users/models.py b/src/users/models.py index d955500..dbc1488 100644 --- a/src/users/models.py +++ b/src/users/models.py @@ -4,6 +4,7 @@ from django.contrib.auth.models import PermissionsMixin from django.db import models from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import Token from app.consts.i18n import Language, TimeZoneName from app.models import BaseModel @@ -77,6 +78,9 @@ class User(AbstractBaseUser, PermissionsMixin, BaseModel): "full_name", ] + # FK Typing + auth_token: Token + def __str__(self): return self.email diff --git a/src/users/services/__init__.py b/src/users/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/users/services/account.py b/src/users/services/account.py new file mode 100644 index 0000000..4d34a52 --- /dev/null +++ b/src/users/services/account.py @@ -0,0 +1,9 @@ +import uuid + +from users.models import User + + +def user_delete_account(user: User): + user.is_active = False + user.email = uuid.uuid4().hex + "@deleted-account.com" + user.save() diff --git a/src/users/services/auth.py b/src/users/services/auth.py new file mode 100644 index 0000000..5c84db8 --- /dev/null +++ b/src/users/services/auth.py @@ -0,0 +1,61 @@ +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import Token + +from app.exceptions import ApplicationError +from users.models import User + + +class InactiveOrInexistentAccount(ApplicationError): + http_status_code = 401 + error_message = _("The '{username}' account was not found or is inactive") + + +class InvalidCredentials(ApplicationError): + http_status_code = 401 + error_message = _("Invalid password for '{username}' account") + + +def authenticate(*, username, password) -> User: + user = User.objects.filter(**{User.USERNAME_FIELD: username}).first() + if user is None: + raise InactiveOrInexistentAccount( + message_format_kwargs={"username": username}, + field_errors={"username": _("Please pick another account")}, + ) + user_check_is_valid(user=user) + if not user.check_password(password): + raise InvalidCredentials( + message_format_kwargs={"username": username}, + field_errors={"username": _("Password mismatch, passwords are case-sensitive")}, + ) + user_authenticated_succesfully(user=user) + return user + + +def user_check_is_valid(*, user: User): + if user.is_active is False: + raise InactiveOrInexistentAccount( + message_format_kwargs={"username": user.get_username()}, + field_errors={"username": _("Please pick another account")}, + ) + + +def user_authenticated_succesfully(user: User): + user.last_login = timezone.now() + user.save(update_fields=["last_login"]) + + +def token_authenticate(*, username, password) -> tuple[User, Token]: + user = authenticate(username=username, password=password) + token, created = Token.objects.get_or_create(user=user) + return user, token + + +def user_update_last_login_on_succesfull_api_authentication(user: User): + """After the user is logged-in, he probably won't be authenticating very often + because of a static token or long-lived JWT tokens, in that case the last_login + field would not reflect the last time that the user has accessed the application. + But we don't do this on every single interaction to not overhead the database""" + if user.last_login is None or user.last_login.date() < timezone.now().date(): + user_authenticated_succesfully(user=user) diff --git a/tests/conftest.py b/tests/conftest.py index 4c29d94..6d371e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import pytest +from django.utils import timezone from tests.fakes import FakePushNotificationExternalService from users.models import User @@ -22,7 +23,8 @@ def visitor_user(): user.set_password("password") user.full_clean() user.save() - return user + timezone.activate(user.time_zone) + yield user @pytest.fixture(autouse=True) diff --git a/tests/test_api/test_v1/test_auth_endpoints.py b/tests/test_api/test_v1/test_auth_endpoints.py new file mode 100644 index 0000000..fd744b0 --- /dev/null +++ b/tests/test_api/test_v1/test_auth_endpoints.py @@ -0,0 +1,69 @@ +import pytest +from django.test.client import Client +from django.urls import reverse + +from app.consts.http import HttpStatusCode +from users.models import User +from users.services import auth + +AUTHENTICATION_URL_NAMES = ["api:v1:auth:auth-token", "api:v1:auth:auth-jwt-token"] + + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name", AUTHENTICATION_URL_NAMES) +def test_auth_inexistent_account(client: Client, url_name): + url = reverse(url_name) + response = client.post(url, data={"username": "foo", "password": "foo"}) + assert response.status_code == HttpStatusCode.HTTP_401_UNAUTHORIZED + data = response.json() + assert data["kind"] == auth.InactiveOrInexistentAccount.__name__ + assert "username" in data["extra"]["fields"] + + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name", AUTHENTICATION_URL_NAMES) +def test_auth_inactive_account(client: Client, visitor_user: User, url_name): + visitor_user.is_active = False + visitor_user.save() + + url = reverse(url_name) + response = client.post( + url, data={"username": visitor_user.get_username(), "password": "password"} + ) + assert response.status_code == HttpStatusCode.HTTP_401_UNAUTHORIZED + data = response.json() + assert data["kind"] == auth.InactiveOrInexistentAccount.__name__ + assert "username" in data["extra"]["fields"] + + +@pytest.mark.django_db +@pytest.mark.parametrize("url_name", AUTHENTICATION_URL_NAMES) +def test_auth_succesfull_login(client: Client, visitor_user: User, url_name): + url = reverse(url_name) + response = client.post( + url, data={"username": visitor_user.get_username(), "password": "password"} + ) + assert response.status_code == HttpStatusCode.HTTP_200_OK + data = response.json() + token_data = data["token"] + assert "access" in token_data + assert "refresh" in token_data + assert "type" in token_data + + +@pytest.mark.django_db +def test_auth_jwt_refresh_token(client: Client, visitor_user: User): + jwt_url = reverse("api:v1:auth:auth-jwt-token") + token_response = client.post( + jwt_url, data={"username": visitor_user.get_username(), "password": "password"} + ) + assert token_response.status_code == HttpStatusCode.HTTP_200_OK + refresh_token = token_response.json()["token"]["refresh"] + assert refresh_token is not None, "Missing refresh token, check settings" + + refresh_url = reverse("api:v1:auth:auth-jwt-refresh") + response = client.post(refresh_url, data={"refresh": refresh_token}) + assert response.status_code == HttpStatusCode.HTTP_200_OK + refresh_data = response.json() + assert "access" in refresh_data + assert "refresh" in refresh_data diff --git a/tests/test_api/test_v1/test_current_user_endpoints.py b/tests/test_api/test_v1/test_current_user_endpoints.py new file mode 100644 index 0000000..03e3bcb --- /dev/null +++ b/tests/test_api/test_v1/test_current_user_endpoints.py @@ -0,0 +1,56 @@ +import pytest +from django.test.client import Client +from django.urls import reverse +from django.utils import timezone + +from app.consts.http import HttpStatusCode +from users.models import User + + +def test_current_user_returns_unauthorized_on_non_logged_in(client: Client): + url = reverse("api:v1:users:me-list") + response = client.get(url) + assert response.status_code == HttpStatusCode.HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +def test_current_user_returns_expected_output(visitor_user: User, client: Client): + url = reverse("api:v1:users:me-list") + client.force_login(visitor_user) + response = client.get(url) + assert response.status_code == HttpStatusCode.HTTP_200_OK + assert response.json() == { + "id": visitor_user.id, + "email": visitor_user.email, + "full_name": visitor_user.full_name, + "notification_token": visitor_user.notification_token, + "language_code": { + "value": visitor_user.language_code, + "human": visitor_user.get_language_code_display(), + }, + "time_zone": { + "value": visitor_user.time_zone, + "human": visitor_user.get_time_zone_display(), + }, + "date_joined": timezone.localtime(visitor_user.date_joined).isoformat(), + "is_staff": visitor_user.is_staff, + "is_superuser": visitor_user.is_superuser, + } + + +@pytest.mark.django_db +def test_current_user_delete_account_returns_unauthorized_on_non_logged_in(client: Client): + url = reverse("api:v1:users:me-delete-account") + response = client.delete(url) + assert response.status_code == HttpStatusCode.HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +def test_current_user_delete_account_returns_expected_output(visitor_user: User, client: Client): + url = reverse("api:v1:users:me-delete-account") + client.force_login(visitor_user) + response = client.delete(url) + assert response.status_code == HttpStatusCode.HTTP_204_NO_CONTENT + with pytest.raises(TypeError): + # Since we don't return anything, trying to return JSON should fail + response.json() diff --git a/tests/test_api/test_v1/test_push_notification_endpoints.py b/tests/test_api/test_v1/test_push_notification_endpoints.py new file mode 100644 index 0000000..ba2936e --- /dev/null +++ b/tests/test_api/test_v1/test_push_notification_endpoints.py @@ -0,0 +1,231 @@ +import pytest +from django.test.client import Client +from django.urls import reverse +from django.utils import timezone +from django.utils.timesince import timesince + +from app.consts.http import HttpStatusCode +from app.consts.push_notification import Status +from push_notifications import services +from push_notifications.models import PushNotification +from users.models import User + + +@pytest.fixture +def visitor_notification(visitor_user: User): + return services.push_notification_create( + user=visitor_user, + kind="int_comm", + title="Hey!", + description="Hello world", + data={"foo": "bar"}, + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "method_name,url_name,reverse_args", + [ + ("GET", "api:v1:push_notifications:notifications-list", ()), + ("PATCH", "api:v1:push_notifications:notifications-read", (1,)), + ("PATCH", "api:v1:push_notifications:notifications-read-many", ()), + ("PUT", "api:v1:push_notifications:notifications-token", ()), + ("DELETE", "api:v1:push_notifications:notifications-token", ()), + ], +) +def test_pn_endpoints_requires_authentication(client: Client, method_name, url_name, reverse_args): + url = reverse(url_name, args=reverse_args) + methods = {"GET": client.get, "PATCH": client.patch, "PUT": client.put, "DELETE": client.delete} + method = methods[method_name] + + response = method(url) + assert response.status_code == HttpStatusCode.HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +def test_pn_list_returns_expected_output(visitor_notification: PushNotification, client: Client): + url = reverse("api:v1:push_notifications:notifications-list") + client.force_login(user=visitor_notification.user) + response = client.get(url) + assert response.status_code == HttpStatusCode.HTTP_200_OK + data = response.json() + assert len(data["results"]) == 1 + result = data["results"][0] + assert result == { + "id": visitor_notification.id, + "title": visitor_notification.title, + "description": visitor_notification.description, + "read_at": None, + "kind": { + "value": visitor_notification.kind, + "human": visitor_notification.get_kind_display(), + }, + "status": { + "value": visitor_notification.status, + "human": visitor_notification.get_status_display(), + }, + "data": { + "id": str(visitor_notification.id), + "createdAt": visitor_notification.created_at.isoformat(), + "readAt": None, + "timeSinceCreated": timesince(visitor_notification.created_at), + "kind": visitor_notification.kind, + "meta": visitor_notification.data, + }, + } + + +@pytest.mark.django_db +def test_pn_list_query_params_invalid(visitor_notification: PushNotification, client: Client): + url = reverse("api:v1:push_notifications:notifications-list") + client.force_login(user=visitor_notification.user) + response = client.get(url, data={"only_read": "foo"}) + assert response.status_code == HttpStatusCode.HTTP_400_BAD_REQUEST + + +@pytest.mark.django_db +def test_pn_list_query_params_valid(visitor_notification: PushNotification, client: Client): + assert visitor_notification.read_at is None + + url = reverse("api:v1:push_notifications:notifications-list") + client.force_login(user=visitor_notification.user) + response = client.get(url, data={"only_read": True}) + assert response.status_code == HttpStatusCode.HTTP_200_OK + assert len(response.json()["results"]) == 0 + + visitor_notification.read_at = timezone.now() + visitor_notification.save() + + response = client.get(url, data={"only_read": True}) + assert len(response.json()["results"]) == 1 + + response = client.get(url, data={"only_read": False}) + assert len(response.json()["results"]) == 0 + + +@pytest.mark.django_db +def test_pn_read_many_all_valid_ids(visitor_notification: PushNotification, client: Client): + url = reverse("api:v1:push_notifications:notifications-read-many") + client.force_login(user=visitor_notification.user) + response = client.patch( + url, data={"ids": [visitor_notification.id]}, content_type="application/json" + ) + assert response.status_code == HttpStatusCode.HTTP_200_OK + assert response.json()["read"] == 1 + + +@pytest.mark.django_db +def test_pn_read_many_ids_not_set(visitor_notification: PushNotification, client: Client): + url = reverse("api:v1:push_notifications:notifications-read-many") + client.force_login(user=visitor_notification.user) + # We can send None/null to read all notifications + response = client.patch(url, data={"ids": None}, content_type="application/json") + assert response.status_code == HttpStatusCode.HTTP_200_OK + assert response.json()["read"] == 1 + + # reset + visitor_notification.read_at = None + visitor_notification.save(force_update=True) + + # We also can send a empty/null data and would read all notifications + response = client.patch(url) + assert response.status_code == HttpStatusCode.HTTP_200_OK + assert response.json()["read"] == 1 + + +@pytest.mark.django_db +def test_pn_read_many_ids_from_other_user_no_update_happens( + visitor_notification: PushNotification, admin_client: Client +): + url = reverse("api:v1:push_notifications:notifications-read-many") + # We're requesting from a user that has no visibility to the notification + response = admin_client.patch( + url, data={"ids": [visitor_notification.id]}, content_type="application/json" + ) + assert response.status_code == HttpStatusCode.HTTP_200_OK + assert response.json()["read"] == 0 + + +@pytest.mark.django_db +def test_pn_read_id_from_other_user_404( + visitor_notification: PushNotification, admin_client: Client +): + # We're requesting from a user that has no visibility to the notification + url = reverse("api:v1:push_notifications:notifications-read", args=(visitor_notification.id,)) + response = admin_client.patch(url) + assert response.status_code == HttpStatusCode.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +def test_pn_read_id_already_read(visitor_notification: PushNotification, client: Client): + # if the notification is already read, it should succeed, but no updates should happen + now = timezone.localtime() + visitor_notification.read_at = now + visitor_notification.save() + + url = reverse("api:v1:push_notifications:notifications-read", args=(visitor_notification.id,)) + client.force_login(visitor_notification.user) + response = client.patch(url) + assert response.status_code == HttpStatusCode.HTTP_200_OK + data = response.json() + assert data["read_at"] == now.isoformat() + + +@pytest.mark.django_db +def test_pn_read_id_expected_output(visitor_notification: PushNotification, client: Client): + # if the notification is not read, it should succeed and updates should happen + assert visitor_notification.read_at is None + assert visitor_notification.status != Status.READ + + url = reverse("api:v1:push_notifications:notifications-read", args=(visitor_notification.id,)) + client.force_login(visitor_notification.user) + response = client.patch(url) + assert response.status_code == HttpStatusCode.HTTP_200_OK + data = response.json() + assert data["read_at"] is not None + assert data["status"]["value"] == Status.READ + + visitor_notification = PushNotification.objects.get(pk=visitor_notification.pk) + assert visitor_notification.read_at is not None + assert visitor_notification.status == Status.READ + + +@pytest.mark.django_db +def test_pn_set_token(client: Client, visitor_user: User): + assert visitor_user.notification_token is None + + url = reverse("api:v1:push_notifications:notifications-token") + client.force_login(visitor_user) + response = client.put( + url, data={"notification_token": "foobar"}, content_type="application/json" + ) + assert response.status_code == HttpStatusCode.HTTP_200_OK + with pytest.raises(TypeError): + # We don't return any data, so trying to get the json should return an error + response.json() + + visitor_user = User.objects.get(pk=visitor_user.pk) + assert visitor_user.notification_token == "foobar" + + +@pytest.mark.django_db +def test_pn_set_token_invalid(client: Client, visitor_user: User): + # Someone could try to opt-out of notifications trying to use this endpoint + # sending a null value, but that's not how we expect it. For that they should + # send a DELETE request instead + url = reverse("api:v1:push_notifications:notifications-token") + client.force_login(visitor_user) + response = client.put(url, data={"notification_token": None}, content_type="application/json") + assert response.status_code == HttpStatusCode.HTTP_400_BAD_REQUEST + assert "notification_token" in response.json()["extra"]["fields"] + + +@pytest.mark.django_db +def test_pn_delete_token(client: Client, visitor_user: User): + url = reverse("api:v1:push_notifications:notifications-token") + client.force_login(visitor_user) + response = client.delete(url) + assert response.status_code == HttpStatusCode.HTTP_200_OK + with pytest.raises(TypeError): + # We don't return any data, so trying to get the json should return an error + response.json() diff --git a/tests/test_public_endpoints.py b/tests/test_public_endpoints.py index 48de15f..e0821fa 100644 --- a/tests/test_public_endpoints.py +++ b/tests/test_public_endpoints.py @@ -8,6 +8,7 @@ [ ("admin:login"), ("admin_password_reset"), + ("api:docs"), ], ) def test_admin_login(client: Client, reversable: str): diff --git a/tests/test_users/test_services/test_account.py b/tests/test_users/test_services/test_account.py new file mode 100644 index 0000000..df5d0ab --- /dev/null +++ b/tests/test_users/test_services/test_account.py @@ -0,0 +1,17 @@ +import pytest + +from users.models import User +from users.services import account + + +@pytest.mark.django_db +def test_user_delete_account_changes_email(visitor_user: User): + previous_email = visitor_user.email + + account.user_delete_account(visitor_user) + + deleted_account = User.objects.get(id=visitor_user.id) + assert deleted_account.is_active is False + assert deleted_account.email != previous_email + + assert User.objects.filter(email=previous_email).exists() is False diff --git a/tests/test_users/test_services/test_auth.py b/tests/test_users/test_services/test_auth.py new file mode 100644 index 0000000..ced5423 --- /dev/null +++ b/tests/test_users/test_services/test_auth.py @@ -0,0 +1,83 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone + +from users.models import User +from users.services import auth + + +@pytest.mark.django_db +def test_authenticate_on_inexistent_account(): + with pytest.raises(auth.InactiveOrInexistentAccount): + auth.authenticate(username="foo", password="123") + + +@pytest.mark.django_db +def test_authenticate_on_inactive_account(visitor_user: User): + visitor_user.is_active = False + visitor_user.save() + + with pytest.raises(auth.InactiveOrInexistentAccount): + auth.authenticate(username=visitor_user.get_username(), password="password") + + +@pytest.mark.django_db +def test_authenticate_on_invalid_credentials(visitor_user: User): + assert visitor_user.check_password("foo") is False + + with pytest.raises(auth.InvalidCredentials): + auth.authenticate(username=visitor_user.get_username(), password="foo") + + +@pytest.mark.django_db +def test_authenticate_valid_credentials(visitor_user: User): + assert visitor_user.is_active + assert visitor_user.check_password("password") + assert visitor_user.last_login is None + + authenticated_user = auth.authenticate( + username=visitor_user.get_username(), password="password" + ) + assert authenticated_user == visitor_user + assert authenticated_user.last_login is not None + + +@pytest.mark.django_db +def test_token_authenticate_valid_credentials(visitor_user: User): + assert visitor_user.is_active + assert visitor_user.check_password("password") + + # The token authenticate method is basically a proxy to the authenticate method + # So there's no need to test all the bad cases, they're already handled above + user, token = auth.token_authenticate(username=visitor_user.get_username(), password="password") + assert visitor_user == user + assert token.user_id == user.id + + +@pytest.mark.django_db +def test_user_updated_last_login_on_succesfull_api_authentication(visitor_user: User): + def get_user(user: User) -> User: + # Tricks mypy to think that the object has changed, so assert statements + # are correctly checked, this is required because the function is modifying + # the user object, and mypy doesn't like that + return user + + # User has never logged in before, update last login + assert visitor_user.last_login is None + auth.user_update_last_login_on_succesfull_api_authentication(user=visitor_user) + visitor_user = get_user(visitor_user) + assert visitor_user.last_login is not None + + # User logged-in yesterday, update last login + yesterday_user = User.objects.get(pk=visitor_user.pk) + yesterday_user.last_login = timezone.now() - timedelta(days=1) + auth.user_update_last_login_on_succesfull_api_authentication(user=yesterday_user) + assert yesterday_user.last_login is not None + assert yesterday_user.last_login.date() == timezone.now().date() + + # user logged in today, no updates + last_login = timezone.now().replace(hour=12) + visitor_user.last_login = last_login + auth.user_update_last_login_on_succesfull_api_authentication(user=visitor_user) + assert visitor_user.last_login == last_login