forked from openedx/openedx-platform
-
Notifications
You must be signed in to change notification settings - Fork 15
fix: free retired learner's email on retirement completion #458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
f709a43
fix: free retired learner's email on retirement completion
rahulkanneri-2u d7ff709
fix: gate auto-freeing of retired emails behind a waffle flag
rahulkanneri-2u 349586f
fix: restore handle_retirement_cancellation body clobbered by email-f…
rahulkanneri-2u 17fe4ea
fix: wrap retirement completion and email-freeing in one transaction
rahulkanneri-2u aa1b8b9
fix: import setup_retirement_states fixture in test_utils.py
rahulkanneri-2u File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
openedx/core/djangoapps/user_api/management/commands/free_retired_user_email.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
66 changes: 66 additions & 0 deletions
66
openedx/core/djangoapps/user_api/management/tests/test_free_retired_user_email.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.