diff --git a/lms/djangoapps/discussion_api/tests/test_views.py b/lms/djangoapps/discussion_api/tests/test_views.py index aafad0df20..1c4a8bdddc 100644 --- a/lms/djangoapps/discussion_api/tests/test_views.py +++ b/lms/djangoapps/discussion_api/tests/test_views.py @@ -10,6 +10,7 @@ from urlparse import urlparse import ddt import httpretty import mock +from django.conf import settings from django.urls import reverse from edx_oauth2_provider.tests.factories import ClientFactory, AccessTokenFactory from opaque_keys.edx.keys import CourseKey @@ -235,6 +236,81 @@ class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """ pass +@httpretty.activate +@mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker') +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ReplaceUsernameViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for ReplaceUsernameView""" + def setUp(self): + super(ReplaceUsernameViewTest, self).setUp() + self.client_user = UserFactory() + self.client_user.username = "test_replace_username_service_worker" + self.new_username = "test_username_replacement" + self.url = reverse("replace_discussion_username") + + def assert_response_correct(self, response, expected_status, expected_content): + """ + Assert that the response has the given status code and content + """ + self.assertEqual(response.status_code, expected_status) + + if expected_content: + self.assertEqual(text_type(response.content), expected_content) + + def build_jwt_headers(self, user): + """ + Helper function for creating headers for the JWT authentication. + """ + token = create_jwt_for_user(user) + headers = {'HTTP_AUTHORIZATION': 'JWT ' + token} + return headers + + def test_missing_params(self): + headers = self.build_jwt_headers(self.client_user) + # Using this instead of ddt so we can access self.user + bad_post_contents = ( + {}, + {"current_username": self.user.username}, + {"new_username": "test_username_replacement"}, + ) + for data in bad_post_contents: + response = self.client.post(self.url, data, **headers) + self.assert_response_correct(response, 400, "") + + def test_basic(self): + """ Check successful replacement """ + self.register_get_username_replacement_response(self.user) + headers = self.build_jwt_headers(self.client_user) + data = {"current_username": self.user.username, "new_username": self.new_username} + response = self.client.post(self.url, data, **headers) + self.assert_response_correct(response, 204, "") + + def test_nonexistant_user(self): + self.register_get_username_replacement_response(self.user) + headers = self.build_jwt_headers(self.client_user) + data = {"current_username": "non-existant-user", "new_username": self.new_username} + response = self.client.post(self.url, data, **headers) + self.assert_response_correct(response, 404, "") + + def test_client_404(self): + self.register_get_username_replacement_response(self.user, status=404) + headers = self.build_jwt_headers(self.client_user) + data = {"current_username": self.user.username, "new_username": self.new_username} + response = self.client.post(self.url, data, **headers) + self.assert_response_correct(response, 404, "") + + def test_client_500(self): + self.register_get_username_replacement_response(self.user, status=500) + headers = self.build_jwt_headers(self.client_user) + data = {"current_username": self.user.username, "new_username": self.new_username} + response = self.client.post(self.url, data, **headers) + self.assert_response_correct(response, 500, "") + + def test_not_authenticated(self): + """ + Override the parent implementation of this, we JWT auth for this API + """ + pass @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) diff --git a/lms/djangoapps/discussion_api/tests/utils.py b/lms/djangoapps/discussion_api/tests/utils.py index 39902d2669..4641ccc807 100644 --- a/lms/djangoapps/discussion_api/tests/utils.py +++ b/lms/djangoapps/discussion_api/tests/utils.py @@ -226,6 +226,15 @@ class CommentsServiceMockMixin(object): status=status ) + def register_get_username_replacement_response(self, user, status=200, body=""): + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + httpretty.register_uri( + httpretty.POST, + "http://localhost:4567/api/v1/users/{id}/replace_username".format(id=user.id), + body=body, + status=status + ) + def register_subscribed_threads_response(self, user, threads, page, num_pages): """Register a mock response for GET on the CS user instance endpoint""" assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' diff --git a/lms/djangoapps/discussion_api/urls.py b/lms/djangoapps/discussion_api/urls.py index f138bfcf41..b154f2fbb0 100644 --- a/lms/djangoapps/discussion_api/urls.py +++ b/lms/djangoapps/discussion_api/urls.py @@ -13,6 +13,7 @@ from discussion_api.views import ( CourseView, ThreadViewSet, RetireUserView, + ReplaceUsernameView, ) ROUTER = SimpleRouter() @@ -40,6 +41,7 @@ urlpatterns = [ name="discussion_course" ), url(r"^v1/accounts/retire_forum", RetireUserView.as_view(), name="retire_discussion_user"), + url(r"^v1/accounts/replace_username", ReplaceUsernameView.as_view(), name="replace_discussion_username"), url( r"^v1/course_topics/{}".format(settings.COURSE_ID_PATTERN), CourseTopicsView.as_view(), diff --git a/lms/djangoapps/discussion_api/views.py b/lms/djangoapps/discussion_api/views.py index 872897ab20..d43ab98f3e 100644 --- a/lms/djangoapps/discussion_api/views.py +++ b/lms/djangoapps/discussion_api/views.py @@ -1,6 +1,7 @@ """ Discussion API views """ +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -49,7 +50,7 @@ from discussion_api.serializers import ( from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser from openedx.core.lib.api.parsers import MergePatchParser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser +from openedx.core.djangoapps.user_api.accounts.permissions import CanReplaceUsername, CanRetireUser from openedx.core.djangoapps.user_api.models import UserRetirementStatus from util.json_request import JsonResponse from xmodule.modulestore.django import modulestore @@ -587,6 +588,57 @@ class RetireUserView(APIView): return Response(status=status.HTTP_204_NO_CONTENT) +class ReplaceUsernameView(APIView): + """ + A request from the settings.USERNAME_REPLACEMENT_WORKER user can replace + the username for a user. This will change their username and update all of + their comments to have the new username + + POST /api/discussion/v1/accounts/replace_username/ + { + "current_username": "users_current_username", + "new_username": "name_to_change_it_to" + } + + Returns empty response if successful + """ + + authentication_classes = (JwtAuthentication,) + permission_classes = (permissions.IsAuthenticated, CanReplaceUsername) + + def post(self, request): + """ + Implements the username replacement endpoint + """ + current_username = request.data.get("current_username") + new_username = request.data.get("new_username") + + if not (current_username and new_username): + return Response(status=status.HTTP_400_BAD_REQUEST) + + try: + current_user = User.objects.get(username=current_username) + cc_user = comment_client.User.from_django_user(current_user) + cc_user.replace_username(new_username) + except User.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + except comment_client.CommentClientRequestError as exc: + if exc.status_code == 404: + return Response(status=status.HTTP_404_NOT_FOUND) + raise + except Exception as exc: + return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response(status=status.HTTP_204_NO_CONTENT) + + + + + + + + + + class CourseDiscussionSettingsAPIView(DeveloperErrorViewMixin, APIView): """ diff --git a/lms/envs/common.py b/lms/envs/common.py index 5688bf9a0c..af88b564b0 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3450,6 +3450,8 @@ RETIREMENT_STATES = [ 'COMPLETE', ] +USERNAME_REPLACEMENT_WORKER = "REPLACE WITH VALID USERNAME" + ############## Settings for Microfrontends ######################### # If running a Gradebook container locally, # modify lms/envs/private.py to give it a non-null value diff --git a/lms/lib/comment_client/user.py b/lms/lib/comment_client/user.py index d358f09c40..d854d2848b 100644 --- a/lms/lib/comment_client/user.py +++ b/lms/lib/comment_client/user.py @@ -180,6 +180,17 @@ class User(models.Model): metric_tags=self._metric_tags ) + def replace_username(self, new_username): + url = _url_for_username_replacement(self.id) + params = {"new_username": new_username} + + utils.perform_request( + 'post', + url, + params, + raw=True, + ) + def _url_for_vote_comment(comment_id): return "{prefix}/comments/{comment_id}/votes".format(prefix=settings.PREFIX, comment_id=comment_id) @@ -213,3 +224,9 @@ def _url_for_retire(user_id): Returns cs_comments_service url endpoint to retire a user (remove all post content, etc.) """ return "{prefix}/users/{user_id}/retire".format(prefix=settings.PREFIX, user_id=user_id) + +def _url_for_username_replacement(user_id): + """ + Returns cs_comments_servuce url endpoint to replace the username of a user + """ + return "{prefix}/users/{user_id}/replace_username".format(prefix=settings.PREFIX, user_id=user_id) diff --git a/openedx/core/djangoapps/user_api/accounts/permissions.py b/openedx/core/djangoapps/user_api/accounts/permissions.py index a7f09ea9a7..84d4155011 100644 --- a/openedx/core/djangoapps/user_api/accounts/permissions.py +++ b/openedx/core/djangoapps/user_api/accounts/permissions.py @@ -6,8 +6,6 @@ from __future__ import unicode_literals from django.conf import settings from rest_framework import permissions -USERNAME_REPLACEMENT_GROUP = "username_replacement_admin" - class CanDeactivateUser(permissions.BasePermission): """ Grants access to AccountDeactivationView if the requesting user is a superuser diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 37866701da..d32c214448 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -1014,6 +1014,9 @@ class UsernameReplacementView(APIView): API will recieve a list of current usernames and their requested new username. If their new username is taken, it will randomly assign a new username. + + This API will be called first, before calling the APIs in other services as this + one handles the checks on the usernames provided. """ authentication_classes = (JwtAuthentication, ) permission_classes = (permissions.IsAuthenticated, CanReplaceUsername)