From c827f099b79ab07df6186eed8bd7f30b1d3f43e2 Mon Sep 17 00:00:00 2001 From: magajh Date: Sun, 21 Mar 2021 17:06:57 -0400 Subject: [PATCH] feat: Add Replace Username endpoint. --- eox_core/api/support/v1/serializers.py | 49 +++++++++++ eox_core/api/support/v1/tests/test_users.py | 85 +++++++++++++++++++ eox_core/api/support/v1/urls.py | 1 + eox_core/api/support/v1/views.py | 61 ++++++++++++- .../backends/comments_service_users_j_v1.py | 17 ++++ .../edxapp_wrapper/comments_service_users.py | 18 ++++ eox_core/settings/common.py | 1 + eox_core/settings/production.py | 4 + 8 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 eox_core/api/support/v1/tests/test_users.py create mode 100644 eox_core/edxapp_wrapper/backends/comments_service_users_j_v1.py create mode 100644 eox_core/edxapp_wrapper/comments_service_users.py diff --git a/eox_core/api/support/v1/serializers.py b/eox_core/api/support/v1/serializers.py index 8aa9067e7..5fbe4ff07 100644 --- a/eox_core/api/support/v1/serializers.py +++ b/eox_core/api/support/v1/serializers.py @@ -7,6 +7,17 @@ from django.utils import timezone from rest_framework import serializers +from eox_core.api.v1.serializers import MAX_SIGNUP_SOURCES_ALLOWED +from eox_core.edxapp_wrapper.users import ( + check_edxapp_account_conflicts, + get_user_signup_source, + get_username_max_length, +) + +UserSignupSource = get_user_signup_source() # pylint: disable=invalid-name + +USERNAME_MAX_LENGTH = get_username_max_length() + class WrittableEdxappRemoveUserSerializer(serializers.Serializer): """ @@ -14,3 +25,41 @@ class WrittableEdxappRemoveUserSerializer(serializers.Serializer): """ case_id = serializers.CharField(write_only=True, default=timezone.now().strftime('%Y%m%d%H%M%S')) is_support_user = serializers.BooleanField(default=True) + + +class WrittableEdxappUsernameSerializer(serializers.Serializer): + """ + Handles the serialization of the data required to update the username of an edxapp user. + """ + new_username = serializers.CharField(max_length=USERNAME_MAX_LENGTH, write_only=True) + + def validate(self, attrs): + """ + When a username update is being made, then it checks that: + - The new username is not already taken by other user. + - The user is not staff or superuser. + - The user has just one signup source. + """ + username = attrs.get("new_username") + conflicts = check_edxapp_account_conflicts(None, username) + if conflicts: + raise serializers.ValidationError({"detail": "An account already exists with the provided username."}) + + if self.instance.is_staff or self.instance.is_superuser: + raise serializers.ValidationError({"detail": "You can't update users with roles like staff or superuser."}) + + if UserSignupSource.objects.filter(user__email=self.instance.email).count() > MAX_SIGNUP_SOURCES_ALLOWED: + raise serializers.ValidationError({"detail": "You can't update users with more than one sign up source."}) + + return attrs + + def update(self, instance, validated_data): + """ + Method to update username of edxapp User. + """ + key = 'username' + if validated_data: + setattr(instance, key, validated_data['new_username']) + instance.save() + + return instance diff --git a/eox_core/api/support/v1/tests/test_users.py b/eox_core/api/support/v1/tests/test_users.py new file mode 100644 index 000000000..fccbbf8b4 --- /dev/null +++ b/eox_core/api/support/v1/tests/test_users.py @@ -0,0 +1,85 @@ +""" +Test module for users viewset. +""" +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from mock import patch +from rest_framework import status +from rest_framework.test import APIClient + + +class EdxappReplaceUsernameAPITest(TestCase): + """Test class for update username APIView.""" + + patch_permissions = patch('eox_core.api.support.v1.permissions.EoxCoreSupportAPIPermission.has_permission', return_value=True) + + def setUp(self): + """Setup method for test class.""" + self.user = User(username="test-username", email="test-username@example.com", password="testusername") + self.client = APIClient() + self.url = reverse("eox-support-api:eox-support-api:edxapp-replace-username") + self.client.force_authenticate(user=self.user) + + @patch_permissions + @patch('eox_core.api.support.v1.views.replace_username_cs_user') + @patch('eox_core.api.support.v1.serializers.UserSignupSource') + @patch('eox_core.api.support.v1.views.get_edxapp_user') + @patch('eox_core.api.support.v1.views.EdxappUserReadOnlySerializer') + def test_replace_username_success(self, user_serializer, get_edxapp_user, signup_source, replace_username_cs_user, _): + """Test the replacement of the username of an edxapp user.""" + update_data = { + "username": self.user.username, + "new_username": "replaced-username", + } + + user_serializer.return_value.data = {} + get_edxapp_user.return_value = self.user + signup_source.objects.filter.return_value.count.return_value = 1 + + response = self.client.patch(self.url, data=update_data, format="json") + + self.assertEqual("replaced-username", self.user.username) + self.assertEqual(status.HTTP_200_OK, response.status_code) + + @patch_permissions + @patch('eox_core.api.support.v1.serializers.UserSignupSource') + @patch('eox_core.api.support.v1.views.get_edxapp_user') + def test_replace_username_bad_sign_up_source(self, get_edxapp_user, signup_source, _): + """ + Tests that when a user has more than one signup source then the + username cannot be replaced. + """ + update_data = { + "username": self.user.username, + "new_username": "another-username", + } + + get_edxapp_user.return_value = self.user + signup_source.objects.filter.return_value.count.return_value = 2 + + response = self.client.patch(self.url, data=update_data, format="json") + + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertEqual(response.content, '{"detail":["You can\'t update users with more than one sign up source."]}' + .encode()) + + @patch_permissions + @patch('eox_core.api.support.v1.serializers.UserSignupSource') + @patch('eox_core.api.support.v1.views.get_edxapp_user') + def test_replace_username_staff_user(self, get_edxapp_user, signup_source, _): + """Tests that if a user is staff or superuser then the username cannot be replaced.""" + user = User(username="test", email="test@example.com", password="testtest", is_staff=True) + update_data = { + "username": user.username, + "new_username": "new-test-username", + } + + get_edxapp_user.return_value = user + signup_source.objects.filter.return_value.count.return_value = 1 + + response = self.client.patch(self.url, data=update_data, format="json") + + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertEqual(response.content, '{"detail":["You can\'t update users with roles like staff or superuser."]}' + .encode()) diff --git a/eox_core/api/support/v1/urls.py b/eox_core/api/support/v1/urls.py index 6801300ff..82ed4edfa 100644 --- a/eox_core/api/support/v1/urls.py +++ b/eox_core/api/support/v1/urls.py @@ -8,4 +8,5 @@ urlpatterns = [ # pylint: disable=invalid-name url(r'^user/$', views.EdxappUser.as_view(), name='edxapp-user'), + url(r'^user/replace-username/$', views.EdxappReplaceUsername.as_view(), name='edxapp-replace-username'), ] diff --git a/eox_core/api/support/v1/views.py b/eox_core/api/support/v1/views.py index de4e07732..d18499eb0 100644 --- a/eox_core/api/support/v1/views.py +++ b/eox_core/api/support/v1/views.py @@ -6,16 +6,20 @@ import logging +from django.conf import settings from django.contrib.sites.shortcuts import get_current_site +from django.db import transaction from rest_framework.authentication import SessionAuthentication from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer from rest_framework.response import Response from rest_framework.views import APIView from eox_core.api.support.v1.permissions import EoxCoreSupportAPIPermission -from eox_core.api.support.v1.serializers import WrittableEdxappRemoveUserSerializer +from eox_core.api.support.v1.serializers import WrittableEdxappRemoveUserSerializer, WrittableEdxappUsernameSerializer +from eox_core.api.v1.serializers import EdxappUserReadOnlySerializer from eox_core.api.v1.views import UserQueryMixin from eox_core.edxapp_wrapper.bearer_authentication import BearerAuthentication +from eox_core.edxapp_wrapper.comments_service_users import replace_username_cs_user from eox_core.edxapp_wrapper.users import delete_edxapp_user, get_edxapp_user LOG = logging.getLogger(__name__) @@ -37,7 +41,7 @@ def delete(self, request, *args, **kwargs): # pylint: disable=too-many-locals For example: **Requests**: - DELETE /eox-core/api/v1/remove-user/ + DELETE /eox-core/support-api/v1/user/ **Request body**: { @@ -61,3 +65,56 @@ def delete(self, request, *args, **kwargs): # pylint: disable=too-many-locals message, status = delete_edxapp_user(**data) return Response(message, status=status) + + +class EdxappReplaceUsername(UserQueryMixin, APIView): + """ + Handles the replacement of the username. + """ + + authentication_classes = (BearerAuthentication, SessionAuthentication) + permission_classes = (EoxCoreSupportAPIPermission,) + renderer_classes = (JSONRenderer, BrowsableAPIRenderer) + + def patch(self, request, *args, **kwargs): + """ + Allows to safely update an Edxapp user's Username along with the + forum associated User. + + For now users that have different signup sources cannot be updated. + + For example: + + **Requests**: + PATCH /eox-core/support-api/v1/replace-username/ + + **Request body** + { + "new_username": "new username" + } + + **Response values** + User serialized. + """ + query = self.get_user_query(request) + user = get_edxapp_user(**query) + data = request.data + + with transaction.atomic(): + serializer = WrittableEdxappUsernameSerializer(user, data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + + data = serializer.validated_data + data["user"] = user + + # Update user in cs_comments_service forums + replace_username_cs_user(**data) + + admin_fields = getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}).get( + "admin_fields", {} + ) + serialized_user = EdxappUserReadOnlySerializer( + user, custom_fields=admin_fields, context={"request": request} + ) + return Response(serialized_user.data) diff --git a/eox_core/edxapp_wrapper/backends/comments_service_users_j_v1.py b/eox_core/edxapp_wrapper/backends/comments_service_users_j_v1.py new file mode 100644 index 000000000..347f688b1 --- /dev/null +++ b/eox_core/edxapp_wrapper/backends/comments_service_users_j_v1.py @@ -0,0 +1,17 @@ +""" Module for the cs_comments_service User object.""" +from openedx.core.djangoapps.django_comment_common.comment_client.user import User # pylint: disable=import-error + + +def replace_username_cs_user(*args, **kwargs): + """ + Replace user's username in cs_comments_service (forums). + + kwargs: + user: edxapp user whose username is being replaced. + new_username: new username. + """ + user = kwargs.get("user") + new_username = kwargs.get("new_username") + + cs_user = User.from_django_user(user) + cs_user.replace_username(new_username) diff --git a/eox_core/edxapp_wrapper/comments_service_users.py b/eox_core/edxapp_wrapper/comments_service_users.py new file mode 100644 index 000000000..55eb3c961 --- /dev/null +++ b/eox_core/edxapp_wrapper/comments_service_users.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +User model wrapper for cs_comments_service public definition. +""" + +from importlib import import_module + +from django.conf import settings + + +def replace_username_cs_user(*args, **kwargs): + """ Gets the User model wrapper for comments service""" + + backend_function = settings.EOX_CORE_COMMENTS_SERVICE_USERS_BACKEND + backend = import_module(backend_function) + + return backend.replace_username_cs_user(*args, **kwargs) diff --git a/eox_core/settings/common.py b/eox_core/settings/common.py index ae5f0cebc..f88992ec2 100644 --- a/eox_core/settings/common.py +++ b/eox_core/settings/common.py @@ -19,6 +19,7 @@ def plugin_settings(settings): Defines eox-core settings when app is used as a plugin to edx-platform. See: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst """ + settings.EOX_CORE_COMMENTS_SERVICE_USERS_BACKEND = "eox_core.edxapp_wrapper.backends.comments_service_users_j_v1" settings.EOX_CORE_USERS_BACKEND = "eox_core.edxapp_wrapper.backends.users_j_v1" settings.EOX_CORE_ENROLLMENT_BACKEND = "eox_core.edxapp_wrapper.backends.enrollment_h_v1" settings.EOX_CORE_PRE_ENROLLMENT_BACKEND = "eox_core.edxapp_wrapper.backends.pre_enrollment_h_v1" diff --git a/eox_core/settings/production.py b/eox_core/settings/production.py index eeff624cd..c878c8ae7 100644 --- a/eox_core/settings/production.py +++ b/eox_core/settings/production.py @@ -15,6 +15,10 @@ def plugin_settings(settings): # pylint: disable=function-redefined Set of plugin settings used by the Open Edx platform. More info: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst """ + settings.EOX_CORE_COMMENTS_SERVICE_USERS_BACKEND = getattr(settings, 'ENV_TOKENS', {}).get( + 'EOX_CORE_COMMENTS_SERVICE_USERS_BACKEND', + settings.EOX_CORE_COMMENTS_SERVICE_USERS_BACKEND + ) settings.EOX_CORE_BEARER_AUTHENTICATION = getattr(settings, 'ENV_TOKENS', {}).get( 'EOX_CORE_BEARER_AUTHENTICATION', settings.EOX_CORE_BEARER_AUTHENTICATION