diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 958daf64d1a6afdbe69880ecf8408e6f504952bc..965ff06610ce672a184501d7c1fb701c088be3bc 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -584,6 +584,7 @@ RETIREMENT_SERVICE_WORKER_USERNAME = ENV_TOKENS.get( 'RETIREMENT_SERVICE_WORKER_USERNAME', RETIREMENT_SERVICE_WORKER_USERNAME ) +RETIREMENT_STATES = ENV_TOKENS.get('RETIREMENT_STATES', RETIREMENT_STATES) ####################### Plugin Settings ########################## diff --git a/cms/envs/common.py b/cms/envs/common.py index 6cecd0441111cefffc88dcfaa7dfd8d719bedc60..778babf0b6111c1a8bc804ac52a34b5c7bdd038f 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -134,6 +134,7 @@ from lms.envs.common import ( RETIRED_EMAIL_FMT, RETIRED_USER_SALTS, RETIREMENT_SERVICE_WORKER_USERNAME, + RETIREMENT_STATES, # Methods to derive settings _make_mako_template_dirs, diff --git a/lms/envs/aws.py b/lms/envs/aws.py index a831b2f80f13f4f004f94dc6621e2ea2a2be7a3f..fe15f327752bf8ab79bff7d01124d69f221eff71 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -1088,6 +1088,7 @@ RETIREMENT_SERVICE_WORKER_USERNAME = ENV_TOKENS.get( 'RETIREMENT_SERVICE_WORKER_USERNAME', RETIREMENT_SERVICE_WORKER_USERNAME ) +RETIREMENT_STATES = ENV_TOKENS.get('RETIREMENT_STATES', RETIREMENT_STATES) ############################### Plugin Settings ############################### diff --git a/lms/envs/common.py b/lms/envs/common.py index d37b3a5baa2b9060d88e278daa4cbe69c9778774..5dd103c97b4dd8614401a8f20392d7b203361ff3 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3414,6 +3414,42 @@ derived('RETIRED_USERNAME_FMT', 'RETIRED_EMAIL_FMT') RETIRED_USER_SALTS = ['abc', '123'] RETIREMENT_SERVICE_WORKER_USERNAME = 'RETIREMENT_SERVICE_USER' +# These states are the default, but are designed to be overridden in configuration. +RETIREMENT_STATES = [ + 'PENDING', + + 'LOCKING_ACCOUNT', + 'LOCKING_COMPLETE', + + 'RETIRING_CREDENTIALS', + 'CREDENTIALS_COMPLETE', + + 'RETIRING_ECOM', + 'ECOM_COMPLETE', + + 'RETIRING_FORUMS', + 'FORUMS_COMPLETE', + + 'RETIRING_EMAIL_LISTS', + 'EMAIL_LISTS_COMPLETE', + + 'RETIRING_ENROLLMENTS', + 'ENROLLMENTS_COMPLETE', + + 'RETIRING_NOTES', + 'NOTES_COMPLETE', + + 'NOTIFYING_PARTNERS', + 'PARTNERS_NOTIFIED', + + 'RETIRING_LMS', + 'LMS_COMPLETE', + + 'ERRORED', + 'ABORTED', + 'COMPLETE', +] + ############### Settings for django-fernet-fields ################## FERNET_KEYS = [ 'DUMMY KEY CHANGE BEFORE GOING TO PRODUCTION', diff --git a/openedx/core/djangoapps/user_api/management/commands/populate_retirement_states.py b/openedx/core/djangoapps/user_api/management/commands/populate_retirement_states.py new file mode 100644 index 0000000000000000000000000000000000000000..1cd10b98d0c010561ec42cf8a77f00f176f4e84e --- /dev/null +++ b/openedx/core/djangoapps/user_api/management/commands/populate_retirement_states.py @@ -0,0 +1,123 @@ +""" +Take the list of states from settings.RETIREMENT_STATES and forces the +RetirementState table to mirror it. + +We use a foreign keyed table for this instead of just using the settings +directly to generate a `choices` tuple for the model because the states +need to be configurable by open source partners and modifying the +`choices` for a model field causes new migrations to be generated, +with a variety of unpleasant follow-on effects for the partner when +upgrading the model at a later date. +""" +from __future__ import print_function + +import copy +import logging + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus + + +LOGGER = logging.getLogger(__name__) + +START_STATE = 'PENDING' +END_STATES = ['ERRORED', 'ABORTED', 'COMPLETE'] +REQUIRED_STATES = copy.deepcopy(END_STATES) +REQUIRED_STATES.insert(0, START_STATE) +REQ_STR = ','.join(REQUIRED_STATES) + + +class Command(BaseCommand): + """ + Implementation of the populate command + """ + help = 'Populates the RetirementState table with the states present in settings.' + + def _validate_new_states(self, new_states): + """ + Check settings for existence of states, required states + """ + if not new_states: + raise CommandError('settings.RETIREMENT_STATES does not exist or is empty.') + + if not set(REQUIRED_STATES).issubset(set(new_states)): + raise CommandError('settings.RETIREMENT_STATES ({}) does not contain all required states ' + '({})'.format(new_states, REQ_STR)) + + # Confirm that the start and end states are in the right places + if new_states.index(START_STATE) != 0: + raise CommandError('{} must be the first state'.format(START_STATE)) + + num_end_states = len(END_STATES) + + if new_states[-num_end_states:] != END_STATES: + raise CommandError('The last {} states must be these (in this order): ' + '{}'.format(num_end_states, END_STATES)) + + def _check_current_users(self): + """ + Check UserRetirementStatus for users currently in progress + """ + if UserRetirementStatus.objects.exclude(current_state__state_name__in=REQUIRED_STATES).exists(): + raise CommandError( + 'Users are currently being processed. All users must be in one of these states to run this command: ' + '{}'.format(REQ_STR) + ) + + def _delete_old_states_and_create_new(self, new_states): + """ + Wipes the RetirementState table and creates new entries based on new_states + - Note that the state_execution_order is incremented by 10 for each entry + this should allow manual insert of "in between" states via the Django admin + if necessary, without having to manually re-sort all of the states. + """ + + # Save off old states before + current_states = RetirementState.objects.all().values_list('state_name', flat=True) + + # Delete all existing rows, easier than messing with the ordering + RetirementState.objects.all().delete() + + # Add new rows, with space in between to manually insert stages via Django admin if necessary + curr_sort_order = 1 + for state in new_states: + row = { + 'state_name': state, + 'state_execution_order': curr_sort_order, + 'is_dead_end_state': state in END_STATES, + 'required': state in REQUIRED_STATES + } + + RetirementState.objects.create(**row) + curr_sort_order += 10 + + # Generate the diff + set_current_states = set(current_states) + set_new_states = set(new_states) + + states_to_create = set_new_states - set_current_states + states_remaining = set_current_states.intersection(set_new_states) + states_to_delete = set_current_states - set_new_states + + return states_to_create, states_remaining, states_to_delete + + def handle(self, *args, **options): + """ + Execute the command. + """ + new_states = settings.RETIREMENT_STATES + self._validate_new_states(new_states) + self._check_current_users() + created, existed, deleted = self._delete_old_states_and_create_new(new_states) + + # Report + print("All states removed and new states added. Differences:") + print(" Added: {}".format(created)) + print(" Removed: {}".format(deleted)) + print(" Remaining: {}".format(existed)) + print("States updated successfully. Current states:") + + for state in RetirementState.objects.all(): + print(state) diff --git a/openedx/core/djangoapps/user_api/management/tests/test_populate_retirement_states.py b/openedx/core/djangoapps/user_api/management/tests/test_populate_retirement_states.py new file mode 100644 index 0000000000000000000000000000000000000000..44c22cfbd1d3ed324abffa79fa789cffc98049f1 --- /dev/null +++ b/openedx/core/djangoapps/user_api/management/tests/test_populate_retirement_states.py @@ -0,0 +1,131 @@ +""" +Test the populate_retirement_states management command +""" +import copy +import pytest + +from django.core.management import call_command, CommandError + +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus +from openedx.core.djangoapps.user_api.management.commands.populate_retirement_states import START_STATE +from student.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +def test_successful_create(settings): + """ + Run the command with default states for a successful initial population + """ + call_command('populate_retirement_states') + curr_states = RetirementState.objects.all().values_list('state_name', flat=True) + assert list(curr_states) == settings.RETIREMENT_STATES + + +def test_successful_update(settings): + """ + Run the command with expected inputs for a successful update + """ + settings.RETIREMENT_STATES = copy.deepcopy(settings.RETIREMENT_STATES) + settings.RETIREMENT_STATES.insert(3, 'FOO_START') + settings.RETIREMENT_STATES.insert(4, 'FOO_COMPLETE') + + call_command('populate_retirement_states') + curr_states = RetirementState.objects.all().values_list('state_name', flat=True) + assert list(curr_states) == settings.RETIREMENT_STATES + + +def test_no_states(settings): + """ + Test with empty settings.RETIREMENT_STATES + """ + settings.RETIREMENT_STATES = None + with pytest.raises(CommandError, match=r'settings.RETIREMENT_STATES does not exist or is empty.'): + call_command('populate_retirement_states') + + settings.RETIREMENT_STATES = [] + with pytest.raises(CommandError, match=r'settings.RETIREMENT_STATES does not exist or is empty.'): + call_command('populate_retirement_states') + + +def test_missing_required_states_start(settings): + """ + Test with missing PENDING + """ + # This is used throughout this file to force pytest to actually revert our settings changes. + # Since we're modifying the list and not directly modifying the settings it doesn't get picked + # up here: + # https://github.com/pytest-dev/pytest-django/blob/master/pytest_django/fixtures.py#L254 + settings.RETIREMENT_STATES = copy.deepcopy(settings.RETIREMENT_STATES) + + # Remove "PENDING" state + del settings.RETIREMENT_STATES[0] + + with pytest.raises(CommandError, match=r'does not contain all required states'): + call_command('populate_retirement_states') + + +def test_missing_required_states_end(settings): + """ + Test with missing required end states + """ + # Remove last state, a required dead end state + settings.RETIREMENT_STATES = copy.deepcopy(settings.RETIREMENT_STATES) + del settings.RETIREMENT_STATES[-1] + + with pytest.raises(CommandError, match=r'does not contain all required states'): + call_command('populate_retirement_states') + + +def test_out_of_order_start_state(settings): + """ + Test with PENDING somewhere other than the beginning + """ + settings.RETIREMENT_STATES = copy.deepcopy(settings.RETIREMENT_STATES) + del settings.RETIREMENT_STATES[0] + settings.RETIREMENT_STATES.insert(4, 'PENDING') + + with pytest.raises(CommandError, match=r'{} must be the first state'.format(START_STATE)): + call_command('populate_retirement_states') + + +def test_out_of_order_end_states(settings): + """ + Test with missing PENDING and/or end states + """ + # Remove last state, a required dead end state + settings.RETIREMENT_STATES = copy.deepcopy(settings.RETIREMENT_STATES) + del settings.RETIREMENT_STATES[-1] + settings.RETIREMENT_STATES.insert(-2, 'COMPLETE') + + with pytest.raises(CommandError, match=r'in this order'): + call_command('populate_retirement_states') + + +def test_end_states_not_at_end(settings): + """ + Test putting a state after the end states + """ + settings.RETIREMENT_STATES = copy.deepcopy(settings.RETIREMENT_STATES) + settings.RETIREMENT_STATES.append('ANOTHER_STATE') + with pytest.raises(CommandError, match=r'in this order'): + call_command('populate_retirement_states') + + +def test_users_in_bad_states(): + """ + Test that having users in the process of retirement cause this to fail + """ + user = UserFactory() + + # First populate the table + call_command('populate_retirement_states') + + # Create a UserRetirementStatus in an active state + retirement = UserRetirementStatus.create_retirement(user) + retirement.current_state = RetirementState.objects.get(state_name='LOCKING_ACCOUNT') + retirement.save() + + # Now try to update + with pytest.raises(CommandError, match=r'Users are currently being processed'): + call_command('populate_retirement_states')