Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from social_django.models import UserSocialAuth
Expand Down Expand Up @@ -68,6 +69,7 @@
UserRetirementPartnerReportingStatus,
UserRetirementStatus
)
from openedx.core.djangoapps.user_api.toggles import FREE_RETIRED_LEARNER_EMAIL_ON_COMPLETION
from openedx.core.djangolib.testing.utils import assert_redact_before_delete, skip_unless_lms
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
Expand Down Expand Up @@ -1318,6 +1320,47 @@ def test_moves(self, start_state, move_to_state, force, expected_response_code):

self.update_and_assert_status(data, expected_response_code)

def test_reaching_complete_does_not_free_email_when_flag_disabled(self):
"""
The auto-free behavior is opt-in via FREE_RETIRED_LEARNER_EMAIL_ON_COMPLETION,
which defaults to disabled.
"""
data = {'new_state': 'COMPLETE', 'response': 'accountretirementcomplete', 'force': True}
self.update_and_assert_status(data)

self.test_user.refresh_from_db()
assert self.test_user.email == self.retirement.original_email

@override_waffle_flag(FREE_RETIRED_LEARNER_EMAIL_ON_COMPLETION, active=True)
def test_reaching_complete_frees_retired_email_when_flag_enabled(self):
"""
Moving a retirement to COMPLETE should free up the learner's retired
email address so it can be reused for a new registration.
"""
data = {'new_state': 'COMPLETE', 'response': 'accountretirementcomplete', 'force': True}
self.update_and_assert_status(data)

self.test_user.refresh_from_db()
assert self.test_user.email == f'{self.retirement.original_email}.freed.{self.test_user.id}'

@override_waffle_flag(FREE_RETIRED_LEARNER_EMAIL_ON_COMPLETION, active=True)
def test_reaching_complete_email_free_failure_fails_request(self):
"""
If freeing the email unexpectedly errors, the request should report
the failure rather than silently swallowing it, and the state
transition to COMPLETE should be rolled back so a retry isn't stuck
needing `force`.
"""
data = {'new_state': 'COMPLETE', 'response': 'accountretirementcomplete', 'force': True}
with mock.patch(
'openedx.core.djangoapps.user_api.accounts.views.free_retired_learner_email',
side_effect=Exception('boom'),
):
self.update_and_assert_status(data, status.HTTP_500_INTERNAL_SERVER_ERROR)

retirement = UserRetirementStatus.objects.get(id=self.retirement.id)
assert retirement.current_state == self.pending_state


@skip_unless_lms
class TestAccountRetirementPost(RetirementTestCase):
Expand Down
60 changes: 60 additions & 0 deletions openedx/core/djangoapps/user_api/accounts/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
from contextlib import contextmanager

import ddt
import pytest
from completion import models
from completion.test_utils import CompletionWaffleTestMixin
from django.conf import settings
from django.db import connection
from django.db.models.signals import pre_delete
from django.test import TestCase
Expand All @@ -21,10 +23,12 @@
from openedx.core.djangoapps.user_api.accounts.signals import redact_social_auth_pii_before_deletion
from openedx.core.djangoapps.user_api.accounts.utils import (
REDACTED_SOCIAL_AUTH_UID_PREFIX,
free_retired_learner_email,
redact_and_delete_historical_social_auth,
redact_and_delete_social_auth,
retrieve_last_sitewide_block_completed,
)
from openedx.core.djangoapps.user_api.models import RetirementState, RetirementStateError
from openedx.core.djangolib.testing.utils import assert_redact_before_delete, skip_unless_lms
from xmodule.modulestore.tests.django_utils import (
SharedModuleStoreTestCase, # pylint: disable=wrong-import-order
Expand All @@ -35,6 +39,11 @@
)

from ..utils import format_social_link, validate_social_link
from .retirement_helpers import ( # pylint: disable=unused-import
RetirementTestCase,
create_retirement_status,
setup_retirement_states
)


# Use a context manager to guarantee signal reconnection between tests.
Expand Down Expand Up @@ -296,3 +305,54 @@ def test_historical_social_auth_redact_before_delete(self):
)
assert not self.historical_social_auth_model.objects.filter(user=self.user).exists()
assert self.historical_social_auth_model.objects.filter(user=other_user).exists()


class FreeRetiredLearnerEmailTest(RetirementTestCase):
"""
Tests for free_retired_learner_email().
"""

def _retire_user_to_state(self, user, state_name):
return create_retirement_status(user, state=RetirementState.objects.get(state_name=state_name))

def test_frees_email_when_retirement_complete(self):
user = UserFactory(email='retired__user_abc123@retired.invalid')
self._retire_user_to_state(user, 'COMPLETE')

free_retired_learner_email(user)

user.refresh_from_db()
assert user.email == f'retired__user_abc123@retired.invalid.freed.{user.id}'

