From c293f47a44dec489590198c86b73aca1192cdb89 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Fri, 3 May 2024 17:10:32 +0200 Subject: [PATCH 01/18] v3 branch init --- docs/source/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 46fcb5ee..36bf05ad 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 From 46d1fd55b46b7c8780d4da44180fde80d210c158 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sat, 4 May 2024 15:08:57 +0200 Subject: [PATCH 02/18] extract new UserViewSet without any actions --- djoser/urls/authtoken.py | 7 +- djoser/urls/base.py | 20 +++- djoser/{views.py => views/__init__.py} | 76 ++++++--------- djoser/views/token/__init__.py | 0 djoser/views/token/create.py | 20 ++++ djoser/views/token/destroy.py | 16 ++++ djoser/views/user/__init__.py | 0 djoser/views/user/base.py | 56 +++++++++++ djoser/views/user/user.py | 92 +++++++++++++++++++ testproject/testapp/tests/test_user_detail.py | 21 +++-- 10 files changed, 248 insertions(+), 60 deletions(-) rename djoser/{views.py => views/__init__.py} (85%) create mode 100644 djoser/views/token/__init__.py create mode 100644 djoser/views/token/create.py create mode 100644 djoser/views/token/destroy.py create mode 100644 djoser/views/user/__init__.py create mode 100644 djoser/views/user/base.py create mode 100644 djoser/views/user/user.py diff --git a/djoser/urls/authtoken.py b/djoser/urls/authtoken.py index ccfc1d82..bf87063e 100644 --- a/djoser/urls/authtoken.py +++ b/djoser/urls/authtoken.py @@ -1,8 +1,9 @@ from django.urls import re_path -from djoser import views +from djoser.views.token.create import TokenCreateView +from djoser.views.token.destroy import TokenDestroyView urlpatterns = [ - re_path(r"^token/login/?$", views.TokenCreateView.as_view(), name="login"), - re_path(r"^token/logout/?$", views.TokenDestroyView.as_view(), name="logout"), + re_path(r"^token/login/?$", TokenCreateView.as_view(), name="login"), + re_path(r"^token/logout/?$", TokenDestroyView.as_view(), name="logout"), ] diff --git a/djoser/urls/base.py b/djoser/urls/base.py index 44afa38e..a02a01ea 100644 --- a/djoser/urls/base.py +++ b/djoser/urls/base.py @@ -1,11 +1,21 @@ -from django.contrib.auth import get_user_model from rest_framework.routers import DefaultRouter -from djoser import views + +from djoser.views import UserViewSet as OldUserViewSet +from djoser.views.user.user import UserViewSet + +old_router = DefaultRouter() +old_router.register("users-old", OldUserViewSet) router = DefaultRouter() -router.register("users", views.UserViewSet) +router.register("users", UserViewSet) + +# User = get_user_model() -User = get_user_model() +# users_url = path("users/", ListCreateUserView.as_view(), name="user-list") -urlpatterns = router.urls +urlpatterns = [ + # users_url, + *old_router.urls, + *router.urls, +] diff --git a/djoser/views.py b/djoser/views/__init__.py similarity index 85% rename from djoser/views.py rename to djoser/views/__init__.py index c0de757c..e96bae26 100644 --- a/djoser/views.py +++ b/djoser/views/__init__.py @@ -1,10 +1,11 @@ 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 import status, mixins from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet from djoser import signals, utils from djoser.compat import get_user_email @@ -13,31 +14,14 @@ 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).""" - - 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): +class UserViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + GenericViewSet, +): serializer_class = settings.SERIALIZERS.user queryset = User.objects.all() permission_classes = settings.PERMISSIONS.user @@ -61,14 +45,15 @@ def get_queryset(self): return queryset def get_permissions(self): - if self.action == "create": - self.permission_classes = settings.PERMISSIONS.user_create + deprecated = ("create", "list") + if self.action in deprecated: + raise RuntimeError 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 == "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": @@ -88,10 +73,9 @@ def get_permissions(self): 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 + deprecated = ("create", "list") + if self.action in deprecated: + raise RuntimeError elif self.action == "destroy" or ( self.action == "me" and self.request and self.request.method == "DELETE" ): @@ -128,18 +112,18 @@ def get_serializer_class(self): 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_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) 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..3cbc63e5 --- /dev/null +++ b/djoser/views/token/create.py @@ -0,0 +1,20 @@ +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..82481ca2 --- /dev/null +++ b/djoser/views/token/destroy.py @@ -0,0 +1,16 @@ +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..e69de29b diff --git a/djoser/views/user/base.py b/djoser/views/user/base.py new file mode 100644 index 00000000..57fe1aed --- /dev/null +++ b/djoser/views/user/base.py @@ -0,0 +1,56 @@ +from django.contrib.auth import get_user_model +from rest_framework import generics, status +from rest_framework.exceptions import NotFound +from django.contrib.auth.tokens import default_token_generator +from rest_framework.response import Response + +from djoser import signals, utils +from djoser.compat import get_user_email + +from djoser.conf import settings + + +User = get_user_model() + + +class GenericUserAPIView(generics.GenericAPIView): + 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_instance(self): + return self.request.user + + 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) diff --git a/djoser/views/user/user.py b/djoser/views/user/user.py new file mode 100644 index 00000000..1ab1291d --- /dev/null +++ b/djoser/views/user/user.py @@ -0,0 +1,92 @@ +from django.contrib.auth import get_user_model +from rest_framework import status, viewsets +from rest_framework.exceptions import NotFound +from rest_framework.response import Response + +from djoser import signals, utils +from djoser.conf import settings +from djoser.compat import get_user_email +from django.contrib.auth.tokens import default_token_generator + +User = get_user_model() + + +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 == "list": + self.permission_classes = settings.PERMISSIONS.user_list + elif self.action == "destroy": + 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": + return settings.SERIALIZERS.user_delete + 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) diff --git a/testproject/testapp/tests/test_user_detail.py b/testproject/testapp/tests/test_user_detail.py index 837f3644..f61c2750 100644 --- a/testproject/testapp/tests/test_user_detail.py +++ b/testproject/testapp/tests/test_user_detail.py @@ -5,7 +5,9 @@ from testapp.tests.common import create_user, login_user import djoser.permissions -import djoser.views + +from djoser.views import UserViewSet as OldUserViewSet +from djoser.views.user.user import UserViewSet class BaseUserViewSetListTest(APITestCase, assertions.StatusCodeAssertionsMixin): @@ -26,14 +28,18 @@ class ModifiedPermissionsTest(APITestCase): def setUp(self): super().setUp() - self.previous_permissions = djoser.views.UserViewSet.permission_classes - djoser.views.UserViewSet.permission_classes = [ + self.previous_permissions = OldUserViewSet.permission_classes + OldUserViewSet.permission_classes = [ + djoser.permissions.CurrentUserOrAdminOrReadOnly + ] + UserViewSet.permission_classes = [ djoser.permissions.CurrentUserOrAdminOrReadOnly ] def tearDown(self): super().tearDown() - djoser.views.UserViewSet.permission_classes = self.previous_permissions + OldUserViewSet.permission_classes = self.previous_permissions + UserViewSet.permission_classes = self.previous_permissions class UserViewSetListTest(BaseUserViewSetListTest): @@ -72,8 +78,11 @@ def test_user_can_get_other_user_detail(self): def test_user_cant_set_other_user_detail(self): login_user(self.client, self.user) - response = self.client.get(reverse("user-detail", args=[self.superuser.pk])) - self.assert_status_equal(response, status.HTTP_200_OK) + response = self.client.patch( + reverse("user-detail", args=[self.superuser.pk]), + data={"email": "eggs@example.com"}, + ) + self.assert_status_equal(response, status.HTTP_404_NOT_FOUND) class UserViewSetEditTest(APITestCase, assertions.StatusCodeAssertionsMixin): From 29aead36bbc480b06184808c447963f8213ec42f Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sat, 4 May 2024 15:18:15 +0200 Subject: [PATCH 03/18] drop router from the new UserViewSet in urls --- djoser/urls/base.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/djoser/urls/base.py b/djoser/urls/base.py index a02a01ea..8b2543aa 100644 --- a/djoser/urls/base.py +++ b/djoser/urls/base.py @@ -1,21 +1,30 @@ +from django.urls import path from rest_framework.routers import DefaultRouter - from djoser.views import UserViewSet as OldUserViewSet from djoser.views.user.user import UserViewSet old_router = DefaultRouter() old_router.register("users-old", OldUserViewSet) -router = DefaultRouter() -router.register("users", UserViewSet) - -# User = get_user_model() - -# users_url = path("users/", ListCreateUserView.as_view(), name="user-list") +user_list = path( + "users/", UserViewSet.as_view({"get": "list", "post": "create"}), name="user-list" +) +user_detail = path( + f"users/<{UserViewSet.lookup_field}>/", + UserViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="user-detail", +) urlpatterns = [ - # users_url, *old_router.urls, - *router.urls, + user_list, + user_detail, ] From 872e7c2bec946358daa0074d1b7825f3dd31089e Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sat, 4 May 2024 15:40:17 +0200 Subject: [PATCH 04/18] extract me viewset --- djoser/urls/base.py | 15 ++++++++++++++- djoser/views/__init__.py | 12 ------------ djoser/views/user/me.py | 18 ++++++++++++++++++ djoser/views/user/user.py | 3 --- 4 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 djoser/views/user/me.py diff --git a/djoser/urls/base.py b/djoser/urls/base.py index 8b2543aa..6de009ab 100644 --- a/djoser/urls/base.py +++ b/djoser/urls/base.py @@ -2,6 +2,7 @@ from rest_framework.routers import DefaultRouter from djoser.views import UserViewSet as OldUserViewSet +from djoser.views.user.me import UserMeViewSet from djoser.views.user.user import UserViewSet old_router = DefaultRouter() @@ -22,9 +23,21 @@ ), name="user-detail", ) - +me_list = path( + "users/me/", + UserMeViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="user-me", +) urlpatterns = [ *old_router.urls, + me_list, user_list, user_detail, ] diff --git a/djoser/views/__init__.py b/djoser/views/__init__.py index e96bae26..ab75bd25 100644 --- a/djoser/views/__init__.py +++ b/djoser/views/__init__.py @@ -148,18 +148,6 @@ def destroy(self, request, *args, **kwargs): 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) diff --git a/djoser/views/user/me.py b/djoser/views/user/me.py new file mode 100644 index 00000000..1cd3a4c3 --- /dev/null +++ b/djoser/views/user/me.py @@ -0,0 +1,18 @@ +from djoser.conf import settings +from djoser.views.user.user import UserViewSet + + +class UserMeViewSet(UserViewSet): + def get_serializer_class(self): + if self.action == "destroy": + serializer_class = super().get_serializer_class() + else: + serializer_class = settings.SERIALIZERS.current_user + return serializer_class + + def get_object(self): + return self.request.user + + def get_queryset(self): + queryset = self.queryset.objects.all() + return queryset.filter(pk=self.request.user.pk) diff --git a/djoser/views/user/user.py b/djoser/views/user/user.py index 1ab1291d..ec7b10f3 100644 --- a/djoser/views/user/user.py +++ b/djoser/views/user/user.py @@ -52,9 +52,6 @@ def get_serializer_class(self): return settings.SERIALIZERS.user_delete 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( From 62723579a817a40c5633cadc6a18dc9cd5f9c728 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sat, 4 May 2024 16:22:47 +0200 Subject: [PATCH 05/18] move the remaining actions out of the old UserViewSet, drop the old UserViewSet --- djoser/urls/base.py | 65 ++++- djoser/views/__init__.py | 277 -------------------- djoser/views/user/activation.py | 30 +++ djoser/views/user/base.py | 48 +--- djoser/views/user/password_reset_confirm.py | 36 +++ djoser/views/user/resend_activation.py | 26 ++ djoser/views/user/reset_password.py | 23 ++ djoser/views/user/reset_username.py | 23 ++ djoser/views/user/reset_username_confirm.py | 37 +++ djoser/views/user/set_password.py | 39 +++ djoser/views/user/set_username.py | 35 +++ 11 files changed, 311 insertions(+), 328 deletions(-) create mode 100644 djoser/views/user/activation.py create mode 100644 djoser/views/user/password_reset_confirm.py create mode 100644 djoser/views/user/resend_activation.py create mode 100644 djoser/views/user/reset_password.py create mode 100644 djoser/views/user/reset_username.py create mode 100644 djoser/views/user/reset_username_confirm.py create mode 100644 djoser/views/user/set_password.py create mode 100644 djoser/views/user/set_username.py diff --git a/djoser/urls/base.py b/djoser/urls/base.py index 6de009ab..1871d2af 100644 --- a/djoser/urls/base.py +++ b/djoser/urls/base.py @@ -1,13 +1,24 @@ +from django.contrib.auth import get_user_model from django.urls import path -from rest_framework.routers import DefaultRouter -from djoser.views import UserViewSet as OldUserViewSet +from djoser.views.user.activation import UserActivationAPIView from djoser.views.user.me import UserMeViewSet +from djoser.views.user.password_reset_confirm import UserPasswordResetConfirmAPIView +from djoser.views.user.resend_activation import UserResendActivationAPIView +from djoser.views.user.reset_password import UserResetPasswordAPIView +from djoser.views.user.reset_username import UserResetUsernameAPIView +from djoser.views.user.reset_username_confirm import UserResetUsernameConfirmAPIView +from djoser.views.user.set_password import UserSetPasswordAPIView +from djoser.views.user.set_username import UserSetUsernameAPIView from djoser.views.user.user import UserViewSet -old_router = DefaultRouter() -old_router.register("users-old", OldUserViewSet) +User = get_user_model() + + +user_activation = path( + "users/activation/", UserActivationAPIView.as_view(), name="user-activation" +) user_list = path( "users/", UserViewSet.as_view({"get": "list", "post": "create"}), name="user-list" ) @@ -23,6 +34,40 @@ ), name="user-detail", ) +user_password_reset_confirm = path( + "users/reset_password_confirm/", + UserPasswordResetConfirmAPIView.as_view(), + name="user-reset-password-confirm", +) +user_resend_activation = path( + "users/resend-activation/", + UserResendActivationAPIView.as_view(), + name="user-resend-activation", +) +user_reset_password = path( + "users/reset_password", + UserResetPasswordAPIView.as_view(), + name="user-reset-password", +) +user_reset_username = path( + f"users/reset_{User.USERNAME_FIELD}", + UserResetUsernameAPIView.as_view(), + name="user-reset-username", +) +user_reset_username_confirm = path( + f"users/reset_{User.USERNAME_FIELD}_confirm", + UserResetUsernameConfirmAPIView.as_view(), + name="user-reset-username-confirm", +) +user_set_password = path( + "users/set_password", UserSetPasswordAPIView.as_view(), name="user-set-password" +) +user_set_username = path( + f"users/set_{User.USERNAME_FIELD}", + UserSetUsernameAPIView.as_view(), + name="user-set-username", +) + me_list = path( "users/me/", UserMeViewSet.as_view( @@ -35,9 +80,17 @@ ), name="user-me", ) + urlpatterns = [ - *old_router.urls, + 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_list, user_detail, + user_list, ] diff --git a/djoser/views/__init__.py b/djoser/views/__init__.py index ab75bd25..e69de29b 100644 --- a/djoser/views/__init__.py +++ b/djoser/views/__init__.py @@ -1,277 +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 status, mixins -from rest_framework.decorators import action -from rest_framework.exceptions import NotFound -from rest_framework.response import Response -from rest_framework.viewsets import GenericViewSet - -from djoser import signals, utils -from djoser.compat import get_user_email -from djoser.conf import settings - -User = get_user_model() - - -class UserViewSet( - mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - GenericViewSet, -): - 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): - deprecated = ("create", "list") - if self.action in deprecated: - raise RuntimeError - 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): - deprecated = ("create", "list") - if self.action in deprecated: - raise RuntimeError - 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(["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/user/activation.py b/djoser/views/user/activation.py new file mode 100644 index 00000000..1bb0d8f5 --- /dev/null +++ b/djoser/views/user/activation.py @@ -0,0 +1,30 @@ +from rest_framework import status +from rest_framework.response import Response + +from djoser import signals +from djoser.compat import get_user_email +from djoser.conf import settings +from djoser.views.user.base import GenericUserAPIView + + +class UserActivationAPIView(GenericUserAPIView): + serializer_class = settings.SERIALIZERS.activation + permission_classes = settings.PERMISSIONS.activation + + 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/user/base.py b/djoser/views/user/base.py index 57fe1aed..3196a27e 100644 --- a/djoser/views/user/base.py +++ b/djoser/views/user/base.py @@ -1,12 +1,6 @@ from django.contrib.auth import get_user_model -from rest_framework import generics, status -from rest_framework.exceptions import NotFound from django.contrib.auth.tokens import default_token_generator -from rest_framework.response import Response - -from djoser import signals, utils -from djoser.compat import get_user_email - +from rest_framework import generics from djoser.conf import settings @@ -14,43 +8,7 @@ class GenericUserAPIView(generics.GenericAPIView): - 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_instance(self): - return self.request.user - - 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) + http_method_names = ["post"] + token_generator = default_token_generator # used in serializers diff --git a/djoser/views/user/password_reset_confirm.py b/djoser/views/user/password_reset_confirm.py new file mode 100644 index 00000000..5027ed90 --- /dev/null +++ b/djoser/views/user/password_reset_confirm.py @@ -0,0 +1,36 @@ +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.compat import get_user_email +from djoser.conf import settings +from djoser.views.user.base import GenericUserAPIView + + +User = get_user_model() + + +class UserPasswordResetConfirmAPIView(GenericUserAPIView): + serializer_class = settings.SERIALIZERS.password_reset_confirm + permission_classes = settings.PERMISSIONS.password_reset_confirm + + 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/user/resend_activation.py b/djoser/views/user/resend_activation.py new file mode 100644 index 00000000..595c1d39 --- /dev/null +++ b/djoser/views/user/resend_activation.py @@ -0,0 +1,26 @@ +from rest_framework import status +from rest_framework.response import Response + +from djoser.compat import get_user_email +from djoser.conf import settings +from djoser.views.user.base import GenericUserAPIView + + +class UserResendActivationAPIView(GenericUserAPIView): + serializer_class = settings.SERIALIZERS.password_reset + permission_classes = settings.PERMISSIONS.password_reset + + 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/user/reset_password.py b/djoser/views/user/reset_password.py new file mode 100644 index 00000000..9aed0023 --- /dev/null +++ b/djoser/views/user/reset_password.py @@ -0,0 +1,23 @@ +from rest_framework import status +from rest_framework.response import Response + +from djoser.compat import get_user_email +from djoser.conf import settings +from djoser.views.user.base import GenericUserAPIView + + +class UserResetPasswordAPIView(GenericUserAPIView): + serializer_class = settings.SERIALIZERS.password_reset + permission_classes = settings.PERMISSIONS.password_reset + + 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/user/reset_username.py b/djoser/views/user/reset_username.py new file mode 100644 index 00000000..d68ed21a --- /dev/null +++ b/djoser/views/user/reset_username.py @@ -0,0 +1,23 @@ +from rest_framework import status +from rest_framework.response import Response + +from djoser.compat import get_user_email +from djoser.conf import settings +from djoser.views.user.base import GenericUserAPIView + + +class UserResetUsernameAPIView(GenericUserAPIView): + serializer_class = settings.SERIALIZERS.username_reset + permission_classes = settings.PERMISSIONS.username_reset + + 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/user/reset_username_confirm.py b/djoser/views/user/reset_username_confirm.py new file mode 100644 index 00000000..358cb0d1 --- /dev/null +++ b/djoser/views/user/reset_username_confirm.py @@ -0,0 +1,37 @@ +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.compat import get_user_email +from djoser.conf import settings +from djoser.views.user.base import GenericUserAPIView + + +User = get_user_model() + + +class UserResetUsernameConfirmAPIView(GenericUserAPIView): + serializer_class = settings.SERIALIZERS.username_reset_confirm + permission_classes = settings.PERMISSIONS.username_reset_confirm + + 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) + 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/user/set_password.py b/djoser/views/user/set_password.py new file mode 100644 index 00000000..de7db140 --- /dev/null +++ b/djoser/views/user/set_password.py @@ -0,0 +1,39 @@ +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.compat import get_user_email +from djoser.conf import settings +from djoser.views.user.base import GenericUserAPIView + + +User = get_user_model() + + +class UserSetPasswordAPIView(GenericUserAPIView): + serializer_class = settings.SERIALIZERS.set_password + permission_classes = settings.PERMISSIONS.set_password + + 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/user/set_username.py b/djoser/views/user/set_username.py new file mode 100644 index 00000000..d5497e52 --- /dev/null +++ b/djoser/views/user/set_username.py @@ -0,0 +1,35 @@ +from rest_framework import status +from rest_framework.response import Response + +from djoser import signals +from djoser.compat import get_user_email +from djoser.conf import settings +from djoser.views.user.base import GenericUserAPIView + + +class UserSetUsernameAPIView(GenericUserAPIView): + serializer_class = settings.SERIALIZERS.set_username + permission_classes = settings.PERMISSIONS.set_username + + 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 = 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) From 0ef9f51a0522237b97fa3f133503e38a15bdd703 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sat, 4 May 2024 16:29:39 +0200 Subject: [PATCH 06/18] rm the old viewset from tests --- testproject/testapp/tests/test_user_delete.py | 3 --- testproject/testapp/tests/test_user_detail.py | 7 +------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/testproject/testapp/tests/test_user_delete.py b/testproject/testapp/tests/test_user_delete.py index be60457e..9b551919 100644 --- a/testproject/testapp/tests/test_user_delete.py +++ b/testproject/testapp/tests/test_user_delete.py @@ -7,7 +7,6 @@ from rest_framework.reverse import reverse from rest_framework.test import APITestCase -import djoser.views from djoser.conf import settings as djoser_settings from .common import PermCheckClass, RunCheck, SerializerCheckClass, create_user @@ -21,8 +20,6 @@ class UserMeDeleteViewTest( assertions.EmailAssertionsMixin, assertions.InstanceAssertionsMixin, ): - viewset = djoser.views.UserViewSet - def test_delete_user_if_logged_in(self): user = create_user() self.assert_instance_exists(User, username="john") diff --git a/testproject/testapp/tests/test_user_detail.py b/testproject/testapp/tests/test_user_detail.py index f61c2750..8d299113 100644 --- a/testproject/testapp/tests/test_user_detail.py +++ b/testproject/testapp/tests/test_user_detail.py @@ -6,7 +6,6 @@ import djoser.permissions -from djoser.views import UserViewSet as OldUserViewSet from djoser.views.user.user import UserViewSet @@ -28,17 +27,13 @@ class ModifiedPermissionsTest(APITestCase): def setUp(self): super().setUp() - self.previous_permissions = OldUserViewSet.permission_classes - OldUserViewSet.permission_classes = [ - djoser.permissions.CurrentUserOrAdminOrReadOnly - ] + self.previous_permissions = UserViewSet.permission_classes UserViewSet.permission_classes = [ djoser.permissions.CurrentUserOrAdminOrReadOnly ] def tearDown(self): super().tearDown() - OldUserViewSet.permission_classes = self.previous_permissions UserViewSet.permission_classes = self.previous_permissions From 3c4b26ca72232b9d8ecf71dab21c9d4145eab842 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sat, 4 May 2024 16:30:16 +0200 Subject: [PATCH 07/18] align UserViewSet get_serializer_class --- djoser/views/user/user.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/djoser/views/user/user.py b/djoser/views/user/user.py index ec7b10f3..973abf65 100644 --- a/djoser/views/user/user.py +++ b/djoser/views/user/user.py @@ -46,11 +46,14 @@ def get_permissions(self): 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 + serializer_class = settings.SERIALIZERS.user_create_password_retype + else: + serializer_class = settings.SERIALIZERS.user_create elif self.action == "destroy": - return settings.SERIALIZERS.user_delete - return self.serializer_class + serializer_class = settings.SERIALIZERS.user_delete + else: + serializer_class = self.serializer_class + return serializer_class def perform_create(self, serializer, *args, **kwargs): user = serializer.save(*args, **kwargs) From 96d1ccf782579d3a44c91227f8bf019083b5bfc3 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sat, 4 May 2024 16:47:20 +0200 Subject: [PATCH 08/18] fix copy-pasted incorrect set username viewset when refactoring --- djoser/views/user/set_username.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/djoser/views/user/set_username.py b/djoser/views/user/set_username.py index d5497e52..de5b47fb 100644 --- a/djoser/views/user/set_username.py +++ b/djoser/views/user/set_username.py @@ -1,12 +1,15 @@ +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.compat import get_user_email from djoser.conf import settings from djoser.views.user.base import GenericUserAPIView +User = get_user_model() + + class UserSetUsernameAPIView(GenericUserAPIView): serializer_class = settings.SERIALIZERS.set_username permission_classes = settings.PERMISSIONS.set_username @@ -19,17 +22,13 @@ def get_serializer_class(self): 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() + user = self.request.user + new_username = serializer.data["new_" + User.USERNAME_FIELD] - signals.user_activated.send( - sender=self.__class__, user=user, request=self.request - ) - - if settings.SEND_CONFIRMATION_EMAIL: + 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.confirmation(self.request, context).send(to) - + settings.EMAIL.username_changed_confirmation(self.request, context).send(to) return Response(status=status.HTTP_204_NO_CONTENT) From dbf874b8f6efde14f799a1ff9d51a8db45d7a5a6 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sat, 4 May 2024 16:47:30 +0200 Subject: [PATCH 09/18] fix tests, drop unused mocks --- testproject/testapp/tests/test_password_reset.py | 2 -- testproject/testapp/tests/test_resend_activation.py | 1 - testproject/testapp/tests/test_reset_username.py | 2 -- testproject/testapp/tests/test_set_username.py | 4 ++-- testproject/testapp/tests/test_user_create.py | 8 ++++---- 5 files changed, 6 insertions(+), 11 deletions(-) diff --git a/testproject/testapp/tests/test_password_reset.py b/testproject/testapp/tests/test_password_reset.py index 96db7a0c..0b531b36 100644 --- a/testproject/testapp/tests/test_password_reset.py +++ b/testproject/testapp/tests/test_password_reset.py @@ -69,7 +69,6 @@ def test_post_should_return_bad_request_if_user_does_not_exist(self): ) @mock.patch("djoser.serializers.User", CustomUser) - @mock.patch("djoser.views.User", CustomUser) @override_settings(AUTH_USER_MODEL="testapp.CustomUser") def test_post_should_send_email_to_custom_user_with_password_reset_link( self, @@ -88,7 +87,6 @@ def test_post_should_send_email_to_custom_user_with_password_reset_link( self.assertIn(site.name, mail.outbox[0].body) @mock.patch("djoser.serializers.User", CustomUser) - @mock.patch("djoser.views.User", CustomUser) @override_settings( AUTH_USER_MODEL="testapp.CustomUser", DJOSER=dict(settings.DJOSER, **{"PASSWORD_RESET_SHOW_EMAIL_NOT_FOUND": True}), diff --git a/testproject/testapp/tests/test_resend_activation.py b/testproject/testapp/tests/test_resend_activation.py index c451b916..da2ba32e 100644 --- a/testproject/testapp/tests/test_resend_activation.py +++ b/testproject/testapp/tests/test_resend_activation.py @@ -53,7 +53,6 @@ def test_dont_resend_activation_when_no_password(self): self.assert_status_equal(response, status.HTTP_204_NO_CONTENT) @mock.patch("djoser.serializers.User", CustomUser) - @mock.patch("djoser.views.User", CustomUser) @override_settings( AUTH_USER_MODEL="testapp.CustomUser", DJOSER=dict(settings.DJOSER, **{"SEND_ACTIVATION_EMAIL": True}), diff --git a/testproject/testapp/tests/test_reset_username.py b/testproject/testapp/tests/test_reset_username.py index 1d210506..2e6b73f4 100644 --- a/testproject/testapp/tests/test_reset_username.py +++ b/testproject/testapp/tests/test_reset_username.py @@ -72,7 +72,6 @@ def test_post_should_return_bad_request_if_user_does_not_exist(self): ) @mock.patch("djoser.serializers.User", CustomUser) - @mock.patch("djoser.views.User", CustomUser) @override_settings(AUTH_USER_MODEL="testapp.CustomUser") def test_post_should_send_email_to_custom_user_with_username_reset_link( self, @@ -91,7 +90,6 @@ def test_post_should_send_email_to_custom_user_with_username_reset_link( self.assertIn(site.name, mail.outbox[0].body) @mock.patch("djoser.serializers.User", CustomUser) - @mock.patch("djoser.views.User", CustomUser) @override_settings( AUTH_USER_MODEL="testapp.CustomUser", DJOSER=dict(settings.DJOSER, **{"USERNAME_RESET_SHOW_EMAIL_NOT_FOUND": True}), diff --git a/testproject/testapp/tests/test_set_username.py b/testproject/testapp/tests/test_set_username.py index d2165e98..dffd94f3 100644 --- a/testproject/testapp/tests/test_set_username.py +++ b/testproject/testapp/tests/test_set_username.py @@ -110,7 +110,7 @@ def test_post_not_set_new_username_if_same(self): "djoser.serializers.SetUsernameSerializer.Meta.fields", (CustomUser.USERNAME_FIELD, "custom_username"), ) - @mock.patch("djoser.views.User", CustomUser) + @mock.patch("djoser.views.user.set_username.User", CustomUser) @override_settings( AUTH_USER_MODEL="testapp.CustomUser", DJOSER=dict(settings.DJOSER, **{"LOGIN_FIELD": CustomUser.USERNAME_FIELD}), @@ -132,7 +132,7 @@ def test_post_set_new_custom_username(self): "djoser.serializers.SetUsernameSerializer.Meta.fields", (CustomUser.USERNAME_FIELD, "custom_username"), ) - @mock.patch("djoser.views.User", CustomUser) + @mock.patch("djoser.views.user.set_username.User", CustomUser) @override_settings( AUTH_USER_MODEL="testapp.CustomUser", DJOSER=dict( diff --git a/testproject/testapp/tests/test_user_create.py b/testproject/testapp/tests/test_user_create.py index a6e8e3da..caa1fb4d 100644 --- a/testproject/testapp/tests/test_user_create.py +++ b/testproject/testapp/tests/test_user_create.py @@ -124,7 +124,7 @@ def test_post_return_400_for_integrity_error(self, perform_create): tuple(CustomUser.REQUIRED_FIELDS) + (CustomUser.USERNAME_FIELD, CustomUser._meta.pk.name, "password"), ) - @mock.patch("djoser.views.User", CustomUser) + @mock.patch("djoser.views.user.user.User", CustomUser) @override_settings(AUTH_USER_MODEL="testapp.CustomUser") def test_post_create_custom_user_with_all_required_fields(self): data = { @@ -152,7 +152,7 @@ def test_post_create_custom_user_with_all_required_fields(self): tuple(CustomUser.REQUIRED_FIELDS) + (CustomUser.USERNAME_FIELD, CustomUser._meta.pk.name, "password"), ) - @mock.patch("djoser.views.User", CustomUser) + @mock.patch("djoser.views.user.user.User", CustomUser) @override_settings(AUTH_USER_MODEL="testapp.CustomUser") def test_post_not_create_custom_user_with_missing_required_fields(self): data = {"custom_username": "john", "password": "secret"} @@ -169,7 +169,7 @@ def test_post_not_create_custom_user_with_missing_required_fields(self): tuple(ExampleUser.REQUIRED_FIELDS) + (ExampleUser.USERNAME_FIELD, ExampleUser._meta.pk.name, "password"), ) - @mock.patch("djoser.views.User", ExampleUser) + @mock.patch("djoser.views.user.user.User", ExampleUser) @override_settings(AUTH_USER_MODEL="testapp.ExampleUser") def test_post_create_custom_user_without_username(self): data = {"password": "secret", "email": "test@user1.com"} @@ -188,7 +188,7 @@ def test_post_create_custom_user_without_username(self): tuple(ExampleUser.REQUIRED_FIELDS) + (ExampleUser.USERNAME_FIELD, ExampleUser._meta.pk.name, "password"), ) - @mock.patch("djoser.views.User", ExampleUser) + @mock.patch("djoser.views.user.user.User", ExampleUser) @override_settings(AUTH_USER_MODEL="testapp.ExampleUser") def test_post_create_custom_user_missing_required_fields(self): data = {"password": "secret"} From 26a5383e9bea54e40877e19b2df7f9ab38d8bde9 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sun, 10 Nov 2024 11:26:18 +0100 Subject: [PATCH 10/18] rm empty views --- djoser/views.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 djoser/views.py diff --git a/djoser/views.py b/djoser/views.py deleted file mode 100644 index e69de29b..00000000 From e20dc24f8a8c598aa5a4676964423922d049efed Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sun, 10 Nov 2024 12:04:37 +0100 Subject: [PATCH 11/18] activation --- djoser/views/user/activation.py | 32 ++++++++++++++++++++++---- djoser/views/user/resend_activation.py | 26 --------------------- 2 files changed, 27 insertions(+), 31 deletions(-) delete mode 100644 djoser/views/user/resend_activation.py diff --git a/djoser/views/user/activation.py b/djoser/views/user/activation.py index 1bb0d8f5..5d8d249a 100644 --- a/djoser/views/user/activation.py +++ b/djoser/views/user/activation.py @@ -1,15 +1,17 @@ -from rest_framework import status +from django.contrib.auth import get_user_model +from rest_framework import generics, status from rest_framework.response import Response from djoser import signals -from djoser.compat import get_user_email from djoser.conf import settings -from djoser.views.user.base import GenericUserAPIView +from djoser.compat import get_user_email +User = get_user_model() -class UserActivationAPIView(GenericUserAPIView): - serializer_class = settings.SERIALIZERS.activation + +class ActivationView(generics.GenericAPIView): permission_classes = settings.PERMISSIONS.activation + serializer_class = settings.SERIALIZERS.activation def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) @@ -28,3 +30,23 @@ def post(self, request, *args, **kwargs): settings.EMAIL.confirmation(self.request, context).send(to) return Response(status=status.HTTP_204_NO_CONTENT) + + +class ResendActivationView(generics.GenericAPIView): + permission_classes = settings.PERMISSIONS.password_reset + serializer_class = settings.SERIALIZERS.password_reset + + 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/user/resend_activation.py b/djoser/views/user/resend_activation.py deleted file mode 100644 index 595c1d39..00000000 --- a/djoser/views/user/resend_activation.py +++ /dev/null @@ -1,26 +0,0 @@ -from rest_framework import status -from rest_framework.response import Response - -from djoser.compat import get_user_email -from djoser.conf import settings -from djoser.views.user.base import GenericUserAPIView - - -class UserResendActivationAPIView(GenericUserAPIView): - serializer_class = settings.SERIALIZERS.password_reset - permission_classes = settings.PERMISSIONS.password_reset - - 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) From df5520cb53256aa8b328fba5e004b0c04fa8479f Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sun, 10 Nov 2024 14:08:18 +0100 Subject: [PATCH 12/18] updated views --- djoser/urls/base.py | 81 +++++++++--------- djoser/views/user/activation.py | 9 +- djoser/views/user/base.py | 2 +- djoser/views/user/me.py | 65 ++++++++++++--- djoser/views/user/password.py | 82 +++++++++++++++++++ djoser/views/user/password_reset_confirm.py | 36 -------- djoser/views/user/reset_password.py | 23 ------ djoser/views/user/reset_username.py | 23 ------ djoser/views/user/reset_username_confirm.py | 37 --------- djoser/views/user/set_password.py | 39 --------- djoser/views/user/set_username.py | 34 -------- djoser/views/user/user.py | 1 + djoser/views/user/username.py | 79 ++++++++++++++++++ .../testapp/tests/test_urls/test_urls.py | 8 +- 14 files changed, 273 insertions(+), 246 deletions(-) create mode 100644 djoser/views/user/password.py delete mode 100644 djoser/views/user/password_reset_confirm.py delete mode 100644 djoser/views/user/reset_password.py delete mode 100644 djoser/views/user/reset_username.py delete mode 100644 djoser/views/user/reset_username_confirm.py delete mode 100644 djoser/views/user/set_password.py delete mode 100644 djoser/views/user/set_username.py create mode 100644 djoser/views/user/username.py diff --git a/djoser/urls/base.py b/djoser/urls/base.py index 1871d2af..812d8a7b 100644 --- a/djoser/urls/base.py +++ b/djoser/urls/base.py @@ -1,24 +1,26 @@ from django.contrib.auth import get_user_model from django.urls import path -from djoser.views.user.activation import UserActivationAPIView -from djoser.views.user.me import UserMeViewSet -from djoser.views.user.password_reset_confirm import UserPasswordResetConfirmAPIView -from djoser.views.user.resend_activation import UserResendActivationAPIView -from djoser.views.user.reset_password import UserResetPasswordAPIView -from djoser.views.user.reset_username import UserResetUsernameAPIView -from djoser.views.user.reset_username_confirm import UserResetUsernameConfirmAPIView -from djoser.views.user.set_password import UserSetPasswordAPIView -from djoser.views.user.set_username import UserSetUsernameAPIView +from djoser.views.user.activation import ( + UserActivationAPIView, + UserResendActivationAPIView, +) +from djoser.views.user.me import UserMeAPIView +from djoser.views.user.password import ( + ResetPasswordConfirmViewAPIView, + ResetPasswordViewAPIView, + SetPasswordViewAPIView, +) from djoser.views.user.user import UserViewSet - +from djoser.views.user.username import ( + ResetUsernameAPIView, + ResetUsernameConfirmAPIView, + SetUsernameAPIView, +) User = get_user_model() - -user_activation = path( - "users/activation/", UserActivationAPIView.as_view(), name="user-activation" -) +# user user_list = path( "users/", UserViewSet.as_view({"get": "list", "post": "create"}), name="user-list" ) @@ -34,53 +36,56 @@ ), name="user-detail", ) -user_password_reset_confirm = path( - "users/reset_password_confirm/", - UserPasswordResetConfirmAPIView.as_view(), - name="user-reset-password-confirm", + +# me +me_list = path( + "users/me/", + UserMeAPIView.as_view(), + name="user-me", +) + +# activation +user_activation = path( + "users/activation/", UserActivationAPIView.as_view(), name="user-activation" ) user_resend_activation = path( "users/resend-activation/", UserResendActivationAPIView.as_view(), name="user-resend-activation", ) + +# password +user_password_reset_confirm = path( + "users/reset_password_confirm/", + ResetPasswordConfirmViewAPIView.as_view(), + name="user-reset-password-confirm", +) user_reset_password = path( "users/reset_password", - UserResetPasswordAPIView.as_view(), + ResetPasswordViewAPIView.as_view(), name="user-reset-password", ) +user_set_password = path( + "users/set_password", SetPasswordViewAPIView.as_view(), name="user-set-password" +) + +# username user_reset_username = path( f"users/reset_{User.USERNAME_FIELD}", - UserResetUsernameAPIView.as_view(), + ResetUsernameAPIView.as_view(), name="user-reset-username", ) user_reset_username_confirm = path( f"users/reset_{User.USERNAME_FIELD}_confirm", - UserResetUsernameConfirmAPIView.as_view(), + ResetUsernameConfirmAPIView.as_view(), name="user-reset-username-confirm", ) -user_set_password = path( - "users/set_password", UserSetPasswordAPIView.as_view(), name="user-set-password" -) user_set_username = path( f"users/set_{User.USERNAME_FIELD}", - UserSetUsernameAPIView.as_view(), + SetUsernameAPIView.as_view(), name="user-set-username", ) -me_list = path( - "users/me/", - UserMeViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="user-me", -) - urlpatterns = [ user_resend_activation, user_activation, diff --git a/djoser/views/user/activation.py b/djoser/views/user/activation.py index 5d8d249a..5bb54ecd 100644 --- a/djoser/views/user/activation.py +++ b/djoser/views/user/activation.py @@ -1,17 +1,19 @@ from django.contrib.auth import get_user_model -from rest_framework import generics, status +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.user.base import GenericUserAPIView User = get_user_model() -class ActivationView(generics.GenericAPIView): +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) @@ -32,9 +34,10 @@ def post(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) -class ResendActivationView(generics.GenericAPIView): +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) diff --git a/djoser/views/user/base.py b/djoser/views/user/base.py index 3196a27e..bb37d72c 100644 --- a/djoser/views/user/base.py +++ b/djoser/views/user/base.py @@ -10,5 +10,5 @@ class GenericUserAPIView(generics.GenericAPIView): queryset = User.objects.all() lookup_field = settings.USER_ID_FIELD - http_method_names = ["post"] token_generator = default_token_generator # used in serializers + http_method_names = [] diff --git a/djoser/views/user/me.py b/djoser/views/user/me.py index 1cd3a4c3..38751f67 100644 --- a/djoser/views/user/me.py +++ b/djoser/views/user/me.py @@ -1,18 +1,63 @@ +from django.contrib.auth import get_user_model +from rest_framework import status, mixins +from rest_framework.exceptions import NotFound +from rest_framework.response import Response + +from djoser import signals, utils from djoser.conf import settings -from djoser.views.user.user import UserViewSet +from djoser.compat import get_user_email +from djoser.views.user.base import GenericUserAPIView +User = get_user_model() -class UserMeViewSet(UserViewSet): - def get_serializer_class(self): - if self.action == "destroy": - serializer_class = super().get_serializer_class() - else: - serializer_class = settings.SERIALIZERS.current_user - return serializer_class - def get_object(self): - return self.request.user +class UserMeAPIView( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + GenericUserAPIView, +): + http_method_names = ["post", "get", "delete"] + permission_classes = settings.PERMISSIONS.user def get_queryset(self): + # probably redundant but better safe than sorry queryset = self.queryset.objects.all() return queryset.filter(pk=self.request.user.pk) + + def get_serializer_class(self): + if self.request.method == "DELETE": + return settings.SERIALIZERS.user_delete + return settings.SERIALIZERS.current_user + + def get_object(self): + if settings.HIDE_USERS and not self.request.user.is_authenticated: + raise NotFound() + return self.request.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 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 permission_denied(self, request, **kwargs): + if settings.HIDE_USERS and request.user.is_authenticated: + raise NotFound() + super().permission_denied(request, **kwargs) diff --git a/djoser/views/user/password.py b/djoser/views/user/password.py new file mode 100644 index 00000000..f1b42a30 --- /dev/null +++ b/djoser/views/user/password.py @@ -0,0 +1,82 @@ +from django.contrib.auth import get_user_model, update_session_auth_hash +from django.utils.timezone import now +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.user.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) + + +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) + + +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/user/password_reset_confirm.py b/djoser/views/user/password_reset_confirm.py deleted file mode 100644 index 5027ed90..00000000 --- a/djoser/views/user/password_reset_confirm.py +++ /dev/null @@ -1,36 +0,0 @@ -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.compat import get_user_email -from djoser.conf import settings -from djoser.views.user.base import GenericUserAPIView - - -User = get_user_model() - - -class UserPasswordResetConfirmAPIView(GenericUserAPIView): - serializer_class = settings.SERIALIZERS.password_reset_confirm - permission_classes = settings.PERMISSIONS.password_reset_confirm - - 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/user/reset_password.py b/djoser/views/user/reset_password.py deleted file mode 100644 index 9aed0023..00000000 --- a/djoser/views/user/reset_password.py +++ /dev/null @@ -1,23 +0,0 @@ -from rest_framework import status -from rest_framework.response import Response - -from djoser.compat import get_user_email -from djoser.conf import settings -from djoser.views.user.base import GenericUserAPIView - - -class UserResetPasswordAPIView(GenericUserAPIView): - serializer_class = settings.SERIALIZERS.password_reset - permission_classes = settings.PERMISSIONS.password_reset - - 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/user/reset_username.py b/djoser/views/user/reset_username.py deleted file mode 100644 index d68ed21a..00000000 --- a/djoser/views/user/reset_username.py +++ /dev/null @@ -1,23 +0,0 @@ -from rest_framework import status -from rest_framework.response import Response - -from djoser.compat import get_user_email -from djoser.conf import settings -from djoser.views.user.base import GenericUserAPIView - - -class UserResetUsernameAPIView(GenericUserAPIView): - serializer_class = settings.SERIALIZERS.username_reset - permission_classes = settings.PERMISSIONS.username_reset - - 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/user/reset_username_confirm.py b/djoser/views/user/reset_username_confirm.py deleted file mode 100644 index 358cb0d1..00000000 --- a/djoser/views/user/reset_username_confirm.py +++ /dev/null @@ -1,37 +0,0 @@ -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.compat import get_user_email -from djoser.conf import settings -from djoser.views.user.base import GenericUserAPIView - - -User = get_user_model() - - -class UserResetUsernameConfirmAPIView(GenericUserAPIView): - serializer_class = settings.SERIALIZERS.username_reset_confirm - permission_classes = settings.PERMISSIONS.username_reset_confirm - - 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) - 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/user/set_password.py b/djoser/views/user/set_password.py deleted file mode 100644 index de7db140..00000000 --- a/djoser/views/user/set_password.py +++ /dev/null @@ -1,39 +0,0 @@ -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.compat import get_user_email -from djoser.conf import settings -from djoser.views.user.base import GenericUserAPIView - - -User = get_user_model() - - -class UserSetPasswordAPIView(GenericUserAPIView): - serializer_class = settings.SERIALIZERS.set_password - permission_classes = settings.PERMISSIONS.set_password - - 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/user/set_username.py b/djoser/views/user/set_username.py deleted file mode 100644 index de5b47fb..00000000 --- a/djoser/views/user/set_username.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_framework import status -from rest_framework.response import Response - -from djoser.compat import get_user_email -from djoser.conf import settings -from djoser.views.user.base import GenericUserAPIView - - -User = get_user_model() - - -class UserSetUsernameAPIView(GenericUserAPIView): - serializer_class = settings.SERIALIZERS.set_username - permission_classes = settings.PERMISSIONS.set_username - - 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 - 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/djoser/views/user/user.py b/djoser/views/user/user.py index 973abf65..f9078562 100644 --- a/djoser/views/user/user.py +++ b/djoser/views/user/user.py @@ -17,6 +17,7 @@ class UserViewSet(viewsets.ModelViewSet): permission_classes = settings.PERMISSIONS.user token_generator = default_token_generator lookup_field = settings.USER_ID_FIELD + http_method_names = ["get", "post", "path", "put", "delete"] def permission_denied(self, request, **kwargs): if ( diff --git a/djoser/views/user/username.py b/djoser/views/user/username.py new file mode 100644 index 00000000..3590c98d --- /dev/null +++ b/djoser/views/user/username.py @@ -0,0 +1,79 @@ +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.user.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 + 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) + + +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) + + +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) + 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/testproject/testapp/tests/test_urls/test_urls.py b/testproject/testapp/tests/test_urls/test_urls.py index b619a187..395653b4 100644 --- a/testproject/testapp/tests/test_urls/test_urls.py +++ b/testproject/testapp/tests/test_urls/test_urls.py @@ -7,6 +7,9 @@ from django.urls import get_resolver +ALLOW_RECREATE = False + + @pytest.mark.django_db def test_urls_have_not_changed(settings): BASE_DIR = settings.BASE_DIR @@ -72,8 +75,9 @@ def get_all_urls(patterns, prefix=""): diff = DeepDiff(current_urls, saved_urls) if diff: - with open(FILE_PATH, "w") as f: - json.dump(current_urls, f, indent=2) + if ALLOW_RECREATE: + with open(FILE_PATH, "w") as f: + json.dump(current_urls, f, indent=2) pytest.fail( f"URL structure has changed. Updated snapshot with new URLs and names. Diff:\n\n{diff}" # noqa: E501 ) From 00065dac59be51544b92efd9bac4b9359304fd49 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sun, 10 Nov 2024 14:13:30 +0100 Subject: [PATCH 13/18] updated urls --- .../tests/test_urls/urls_snapshot.json | 111 ++---------------- 1 file changed, 13 insertions(+), 98 deletions(-) diff --git a/testproject/testapp/tests/test_urls/urls_snapshot.json b/testproject/testapp/tests/test_urls/urls_snapshot.json index b52be00f..60e01a59 100644 --- a/testproject/testapp/tests/test_urls/urls_snapshot.json +++ b/testproject/testapp/tests/test_urls/urls_snapshot.json @@ -78,15 +78,7 @@ ] }, { - "pattern": "^auth/^users/$", - "name": "user-list", - "allowed_methods": [ - "get", - "post" - ] - }, - { - "pattern": "^auth/^users/(?P[^/.]+)/$", + "pattern": "^auth/^users/(?P[^/]+)/\\Z", "name": "user-detail", "allowed_methods": [ "get", @@ -96,155 +88,78 @@ ] }, { - "pattern": "^auth/^users/(?P[^/.]+)\\.(?P[a-z0-9]+)/?$", - "name": "user-detail", + "pattern": "^auth/^users/\\Z", + "name": "user-list", "allowed_methods": [ "get", - "put", - "patch", - "delete" - ] - }, - { - "pattern": "^auth/^users/activation/$", - "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/$", - "name": "user-me", - "allowed_methods": [ - "get", - "put", - "patch", - "delete" - ] - }, - { - "pattern": "^auth/^users/me\\.(?P[a-z0-9]+)/?$", + "pattern": "^auth/^users/me/\\Z", "name": "user-me", "allowed_methods": [ + "post", "get", - "put", - "patch", "delete" ] }, { - "pattern": "^auth/^users/resend_activation/$", + "pattern": "^auth/^users/resend\\-activation/\\Z", "name": "user-resend-activation", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/resend_activation\\.(?P[a-z0-9]+)/?$", - "name": "user-resend-activation", - "allowed_methods": [ - "post" - ] - }, - { - "pattern": "^auth/^users/reset_password/$", - "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/$", - "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/$", - "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/$", - "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\\Z", "name": "user-set-password", "allowed_methods": [ "post" ] }, { - "pattern": "^auth/^users/set_password\\.(?P[a-z0-9]+)/?$", - "name": "user-set-password", - "allowed_methods": [ - "post" - ] - }, - { - "pattern": "^auth/^users/set_username/$", + "pattern": "^auth/^users/set_username\\Z", "name": "user-set-username", "allowed_methods": [ "post" ] }, - { - "pattern": "^auth/^users/set_username\\.(?P[a-z0-9]+)/?$", - "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, From 954390bc4eff2ba5d778d9a2bef44b182420803f Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sun, 10 Nov 2024 14:27:19 +0100 Subject: [PATCH 14/18] mv --- djoser/urls/base.py | 10 +++++----- djoser/views/{user => }/activation.py | 2 +- djoser/views/{user => }/base.py | 0 djoser/views/{user => }/me.py | 5 +++-- djoser/views/{user => }/password.py | 2 +- djoser/views/{user => }/user.py | 0 djoser/views/user/__init__.py | 0 djoser/views/{user => }/username.py | 2 +- testproject/testapp/tests/test_user_detail.py | 2 +- 9 files changed, 12 insertions(+), 11 deletions(-) rename djoser/views/{user => }/activation.py (97%) rename djoser/views/{user => }/base.py (100%) rename djoser/views/{user => }/me.py (93%) rename djoser/views/{user => }/password.py (98%) rename djoser/views/{user => }/user.py (100%) delete mode 100644 djoser/views/user/__init__.py rename djoser/views/{user => }/username.py (98%) diff --git a/djoser/urls/base.py b/djoser/urls/base.py index 812d8a7b..da29efd7 100644 --- a/djoser/urls/base.py +++ b/djoser/urls/base.py @@ -1,18 +1,18 @@ from django.contrib.auth import get_user_model from django.urls import path -from djoser.views.user.activation import ( +from djoser.views.activation import ( UserActivationAPIView, UserResendActivationAPIView, ) -from djoser.views.user.me import UserMeAPIView -from djoser.views.user.password import ( +from djoser.views.me import UserMeAPIView +from djoser.views.password import ( ResetPasswordConfirmViewAPIView, ResetPasswordViewAPIView, SetPasswordViewAPIView, ) -from djoser.views.user.user import UserViewSet -from djoser.views.user.username import ( +from djoser.views.user import UserViewSet +from djoser.views.username import ( ResetUsernameAPIView, ResetUsernameConfirmAPIView, SetUsernameAPIView, diff --git a/djoser/views/user/activation.py b/djoser/views/activation.py similarity index 97% rename from djoser/views/user/activation.py rename to djoser/views/activation.py index 5bb54ecd..dc2e3638 100644 --- a/djoser/views/user/activation.py +++ b/djoser/views/activation.py @@ -5,7 +5,7 @@ from djoser import signals from djoser.conf import settings from djoser.compat import get_user_email -from djoser.views.user.base import GenericUserAPIView +from djoser.views.base import GenericUserAPIView User = get_user_model() diff --git a/djoser/views/user/base.py b/djoser/views/base.py similarity index 100% rename from djoser/views/user/base.py rename to djoser/views/base.py diff --git a/djoser/views/user/me.py b/djoser/views/me.py similarity index 93% rename from djoser/views/user/me.py rename to djoser/views/me.py index 38751f67..488007b0 100644 --- a/djoser/views/user/me.py +++ b/djoser/views/me.py @@ -6,7 +6,7 @@ from djoser import signals, utils from djoser.conf import settings from djoser.compat import get_user_email -from djoser.views.user.base import GenericUserAPIView +from djoser.views.base import GenericUserAPIView User = get_user_model() @@ -17,8 +17,9 @@ class UserMeAPIView( mixins.DestroyModelMixin, GenericUserAPIView, ): - http_method_names = ["post", "get", "delete"] + http_method_names = ["post", "get", "put", "patch", "delete"] permission_classes = settings.PERMISSIONS.user + lookup_field = None def get_queryset(self): # probably redundant but better safe than sorry diff --git a/djoser/views/user/password.py b/djoser/views/password.py similarity index 98% rename from djoser/views/user/password.py rename to djoser/views/password.py index f1b42a30..5ac0bf68 100644 --- a/djoser/views/user/password.py +++ b/djoser/views/password.py @@ -6,7 +6,7 @@ from djoser import utils from djoser.conf import settings from djoser.compat import get_user_email -from djoser.views.user.base import GenericUserAPIView +from djoser.views.base import GenericUserAPIView User = get_user_model() diff --git a/djoser/views/user/user.py b/djoser/views/user.py similarity index 100% rename from djoser/views/user/user.py rename to djoser/views/user.py diff --git a/djoser/views/user/__init__.py b/djoser/views/user/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/djoser/views/user/username.py b/djoser/views/username.py similarity index 98% rename from djoser/views/user/username.py rename to djoser/views/username.py index 3590c98d..b8372493 100644 --- a/djoser/views/user/username.py +++ b/djoser/views/username.py @@ -5,7 +5,7 @@ from djoser.conf import settings from djoser.compat import get_user_email -from djoser.views.user.base import GenericUserAPIView +from djoser.views.base import GenericUserAPIView User = get_user_model() diff --git a/testproject/testapp/tests/test_user_detail.py b/testproject/testapp/tests/test_user_detail.py index 8d299113..67ece6c0 100644 --- a/testproject/testapp/tests/test_user_detail.py +++ b/testproject/testapp/tests/test_user_detail.py @@ -6,7 +6,7 @@ import djoser.permissions -from djoser.views.user.user import UserViewSet +from djoser.views.user import UserViewSet class BaseUserViewSetListTest(APITestCase, assertions.StatusCodeAssertionsMixin): From c88b7c1c1809f3caacd70f3fe1171f5c0745e817 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sun, 10 Nov 2024 14:38:12 +0100 Subject: [PATCH 15/18] align some tests --- djoser/urls/base.py | 9 ++++++++- djoser/views/me.py | 4 +++- djoser/views/user.py | 19 +++++++++++++------ .../tests/test_urls/urls_snapshot.json | 3 ++- testproject/testapp/tests/test_user_create.py | 8 ++++---- 5 files changed, 30 insertions(+), 13 deletions(-) diff --git a/djoser/urls/base.py b/djoser/urls/base.py index da29efd7..347212eb 100644 --- a/djoser/urls/base.py +++ b/djoser/urls/base.py @@ -40,7 +40,14 @@ # me me_list = path( "users/me/", - UserMeAPIView.as_view(), + UserMeAPIView.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), name="user-me", ) diff --git a/djoser/views/me.py b/djoser/views/me.py index 488007b0..e8f93eb5 100644 --- a/djoser/views/me.py +++ b/djoser/views/me.py @@ -2,6 +2,7 @@ from rest_framework import status, mixins from rest_framework.exceptions import NotFound from rest_framework.response import Response +from rest_framework.viewsets import ViewSetMixin from djoser import signals, utils from djoser.conf import settings @@ -15,9 +16,10 @@ class UserMeAPIView( mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, + ViewSetMixin, GenericUserAPIView, ): - http_method_names = ["post", "get", "put", "patch", "delete"] + http_method_names = ["get", "put", "patch", "delete"] permission_classes = settings.PERMISSIONS.user lookup_field = None diff --git a/djoser/views/user.py b/djoser/views/user.py index f9078562..a5376ddc 100644 --- a/djoser/views/user.py +++ b/djoser/views/user.py @@ -1,22 +1,29 @@ from django.contrib.auth import get_user_model -from rest_framework import status, viewsets +from rest_framework import status, mixins from rest_framework.exceptions import NotFound from rest_framework.response import Response +from rest_framework.viewsets import ViewSetMixin from djoser import signals, utils from djoser.conf import settings from djoser.compat import get_user_email -from django.contrib.auth.tokens import default_token_generator + +from djoser.views.base import GenericUserAPIView User = get_user_model() -class UserViewSet(viewsets.ModelViewSet): +class UserViewSet( + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + ViewSetMixin, + GenericUserAPIView, +): 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 http_method_names = ["get", "post", "path", "put", "delete"] def permission_denied(self, request, **kwargs): diff --git a/testproject/testapp/tests/test_urls/urls_snapshot.json b/testproject/testapp/tests/test_urls/urls_snapshot.json index 60e01a59..0991c3f8 100644 --- a/testproject/testapp/tests/test_urls/urls_snapshot.json +++ b/testproject/testapp/tests/test_urls/urls_snapshot.json @@ -106,8 +106,9 @@ "pattern": "^auth/^users/me/\\Z", "name": "user-me", "allowed_methods": [ - "post", "get", + "put", + "patch", "delete" ] }, diff --git a/testproject/testapp/tests/test_user_create.py b/testproject/testapp/tests/test_user_create.py index b0dd43d0..f1122eef 100644 --- a/testproject/testapp/tests/test_user_create.py +++ b/testproject/testapp/tests/test_user_create.py @@ -123,7 +123,7 @@ def test_post_return_400_for_integrity_error(self, perform_create): tuple(CustomUser.REQUIRED_FIELDS) + (CustomUser.USERNAME_FIELD, CustomUser._meta.pk.name, "password"), ) - @mock.patch("djoser.views.user.user.User", CustomUser) + @mock.patch("djoser.views.user.User", CustomUser) @override_settings(AUTH_USER_MODEL="testapp.CustomUser") def test_post_create_custom_user_with_all_required_fields(self): data = { @@ -151,7 +151,7 @@ def test_post_create_custom_user_with_all_required_fields(self): tuple(CustomUser.REQUIRED_FIELDS) + (CustomUser.USERNAME_FIELD, CustomUser._meta.pk.name, "password"), ) - @mock.patch("djoser.views.user.user.User", CustomUser) + @mock.patch("djoser.views.user.User", CustomUser) @override_settings(AUTH_USER_MODEL="testapp.CustomUser") def test_post_not_create_custom_user_with_missing_required_fields(self): data = {"custom_username": "john", "password": "secret"} @@ -168,7 +168,7 @@ def test_post_not_create_custom_user_with_missing_required_fields(self): tuple(ExampleUser.REQUIRED_FIELDS) + (ExampleUser.USERNAME_FIELD, ExampleUser._meta.pk.name, "password"), ) - @mock.patch("djoser.views.user.user.User", ExampleUser) + @mock.patch("djoser.views.user.User", ExampleUser) @override_settings(AUTH_USER_MODEL="testapp.ExampleUser") def test_post_create_custom_user_without_username(self): data = {"password": "secret", "email": "test@user1.com"} @@ -187,7 +187,7 @@ def test_post_create_custom_user_without_username(self): tuple(ExampleUser.REQUIRED_FIELDS) + (ExampleUser.USERNAME_FIELD, ExampleUser._meta.pk.name, "password"), ) - @mock.patch("djoser.views.user.user.User", ExampleUser) + @mock.patch("djoser.views.user.User", ExampleUser) @override_settings(AUTH_USER_MODEL="testapp.ExampleUser") def test_post_create_custom_user_missing_required_fields(self): data = {"password": "secret"} From 44ea180c7d52235a1820d40517a8f90855ea3a80 Mon Sep 17 00:00:00 2001 From: Tom Wojcik Date: Sun, 10 Nov 2024 14:40:16 +0100 Subject: [PATCH 16/18] mv re_path to path in jwt and auth token --- djoser/urls/authtoken.py | 6 +++--- djoser/urls/jwt.py | 8 ++++---- testproject/testapp/tests/test_urls/urls_snapshot.json | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/djoser/urls/authtoken.py b/djoser/urls/authtoken.py index bf87063e..18ada330 100644 --- a/djoser/urls/authtoken.py +++ b/djoser/urls/authtoken.py @@ -1,9 +1,9 @@ -from django.urls import re_path +from django.urls import path from djoser.views.token.create import TokenCreateView from djoser.views.token.destroy import TokenDestroyView urlpatterns = [ - re_path(r"^token/login/?$", TokenCreateView.as_view(), name="login"), - re_path(r"^token/logout/?$", TokenDestroyView.as_view(), name="logout"), + path("token/login/", TokenCreateView.as_view(), name="login"), + path("token/logout/", TokenDestroyView.as_view(), name="logout"), ] diff --git a/djoser/urls/jwt.py b/djoser/urls/jwt.py index 474411f1..f43728a3 100644 --- a/djoser/urls/jwt.py +++ b/djoser/urls/jwt.py @@ -1,8 +1,8 @@ -from django.urls import re_path +from django.urls import path from rest_framework_simplejwt import views 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"), ] diff --git a/testproject/testapp/tests/test_urls/urls_snapshot.json b/testproject/testapp/tests/test_urls/urls_snapshot.json index 0991c3f8..4e70a2ae 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,7 @@ ] }, { - "pattern": "^auth/^jwt/refresh/?", + "pattern": "^auth/^jwt/refresh/\\Z", "name": "jwt-refresh", "allowed_methods": [ "get", @@ -26,7 +26,7 @@ ] }, { - "pattern": "^auth/^jwt/verify/?", + "pattern": "^auth/^jwt/verify/\\Z", "name": "jwt-verify", "allowed_methods": [ "get", @@ -52,7 +52,7 @@ ] }, { - "pattern": "^auth/^token/login/?$", + "pattern": "^auth/^token/login/\\Z", "name": "login", "allowed_methods": [ "get", @@ -65,7 +65,7 @@ ] }, { - "pattern": "^auth/^token/logout/?$", + "pattern": "^auth/^token/logout/\\Z", "name": "logout", "allowed_methods": [ "get", From ed6037b6bfad6c6e3299382bc257d61a8bf3420f Mon Sep 17 00:00:00 2001 From: Tomasz Wojcik Date: Mon, 3 Mar 2025 20:55:37 +0100 Subject: [PATCH 17/18] rm useless pytest.ini and python_paths that doesn't work --- pyproject.toml | 1 - pytest.ini | 3 --- 2 files changed, 4 deletions(-) delete mode 100644 pytest.ini diff --git a/pyproject.toml b/pyproject.toml index c6da5853..11f5d5f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,7 +140,6 @@ in-place = true [tool.pytest.ini_options] minversion = "7.0" DJANGO_SETTINGS_MODULE = "testproject.settings" -python_paths = "testproject" [tool.coverage.run] source = ["djoser"] 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 From 3b53f503bd18f7de2364511bf16898ff9909e27b Mon Sep 17 00:00:00 2001 From: Tomasz Wojcik Date: Mon, 3 Mar 2025 20:57:02 +0100 Subject: [PATCH 18/18] add new granular views --- djoser/views/user.py | 129 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/djoser/views/user.py b/djoser/views/user.py index a5376ddc..90e6bcc6 100644 --- a/djoser/views/user.py +++ b/djoser/views/user.py @@ -1,5 +1,5 @@ from django.contrib.auth import get_user_model -from rest_framework import status, mixins +from rest_framework import status, mixins, generics, viewsets from rest_framework.exceptions import NotFound from rest_framework.response import Response from rest_framework.viewsets import ViewSetMixin @@ -98,3 +98,130 @@ def destroy(self, request, *args, **kwargs): utils.logout_user(self.request) self.perform_destroy(instance) return Response(status=status.HTTP_204_NO_CONTENT) + + +class UserBaseView(GenericUserAPIView): + """Base view for user views with common methods.""" + serializer_class = settings.SERIALIZERS.user + + def permission_denied(self, request, **kwargs): + 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, **kwargs) + + 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 + + +class UserListView(UserBaseView, generics.ListAPIView): + """View for listing users.""" + permission_classes = settings.PERMISSIONS.user_list + http_method_names = ["get"] + + +class UserCreateView(UserBaseView, generics.CreateAPIView): + """View for creating a user.""" + permission_classes = settings.PERMISSIONS.user_create + http_method_names = ["post"] + + 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) + + +class UserListCreateView(UserBaseView, generics.ListCreateAPIView): + """View for listing and creating users.""" + + def get_permissions(self): + if self.request.method == 'POST': + self.permission_classes = settings.PERMISSIONS.user_create + else: + self.permission_classes = settings.PERMISSIONS.user_list + return super().get_permissions() + + def get_serializer_class(self): + if self.request.method == 'POST': + if settings.USER_CREATE_PASSWORD_RETYPE: + return settings.SERIALIZERS.user_create_password_retype + return settings.SERIALIZERS.user_create + return self.serializer_class + + 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) + + +class UserRetrieveView(UserBaseView, generics.RetrieveAPIView): + """View for retrieving a user.""" + permission_classes = settings.PERMISSIONS.user + http_method_names = ["get"] + + +class UserUpdateView(UserBaseView, generics.UpdateAPIView): + """View for updating a user.""" + permission_classes = settings.PERMISSIONS.user + http_method_names = ["put", "patch"] + + 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) + + +class UserDeleteView(UserBaseView, generics.DestroyAPIView): + """View for deleting a user.""" + permission_classes = settings.PERMISSIONS.user_delete + http_method_names = ["delete"] + + 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)