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