feat: update account API to allow pending name changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user