Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion common/djangoapps/student/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -904,14 +904,36 @@ class PendingEmailChange(DeletableByUserValue, models.Model): # noqa: DJ008
"""
This model keeps track of pending requested changes to a user's email address.

.. pii: Contains new_email, retired in AccountRetirementView
.. pii: Contains new_email, redacted then deleted in AccountRetirementView
.. pii_types: email_address
.. pii_retirement: local_api
"""
user = models.OneToOneField(User, unique=True, db_index=True, on_delete=models.CASCADE)
new_email = models.CharField(blank=True, max_length=255, db_index=True)
activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)

@classmethod
def delete_by_user_value(cls, value, field):
"""
Deletes instances of this model where ``field`` equals ``value``.

Automatically redacts new_email before deletion to ensure PII is cleared.
Uses bulk ORM update for efficiency.

Returns True if any instances were deleted.
Returns False otherwise.
"""
filter_kwargs = {field: value}
records_matching_user_value = cls.objects.filter(**filter_kwargs)

if not records_matching_user_value.exists():
return False

# Redact new_email before deletion using bulk update
records_matching_user_value.update(new_email='[email protected]')
records_matching_user_value.delete()
return True

def request_change(self, email):
"""Request a change to a user's email.

Expand Down
28 changes: 28 additions & 0 deletions common/djangoapps/student/tests/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core import mail
from django.db import transaction
from django.db.models.signals import pre_delete
from django.http import HttpResponse
from django.test import TransactionTestCase, override_settings
from django.test.client import RequestFactory
Expand Down Expand Up @@ -608,6 +609,33 @@ def test_successful_email_change(self, test_body_type, test_marketing_enabled, m
assert self.pending_change_request.new_email == User.objects.get(username=self.user.username).email
assert PendingEmailChange.objects.count() == 0

@skip_unless_lms
@patch('common.djangoapps.student.views.management.ace')
def test_successful_email_change_redacts_pending_email_before_delete(self, ace_mail):
original_email = self.user.email
expected_new_email = self.pending_change_request.new_email
captured_state = {}

def capture_before_delete(sender, instance, **kwargs):
captured_state['new_email'] = instance.new_email

ace_mail.send.side_effect = [None, None]
pre_delete.connect(capture_before_delete, sender=PendingEmailChange)
try:
response = confirm_email_change(self.request, self.key)
finally:
pre_delete.disconnect(capture_before_delete, sender=PendingEmailChange)

assert response.status_code == 200
assert mock_render_to_response('email_change_successful.html', {
'old_email': original_email,
'new_email': expected_new_email,
}).content.decode('utf-8') == response.content.decode('utf-8')
assert captured_state['new_email'] == '[email protected]'
assert User.objects.get(username=self.user.username).email == expected_new_email
assert PendingEmailChange.objects.count() == 0
assert ace_mail.send.call_count == 2

@patch('common.djangoapps.student.views.PendingEmailChange.objects.get', Mock(side_effect=TestException))
def test_always_rollback(self):
connection = transaction.get_connection()
Expand Down
30 changes: 30 additions & 0 deletions common/djangoapps/student/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.cache import cache
from django.db.models.functions import Lower
from django.db.models.signals import pre_delete
from django.test import TestCase, override_settings
from edx_toggles.toggles.testutils import override_waffle_flag
from freezegun import freeze_time
Expand All @@ -30,6 +31,7 @@
UserAttribute,
UserCelebration,
UserProfile,
get_retired_email_by_email,

Check failure on line 34 in common/djangoapps/student/tests/test_models.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (F401)

common/djangoapps/student/tests/test_models.py:34:5: F401 `common.djangoapps.student.models.get_retired_email_by_email` imported but unused help: Remove unused import: `common.djangoapps.student.models.get_retired_email_by_email`
)
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
Expand Down Expand Up @@ -600,6 +602,34 @@
assert not record_was_deleted
assert 1 == len(PendingEmailChange.objects.all())

def test_delete_by_user_value_redacts_pending_email_before_deletion(self):
"""
Verify that delete_by_user_value redacts new_email before deletion.
"""
expected_redacted_email = '[email protected]'
captured_state = {}

Check failure on line 611 in common/djangoapps/student/tests/test_models.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (W293)

common/djangoapps/student/tests/test_models.py:611:1: W293 Blank line contains whitespace help: Remove whitespace from blank line
def capture_before_delete(sender, instance, **kwargs):
"""
Capture email and activation_key before deletion.
"""
captured_state['new_email'] = instance.new_email
captured_state['activation_key'] = instance.activation_key

Check failure on line 618 in common/djangoapps/student/tests/test_models.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (W293)

common/djangoapps/student/tests/test_models.py:618:1: W293 Blank line contains whitespace help: Remove whitespace from blank line
pre_delete.connect(capture_before_delete, sender=PendingEmailChange)
try:
assert PendingEmailChange.objects.filter(user=self.user).exists()

Check failure on line 622 in common/djangoapps/student/tests/test_models.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (W293)

common/djangoapps/student/tests/test_models.py:622:1: W293 Blank line contains whitespace help: Remove whitespace from blank line
record_was_deleted = PendingEmailChange.delete_by_user_value(self.user, field='user')
assert record_was_deleted

Check failure on line 625 in common/djangoapps/student/tests/test_models.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (W293)

common/djangoapps/student/tests/test_models.py:625:1: W293 Blank line contains whitespace help: Remove whitespace from blank line
assert captured_state['new_email'] == expected_redacted_email
assert captured_state['activation_key'] == self.email_change.activation_key

Check failure on line 628 in common/djangoapps/student/tests/test_models.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (W293)

common/djangoapps/student/tests/test_models.py:628:1: W293 Blank line contains whitespace help: Remove whitespace from blank line
assert not PendingEmailChange.objects.filter(user=self.user).exists()
finally:
pre_delete.disconnect(capture_before_delete, sender=PendingEmailChange)


class TestCourseEnrollmentAllowed(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring

Expand Down
12 changes: 8 additions & 4 deletions common/djangoapps/student/views/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,16 +961,20 @@ def confirm_email_change(request, key):
transaction.set_rollback(True)
return response

user.email = pec.new_email
new_email = pec.new_email
user.email = new_email
user.save()
pec.delete()

# Use model-level deletion to redact pending email before delete.
PendingEmailChange.delete_by_user_value(user, field="user")

# And send it to the new email...
msg.recipient = Recipient(user.id, pec.new_email)
msg.recipient = Recipient(user.id, new_email)
try:
ace.send(msg)
except Exception: # pylint: disable=broad-except
log.warning('Unable to send confirmation email to new address', exc_info=True)
response = render_to_response("email_change_failed.html", {'email': pec.new_email})
response = render_to_response("email_change_failed.html", {'email': new_email})
transaction.set_rollback(True)
return response

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,84 @@
Test cases to cover account retirement views
"""