def test_raises_when_retirement_still_in_progress(self):
user = UserFactory(email='retired__user_abc123@retired.invalid')
self._retire_user_to_state(user, 'RETIRING_LMS')

with pytest.raises(RetirementStateError):
free_retired_learner_email(user)

def test_is_idempotent(self):
user = UserFactory(email='retired__user_abc123@retired.invalid')
self._retire_user_to_state(user, 'COMPLETE')

free_retired_learner_email(user)
user.refresh_from_db()
freed_email = user.email

free_retired_learner_email(user)
user.refresh_from_db()
assert user.email == freed_email

def test_frees_email_when_status_row_archived(self):
user = UserFactory(email=f'retired__user_abc123@{settings.RETIRED_EMAIL_DOMAIN}')

free_retired_learner_email(user)

user.refresh_from_db()
assert user.email.endswith(f'.freed.{user.id}')

def test_raises_when_user_does_not_appear_retired(self):
user = UserFactory(email='still.active@example.com')

with pytest.raises(RetirementStateError):
free_retired_learner_email(user)
42 changes: 41 additions & 1 deletion openedx/core/djangoapps/user_api/accounts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order

from ..models import UserRetirementStatus
from ..models import RetirementStateError, UserRetirementStatus

# Prefix and suffix used to build a per-record redacted uid for UserSocialAuth.
REDACTED_SOCIAL_AUTH_UID_PREFIX = 'redacted-before-delete-'
Expand Down Expand Up @@ -311,3 +311,43 @@ def handle_retirement_cancellation(retirement, email_address=None):
retirement.user.save()

retirement.delete()


def _is_retired_email_format(email):
"""
Returns True if the given email address is in the retired-email domain
used by settings.RETIRED_EMAIL_DOMAIN.
"""
return email.endswith(f'@{settings.RETIRED_EMAIL_DOMAIN}')


def free_retired_learner_email(user):
"""
Lets a fully-retired learner reuse their original email address by appending
a suffix to the retired-hash email currently on their auth_user row. This
only mutates that one column - the row and its retirement history are kept
for compliance, and is_email_retired() will no longer match the freed value.

Raises RetirementStateError if the user's retirement isn't in a state where
it's safe to free the email (still in progress, or doesn't look retired at all).
"""
freed_suffix = f'.freed.{user.id}'
if user.email.endswith(freed_suffix):
LOGGER.info(f"Email for user {user.id} was already freed, nothing to do.")
return

try:
retirement = UserRetirementStatus.objects.select_related('current_state').get(user=user)
if retirement.current_state.state_name != 'COMPLETE':
raise RetirementStateError(
f"Cannot free email for user {user.id}: retirement is in state "
f"'{retirement.current_state.state_name}', not COMPLETE."
)
except UserRetirementStatus.DoesNotExist:
if not _is_retired_email_format(user.email):
raise RetirementStateError(f"User {user.id} does not appear to be a retired user.") # lint-amnesty, pylint: disable=raise-missing-from

old_email = user.email
user.email = old_email + freed_suffix
user.save(update_fields=['email'])
LOGGER.info(f"Freed retired email for user {user.id}: '{old_email}' -> '{user.email}'.")

@vgulati-apphelix vgulati-apphelix Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logger to be sanitized - remove email & username.

17 changes: 15 additions & 2 deletions openedx/core/djangoapps/user_api/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
UserRetirementPartnerReportingStatus,
UserRetirementStatus,
)
from ..toggles import should_free_retired_learner_email_on_completion
from .api import get_account_settings, update_account_settings
from .permissions import (
CanCancelUserRetirement,
Expand All @@ -97,7 +98,11 @@
UserSearchEmailSerializer,
)
from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS
from .utils import create_retirement_request_and_deactivate_account, username_suffix_generator
from .utils import (
create_retirement_request_and_deactivate_account,
free_retired_learner_email,
username_suffix_generator,
)

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -1007,7 +1012,15 @@ def partial_update(self, request):
if retirement is None:
raise UserRetirementStatus.DoesNotExist()

retirement.update_state(request.data)
with transaction.atomic():
retirement.update_state(request.data)

if (
retirement.current_state.state_name == 'COMPLETE' and
should_free_retired_learner_email_on_completion()
):
free_retired_learner_email(retirement.user)

return Response(status=status.HTTP_204_NO_CONTENT)
except UserRetirementStatus.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
Frees a retired learner's email address so it can be reused for a new
registration, without touching the archived UserRetirementStatus row.
"""
import logging

from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.management.base import BaseCommand, CommandError

from openedx.core.djangoapps.user_api.accounts.utils import free_retired_learner_email
from openedx.core.djangoapps.user_api.models import RetirementStateError

logger = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Implementation of the free_retired_user_email command.
"""
help = "Frees a retired learner's email address so it can be reused for a new registration."

