From 65905a01eca6181dd4992f97107e98f00bbfc027 Mon Sep 17 00:00:00 2001 From: Bianca Severino <biancasev@gmail.com> Date: Wed, 25 Aug 2021 14:26:18 -0400 Subject: [PATCH] feat: update account API to allow pending name changes --- common/djangoapps/student/models.py | 2 +- common/djangoapps/student/models_api.py | 51 +++++++++++++ .../djangoapps/student/signals/receivers.py | 23 +++++- .../djangoapps/student/tests/test_models.py | 51 +++++++++++-- .../student/tests/test_receivers.py | 33 ++++++++- lms/envs/common.py | 1 + .../core/djangoapps/user_api/accounts/api.py | 22 ++++++ .../user_api/accounts/serializers.py | 34 ++++++++- .../user_api/accounts/tests/test_api.py | 39 ++++++++++ .../user_api/accounts/tests/test_views.py | 74 +++++++++++++++++-- .../djangoapps/user_api/accounts/views.py | 41 ++++++++++ openedx/core/djangoapps/user_api/urls.py | 6 ++ 12 files changed, 355 insertions(+), 22 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 4918aaf8081..24876f40c51 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -924,7 +924,7 @@ class Registration(models.Model): class PendingNameChange(DeletableByUserValue, models.Model): """ - This model keeps track of pending requested changes to a user's email address. + This model keeps track of pending requested changes to a user's name. .. pii: Contains new_name, retired in LMSAccountRetirementView .. pii_types: name diff --git a/common/djangoapps/student/models_api.py b/common/djangoapps/student/models_api.py index b7cc4d2522f..4d34d6ed1dc 100644 --- a/common/djangoapps/student/models_api.py +++ b/common/djangoapps/student/models_api.py @@ -1,8 +1,11 @@ """ Provides Python APIs exposed from Student models. """ +import datetime import logging +from pytz import UTC + from common.djangoapps.student.models import CourseAccessRole as _CourseAccessRole from common.djangoapps.student.models import CourseEnrollment as _CourseEnrollment from common.djangoapps.student.models import ManualEnrollmentAudit as _ManualEnrollmentAudit @@ -16,6 +19,7 @@ from common.djangoapps.student.models import ( ALLOWEDTOENROLL_TO_UNENROLLED as _ALLOWEDTOENROLL_TO_UNENROLLED, DEFAULT_TRANSITION_STATE as _DEFAULT_TRANSITION_STATE, ) +from common.djangoapps.student.models import PendingNameChange as _PendingNameChange from common.djangoapps.student.models import UserProfile as _UserProfile # This is done so that if these strings change within the app, we can keep exported constants the same @@ -103,3 +107,50 @@ def get_course_access_role(user, org, course_id, role): }) return None return course_access_role + + +def do_name_change_request(user, new_name, rationale): + """ + Create a name change request. This either updates the user's current PendingNameChange, or creates + a new one if it doesn't exist. Returns the PendingNameChange object and a boolean describing whether + or not a new one was created. + """ + user_profile = _UserProfile.objects.get(user=user) + if user_profile.name == new_name: + log_msg = ( + 'user_id={user_id} requested a name change, but the requested name is the same as' + 'their current profile name. Not taking any action.'.format(user_id=user.id) + ) + log.warning(log_msg) + return None, False + + pending_name_change, created = _PendingNameChange.objects.update_or_create( + user=user, + defaults={ + 'new_name': new_name, + 'rationale': rationale + } + ) + + return pending_name_change, created + + +def confirm_name_change(user, pending_name_change): + """ + Confirm a pending name change. This updates the user's profile name and deletes the + PendingNameChange object. + """ + user_profile = _UserProfile.objects.get(user=user) + + # Store old name in profile metadata + meta = user_profile.get_meta() + if 'old_names' not in meta: + meta['old_names'] = [] + meta['old_names'].append( + [user_profile.name, pending_name_change.rationale, datetime.datetime.now(UTC).isoformat()] + ) + user_profile.set_meta(meta) + + user_profile.name = pending_name_change.new_name + user_profile.save() + pending_name_change.delete() diff --git a/common/djangoapps/student/signals/receivers.py b/common/djangoapps/student/signals/receivers.py index 93cda209eee..6af93ee1068 100644 --- a/common/djangoapps/student/signals/receivers.py +++ b/common/djangoapps/student/signals/receivers.py @@ -9,10 +9,18 @@ from django.contrib.auth import get_user_model from django.db import IntegrityError from django.db.models.signals import post_save, pre_save from django.dispatch import receiver +from edx_name_affirmation.signals import VERIFIED_NAME_APPROVED from lms.djangoapps.courseware.toggles import courseware_mfe_progress_milestones_are_active from common.djangoapps.student.helpers import EMAIL_EXISTS_MSG_FMT, USERNAME_EXISTS_MSG_FMT, AccountValidationError -from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentCelebration, is_email_retired, is_username_retired # lint-amnesty, pylint: disable=line-too-long +from common.djangoapps.student.models import ( + CourseEnrollment, + CourseEnrollmentCelebration, + PendingNameChange, + is_email_retired, + is_username_retired +) +from common.djangoapps.student.models_api import confirm_name_change @receiver(pre_save, sender=get_user_model()) @@ -70,3 +78,16 @@ def create_course_enrollment_celebration(sender, instance, created, **kwargs): except IntegrityError: # A celebration object was already created. Shouldn't happen, but ignore it if it does. pass + + +@receiver(VERIFIED_NAME_APPROVED) +def listen_for_verified_name_approved(sender, user_id, profile_name, **kwargs): + """ + If the user has a pending name change that corresponds to an approved verified name, confirm it. + """ + user = get_user_model().objects.get(id=user_id) + try: + pending_name_change = PendingNameChange.objects.get(user=user, new_name=profile_name) + confirm_name_change(user, pending_name_change) + except PendingNameChange.DoesNotExist: + pass diff --git a/common/djangoapps/student/tests/test_models.py b/common/djangoapps/student/tests/test_models.py index 8040ad7d95e..6bd2cc7486e 100644 --- a/common/djangoapps/student/tests/test_models.py +++ b/common/djangoapps/student/tests/test_models.py @@ -29,7 +29,7 @@ from common.djangoapps.student.models import ( UserCelebration, UserProfile ) -from common.djangoapps.student.models_api import get_name +from common.djangoapps.student.models_api import confirm_name_change, do_name_change_request, get_name from common.djangoapps.student.tests.factories import AccountRecoveryFactory, CourseEnrollmentFactory, UserFactory from lms.djangoapps.courseware.models import DynamicUpgradeDeadlineConfiguration from lms.djangoapps.courseware.toggles import ( @@ -467,21 +467,56 @@ class PendingNameChangeTests(SharedModuleStoreTestCase): super().setUpClass() cls.user = UserFactory() cls.user2 = UserFactory() + cls.name = cls.user.profile.name + cls.new_name = 'New Name' + cls.updated_name = 'Updated Name' + cls.rationale = 'Testing name change' - def setUp(self): # lint-amnesty, pylint: disable=super-method-not-called - self.name_change, _ = PendingNameChange.objects.get_or_create( - user=self.user, - new_name='New Name PII', - rationale='for testing!' - ) - assert 1 == len(PendingNameChange.objects.all()) + def test_do_name_change_request(self): + """ + Test basic name change request functionality. + """ + do_name_change_request(self.user, self.new_name, self.rationale) + self.assertEqual(PendingNameChange.objects.count(), 1) + + def test_same_name(self): + """ + Test that attempting a name change with the same name as the user's current profile + name will not result in a new pending name change request. + """ + pending_name_change = do_name_change_request(self.user, self.name, self.rationale)[0] + self.assertIsNone(pending_name_change) + + def test_update_name_change(self): + """ + Test that if a user already has a name change request, creating another request will + update the current one. + """ + do_name_change_request(self.user, self.new_name, self.rationale) + do_name_change_request(self.user, self.updated_name, self.rationale) + self.assertEqual(PendingNameChange.objects.count(), 1) + pending_name_change = PendingNameChange.objects.get(user=self.user) + self.assertEqual(pending_name_change.new_name, self.updated_name) + + def test_confirm_name_change(self): + """ + Test that confirming a name change request updates the user's profile name and deletes + the request. + """ + pending_name_change = do_name_change_request(self.user, self.new_name, self.rationale)[0] + confirm_name_change(self.user, pending_name_change) + user_profile = UserProfile.objects.get(user=self.user) + self.assertEqual(user_profile.name, self.new_name) + self.assertEqual(PendingNameChange.objects.count(), 0) def test_delete_by_user_removes_pending_name_change(self): + do_name_change_request(self.user, self.new_name, self.rationale) record_was_deleted = PendingNameChange.delete_by_user_value(self.user, field='user') assert record_was_deleted assert 0 == len(PendingNameChange.objects.all()) def test_delete_by_user_no_effect_for_user_with_no_name_change(self): + do_name_change_request(self.user, self.new_name, self.rationale) record_was_deleted = PendingNameChange.delete_by_user_value(self.user2, field='user') assert not record_was_deleted assert 1 == len(PendingNameChange.objects.all()) diff --git a/common/djangoapps/student/tests/test_receivers.py b/common/djangoapps/student/tests/test_receivers.py index fd992269bdb..4d1a754b68f 100644 --- a/common/djangoapps/student/tests/test_receivers.py +++ b/common/djangoapps/student/tests/test_receivers.py @@ -1,9 +1,18 @@ """ Tests for student signal receivers. """ +from edx_name_affirmation.signals import VERIFIED_NAME_APPROVED from edx_toggles.toggles.testutils import override_waffle_flag from lms.djangoapps.courseware.toggles import COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES -from common.djangoapps.student.models import CourseEnrollmentCelebration -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory +from common.djangoapps.student.models import ( + CourseEnrollmentCelebration, + PendingNameChange, + UserProfile +) +from common.djangoapps.student.tests.factories import ( + CourseEnrollmentFactory, + UserFactory, + UserProfileFactory +) from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -33,3 +42,23 @@ class ReceiversTest(SharedModuleStoreTestCase): """ Test we don't make a celebration if the MFE redirect waffle flag is off """ CourseEnrollmentFactory() assert CourseEnrollmentCelebration.objects.count() == 0 + + def test_listen_for_verified_name_approved(self): + """ + Test that profile name is updated when a pending name change is approved + """ + user = UserFactory(email='email@test.com', username='jdoe') + UserProfileFactory(user=user) + + new_name = 'John Doe' + PendingNameChange.objects.create(user=user, new_name=new_name) + assert PendingNameChange.objects.count() == 1 + + # Send a VERIFIED_NAME_APPROVED signal where the profile name matches the name + # change request + VERIFIED_NAME_APPROVED.send(sender=None, user_id=user.id, profile_name=new_name) + + # Assert that the pending name change was deleted and the profile name was updated + assert PendingNameChange.objects.count() == 0 + profile = UserProfile.objects.get(user=user) + assert profile.name == new_name diff --git a/lms/envs/common.py b/lms/envs/common.py index 4cb0cefdb96..d8eff025ec4 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3998,6 +3998,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION["admin_fields"] = ( "year_of_birth", "phone_number", "activation_key", + "pending_name_change", "is_verified_name_enabled", ] ) diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 11dc4c61bad..f4a37ba6d5c 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.core.validators import ValidationError, validate_email from django.utils.translation import override as override_language from django.utils.translation import ugettext as _ +from edx_name_affirmation.toggles import is_verified_name_enabled from pytz import UTC from common.djangoapps.student import views as student_views from common.djangoapps.student.models import ( @@ -22,6 +23,7 @@ from common.djangoapps.student.models import ( ) from common.djangoapps.util.model_utils import emit_settings_changed_event from common.djangoapps.util.password_policy_validators import validate_password +from lms.djangoapps.certificates.api import get_certificates_for_user from openedx.core.djangoapps.user_api import accounts, errors, helpers from openedx.core.djangoapps.user_api.errors import ( @@ -240,6 +242,7 @@ def _validate_name_change(user_profile, data, field_errors): return None old_name = user_profile.name + try: validate_name(data['name']) except ValidationError as err: @@ -249,9 +252,28 @@ def _validate_name_change(user_profile, data, field_errors): } return None + if _does_name_change_require_verification(user_profile.user, old_name, data['name']): + err_msg = 'This name change requires ID verification.' + field_errors['name'] = { + 'developer_message': err_msg, + 'user_message': err_msg + } + return None + return old_name +def _does_name_change_require_verification(user, old_name, new_name): + """ + If name change requires verification, do not update it through this API. + """ + return ( + is_verified_name_enabled() + and old_name != new_name + and len(get_certificates_for_user(user.username)) > 0 + ) + + def _get_old_language_proficiencies_if_updating(user_profile, data): if "language_proficiencies" in data: return list(user_profile.language_proficiencies.values('code')) diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index c6198ef51ce..b7060bcb185 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -15,14 +15,20 @@ from rest_framework import serializers from edx_name_affirmation.toggles import is_verified_name_enabled -from common.djangoapps.student.models import UserPasswordToggleHistory +from common.djangoapps.student.models import ( + LanguageProficiency, + PendingNameChange, + SocialLink, + UserPasswordToggleHistory, + UserProfile +) from lms.djangoapps.badges.utils import badges_enabled from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api import errors from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled from openedx.core.djangoapps.user_api.models import RetirementState, UserPreference, UserRetirementStatus from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin -from common.djangoapps.student.models import LanguageProficiency, SocialLink, UserProfile +from openedx.core.djangoapps.user_authn.views.registration_form import contains_html, contains_url from . import ( ACCOUNT_VISIBILITY_PREF_KEY, @@ -163,6 +169,7 @@ class UserReadOnlySerializer(serializers.Serializer): # lint-amnesty, pylint: d "social_links": None, "extended_profile_fields": None, "phone_number": None, + "pending_name_change": None, "is_verified_name_enabled": is_verified_name_enabled(), } @@ -196,6 +203,12 @@ class UserReadOnlySerializer(serializers.Serializer): # lint-amnesty, pylint: d } ) + try: + pending_name_change = PendingNameChange.objects.get(user=user) + data.update({"pending_name_change": pending_name_change.new_name}) + except PendingNameChange.DoesNotExist: + pass + if is_secondary_email_feature_enabled(): data.update( { @@ -528,6 +541,23 @@ class UserRetirementPartnerReportSerializer(serializers.Serializer): pass +class PendingNameChangeSerializer(serializers.Serializer): # lint-amnesty, pylint: disable=abstract-method + """ + Serialize the PendingNameChange model + """ + new_name = serializers.CharField() + + class Meta: + model = PendingNameChange + fields = ('new_name',) + + def validate_new_name(self, new_name): + if contains_html(new_name): + raise serializers.ValidationError('Name cannot contain the following characters: < >') + if contains_url(new_name): + raise serializers.ValidationError('Name cannot contain a URL') + + def get_extended_profile(user_profile): """ Returns the extended user profile fields stored in user_profile.meta diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 496641040ae..b3a59f69013 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -16,6 +16,8 @@ from django.http import HttpResponse from django.test import TestCase from django.test.client import RequestFactory from django.urls import reverse +from edx_name_affirmation.toggles import VERIFIED_NAME_FLAG +from edx_toggles.toggles.testutils import override_waffle_flag from social_django.models import UserSocialAuth from common.djangoapps.student.models import ( AccountRecovery, @@ -361,6 +363,42 @@ class TestAccountApi(UserSettingsEventTestMixin, EmailTemplateTagMixin, CreateAc assert 'Valid e-mail address required.' in field_errors['email']['developer_message'] assert 'Full Name cannot contain the following characters: < >' in field_errors['name']['user_message'] + @override_waffle_flag(VERIFIED_NAME_FLAG, active=True) + @patch( + 'openedx.core.djangoapps.user_api.accounts.api.get_certificates_for_user', + Mock(return_value=['mock certificate']) + ) + def test_name_update_requires_idv(self): + """ + Test that a name change is blocked through this API if it requires ID verification. + In this case, the user has at least one certificate. + """ + update = {'name': 'New Name'} + + with pytest.raises(AccountValidationError) as context_manager: + update_account_settings(self.user, update) + + field_errors = context_manager.value.field_errors + assert len(field_errors) == 1 + assert field_errors['name']['developer_message'] == 'This name change requires ID verification.' + + account_settings = get_account_settings(self.default_request)[0] + assert account_settings['name'] != 'New Name' + + @override_waffle_flag(VERIFIED_NAME_FLAG, active=True) + @patch( + 'openedx.core.djangoapps.user_api.accounts.api.get_certificates_for_user', + Mock(return_value=[]) + ) + def test_name_update_does_not_require_idv(self): + """ + Test that the user can change their name freely if it does not require verification. + """ + update = {'name': 'New Name'} + update_account_settings(self.user, update) + account_settings = get_account_settings(self.default_request)[0] + assert account_settings['name'] == 'New Name' + @patch('django.core.mail.EmailMultiAlternatives.send') @patch('common.djangoapps.student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) # lint-amnesty, pylint: disable=line-too-long def test_update_sending_email_fails(self, send_mail): @@ -555,6 +593,7 @@ class AccountSettingsOnCreationTest(CreateAccountMixin, TestCase): 'time_zone': None, 'course_certificates': None, 'phone_number': None, + 'pending_name_change': None, 'is_verified_name_enabled': False, } diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 15c69de3d49..870334cd03f 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -60,6 +60,16 @@ class UserAPITestCase(APITestCase): client.login(username=user.username, password=TEST_PASSWORD) return client + def send_post(self, client, json_data, content_type='application/json', expected_status=201): + """ + Helper method for sending a post to the server, defaulting to application/json content_type. + Verifies the expected status and returns the response. + """ + # pylint: disable=no-member + response = client.post(self.url, data=json.dumps(json_data), content_type=content_type) + assert expected_status == response.status_code + return response + def send_patch(self, client, json_data, content_type="application/merge-patch+json", expected_status=200): """ Helper method for sending a patch to the server, defaulting to application/merge-patch+json content_type. @@ -276,7 +286,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): Verify that all account fields are returned (even those that are not shareable). """ data = response.data - assert 29 == len(data) + assert 30 == len(data) # public fields (3) expected_account_privacy = ( @@ -417,7 +427,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): """ self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.create_mock_profile(self.user) - with self.assertNumQueries(26): + with self.assertNumQueries(27): response = self.send_get(self.different_client) self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY) @@ -432,7 +442,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): """ self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD) self.create_mock_profile(self.user) - with self.assertNumQueries(26): + with self.assertNumQueries(27): response = self.send_get(self.different_client) self._verify_private_account_response(response) @@ -556,7 +566,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): with self.assertNumQueries(queries): response = self.send_get(self.client) data = response.data - assert 29 == len(data) + assert 30 == len(data) assert self.user.username == data['username'] assert ((self.user.first_name + ' ') + self.user.last_name) == data['name'] for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"): @@ -579,12 +589,12 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): assert data['accomplishments_shared'] is False self.client.login(username=self.user.username, password=TEST_PASSWORD) - verify_get_own_information(24) + verify_get_own_information(25) # Now make sure that the user can get the same information, even if not active self.user.is_active = False self.user.save() - verify_get_own_information(15) + verify_get_own_information(16) def test_get_account_empty_string(self): """ @@ -599,7 +609,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): legacy_profile.save() self.client.login(username=self.user.username, password=TEST_PASSWORD) - with self.assertNumQueries(24): + with self.assertNumQueries(25): response = self.send_get(self.client) for empty_field in ("level_of_education", "gender", "country", "state", "bio",): assert response.data[empty_field] is None @@ -955,7 +965,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase): response = self.send_get(client) if has_full_access: data = response.data - assert 29 == len(data) + assert 30 == len(data) assert self.user.username == data['username'] assert ((self.user.first_name + ' ') + self.user.last_name) == data['name'] assert self.user.email == data['email'] @@ -1014,6 +1024,54 @@ class TestAccountAPITransactions(TransactionTestCase): assert 'm' == data['gender'] +@ddt.ddt +class NameChangeViewTests(UserAPITestCase): + """ NameChangeView tests """ + + def setUp(self): + super().setUp() + self.url = reverse('name_change') + + def test_request_succeeds(self): + """ + Test that a valid name change request succeeds. + """ + self.client.login(username=self.user.username, password=TEST_PASSWORD) + self.send_post(self.client, {'name': 'New Name'}) + + def test_unauthenticated(self): + """ + Test that a name change request fails for an unauthenticated user. + """ + self.send_post(self.client, {'name': 'New Name'}, expected_status=401) + + def test_empty_request(self): + """ + Test that an empty request fails. + """ + self.client.login(username=self.user.username, password=TEST_PASSWORD) + self.send_post(self.client, {}, expected_status=400) + + def test_blank_name(self): + """ + Test that a blank name string fails. + """ + self.client.login(username=self.user.username, password=TEST_PASSWORD) + self.send_post(self.client, {'name': ''}, expected_status=400) + + @ddt.data('<html>invalid name</html>', 'https://invalid.com') + def test_fails_validation(self, invalid_name): + """ + Test that an invalid name will return an error. + """ + self.client.login(username=self.user.username, password=TEST_PASSWORD) + self.send_post( + self.client, + {'name': invalid_name}, + expected_status=400 + ) + + @ddt.ddt @mock.patch('django.conf.settings.USERNAME_REPLACEMENT_WORKER', 'test_replace_username_service_worker') class UsernameReplacementViewTests(APITestCase): diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 2ae2d1f780e..c29e6abf57f 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -55,6 +55,7 @@ from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable= get_retired_username_by_username, is_username_retired ) +from common.djangoapps.student.models_api import do_name_change_request from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments @@ -79,6 +80,7 @@ from ..models import ( from .api import get_account_settings, update_account_settings from .permissions import CanDeactivateUser, CanReplaceUsername, CanRetireUser from .serializers import ( + PendingNameChangeSerializer, UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer, UserSearchEmailSerializer @@ -246,6 +248,9 @@ class AccountViewSet(ViewSet): * phone_number: The phone number for the user. String of numbers with an optional `+` sign at the start. + * pending_name_change: If the user has an active name change request, returns the + requested name. + * is_verified_name_enabled: Temporary flag to control verified name field - see https://github.com/edx/edx-name-affirmation/blob/main/edx_name_affirmation/toggles.py @@ -416,6 +421,42 @@ class AccountViewSet(ViewSet): return Response(account_settings) +class NameChangeView(APIView): + """ + Request a profile name change. This creates a PendingNameChange to be verified later, + rather than updating the user's profile name directly. + """ + authentication_classes = (JwtAuthentication, SessionAuthentication,) + permission_classes = (permissions.IsAuthenticated,) + + def post(self, request): + """ + POST /api/user/v1/accounts/name_change/ + + Example request: + { + "name": "Jon Doe" + } + """ + user = request.user + new_name = request.data.get('name', None) + rationale = f'Name change requested through account API by {user.username}' + + serializer = PendingNameChangeSerializer(data={'new_name': new_name}) + + if serializer.is_valid(): + pending_name_change = do_name_change_request(user, new_name, rationale)[0] + if pending_name_change: + return Response(status=status.HTTP_201_CREATED) + else: + return Response( + 'The name given was identical to the current name.', + status=status.HTTP_400_BAD_REQUEST + ) + + return Response(status=status.HTTP_400_BAD_REQUEST, data=serializer.errors) + + class AccountDeactivationView(APIView): """ Account deactivation viewset. Currently only supports POST requests. diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index 0006671f1b7..2894223ed68 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -16,6 +16,7 @@ from .accounts.views import ( AccountViewSet, DeactivateLogoutView, LMSAccountRetirementView, + NameChangeView, UsernameReplacementView ) from . import views as user_api_views @@ -117,6 +118,11 @@ urlpatterns = [ DeactivateLogoutView.as_view(), name='deactivate_logout' ), + url( + r'^v1/accounts/name_change/$', + NameChangeView.as_view(), + name='name_change' + ), url( fr'^v1/accounts/{settings.USERNAME_PATTERN}/verification_status/$', IDVerificationStatusView.as_view(), -- GitLab