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,7 +16,6 @@
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 @@ -69,7 +68,6 @@
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 @@ -1320,47 +1318,6 @@ 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: 0 additions & 60 deletions openedx/core/djangoapps/user_api/accounts/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@
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 @@ -23,12 +21,10 @@
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 @@ -39,11 +35,6 @@
)

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 @@ -305,54 +296,3 @@ 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: 1 addition & 41 deletions 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 RetirementStateError, UserRetirementStatus
from ..models import 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,43 +311,3 @@ 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}'.")
17 changes: 2 additions & 15 deletions openedx/core/djangoapps/user_api/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
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 @@ -98,11 +97,7 @@
UserSearchEmailSerializer,
)
from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS
from .utils import (
create_retirement_request_and_deactivate_account,
free_retired_learner_email,
username_suffix_generator,
)
from .utils import create_retirement_request_and_deactivate_account, username_suffix_generator

log = logging.getLogger(__name__)

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

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)

retirement.update_state(request.data)
return Response(status=status.HTTP_204_NO_CONTENT)
except UserRetirementStatus.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
Expand Down

This file was deleted.

This file was deleted.

27 changes: 0 additions & 27 deletions openedx/core/djangoapps/user_api/toggles.py

This file was deleted.

Loading