def add_arguments(self, parser):
parser.add_argument('--username', type=str, help='Username of the retired learner to free.')
parser.add_argument('--user_id', type=int, help='User ID of the retired learner to free.')
Comment on lines +23 to +24

@vgulati-apphelix vgulati-apphelix Sep 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
parser.add_argument('--username', type=str, help='Username of the retired learner to free.')
parser.add_argument('--user_id', type=int, help='User ID of the retired learner to free.')
user_group = parser.add_mutually_exclusive_group(required=True)
user_group.add_argument('--username', type=str, help='Username of the retired learner to allow email reuse.',)
user_group.add_argument('--user-id', type=int, help='User ID of the retired learner to allow email reuse.',)


def handle(self, *args, **options):
username = options['username']
user_id = options['user_id']

if bool(username) == bool(user_id):
raise CommandError('Please provide exactly one of --username or --user_id.')

try:
user = User.objects.get(username=username) if username else User.objects.get(id=user_id)
except User.DoesNotExist:
raise CommandError(f'No user found for username={username!r} user_id={user_id!r}.') # lint-amnesty, pylint: disable=raise-missing-from

try:
free_retired_learner_email(user)
except RetirementStateError as exc:
raise CommandError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from

logger.info(f'Successfully freed email for user {user.id}.')
print(f'Successfully freed email for user {user.id}.')
Comment on lines +43 to +44

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
logger.info(f'Successfully freed email for user {user.id}.')
print(f'Successfully freed email for user {user.id}.')
logger.info(f'Successfully freed email for user {user.id}.')

Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
Test the free_retired_user_email management command
"""


import pytest
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.management import CommandError, call_command

from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import
create_retirement_status,
setup_retirement_states
)
from openedx.core.djangoapps.user_api.models import RetirementState
from common.djangoapps.student.tests.factories import UserFactory

pytestmark = pytest.mark.django_db


def _retire_user(user, state_name):
return create_retirement_status(user, state=RetirementState.objects.get(state_name=state_name))


def test_frees_email_by_username(setup_retirement_states, capsys): # pylint: disable=redefined-outer-name, unused-argument
user = UserFactory(email='retired__user_abc123@retired.invalid')
_retire_user(user, 'COMPLETE')

call_command('free_retired_user_email', username=user.username)

user.refresh_from_db()
assert user.email == f'retired__user_abc123@retired.invalid.freed.{user.id}'
assert 'Successfully freed email' in capsys.readouterr().out


def test_frees_email_by_user_id(setup_retirement_states): # pylint: disable=redefined-outer-name, unused-argument
user = UserFactory(email='retired__user_abc123@retired.invalid')
_retire_user(user, 'COMPLETE')

call_command('free_retired_user_email', user_id=user.id)

user.refresh_from_db()
assert user.email.endswith(f'.freed.{user.id}')


def test_requires_exactly_one_identifier():
with pytest.raises(CommandError, match=r'exactly one of --username or --user_id'):
call_command('free_retired_user_email')

with pytest.raises(CommandError, match=r'exactly one of --username or --user_id'):
call_command('free_retired_user_email', username='someone', user_id=1)


def test_unknown_user():
with pytest.raises(CommandError, match=r'No user found'):
call_command('free_retired_user_email', username='nonexistent')


def test_blocked_while_retirement_in_progress(setup_retirement_states): # pylint: disable=redefined-outer-name, unused-argument
user = UserFactory(email='retired__user_abc123@retired.invalid')
_retire_user(user, 'RETIRING_LMS')

with pytest.raises(CommandError, match=r'not COMPLETE'):
call_command('free_retired_user_email', username=user.username)

user.refresh_from_db()
assert User.objects.get(id=user.id).email == 'retired__user_abc123@retired.invalid'
27 changes: 27 additions & 0 deletions openedx/core/djangoapps/user_api/toggles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
Toggles for the user_api app.
"""

from edx_toggles.toggles import WaffleFlag

# .. toggle_name: user_api.free_retired_learner_email_on_completion
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: When enabled, a learner's retired email address is automatically freed
# (see free_retired_learner_email) as soon as their retirement reaches the COMPLETE state via
# PATCH /api/user/v1/accounts/update_retirement_status/. This lets the behavior be turned off
# without pausing the retirement pipeline itself; the free_retired_user_email management command
# is unaffected by this toggle.
# .. toggle_use_cases: opt_in
# .. toggle_creation_date: 2026-09-04
FREE_RETIRED_LEARNER_EMAIL_ON_COMPLETION = WaffleFlag(
'user_api.free_retired_learner_email_on_completion', __name__
)


def should_free_retired_learner_email_on_completion():
"""
Returns True if a learner's retired email should be automatically freed
when their retirement reaches the COMPLETE state.
"""
return FREE_RETIRED_LEARNER_EMAIL_ON_COMPLETION.is_enabled()
Loading