import datetime
import json
from unittest import mock
from zoneinfo import ZoneInfo

import ddt
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.contrib.sites.models import Site
from django.core import mail
from django.core.cache import cache
from django.db.models.signals import pre_delete
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from social_django.models import UserSocialAuth
from wiki.models import Article, ArticleRevision
from wiki.models.pluginbase import RevisionPlugin, RevisionPluginRevision

from common.djangoapps.entitlements.models import CourseEntitlementSupportDetail
from common.djangoapps.entitlements.tests.factories import CourseEntitlementFactory
from common.djangoapps.student.models import (
AccountRecovery,
CourseEnrollment,
CourseEnrollmentAllowed,
ManualEnrollmentAudit,
PendingEmailChange,
PendingNameChange,
Registration,
SocialLink,
UserProfile,
get_retired_email_by_email,
get_retired_username_by_username,
)
from common.djangoapps.student.tests.factories import (
AccountRecoveryFactory,
ContentTypeFactory,
CourseEnrollmentAllowedFactory,
PendingEmailChangeFactory,
PermissionFactory,
SuperuserFactory,
UserFactory,
)
from lms.djangoapps.certificates.api import get_certificate_for_user_id
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.djangoapps.course_groups.models import CourseUserGroup, UnregisteredLearnerCohortAssignments
from openedx.core.djangoapps.credit.models import (
CreditCourse,
CreditProvider,
CreditRequest,
CreditRequirement,
CreditRequirementStatus,
)
from openedx.core.djangoapps.external_user_ids.models import ExternalIdType
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory
from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementPartnerReportView
from openedx.core.djangoapps.user_api.models import (
RetirementState,
UserOrgTag,
UserRetirementPartnerReportingStatus,
UserRetirementStatus,
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory

from ...tests.factories import UserOrgTagFactory
from ..views import USER_PROFILE_PII, AccountRetirementView
from .retirement_helpers import ( # pylint: disable=unused-import
RetirementTestCase,
create_retirement_status,
fake_completed_retirement,
setup_retirement_states, # noqa: F401
)

Check failure on line 82 in openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (I001)

openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py:5:1: I001 Import block is un-sorted or un-formatted help: Organize imports


def build_jwt_headers(user):
Expand Down Expand Up @@ -1467,7 +1468,11 @@

@mock.patch('openedx.core.djangoapps.user_api.accounts.views.get_profile_image_names')
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.remove_profile_images')
def test_retire_user(self, mock_remove_profile_images, mock_get_profile_image_names):
def test_retire_user(
self,
mock_remove_profile_images,
mock_get_profile_image_names,
):
data = {'username': self.original_username}
self.post_and_assert_status(data)

Expand Down Expand Up @@ -1498,6 +1503,7 @@

self._entitlement_support_detail_assertions()

mock_redact_pending_email.assert_called_once_with(self.test_user, field="user")

Check failure on line 1506 in openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (F821)

openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py:1506:9: F821 Undefined name `mock_redact_pending_email`
assert not PendingEmailChange.objects.filter(user=self.test_user).exists()
assert not UserOrgTag.objects.filter(user=self.test_user).exists()

Expand All @@ -1510,6 +1516,35 @@
fake_completed_retirement(self.test_user)
self.post_and_assert_status(data)

def test_retire_user_redacts_pending_email_before_delete(self):
"""
Verify that delete_by_user_value redacts new_email using bulk update before deletion.
"""
expected_redacted_email = '[email protected]'
captured_state = {}

def capture_before_delete(sender, instance, **kwargs):
"""Capture email value before it's deleted."""
captured_state['new_email'] = instance.new_email

# Connect signal to capture pre-delete state
pre_delete.connect(capture_before_delete, sender=PendingEmailChange)
try:
# Verify the record exists with original email before retirement
assert PendingEmailChange.objects.filter(user=self.test_user).exists()

Check failure on line 1535 in openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (W293)

openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py:1535:1: W293 Blank line contains whitespace help: Remove whitespace from blank line
# Retire the user
data = {'username': self.original_username}
self.post_and_assert_status(data)

Check failure on line 1539 in openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (W293)

openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py:1539:1: W293 Blank line contains whitespace help: Remove whitespace from blank line
# Verify the redaction happened before deletion
assert captured_state.get('new_email') == expected_redacted_email

# Verify the record was deleted
assert not PendingEmailChange.objects.filter(user=self.test_user).exists()
finally:
pre_delete.disconnect(capture_before_delete, sender=PendingEmailChange)

@mock.patch('openedx.core.djangoapps.user_api.accounts.views.USER_RETIRE_LMS_CRITICAL')
def test_retirement_sends_critical_signal_with_retirement_data(self, mock_signal):
"""
Expand Down
4 changes: 3 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,9 @@ def post(self, request):

self.retire_entitlement_support_detail(user)

# Retire misc. models that may contain PII of this user
# Retire misc. models that may contain PII of this user.
# PendingEmailChange.delete_by_user_value() automatically redacts new_email
# before deletion because downstream systems may preserve soft-deleted snapshots.
PendingEmailChange.delete_by_user_value(user, field="user")
UserOrgTag.delete_by_user_value(user, field="user")

Expand Down
Loading