diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ddbcdd5c..4aff2195 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,12 +20,14 @@ repos: require_serial: true types: [ python ] - # broken? -# - repo: https://github.com/myint/docformatter -# rev: v1.5.1 -# hooks: -# - id: docformatter -# language: python + - repo: local + hooks: + - id: docformatter + name: docformatter + entry: docformatter + language: system + types: [python] + args: [--in-place, --wrap-summaries=88, --wrap-descriptions=88, --make-summary-multi-line] - repo: https://github.com/charliermarsh/ruff-pre-commit rev: 'v0.9.4' diff --git a/djoser/auth_backends.py b/djoser/auth_backends.py index 1b9991a0..6bab40f1 100644 --- a/djoser/auth_backends.py +++ b/djoser/auth_backends.py @@ -7,8 +7,9 @@ class LoginFieldBackend(ModelBackend): - """Allows to log in by a different value than the default Django - USERNAME_FIELD.""" + """ + Allows to log in by a different value than the default Django USERNAME_FIELD. + """ def authenticate(self, request, username=None, password=None, **kwargs): if username is None: diff --git a/djoser/conf.py b/djoser/conf.py index 02dc7b8e..c18ad13a 100644 --- a/djoser/conf.py +++ b/djoser/conf.py @@ -101,6 +101,30 @@ def __getattribute__(self, item): "token_destroy": ["rest_framework.permissions.IsAuthenticated"], } ), + "VIEWS": ObjDict( + { + "activation": "djoser.views.activation.activate.UserActivationAPIView", + "resend_activation": "djoser.views.activation.resend.UserResendActivationAPIView", + "password_reset": "djoser.views.password.reset.ResetPasswordViewAPIView", + "password_reset_confirm": "djoser.views.password.reset_confirm.ResetPasswordConfirmViewAPIView", + "set_password": "djoser.views.password.set.SetPasswordViewAPIView", + "username_reset": "djoser.views.username.reset.ResetUsernameAPIView", + "username_reset_confirm": "djoser.views.username.reset_confirm.ResetUsernameConfirmAPIView", + "set_username": "djoser.views.username.set.SetUsernameAPIView", + "user_create": "djoser.views.user.create.UserCreateView", + "user_delete": "djoser.views.user.delete.UserDeleteView", + "user_list": "djoser.views.user.list.UserListView", + "user_detail": "djoser.views.user.retrieve.UserRetrieveView", + "user_update_put": "djoser.views.user.update_put.UserPutView", + "user_update_patch": "djoser.views.user.update_patch.UserPatchView", + "user_me_get": "djoser.views.me.retrieve.UserMeRetrieveView", + "user_me_put": "djoser.views.me.update.UserMeUpdateView", + "user_me_patch": "djoser.views.me.update.UserMeUpdateView", + "user_me_delete": "djoser.views.me.delete.UserMeDeleteView", + "token_create": "djoser.views.token.create.TokenCreateView", + "token_destroy": "djoser.views.token.destroy.TokenDestroyView", + } + ), "WEBAUTHN": ObjDict( { "RP_NAME": "localhost", diff --git a/djoser/urls/activation.py b/djoser/urls/activation.py new file mode 100644 index 00000000..775263b8 --- /dev/null +++ b/djoser/urls/activation.py @@ -0,0 +1,23 @@ +from django.urls import path + +from djoser.conf import settings + +urlpatterns = [] + +# Configure activation endpoint +activation_view = getattr(settings.VIEWS, "activation", None) +if activation_view: + user_activation = path( + "users/activation/", activation_view.as_view(), name="user-activation" + ) + urlpatterns.append(user_activation) + +# Configure resend activation endpoint +resend_activation_view = getattr(settings.VIEWS, "resend_activation", None) +if resend_activation_view: + user_resend_activation = path( + "users/resend_activation/", + resend_activation_view.as_view(), + name="user-resend-activation", + ) + urlpatterns.append(user_resend_activation) diff --git a/djoser/urls/authtoken.py b/djoser/urls/authtoken.py index ccfc1d82..cc66856a 100644 --- a/djoser/urls/authtoken.py +++ b/djoser/urls/authtoken.py @@ -1,8 +1,12 @@ -from django.urls import re_path +from django.urls import path -from djoser import views +from djoser.conf import settings +from djoser.urls.utils import AppendSlashRedirectView urlpatterns = [ - re_path(r"^token/login/?$", views.TokenCreateView.as_view(), name="login"), - re_path(r"^token/logout/?$", views.TokenDestroyView.as_view(), name="logout"), + path("token/login/", settings.VIEWS.token_create.as_view(), name="login"), + path("token/logout/", settings.VIEWS.token_destroy.as_view(), name="logout"), + # djoser 2.x accepted these without the trailing slash. + path("token/login", AppendSlashRedirectView.as_view()), + path("token/logout", AppendSlashRedirectView.as_view()), ] diff --git a/djoser/urls/base.py b/djoser/urls/base.py index 44afa38e..459210c0 100644 --- a/djoser/urls/base.py +++ b/djoser/urls/base.py @@ -1,11 +1,40 @@ -from django.contrib.auth import get_user_model -from rest_framework.routers import DefaultRouter +from rest_framework.urlpatterns import format_suffix_patterns -from djoser import views +from djoser.urls.activation import ( + user_activation, + user_resend_activation, +) +from djoser.urls.me import me_list +from djoser.urls.password import ( + user_password_reset_confirm, + user_reset_password, + user_set_password, +) +from djoser.urls.user import ( + user_detail_update_delete, + user_list_create, +) +from djoser.urls.username import ( + user_reset_username, + user_reset_username_confirm, + user_set_username, +) -router = DefaultRouter() -router.register("users", views.UserViewSet) -User = get_user_model() - -urlpatterns = router.urls +# format_suffix_patterns restores the .json/.api routes DRF's DefaultRouter used to +# generate, e.g. /users/1.json. It relies on the lookup converter excluding dots. +urlpatterns = format_suffix_patterns( + [ + user_resend_activation, + user_activation, + user_password_reset_confirm, + user_reset_username_confirm, + user_reset_password, + user_set_password, + user_set_username, + user_reset_username, + me_list, + user_detail_update_delete, + user_list_create, + ] +) diff --git a/djoser/urls/converters.py b/djoser/urls/converters.py new file mode 100644 index 00000000..2e04f9a3 --- /dev/null +++ b/djoser/urls/converters.py @@ -0,0 +1,16 @@ +class UserLookupConverter: + """ + Matches a user lookup value, excluding dots. + + Mirrors the regex DRF's SimpleRouter used before the URLs were written by hand. + Excluding dots is what keeps ``/users/1.json`` routable as a format suffix rather + than being swallowed as a lookup value of ``"1.json"``. + """ + + regex = r"[^/.]+" + + def to_python(self, value): + return value + + def to_url(self, value): + return str(value) diff --git a/djoser/urls/jwt.py b/djoser/urls/jwt.py index 474411f1..3ee4dc6f 100644 --- a/djoser/urls/jwt.py +++ b/djoser/urls/jwt.py @@ -1,8 +1,14 @@ -from django.urls import re_path +from django.urls import path from rest_framework_simplejwt import views +from djoser.urls.utils import AppendSlashRedirectView + urlpatterns = [ - re_path(r"^jwt/create/?", views.TokenObtainPairView.as_view(), name="jwt-create"), - re_path(r"^jwt/refresh/?", views.TokenRefreshView.as_view(), name="jwt-refresh"), - re_path(r"^jwt/verify/?", views.TokenVerifyView.as_view(), name="jwt-verify"), + path("jwt/create/", views.TokenObtainPairView.as_view(), name="jwt-create"), + path("jwt/refresh/", views.TokenRefreshView.as_view(), name="jwt-refresh"), + path("jwt/verify/", views.TokenVerifyView.as_view(), name="jwt-verify"), + # djoser 2.x accepted these without the trailing slash. + path("jwt/create", AppendSlashRedirectView.as_view()), + path("jwt/refresh", AppendSlashRedirectView.as_view()), + path("jwt/verify", AppendSlashRedirectView.as_view()), ] diff --git a/djoser/urls/me.py b/djoser/urls/me.py new file mode 100644 index 00000000..2ca98c10 --- /dev/null +++ b/djoser/urls/me.py @@ -0,0 +1,23 @@ +from django.urls import path + +from djoser.urls.utils import create_configurable_dispatcher + +# Create configurable dispatcher for user "me" endpoint +me_dispatcher = create_configurable_dispatcher( + { + "GET": "user_me_get", + "PUT": "user_me_put", + "PATCH": "user_me_patch", + "DELETE": "user_me_delete", + } +) + +urlpatterns = [] + +if me_dispatcher: + me_list = path( + "users/me/", + me_dispatcher, + name="user-me", + ) + urlpatterns.append(me_list) diff --git a/djoser/urls/password.py b/djoser/urls/password.py new file mode 100644 index 00000000..0b6e55fa --- /dev/null +++ b/djoser/urls/password.py @@ -0,0 +1,33 @@ +from django.urls import path + +from djoser.conf import settings + +urlpatterns = [] + +# Configure password reset confirm endpoint +password_reset_confirm_view = getattr(settings.VIEWS, "password_reset_confirm", None) +if password_reset_confirm_view: + user_password_reset_confirm = path( + "users/reset_password_confirm/", + password_reset_confirm_view.as_view(), + name="user-reset-password-confirm", + ) + urlpatterns.append(user_password_reset_confirm) + +# Configure password reset endpoint +password_reset_view = getattr(settings.VIEWS, "password_reset", None) +if password_reset_view: + user_reset_password = path( + "users/reset_password/", + password_reset_view.as_view(), + name="user-reset-password", + ) + urlpatterns.append(user_reset_password) + +# Configure set password endpoint +set_password_view = getattr(settings.VIEWS, "set_password", None) +if set_password_view: + user_set_password = path( + "users/set_password/", set_password_view.as_view(), name="user-set-password" + ) + urlpatterns.append(user_set_password) diff --git a/djoser/urls/user.py b/djoser/urls/user.py new file mode 100644 index 00000000..486857e8 --- /dev/null +++ b/djoser/urls/user.py @@ -0,0 +1,44 @@ +from django.urls import path, register_converter + +from djoser.urls.converters import UserLookupConverter +from djoser.urls.utils import create_configurable_dispatcher + +register_converter(UserLookupConverter, "djoser_lookup") + +# Create configurable dispatchers for user endpoints +user_list_create_dispatcher = create_configurable_dispatcher( + { + "GET": "user_list", + "POST": "user_create", + } +) + +user_detail_dispatcher = create_configurable_dispatcher( + { + "GET": "user_detail", + "PUT": "user_update_put", + "PATCH": "user_update_patch", + "DELETE": "user_delete", + } +) + +urlpatterns = [] + +if user_list_create_dispatcher: + user_list_create = path( + "users/", + user_list_create_dispatcher, + name="user-list", + ) + urlpatterns.append(user_list_create) + +if user_detail_dispatcher: + # Import to get lookup field - only if needed + from djoser.views.user.retrieve import UserRetrieveView + + user_detail_update_delete = path( + f"users//", + user_detail_dispatcher, + name="user-detail", + ) + urlpatterns.append(user_detail_update_delete) diff --git a/djoser/urls/username.py b/djoser/urls/username.py new file mode 100644 index 00000000..40678e38 --- /dev/null +++ b/djoser/urls/username.py @@ -0,0 +1,38 @@ +from django.contrib.auth import get_user_model +from django.urls import path + +from djoser.conf import settings + +User = get_user_model() + +urlpatterns = [] + +# Configure username reset endpoint +username_reset_view = getattr(settings.VIEWS, "username_reset", None) +if username_reset_view: + user_reset_username = path( + f"users/reset_{User.USERNAME_FIELD}/", + username_reset_view.as_view(), + name="user-reset-username", + ) + urlpatterns.append(user_reset_username) + +# Configure username reset confirm endpoint +username_reset_confirm_view = getattr(settings.VIEWS, "username_reset_confirm", None) +if username_reset_confirm_view: + user_reset_username_confirm = path( + f"users/reset_{User.USERNAME_FIELD}_confirm/", + username_reset_confirm_view.as_view(), + name="user-reset-username-confirm", + ) + urlpatterns.append(user_reset_username_confirm) + +# Configure set username endpoint +set_username_view = getattr(settings.VIEWS, "set_username", None) +if set_username_view: + user_set_username = path( + f"users/set_{User.USERNAME_FIELD}/", + set_username_view.as_view(), + name="user-set-username", + ) + urlpatterns.append(user_set_username) diff --git a/djoser/urls/utils.py b/djoser/urls/utils.py new file mode 100644 index 00000000..0ebd5b03 --- /dev/null +++ b/djoser/urls/utils.py @@ -0,0 +1,106 @@ +from typing import Dict, Type, Callable, Any, Optional +from django.http import ( + HttpRequest, + HttpResponseNotAllowed, + HttpResponsePermanentRedirect, +) +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View + +try: + from django.http import HttpResponseBase +except ImportError: + # Django < 4.1 + from django.http.response import HttpResponseBase +from rest_framework.views import APIView + + +class HttpResponsePermanentRedirectPreserveMethod(HttpResponsePermanentRedirect): + """ + 308 Permanent Redirect. + + Unlike a 301, clients must repeat the request with the original method and body, + which is what makes this usable in front of POST-only endpoints. + """ + + status_code = 308 + + +class AppendSlashRedirectView(View): + """ + Redirects a slash-less URL to its canonical slash-terminated form. + + djoser 2.x matched these endpoints with an optional trailing slash. Switching to + path() made the slash mandatory, so callers using the slash-less form got a 404. + """ + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any): + target = f"{request.path}/" + query_string = request.META.get("QUERY_STRING") + if query_string: + target = f"{target}?{query_string}" + return HttpResponsePermanentRedirectPreserveMethod(target) + + +def create_dispatcher( + method_view_map: Dict[str, Type[APIView]], +) -> Callable[[HttpRequest, Any, Any], HttpResponseBase]: + """ + Creates a dispatcher function that routes requests to different views based on HTTP + method. + + Args: + method_view_map: Dict mapping HTTP methods to view classes + e.g., {"GET": ListView, "POST": CreateView} + + Returns: + A dispatcher function that can be used directly in URL patterns + """ + + def dispatcher(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase: + method = request.method + if method in method_view_map: + view_class = method_view_map[method] + return view_class.as_view()(request, *args, **kwargs) + else: + allowed_methods = list(method_view_map.keys()) + return HttpResponseNotAllowed(allowed_methods) + + # CsrfViewMiddleware decides exemption by inspecting the callable the URL resolver + # returns, which is this dispatcher rather than the wrapped view. Without this, + # the csrf_exempt that APIView.as_view() applies never reaches the middleware and + # CSRF is enforced on every djoser endpoint routed through a dispatcher. + dispatcher = csrf_exempt(dispatcher) + + # Store allowed methods as attribute for URL test introspection + setattr(dispatcher, "_allowed_methods", list(method_view_map.keys())) + return dispatcher + + +def create_configurable_dispatcher( + method_config_map: Dict[str, str], +) -> Optional[Callable[[HttpRequest, Any, Any], HttpResponseBase]]: + """ + Creates a dispatcher function using configurable view paths from djoser settings. + + Args: + method_config_map: Dict mapping HTTP methods to view configuration keys + e.g., {"GET": "user_me_get", "PUT": "user_me_put"} + + Returns: + A dispatcher function, or None if no views are configured + """ + from djoser.conf import settings + + method_view_map = {} + + for method, config_key in method_config_map.items(): + if config_key is not None: + view_class = getattr(settings.VIEWS, config_key, None) + if view_class is not None: + method_view_map[method] = view_class + + if not method_view_map: + return None + + return create_dispatcher(method_view_map) diff --git a/djoser/views.py b/djoser/views.py deleted file mode 100644 index 09b9e46a..00000000 --- a/djoser/views.py +++ /dev/null @@ -1,307 +0,0 @@ -from django.contrib.auth import get_user_model, update_session_auth_hash -from django.contrib.auth.tokens import default_token_generator -from django.utils.timezone import now -from rest_framework import generics, status, views, viewsets -from rest_framework.decorators import action -from rest_framework.exceptions import NotFound -from rest_framework.response import Response -from rest_framework.serializers import Serializer - -from djoser import signals, utils -from djoser.compat import get_user_email -from djoser.conf import settings - -User = get_user_model() - - -class TokenCreateView(utils.ActionViewMixin, generics.GenericAPIView): - """Use this endpoint to obtain user authentication token.""" - - serializer_class = settings.SERIALIZERS.token_create - permission_classes = settings.PERMISSIONS.token_create - - def _action(self, serializer): - token = utils.login_user(self.request, serializer.user) - token_serializer_class = settings.SERIALIZERS.token - return Response( - data=token_serializer_class(token).data, status=status.HTTP_200_OK - ) - - -class TokenDestroyView(views.APIView): - """Use this endpoint to logout user (remove user authentication token).""" - - serializer_class = Serializer - permission_classes = settings.PERMISSIONS.token_destroy - - def post(self, request): - utils.logout_user(request) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class UserViewSet(viewsets.ModelViewSet): - serializer_class = settings.SERIALIZERS.user - queryset = User.objects.all() - permission_classes = settings.PERMISSIONS.user - token_generator = default_token_generator - lookup_field = settings.USER_ID_FIELD - - def permission_denied(self, request, **kwargs): - if ( - settings.HIDE_USERS - and request.user.is_authenticated - and self.action in ["update", "partial_update", "list", "retrieve"] - ): - raise NotFound() - super().permission_denied(request, **kwargs) - - def get_queryset(self): - user = self.request.user - queryset = super().get_queryset() - if settings.HIDE_USERS and self.action == "list" and not user.is_staff: - queryset = queryset.filter(pk=user.pk) - return queryset - - def get_permissions(self): - if self.action == "create": - self.permission_classes = settings.PERMISSIONS.user_create - elif self.action == "activation": - self.permission_classes = settings.PERMISSIONS.activation - elif self.action == "resend_activation": - self.permission_classes = settings.PERMISSIONS.password_reset - elif self.action == "list": - self.permission_classes = settings.PERMISSIONS.user_list - elif self.action == "reset_password": - self.permission_classes = settings.PERMISSIONS.password_reset - elif self.action == "reset_password_confirm": - self.permission_classes = settings.PERMISSIONS.password_reset_confirm - elif self.action == "set_password": - self.permission_classes = settings.PERMISSIONS.set_password - elif self.action == "set_username": - self.permission_classes = settings.PERMISSIONS.set_username - elif self.action == "reset_username": - self.permission_classes = settings.PERMISSIONS.username_reset - elif self.action == "reset_username_confirm": - self.permission_classes = settings.PERMISSIONS.username_reset_confirm - elif self.action == "destroy" or ( - self.action == "me" and self.request and self.request.method == "DELETE" - ): - self.permission_classes = settings.PERMISSIONS.user_delete - return super().get_permissions() - - def get_serializer_class(self): - if self.action == "create": - if settings.USER_CREATE_PASSWORD_RETYPE: - return settings.SERIALIZERS.user_create_password_retype - return settings.SERIALIZERS.user_create - elif self.action == "destroy" or ( - self.action == "me" and self.request and self.request.method == "DELETE" - ): - return settings.SERIALIZERS.user_delete - elif self.action == "activation": - return settings.SERIALIZERS.activation - elif self.action == "resend_activation": - return settings.SERIALIZERS.password_reset - elif self.action == "reset_password": - return settings.SERIALIZERS.password_reset - elif self.action == "reset_password_confirm": - if settings.PASSWORD_RESET_CONFIRM_RETYPE: - return settings.SERIALIZERS.password_reset_confirm_retype - return settings.SERIALIZERS.password_reset_confirm - elif self.action == "set_password": - if settings.SET_PASSWORD_RETYPE: - return settings.SERIALIZERS.set_password_retype - return settings.SERIALIZERS.set_password - elif self.action == "set_username": - if settings.SET_USERNAME_RETYPE: - return settings.SERIALIZERS.set_username_retype - return settings.SERIALIZERS.set_username - elif self.action == "reset_username": - return settings.SERIALIZERS.username_reset - elif self.action == "reset_username_confirm": - if settings.USERNAME_RESET_CONFIRM_RETYPE: - return settings.SERIALIZERS.username_reset_confirm_retype - return settings.SERIALIZERS.username_reset_confirm - elif self.action == "me": - return settings.SERIALIZERS.current_user - - return self.serializer_class - - def get_instance(self): - return self.request.user - - def perform_create(self, serializer, *args, **kwargs): - user = serializer.save(*args, **kwargs) - signals.user_registered.send( - sender=self.__class__, user=user, request=self.request - ) - - context = {"user": user} - to = [get_user_email(user)] - if settings.SEND_ACTIVATION_EMAIL: - settings.EMAIL.activation(self.request, context).send(to) - elif settings.SEND_CONFIRMATION_EMAIL: - settings.EMAIL.confirmation(self.request, context).send(to) - - def perform_update(self, serializer, *args, **kwargs): - super().perform_update(serializer, *args, **kwargs) - user = serializer.instance - signals.user_updated.send( - sender=self.__class__, user=user, request=self.request - ) - - # should we send activation email after update? - if settings.SEND_ACTIVATION_EMAIL and not user.is_active: - context = {"user": user} - to = [get_user_email(user)] - settings.EMAIL.activation(self.request, context).send(to) - - def destroy(self, request, *args, **kwargs): - instance = self.get_object() - serializer = self.get_serializer(instance, data=request.data) - serializer.is_valid(raise_exception=True) - - if instance == request.user: - utils.logout_user(self.request) - self.perform_destroy(instance) - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(["get", "put", "patch", "delete"], detail=False) - def me(self, request, *args, **kwargs): - self.get_object = self.get_instance - if request.method == "GET": - return self.retrieve(request, *args, **kwargs) - elif request.method == "PUT": - return self.update(request, *args, **kwargs) - elif request.method == "PATCH": - return self.partial_update(request, *args, **kwargs) - elif request.method == "DELETE": - return self.destroy(request, *args, **kwargs) - - @action(["post"], detail=False) - def activation(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.user - user.is_active = True - user.save() - - signals.user_activated.send( - sender=self.__class__, user=user, request=self.request - ) - - if settings.SEND_CONFIRMATION_EMAIL: - context = {"user": user} - to = [get_user_email(user)] - settings.EMAIL.confirmation(self.request, context).send(to) - - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(["post"], detail=False) - def resend_activation(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.get_user(is_active=False) - - if not settings.SEND_ACTIVATION_EMAIL: - return Response(status=status.HTTP_400_BAD_REQUEST) - - if user: - context = {"user": user} - to = [get_user_email(user)] - settings.EMAIL.activation(self.request, context).send(to) - - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(["post"], detail=False) - def set_password(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - self.request.user.set_password(serializer.data["new_password"]) - self.request.user.save() - - if settings.PASSWORD_CHANGED_EMAIL_CONFIRMATION: - context = {"user": self.request.user} - to = [get_user_email(self.request.user)] - settings.EMAIL.password_changed_confirmation(self.request, context).send(to) - - if settings.LOGOUT_ON_PASSWORD_CHANGE: - utils.logout_user(self.request) - elif settings.CREATE_SESSION_ON_LOGIN: - update_session_auth_hash(self.request, self.request.user) - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(["post"], detail=False) - def reset_password(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.get_user() - - if user: - context = {"user": user} - to = [get_user_email(user)] - settings.EMAIL.password_reset(self.request, context).send(to) - - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(["post"], detail=False) - def reset_password_confirm(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - serializer.user.set_password(serializer.data["new_password"]) - if hasattr(serializer.user, "last_login"): - serializer.user.last_login = now() - serializer.user.save() - - if settings.PASSWORD_CHANGED_EMAIL_CONFIRMATION: - context = {"user": serializer.user} - to = [get_user_email(serializer.user)] - settings.EMAIL.password_changed_confirmation(self.request, context).send(to) - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(["post"], detail=False, url_path=f"set_{User.USERNAME_FIELD}") - def set_username(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = self.request.user - new_username = serializer.data["new_" + User.USERNAME_FIELD] - - setattr(user, User.USERNAME_FIELD, new_username) - user.save() - if settings.USERNAME_CHANGED_EMAIL_CONFIRMATION: - context = {"user": user} - to = [get_user_email(user)] - settings.EMAIL.username_changed_confirmation(self.request, context).send(to) - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(["post"], detail=False, url_path=f"reset_{User.USERNAME_FIELD}") - def reset_username(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.get_user() - - if user: - context = {"user": user} - to = [get_user_email(user)] - settings.EMAIL.username_reset(self.request, context).send(to) - - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(["post"], detail=False, url_path=f"reset_{User.USERNAME_FIELD}_confirm") - def reset_username_confirm(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - new_username = serializer.data["new_" + User.USERNAME_FIELD] - - setattr(serializer.user, User.USERNAME_FIELD, new_username) - if hasattr(serializer.user, "last_login"): - serializer.user.last_login = now() - serializer.user.save() - - if settings.USERNAME_CHANGED_EMAIL_CONFIRMATION: - context = {"user": serializer.user} - to = [get_user_email(serializer.user)] - settings.EMAIL.username_changed_confirmation(self.request, context).send(to) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/__init__.py b/djoser/views/__init__.py new file mode 100644 index 00000000..20384eaf --- /dev/null +++ b/djoser/views/__init__.py @@ -0,0 +1,46 @@ +# Import views for backward compatibility +from django.contrib.auth import get_user_model + +from .user import ( + UserListView, + UserCreateView, + UserRetrieveView, + UserDeleteView, + UserPatchView, + UserPutView, +) + +from .activation import UserActivationAPIView, UserResendActivationAPIView +from .password import ( + ResetPasswordConfirmViewAPIView, + ResetPasswordViewAPIView, + SetPasswordViewAPIView, +) +from .username import ( + ResetUsernameAPIView, + ResetUsernameConfirmAPIView, + SetUsernameAPIView, +) + +# Export User model for backward compatibility with tests +User = get_user_model() + +__all__ = [ + # User model + "User", + "UserListView", + "UserCreateView", + "UserRetrieveView", + "UserDeleteView", + "UserPutView", + "UserPatchView", + # Other views + "UserActivationAPIView", + "UserResendActivationAPIView", + "ResetPasswordConfirmViewAPIView", + "ResetPasswordViewAPIView", + "SetPasswordViewAPIView", + "ResetUsernameAPIView", + "ResetUsernameConfirmAPIView", + "SetUsernameAPIView", +] diff --git a/djoser/views/activation/__init__.py b/djoser/views/activation/__init__.py new file mode 100644 index 00000000..dcf9bc86 --- /dev/null +++ b/djoser/views/activation/__init__.py @@ -0,0 +1,7 @@ +from .activate import UserActivationAPIView +from .resend import UserResendActivationAPIView + +__all__ = [ + "UserActivationAPIView", + "UserResendActivationAPIView", +] diff --git a/djoser/views/activation/activate.py b/djoser/views/activation/activate.py new file mode 100644 index 00000000..621dc7b8 --- /dev/null +++ b/djoser/views/activation/activate.py @@ -0,0 +1,34 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.response import Response + +from djoser import signals +from djoser.conf import settings +from djoser.compat import get_user_email +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class UserActivationAPIView(GenericUserAPIView): + permission_classes = settings.PERMISSIONS.activation + serializer_class = settings.SERIALIZERS.activation + http_method_names = ["post"] + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.user + user.is_active = True + user.save() + + signals.user_activated.send( + sender=self.__class__, user=user, request=self.request + ) + + if settings.SEND_CONFIRMATION_EMAIL: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.confirmation(self.request, context).send(to) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/activation/resend.py b/djoser/views/activation/resend.py new file mode 100644 index 00000000..94a46d53 --- /dev/null +++ b/djoser/views/activation/resend.py @@ -0,0 +1,30 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.response import Response + +from djoser.conf import settings +from djoser.compat import get_user_email +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class UserResendActivationAPIView(GenericUserAPIView): + permission_classes = settings.PERMISSIONS.password_reset + serializer_class = settings.SERIALIZERS.password_reset + http_method_names = ["post"] + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.get_user(is_active=False) + + if not settings.SEND_ACTIVATION_EMAIL: + return Response(status=status.HTTP_400_BAD_REQUEST) + + if user: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.activation(self.request, context).send(to) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/base.py b/djoser/views/base.py new file mode 100644 index 00000000..bb37d72c --- /dev/null +++ b/djoser/views/base.py @@ -0,0 +1,14 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.tokens import default_token_generator +from rest_framework import generics +from djoser.conf import settings + + +User = get_user_model() + + +class GenericUserAPIView(generics.GenericAPIView): + queryset = User.objects.all() + lookup_field = settings.USER_ID_FIELD + token_generator = default_token_generator # used in serializers + http_method_names = [] diff --git a/djoser/views/me/__init__.py b/djoser/views/me/__init__.py new file mode 100644 index 00000000..bb1f2841 --- /dev/null +++ b/djoser/views/me/__init__.py @@ -0,0 +1,11 @@ +from .base import BaseMeAPIView +from .delete import UserMeDeleteView +from .retrieve import UserMeRetrieveView +from .update import UserMeUpdateView + +__all__ = [ + "BaseMeAPIView", + "UserMeDeleteView", + "UserMeRetrieveView", + "UserMeUpdateView", +] diff --git a/djoser/views/me/base.py b/djoser/views/me/base.py new file mode 100644 index 00000000..7d1de786 --- /dev/null +++ b/djoser/views/me/base.py @@ -0,0 +1,29 @@ +from django.contrib.auth import get_user_model +from rest_framework.exceptions import NotFound + +from djoser.conf import settings +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class BaseMeAPIView(GenericUserAPIView): + """ + Base class for user 'me' views with common functionality. + """ + + permission_classes = settings.PERMISSIONS.user + lookup_field = None + + def get_queryset(self): + return super().get_queryset().filter(pk=self.request.user.pk) + + def get_object(self): + if settings.HIDE_USERS and not self.request.user.is_authenticated: + raise NotFound() + return self.request.user + + def permission_denied(self, request, message=None, code=None): + if settings.HIDE_USERS and request.user.is_authenticated: + raise NotFound() + super().permission_denied(request, message, code) diff --git a/djoser/views/me/delete.py b/djoser/views/me/delete.py new file mode 100644 index 00000000..76f753b1 --- /dev/null +++ b/djoser/views/me/delete.py @@ -0,0 +1,34 @@ +from rest_framework import status, mixins +from rest_framework.response import Response + +from djoser import utils +from djoser.conf import settings +from .base import BaseMeAPIView + + +class UserMeDeleteView(mixins.DestroyModelMixin, BaseMeAPIView): + """ + Delete current user account. + """ + + http_method_names = ["delete"] + + def get_permissions(self): + self.permission_classes = settings.PERMISSIONS.user_delete + return super().get_permissions() + + def get_serializer_class(self): + return settings.SERIALIZERS.user_delete + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data) + serializer.is_valid(raise_exception=True) + + if instance == request.user: + utils.logout_user(self.request) + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) diff --git a/djoser/views/me/retrieve.py b/djoser/views/me/retrieve.py new file mode 100644 index 00000000..bcea04cc --- /dev/null +++ b/djoser/views/me/retrieve.py @@ -0,0 +1,18 @@ +from rest_framework import mixins + +from djoser.conf import settings +from .base import BaseMeAPIView + + +class UserMeRetrieveView(mixins.RetrieveModelMixin, BaseMeAPIView): + """ + Retrieve current user details. + """ + + http_method_names = ["get"] + + def get_serializer_class(self): + return settings.SERIALIZERS.current_user + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) diff --git a/djoser/views/me/update.py b/djoser/views/me/update.py new file mode 100644 index 00000000..ede95551 --- /dev/null +++ b/djoser/views/me/update.py @@ -0,0 +1,35 @@ +from rest_framework import mixins + +from djoser import signals +from djoser.conf import settings +from djoser.compat import get_user_email +from .base import BaseMeAPIView + + +class UserMeUpdateView(mixins.UpdateModelMixin, BaseMeAPIView): + """ + Update current user details. + """ + + http_method_names = ["put", "patch"] + + def get_serializer_class(self): + return settings.SERIALIZERS.current_user + + def perform_update(self, serializer): + super().perform_update(serializer) + user = serializer.instance + signals.user_updated.send( + sender=self.__class__, user=user, request=self.request + ) + + if settings.SEND_ACTIVATION_EMAIL and not user.is_active: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.activation(self.request, context).send(to) + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) diff --git a/djoser/views/password/__init__.py b/djoser/views/password/__init__.py new file mode 100644 index 00000000..97abf723 --- /dev/null +++ b/djoser/views/password/__init__.py @@ -0,0 +1,9 @@ +from .reset import ResetPasswordViewAPIView +from .reset_confirm import ResetPasswordConfirmViewAPIView +from .set import SetPasswordViewAPIView + +__all__ = [ + "ResetPasswordViewAPIView", + "ResetPasswordConfirmViewAPIView", + "SetPasswordViewAPIView", +] diff --git a/djoser/views/password/reset.py b/djoser/views/password/reset.py new file mode 100644 index 00000000..654e6b09 --- /dev/null +++ b/djoser/views/password/reset.py @@ -0,0 +1,27 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.response import Response + +from djoser.conf import settings +from djoser.compat import get_user_email +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class ResetPasswordViewAPIView(GenericUserAPIView): + permission_classes = settings.PERMISSIONS.password_reset + serializer_class = settings.SERIALIZERS.password_reset + http_method_names = ["post"] + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.get_user() + + if user: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.password_reset(self.request, context).send(to) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/password/reset_confirm.py b/djoser/views/password/reset_confirm.py new file mode 100644 index 00000000..ccf48a12 --- /dev/null +++ b/djoser/views/password/reset_confirm.py @@ -0,0 +1,35 @@ +from django.contrib.auth import get_user_model +from django.utils.timezone import now +from rest_framework import status +from rest_framework.response import Response + +from djoser.conf import settings +from djoser.compat import get_user_email +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class ResetPasswordConfirmViewAPIView(GenericUserAPIView): + permission_classes = settings.PERMISSIONS.password_reset_confirm + http_method_names = ["post"] + + def get_serializer_class(self): + if settings.PASSWORD_RESET_CONFIRM_RETYPE: + return settings.SERIALIZERS.password_reset_confirm_retype + return settings.SERIALIZERS.password_reset_confirm + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + serializer.user.set_password(serializer.data["new_password"]) + if hasattr(serializer.user, "last_login"): + serializer.user.last_login = now() + serializer.user.save() + + if settings.PASSWORD_CHANGED_EMAIL_CONFIRMATION: + context = {"user": serializer.user} + to = [get_user_email(serializer.user)] + settings.EMAIL.password_changed_confirmation(self.request, context).send(to) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/password/set.py b/djoser/views/password/set.py new file mode 100644 index 00000000..a22757d8 --- /dev/null +++ b/djoser/views/password/set.py @@ -0,0 +1,38 @@ +from django.contrib.auth import get_user_model, update_session_auth_hash +from rest_framework import status +from rest_framework.response import Response + +from djoser import utils +from djoser.conf import settings +from djoser.compat import get_user_email +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class SetPasswordViewAPIView(GenericUserAPIView): + permission_classes = settings.PERMISSIONS.set_password + http_method_names = ["post"] + + def get_serializer_class(self): + if settings.SET_PASSWORD_RETYPE: + return settings.SERIALIZERS.set_password_retype + return settings.SERIALIZERS.set_password + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + self.request.user.set_password(serializer.data["new_password"]) + self.request.user.save() + + if settings.PASSWORD_CHANGED_EMAIL_CONFIRMATION: + context = {"user": self.request.user} + to = [get_user_email(self.request.user)] + settings.EMAIL.password_changed_confirmation(self.request, context).send(to) + + if settings.LOGOUT_ON_PASSWORD_CHANGE: + utils.logout_user(self.request) + elif settings.CREATE_SESSION_ON_LOGIN: + update_session_auth_hash(self.request, self.request.user) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/token/__init__.py b/djoser/views/token/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/djoser/views/token/create.py b/djoser/views/token/create.py new file mode 100644 index 00000000..be6ae7a6 --- /dev/null +++ b/djoser/views/token/create.py @@ -0,0 +1,22 @@ +from rest_framework import generics, status +from rest_framework.response import Response + +from djoser import utils +from djoser.conf import settings + + +class TokenCreateView(utils.ActionViewMixin, generics.GenericAPIView): + """ + Use this endpoint to obtain user authentication token. + """ + + serializer_class = settings.SERIALIZERS.token_create + permission_classes = settings.PERMISSIONS.token_create + queryset = settings.TOKEN_MODEL.objects.none() + + def _action(self, serializer): + token = utils.login_user(self.request, serializer.user) + token_serializer_class = settings.SERIALIZERS.token + return Response( + data=token_serializer_class(token).data, status=status.HTTP_200_OK + ) diff --git a/djoser/views/token/destroy.py b/djoser/views/token/destroy.py new file mode 100644 index 00000000..5bae52af --- /dev/null +++ b/djoser/views/token/destroy.py @@ -0,0 +1,18 @@ +from rest_framework import status, generics, serializers +from rest_framework.response import Response + +from djoser import utils +from djoser.conf import settings + + +class TokenDestroyView(generics.GenericAPIView): + """ + Use this endpoint to logout user (remove user authentication token). + """ + + serializer_class = serializers.Serializer + permission_classes = settings.PERMISSIONS.token_destroy + + def post(self, request): + utils.logout_user(request) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/user/__init__.py b/djoser/views/user/__init__.py new file mode 100644 index 00000000..4466189d --- /dev/null +++ b/djoser/views/user/__init__.py @@ -0,0 +1,17 @@ +from .base import UserBaseView +from .create import UserCreateView +from .delete import UserDeleteView +from .list import UserListView +from .retrieve import UserRetrieveView +from .update_patch import UserPatchView +from .update_put import UserPutView + +__all__ = [ + "UserBaseView", + "UserCreateView", + "UserDeleteView", + "UserListView", + "UserRetrieveView", + "UserPatchView", + "UserPutView", +] diff --git a/djoser/views/user/base.py b/djoser/views/user/base.py new file mode 100644 index 00000000..a7d7db8b --- /dev/null +++ b/djoser/views/user/base.py @@ -0,0 +1,33 @@ +from django.contrib.auth import get_user_model +from rest_framework.exceptions import NotFound + +from djoser.conf import settings +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class UserBaseView(GenericUserAPIView): + """ + Base view for user views with common methods. + """ + + serializer_class = settings.SERIALIZERS.user + + def permission_denied(self, request, message=None, code=None): + action = getattr(self, "action", None) + if ( + settings.HIDE_USERS + and request.user.is_authenticated + and action in ["update", "partial_update", "list", "retrieve"] + ): + raise NotFound() + super().permission_denied(request, message, code) + + def get_queryset(self): + user = self.request.user + queryset = super().get_queryset() + action = getattr(self, "action", None) + if settings.HIDE_USERS and action == "list" and not user.is_staff: + queryset = queryset.filter(pk=user.pk) + return queryset diff --git a/djoser/views/user/create.py b/djoser/views/user/create.py new file mode 100644 index 00000000..f9dfec92 --- /dev/null +++ b/djoser/views/user/create.py @@ -0,0 +1,32 @@ +from rest_framework import generics + +from djoser import signals +from djoser.conf import settings +from djoser.compat import get_user_email +from .base import UserBaseView + + +class UserCreateView(UserBaseView, generics.CreateAPIView): + """POST /users/ - Create user""" + + permission_classes = settings.PERMISSIONS.user_create + http_method_names = ["post"] + action = "create" + + def get_serializer_class(self): + if settings.USER_CREATE_PASSWORD_RETYPE: + return settings.SERIALIZERS.user_create_password_retype + return settings.SERIALIZERS.user_create + + def perform_create(self, serializer, *args, **kwargs): + user = serializer.save(*args, **kwargs) + signals.user_registered.send( + sender=self.__class__, user=user, request=self.request + ) + + context = {"user": user} + to = [get_user_email(user)] + if settings.SEND_ACTIVATION_EMAIL: + settings.EMAIL.activation(self.request, context).send(to) + elif settings.SEND_CONFIRMATION_EMAIL: + settings.EMAIL.confirmation(self.request, context).send(to) diff --git a/djoser/views/user/delete.py b/djoser/views/user/delete.py new file mode 100644 index 00000000..cc0d589f --- /dev/null +++ b/djoser/views/user/delete.py @@ -0,0 +1,40 @@ +from rest_framework import status, generics +from rest_framework.response import Response + +from djoser import utils +from djoser.conf import settings +from .base import UserBaseView + + +class UserDeleteView(UserBaseView, generics.DestroyAPIView): + """DELETE /users/{id}/ - Delete user""" + + http_method_names = ["delete"] + action = "destroy" + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + + This ensures permissions are evaluated at request time, not class definition + time. + """ + permission_classes = settings.PERMISSIONS.user_delete + return [permission() for permission in permission_classes] + + def get_serializer_class(self): + return settings.SERIALIZERS.user_delete + + def destroy(self, request, *args, **kwargs): + # Ensure permissions are checked first + self.check_permissions(request) + instance = self.get_object() + self.check_object_permissions(request, instance) + + serializer = self.get_serializer(instance, data=request.data) + serializer.is_valid(raise_exception=True) + + if instance == request.user: + utils.logout_user(self.request) + self.perform_destroy(instance) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/user/list.py b/djoser/views/user/list.py new file mode 100644 index 00000000..5d3085f5 --- /dev/null +++ b/djoser/views/user/list.py @@ -0,0 +1,12 @@ +from rest_framework import generics + +from djoser.conf import settings +from .base import UserBaseView + + +class UserListView(UserBaseView, generics.ListAPIView): + """GET /users/ - List users""" + + permission_classes = settings.PERMISSIONS.user_list + http_method_names = ["get"] + action = "list" diff --git a/djoser/views/user/retrieve.py b/djoser/views/user/retrieve.py new file mode 100644 index 00000000..a5c16852 --- /dev/null +++ b/djoser/views/user/retrieve.py @@ -0,0 +1,26 @@ +from rest_framework import generics + +from djoser.conf import settings +from .base import UserBaseView + + +class UserRetrieveView(UserBaseView, generics.RetrieveAPIView): + """GET /users/{id}/ - Retrieve user""" + + http_method_names = ["get"] + action = "retrieve" + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + + This ensures permissions are evaluated at request time, not class definition + time. If permission_classes is set directly on this class (e.g., by tests), use + that. Otherwise, use settings. + """ + # Check if permission_classes was set directly on this class (not inherited) + if "permission_classes" in self.__class__.__dict__: + permission_classes = self.permission_classes + else: + permission_classes = settings.PERMISSIONS.user + return [permission() for permission in permission_classes] diff --git a/djoser/views/user/update_patch.py b/djoser/views/user/update_patch.py new file mode 100644 index 00000000..c29cf959 --- /dev/null +++ b/djoser/views/user/update_patch.py @@ -0,0 +1,37 @@ +from rest_framework import generics + +from djoser import signals +from djoser.conf import settings +from djoser.compat import get_user_email +from .base import UserBaseView + + +class UserPatchView(UserBaseView, generics.UpdateAPIView): + """PATCH /users/{id}/ - Partial update user""" + + http_method_names = ["patch"] + action = "partial_update" + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + """ + # Check if permission_classes was set directly on this class (not inherited) + if "permission_classes" in self.__class__.__dict__: + permission_classes = self.permission_classes + else: + permission_classes = settings.PERMISSIONS.user + return [permission() for permission in permission_classes] + + def perform_update(self, serializer, *args, **kwargs): + super().perform_update(serializer, *args, **kwargs) + user = serializer.instance + signals.user_updated.send( + sender=self.__class__, user=user, request=self.request + ) + + # should we send activation email after update? + if settings.SEND_ACTIVATION_EMAIL and not user.is_active: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.activation(self.request, context).send(to) diff --git a/djoser/views/user/update_put.py b/djoser/views/user/update_put.py new file mode 100644 index 00000000..6bb8e783 --- /dev/null +++ b/djoser/views/user/update_put.py @@ -0,0 +1,37 @@ +from rest_framework import generics + +from djoser import signals +from djoser.conf import settings +from djoser.compat import get_user_email +from .base import UserBaseView + + +class UserPutView(UserBaseView, generics.UpdateAPIView): + """PUT /users/{id}/ - Full update user""" + + http_method_names = ["put"] + action = "update" + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + """ + # Check if permission_classes was set directly on this class (not inherited) + if "permission_classes" in self.__class__.__dict__: + permission_classes = self.permission_classes + else: + permission_classes = settings.PERMISSIONS.user + return [permission() for permission in permission_classes] + + def perform_update(self, serializer, *args, **kwargs): + super().perform_update(serializer, *args, **kwargs) + user = serializer.instance + signals.user_updated.send( + sender=self.__class__, user=user, request=self.request + ) + + # should we send activation email after update? + if settings.SEND_ACTIVATION_EMAIL and not user.is_active: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.activation(self.request, context).send(to) diff --git a/djoser/views/username/__init__.py b/djoser/views/username/__init__.py new file mode 100644 index 00000000..79abe44d --- /dev/null +++ b/djoser/views/username/__init__.py @@ -0,0 +1,9 @@ +from .reset import ResetUsernameAPIView +from .reset_confirm import ResetUsernameConfirmAPIView +from .set import SetUsernameAPIView + +__all__ = [ + "ResetUsernameAPIView", + "ResetUsernameConfirmAPIView", + "SetUsernameAPIView", +] diff --git a/djoser/views/username/reset.py b/djoser/views/username/reset.py new file mode 100644 index 00000000..6e42f6d8 --- /dev/null +++ b/djoser/views/username/reset.py @@ -0,0 +1,27 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.response import Response + +from djoser.conf import settings +from djoser.compat import get_user_email +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class ResetUsernameAPIView(GenericUserAPIView): + permission_classes = settings.PERMISSIONS.username_reset + serializer_class = settings.SERIALIZERS.username_reset + http_method_names = ["post"] + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.get_user() + + if user: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.username_reset(self.request, context).send(to) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/username/reset_confirm.py b/djoser/views/username/reset_confirm.py new file mode 100644 index 00000000..e16021e4 --- /dev/null +++ b/djoser/views/username/reset_confirm.py @@ -0,0 +1,38 @@ +from django.contrib.auth import get_user_model +from django.utils.timezone import now +from rest_framework import status +from rest_framework.response import Response + +from djoser.conf import settings +from djoser.compat import get_user_email +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class ResetUsernameConfirmAPIView(GenericUserAPIView): + permission_classes = settings.PERMISSIONS.username_reset_confirm + http_method_names = ["post"] + + def get_serializer_class(self): + if settings.USERNAME_RESET_CONFIRM_RETYPE: + return settings.SERIALIZERS.username_reset_confirm_retype + return settings.SERIALIZERS.username_reset_confirm + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + # Use get_user_model() to ensure we get the correct model even if it's mocked + User = get_user_model() + new_username = serializer.data["new_" + User.USERNAME_FIELD] + + setattr(serializer.user, User.USERNAME_FIELD, new_username) + if hasattr(serializer.user, "last_login"): + serializer.user.last_login = now() + serializer.user.save() + + if settings.USERNAME_CHANGED_EMAIL_CONFIRMATION: + context = {"user": serializer.user} + to = [get_user_email(serializer.user)] + settings.EMAIL.username_changed_confirmation(self.request, context).send(to) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/djoser/views/username/set.py b/djoser/views/username/set.py new file mode 100644 index 00000000..a659ead8 --- /dev/null +++ b/djoser/views/username/set.py @@ -0,0 +1,36 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.response import Response + +from djoser.conf import settings +from djoser.compat import get_user_email +from djoser.views.base import GenericUserAPIView + +User = get_user_model() + + +class SetUsernameAPIView(GenericUserAPIView): + permission_classes = settings.PERMISSIONS.set_username + http_method_names = ["post"] + + def get_serializer_class(self): + if settings.SET_USERNAME_RETYPE: + return settings.SERIALIZERS.set_username_retype + return settings.SERIALIZERS.set_username + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = self.request.user + # Use get_user_model() to ensure we get the correct model even if it's mocked + User = get_user_model() + new_username = serializer.data["new_" + User.USERNAME_FIELD] + + setattr(user, User.USERNAME_FIELD, new_username) + user.save() + + if settings.USERNAME_CHANGED_EMAIL_CONFIRMATION: + context = {"user": user} + to = [get_user_email(user)] + settings.EMAIL.username_changed_confirmation(self.request, context).send(to) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/docs/source/index.rst b/docs/source/index.rst index 095948fb..cd4c73c6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -8,7 +8,7 @@ Welcome to djoser's documentation! .. note:: - djoser 2.x is not backward compatible with djoser 1.x + djoser 3.x is not backward compatible with djoser 2.x .. toctree:: :maxdepth: 2 diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 67a0f55c..00000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE = testproject.settings -python_paths = testproject diff --git a/testproject/testapp/tests/test_configurable_views.py b/testproject/testapp/tests/test_configurable_views.py new file mode 100644 index 00000000..7ee6cdf6 --- /dev/null +++ b/testproject/testapp/tests/test_configurable_views.py @@ -0,0 +1,318 @@ +import pytest +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from rest_framework import status + +from djoser.views.me import UserMeRetrieveView + + +class CustomUserMeView(UserMeRetrieveView): + """ + Custom view that adds extra data to demonstrate view replacement. + """ + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + if response.status_code == 200: + response.data["custom_field"] = "custom_value" + response.data["view_type"] = "custom" + return response + + +class TestConfigurableViews: + """ + Test the configurable view system functionality. + """ + + def test_default_views_work(self, authenticated_client, user): + """ + Test that default views work without any configuration. + """ + response = authenticated_client.get(reverse("user-me")) + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == user.id + # Should not have custom fields from custom view + assert "custom_field" not in response.data + assert "view_type" not in response.data + + def test_partial_method_configuration(self, authenticated_client, user): + """ + Test configuring only some methods while leaving others as default. + """ + # This test uses default configuration but verifies the structure + + # All methods should work with defaults + response = authenticated_client.get(reverse("user-me")) + assert response.status_code == status.HTTP_200_OK + + response = authenticated_client.put(reverse("user-me"), {"first_name": "Test"}) + assert response.status_code == status.HTTP_200_OK + + response = authenticated_client.patch( + reverse("user-me"), {"first_name": "Test2"} + ) + assert response.status_code == status.HTTP_200_OK + + def test_view_configuration_structure(self): + """ + Test that the view configuration has the expected structure. + """ + from djoser.conf import settings + + # Check that VIEWS exists and has expected keys + assert hasattr(settings, "VIEWS") + + expected_keys = [ + "activation", + "resend_activation", + "password_reset", + "password_reset_confirm", + "set_password", + "username_reset", + "username_reset_confirm", + "set_username", + "user_create", + "user_delete", + "user_list", + "user_detail", + "user_update_put", + "user_update_patch", + "user_me_get", + "user_me_put", + "user_me_patch", + "user_me_delete", + "token_create", + "token_destroy", + ] + + for key in expected_keys: + assert hasattr(settings.VIEWS, key), f"Missing view configuration: {key}" + + # Check that default values are proper classes (imported from strings) + user_me_get_view = getattr(settings.VIEWS, "user_me_get", None) + assert user_me_get_view.__name__ == "UserMeRetrieveView" + + def test_default_user_me_view(self, authenticated_client, user): + """ + Test that default user-me view works without custom fields. + """ + response = authenticated_client.get(reverse("user-me")) + assert response.status_code == status.HTTP_200_OK + assert "custom_field" not in response.data + assert "view_type" not in response.data + + @pytest.mark.skip( + reason="View configuration changes require process restart due to URL dispatcher caching" # noqa: E501 + ) + def test_djoser_settings_fixture_works(self, authenticated_client, user): + """ + Test that the djoser_settings fixture can modify configuration. + """ + from django.urls import reverse + + # Instead of trying to modify the actual dispatcher, mock the view directly + with patch("djoser.urls.utils.create_configurable_dispatcher") as mock_create: + # Create a mock dispatcher that returns our custom view + def mock_dispatcher(request, *args, **kwargs): + if request.method == "GET": + return CustomUserMeView.as_view()(request, *args, **kwargs) + from django.http import HttpResponseNotAllowed + + return HttpResponseNotAllowed(["GET"]) + + mock_create.return_value = mock_dispatcher + + # Make HTTP request and verify the custom view is being used + response = authenticated_client.get(reverse("user-me")) + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == user.id + # Custom view should add these fields + assert response.data["custom_field"] == "custom_value" + assert response.data["view_type"] == "custom" + + +class TestViewConfigurationUnits: + """ + Unit tests for view configuration components. + """ + + def test_configurable_dispatcher_with_valid_views(self): + """ + Test that create_configurable_dispatcher works with valid view keys. + """ + from djoser.urls.utils import create_configurable_dispatcher + + # Test with valid view configuration + dispatcher = create_configurable_dispatcher( + { + "GET": "user_me_get", + "PUT": "user_me_put", + } + ) + assert dispatcher is not None + assert callable(dispatcher) + + def test_configurable_dispatcher_with_none_values(self): + """ + Test that create_configurable_dispatcher handles None values correctly. + """ + from djoser.urls.utils import create_configurable_dispatcher + + # Test with non-existent key that returns None + dispatcher = create_configurable_dispatcher( + { + "GET": "non_existent_view_key_that_returns_none", + } + ) + assert dispatcher is None + + def test_custom_view_class_definition(self): + """ + Test that our custom view can be imported and has correct structure. + """ + assert callable(CustomUserMeView) + assert issubclass(CustomUserMeView, UserMeRetrieveView) + + # Test that it has the expected method + assert hasattr(CustomUserMeView, "get") + + +class TestViewConfigurationManual(TestCase): + """ + Test view configuration by manually modifying settings. + """ + + def test_view_path_resolution(self): + """ + Test that view paths resolve correctly to actual view classes. + """ + from djoser.conf import settings + from djoser.views.me.retrieve import UserMeRetrieveView + + # Test that view resolution works + view_class = getattr(settings.VIEWS, "user_me_get", None) + assert callable(view_class) + assert issubclass(view_class, UserMeRetrieveView) + + def test_settings_has_views_configuration(self): + """ + Test that settings object has VIEWS configuration. + """ + from djoser.conf import settings, default_settings + + # Settings should have VIEWS + assert hasattr(settings, "VIEWS") + + # Default settings should have VIEWS as ObjDict + assert "VIEWS" in default_settings + + # VIEWS should be an ObjDict instance + from djoser.conf import ObjDict + + assert isinstance(default_settings["VIEWS"], ObjDict) + + def test_objdict_behavior(self): + """ + Test that ObjDict behaves correctly for view configuration. + """ + from djoser.conf import ObjDict + + # Create test ObjDict with string value + test_config = ObjDict( + {"test_view": "djoser.views.me.retrieve.UserMeRetrieveView"} + ) + + # First access should import the string + view_class = test_config.test_view + assert callable(view_class) + assert view_class.__name__ == "UserMeRetrieveView" + + # Second access should return the cached imported class + view_class2 = test_config.test_view + assert view_class == view_class2 + + # Test with None value + test_config["none_view"] = None + assert test_config.none_view is None + + +class TestDispatcherCreation(TestCase): + """ + Test dispatcher creation functionality. + """ + + def test_view_dispatcher_creation(self): + """ + Test that view dispatchers can be created with different configurations. + """ + from djoser.urls.utils import create_dispatcher, create_configurable_dispatcher + from djoser.views.me.retrieve import UserMeRetrieveView + from djoser.views.me.update import UserMeUpdateView + + # Test basic dispatcher creation + dispatcher = create_dispatcher( + { + "GET": UserMeRetrieveView, + "PUT": UserMeUpdateView, + } + ) + assert dispatcher is not None + assert callable(dispatcher) + + # Test configurable dispatcher + config_dispatcher = create_configurable_dispatcher( + { + "GET": "user_me_get", + "PUT": "user_me_put", + } + ) + assert config_dispatcher is not None + assert callable(config_dispatcher) + + def test_dispatcher_with_empty_methods(self): + """ + Test dispatcher behavior with empty method configurations. + """ + from djoser.urls.utils import create_dispatcher, create_configurable_dispatcher + + # Empty method map should still create a dispatcher + dispatcher = create_dispatcher({}) + assert dispatcher is not None + assert callable(dispatcher) + + # Empty configurable dispatcher with non-existent keys should return None + config_dispatcher = create_configurable_dispatcher( + { + "GET": "definitely_non_existent_view_key", + "POST": "another_non_existent_key", + } + ) + assert config_dispatcher is None + + +def test_every_configured_view_key_is_used(settings): + """ + A key in VIEWS that no URLConf reads is a silent no-op for anyone overriding it. + """ + import pathlib + import re + + from djoser.conf import default_settings + + defined = set(default_settings["VIEWS"].keys()) + + read = set() + for module in pathlib.Path("djoser/urls").glob("*.py"): + source = module.read_text() + # {"GET": "user_list", ...} method -> config key maps + read |= set(re.findall(r'"(?:GET|POST|PUT|PATCH|DELETE)":\s*"(\w+)"', source)) + # getattr(settings.VIEWS, "activation", None) + read |= set(re.findall(r'settings\.VIEWS,\s*"(\w+)"', source)) + # settings.VIEWS.token_create + read |= set(re.findall(r"settings\.VIEWS\.(\w+)", source)) + + assert ( + defined - read == set() + ), f"VIEWS keys never read by any URLConf: {defined - read}" diff --git a/testproject/testapp/tests/test_me_base.py b/testproject/testapp/tests/test_me_base.py new file mode 100644 index 00000000..a01e273d --- /dev/null +++ b/testproject/testapp/tests/test_me_base.py @@ -0,0 +1,122 @@ +import pytest +from unittest.mock import Mock, patch +from django.contrib.auth import get_user_model +from rest_framework.exceptions import NotFound +from rest_framework.test import APIRequestFactory + +from djoser.views.me.base import BaseMeAPIView + +User = get_user_model() + + +@pytest.mark.django_db +class TestBaseMeAPIView: + def test_get_queryset_with_custom_queryset(self, user): + view = BaseMeAPIView() + view.queryset = User.objects.all() # Set custom queryset + + factory = APIRequestFactory() + request = factory.get("/me/") + request.user = user + view.request = request + + queryset = view.get_queryset() + + # Should return filtered queryset for current user + assert hasattr(queryset, "filter") + # Convert to list to evaluate the queryset + result_list = list(queryset) + assert len(result_list) == 1 + assert result_list[0] == user + + def test_get_object_hide_users_unauthenticated(self, djoser_settings): + view = BaseMeAPIView() + + factory = APIRequestFactory() + request = factory.get("/me/") + + # Create an unauthenticated user + unauthenticated_user = Mock() + unauthenticated_user.is_authenticated = False + request.user = unauthenticated_user + view.request = request + + djoser_settings["HIDE_USERS"] = True + with pytest.raises(NotFound): + view.get_object() + + def test_permission_denied_hide_users_authenticated(self, user, djoser_settings): + view = BaseMeAPIView() + + factory = APIRequestFactory() + request = factory.get("/me/") + request.user = user # authenticated user + + djoser_settings["HIDE_USERS"] = True + with pytest.raises(NotFound): + view.permission_denied(request, message="Test message", code="test_code") + + def test_permission_denied_hide_users_false(self, user, djoser_settings): + """ + Test that super().permission_denied is called when HIDE_USERS=False. + """ + view = BaseMeAPIView() + + factory = APIRequestFactory() + request = factory.get("/me/") + request.user = user + + djoser_settings["HIDE_USERS"] = False + with patch.object( + view.__class__.__bases__[0], "permission_denied" + ) as mock_super: + view.permission_denied(request, message="Test message", code="test_code") + mock_super.assert_called_once_with(request, "Test message", "test_code") + + def test_get_object_normal_case(self, user, djoser_settings): + """ + Test get_object normal case when HIDE_USERS=False. + """ + view = BaseMeAPIView() + + factory = APIRequestFactory() + request = factory.get("/me/") + request.user = user + view.request = request + + djoser_settings["HIDE_USERS"] = False + result = view.get_object() + assert result == user + + def test_get_object_hide_users_authenticated(self, user, djoser_settings): + """ + Test get_object when HIDE_USERS=True but user is authenticated (should work) + """ + view = BaseMeAPIView() + + factory = APIRequestFactory() + request = factory.get("/me/") + request.user = user # authenticated user + view.request = request + + djoser_settings["HIDE_USERS"] = True + result = view.get_object() + assert result == user # Should return user since they're authenticated + + +def test_get_queryset_returns_only_current_user(user, api_client): + """ + get_queryset must be usable; it previously called .objects on a QuerySet. + """ + from rest_framework.test import APIRequestFactory + + from testapp.factories import UserFactory + + UserFactory.create(username="other", email="other@example.com") + + view = BaseMeAPIView() + request = APIRequestFactory().get("/me/") + request.user = user + view.request = request + + assert list(view.get_queryset()) == [user] diff --git a/testproject/testapp/tests/test_urls/test_urls.py b/testproject/testapp/tests/test_urls/test_urls.py index 74b2513e..3a10d2cf 100644 --- a/testproject/testapp/tests/test_urls/test_urls.py +++ b/testproject/testapp/tests/test_urls/test_urls.py @@ -5,7 +5,24 @@ import pytest from deepdiff import DeepDiff from django.test import Client -from django.urls import get_resolver +from django.urls import get_resolver, resolve +from django.http import HttpResponseNotAllowed +from django.test import RequestFactory +from rest_framework.views import APIView + + +@pytest.mark.parametrize( + "path", + ["/auth/users/", "/auth/users/me/", "/auth/users/1/"], +) +def test_dispatcher_routed_urls_are_csrf_exempt(path): + """ + CsrfViewMiddleware inspects the callable the resolver returns. + + The dispatcher wraps the view, so it has to carry csrf_exempt itself or CSRF gets + enforced on endpoints that DRF exempts. + """ + assert getattr(resolve(path).func, "csrf_exempt", False) is True @pytest.mark.django_db @@ -31,6 +48,24 @@ def test_registration_works_with_csrf_middleware_enabled(settings): assert response.status_code == 201, response.content +def test_create_dispatcher_not_allowed(): + from djoser.urls.utils import create_dispatcher + + class DummyView(APIView): + def get(self, request): + return "response" + + method_view_map = {"GET": DummyView} + dispatcher = create_dispatcher(method_view_map) + + factory = RequestFactory() + request = factory.post("/") + + response = dispatcher(request) + assert isinstance(response, HttpResponseNotAllowed) + assert "GET" in response["Allow"] + + @pytest.mark.django_db def test_urls_have_not_changed(settings): BASE_DIR = settings.BASE_DIR @@ -62,10 +97,19 @@ def get_all_urls(patterns, prefix=""): view, "view_class" ): # assume all, even though probably not allowed_methods = view.view_class.http_method_names + elif ( + hasattr(view, "__name__") + and view.__name__ == "dispatcher" + and hasattr(view, "_allowed_methods") + ): + allowed_methods = [ + method.lower() for method in view._allowed_methods + ] else: raise NotImplementedError( "Function based views are not supported" ) + # head is not present in the CI for some reason... with suppress(ValueError): i = allowed_methods.index("head") @@ -101,3 +145,42 @@ def get_all_urls(patterns, prefix=""): pytest.fail( f"URL structure has changed. Updated snapshot with new URLs and names. Diff:\n\n{diff}" # noqa: E501 ) + + +@pytest.mark.parametrize( + "path,expected_kwargs", + [ + ("/auth/users/1.json", {"id": "1", "format": "json"}), + ("/auth/users.json", {"format": "json"}), + ("/auth/users/1/", {"id": "1"}), + ], +) +def test_format_suffix_routes_resolve(path, expected_kwargs): + """ + DRF's DefaultRouter generated these before the URLs were hand-written. + """ + assert resolve(path).kwargs == expected_kwargs + + +def test_user_lookup_does_not_swallow_dots(): + """ + A dotted segment must be read as a format suffix, not as the lookup value. + """ + assert resolve("/auth/users/1.json").kwargs["id"] == "1" + + +@pytest.mark.parametrize( + "path", + ["/auth/token/login", "/auth/token/logout", "/auth/jwt/create", "/auth/jwt/verify"], +) +@pytest.mark.django_db +def test_slashless_token_urls_redirect_preserving_method(path): + """ + Djoser 2.x matched these with an optional trailing slash. + + A 308 is used rather than a 301 so clients repeat the POST with its body instead of + downgrading to GET. + """ + response = Client().post(path, {}, content_type="application/json") + assert response.status_code == 308 + assert response["Location"] == f"{path}/" diff --git a/testproject/testapp/tests/test_urls/urls_snapshot.json b/testproject/testapp/tests/test_urls/urls_snapshot.json index b52be00f..dcc67fb5 100644 --- a/testproject/testapp/tests/test_urls/urls_snapshot.json +++ b/testproject/testapp/tests/test_urls/urls_snapshot.json @@ -1,6 +1,6 @@ [ { - "pattern": "^auth/^jwt/create/?", + "pattern": "^auth/^jwt/create/\\Z", "name": "jwt-create", "allowed_methods": [ "get", @@ -13,7 +13,20 @@ ] }, { - "pattern": "^auth/^jwt/refresh/?", + "pattern": "^auth/^jwt/create\\Z", + "name": null, + "allowed_methods": [ + "get", + "post", + "put", + "patch", + "delete", + "options", + "trace" + ] + }, + { + "pattern": "^auth/^jwt/refresh/\\Z", "name": "jwt-refresh", "allowed_methods": [ "get", @@ -26,7 +39,20 @@ ] }, { - "pattern": "^auth/^jwt/verify/?", + "pattern": "^auth/^jwt/refresh\\Z", + "name": null, + "allowed_methods": [ + "get", + "post", + "put", + "patch", + "delete", + "options", + "trace" + ] + }, + { + "pattern": "^auth/^jwt/verify/\\Z", "name": "jwt-verify", "allowed_methods": [ "get", @@ -38,6 +64,19 @@ "trace" ] }, + { + "pattern": "^auth/^jwt/verify\\Z", + "name": null, + "allowed_methods": [ + "get", + "post", + "put", + "patch", + "delete", + "options", + "trace" + ] + }, { "pattern": "^auth/^o/(?P\\S+)/$", "name": "provider-auth", @@ -52,7 +91,7 @@ ] }, { - "pattern": "^auth/^token/login/?$", + "pattern": "^auth/^token/login/\\Z", "name": "login", "allowed_methods": [ "get", @@ -65,7 +104,20 @@ ] }, { - "pattern": "^auth/^token/logout/?$", + "pattern": "^auth/^token/login\\Z", + "name": null, + "allowed_methods": [ + "get", + "post", + "put", + "patch", + "delete", + "options", + "trace" + ] + }, + { + "pattern": "^auth/^token/logout/\\Z", "name": "logout", "allowed_methods": [ "get", @@ -78,7 +130,20 @@ ] }, { - "pattern": "^auth/^users/$", + "pattern": "^auth/^token/logout\\Z", + "name": null, + "allowed_methods": [ + "get", + "post", + "put", + "patch", + "delete", + "options", + "trace" + ] + }, + { + "pattern": "^auth/^users(?P\\.[a-z0-9]+/?)\\Z", "name": "user-list", "allowed_methods": [ "get", @@ -86,7 +151,7 @@ ] }, { - "pattern": "^auth/^users/(?P[^/.]+)/$", + "pattern": "^auth/^users/(?P[^/.]+)(?P\\.[a-z0-9]+/?)\\Z", "name": "user-detail", "allowed_methods": [ "get", @@ -96,7 +161,7 @@ ] }, { - "pattern": "^auth/^users/(?P[^/.]+)\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/(?P[^/.]+)/\\Z", "name": "user-detail", "allowed_methods": [ "get", @@ -106,21 +171,29 @@ ] }, { - "pattern": "^auth/^users/activation/$", + "pattern": "^auth/^users/\\Z", + "name": "user-list", + "allowed_methods": [ + "get", + "post" + ] + }, + { + "pattern": "^auth/^users/activation(?P\\.[a-z0-9]+/?)\\Z", "name": "user-activation", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/activation\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/activation/\\Z", "name": "user-activation", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/me/$", + "pattern": "^auth/^users/me(?P\\.[a-z0-9]+/?)\\Z", "name": "user-me", "allowed_methods": [ "get", @@ -130,7 +203,7 @@ ] }, { - "pattern": "^auth/^users/me\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/me/\\Z", "name": "user-me", "allowed_methods": [ "get", @@ -140,111 +213,103 @@ ] }, { - "pattern": "^auth/^users/resend_activation/$", + "pattern": "^auth/^users/resend_activation(?P\\.[a-z0-9]+/?)\\Z", "name": "user-resend-activation", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/resend_activation\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/resend_activation/\\Z", "name": "user-resend-activation", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/reset_password/$", + "pattern": "^auth/^users/reset_password(?P\\.[a-z0-9]+/?)\\Z", "name": "user-reset-password", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/reset_password\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/reset_password/\\Z", "name": "user-reset-password", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/reset_password_confirm/$", + "pattern": "^auth/^users/reset_password_confirm(?P\\.[a-z0-9]+/?)\\Z", "name": "user-reset-password-confirm", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/reset_password_confirm\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/reset_password_confirm/\\Z", "name": "user-reset-password-confirm", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/reset_username/$", + "pattern": "^auth/^users/reset_username(?P\\.[a-z0-9]+/?)\\Z", "name": "user-reset-username", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/reset_username\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/reset_username/\\Z", "name": "user-reset-username", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/reset_username_confirm/$", + "pattern": "^auth/^users/reset_username_confirm(?P\\.[a-z0-9]+/?)\\Z", "name": "user-reset-username-confirm", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/reset_username_confirm\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/reset_username_confirm/\\Z", "name": "user-reset-username-confirm", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/set_password/$", + "pattern": "^auth/^users/set_password(?P\\.[a-z0-9]+/?)\\Z", "name": "user-set-password", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/set_password\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/set_password/\\Z", "name": "user-set-password", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/set_username/$", + "pattern": "^auth/^users/set_username(?P\\.[a-z0-9]+/?)\\Z", "name": "user-set-username", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/set_username\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/set_username/\\Z", "name": "user-set-username", "allowed_methods": [ "post" ] }, - { - "pattern": "^auth/^users\\.(?P[a-z0-9]+)/?$", - "name": "user-list", - "allowed_methods": [ - "get", - "post" - ] - }, { "pattern": "^webauthn-example/$", "name": null, diff --git a/testproject/testapp/tests/test_user_view.py b/testproject/testapp/tests/test_user_view.py index a1c0be38..ced7a71b 100644 --- a/testproject/testapp/tests/test_user_view.py +++ b/testproject/testapp/tests/test_user_view.py @@ -110,3 +110,28 @@ def test_fail_404_without_permission( assert response1.status_code == status.HTTP_404_NOT_FOUND response2 = authenticated_client.get(user_url) assert response2.status_code == status.HTTP_200_OK + + +@pytest.mark.parametrize("action", ["list", "retrieve", "update", "partial_update"]) +def test_hide_users_404s_regardless_of_permission_class(djoser_settings, user, action): + """ + HIDE_USERS exists to stop user enumeration, so it must 404 for every permission + class, including djoser's own defaults. + """ + from rest_framework.exceptions import NotFound + from rest_framework.test import APIRequestFactory + + from djoser.permissions import CurrentUserOrAdmin, CurrentUserOrAdminOrReadOnly + from djoser.views.user.retrieve import UserRetrieveView + + djoser_settings["HIDE_USERS"] = True + + for permission_class in (CurrentUserOrAdmin, CurrentUserOrAdminOrReadOnly): + view = UserRetrieveView() + view.action = action + view.permission_classes = [permission_class] + request = APIRequestFactory().get("/users/1/") + request.user = user + + with pytest.raises(NotFound): + view.permission_denied(request)