Merge pull request #28546 from edx/bseverino/pending-name-change

[MST-803] Update account API to allow pending name changes
This commit is contained in:
Bianca Severino
2021-09-02 14:45:00 -04:00
committed by GitHub
12 changed files with 355 additions and 22 deletions

View File

@@ -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'))

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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):

View File

@@ -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.

View File

@@ -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(),