Mgmt command to cancel user retirement request.

This commit is contained in:
John Eskew
2018-07-30 16:32:54 -04:00
parent 9ad65031a0
commit f92a8f6e24
9 changed files with 228 additions and 122 deletions

View File

@@ -2,7 +2,7 @@
Test signal handlers for the survey app
"""
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_retirement
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_completed_retirement
from student.tests.factories import UserFactory
from survey.models import SurveyAnswer
from survey.tests.factories import SurveyAnswerFactory
@@ -43,7 +43,7 @@ class SurveyRetireSignalTests(ModuleStoreTestCase):
# Run twice to make sure no errors are raised
_listen_for_lms_retire(sender=self.__class__, user=answer.user)
fake_retirement(answer.user)
fake_completed_retirement(answer.user)
_listen_for_lms_retire(sender=self.__class__, user=answer.user)
# All values for this user should still be here and just be an empty string

View File

@@ -9,7 +9,7 @@ from pytz import UTC
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
from lms.djangoapps.verify_student.signals import _listen_for_course_publish, _listen_for_lms_retire
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_retirement
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_completed_retirement
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -95,7 +95,7 @@ class RetirementSignalTest(ModuleStoreTestCase):
# Run this twice to make sure there are no errors raised 2nd time through
_listen_for_lms_retire(sender=self.__class__, user=verification.user)
fake_retirement(verification.user)
fake_completed_retirement(verification.user)
_listen_for_lms_retire(sender=self.__class__, user=verification.user)
ver_obj = SoftwareSecurePhotoVerification.objects.get(user=verification.user)

View File

@@ -15,7 +15,10 @@ from openedx.core.djangoapps.credit.models import (
CreditRequirement,
CreditRequirementStatus
)
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import RetirementTestCase
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import
RetirementTestCase,
setup_retirement_states
)
from openedx.core.djangoapps.user_api.models import UserRetirementStatus
from student.tests.factories import UserFactory
@@ -97,7 +100,7 @@ class CreditEligibilityModelTests(TestCase):
self.assertEqual(len(requirements), 1)
class CreditRequirementStatusTests(TestCase):
class CreditRequirementStatusTests(RetirementTestCase):
"""
Tests for credit requirement status models.
"""
@@ -105,7 +108,6 @@ class CreditRequirementStatusTests(TestCase):
def setUp(self):
super(CreditRequirementStatusTests, self).setUp()
self.course_key = CourseKey.from_string("edX/DemoX/Demo_Course")
RetirementTestCase.setup_states()
self.old_username = "username"
self.user = UserFactory(username=self.old_username)
self.retirement = UserRetirementStatus.create_retirement(self.user)
@@ -170,14 +172,13 @@ class CreditRequirementStatusTests(TestCase):
self.assertFalse(retirement_succeeded)
class CreditRequestTest(TestCase):
class CreditRequestTest(RetirementTestCase):
"""
The CreditRequest model's test suite.
"""
def setUp(self):
super(CreditRequestTest, self).setUp()
RetirementTestCase.setup_states()
self.user = UserFactory.create()
self.retirement = UserRetirementStatus.create_retirement(self.user)
self.credit_course = CreditCourse.objects.create()

View File

@@ -3,6 +3,7 @@ Helpers for testing retirement functionality
"""
import datetime
import pytest
import pytz
from django.test import TestCase
from social_django.models import UserSocialAuth
@@ -21,75 +22,92 @@ from student.tests.factories import UserFactory
from ..views import AccountRetirementView
@pytest.fixture
def setup_retirement_states(scope="module"): # pylint: disable=unused-argument
"""
Create basic states that mimic the retirement process.
"""
default_states = [
('PENDING', 1, False, True),
('LOCKING_ACCOUNT', 20, False, False),
('LOCKING_COMPLETE', 30, False, False),
('RETIRING_CREDENTIALS', 40, False, False),
('CREDENTIALS_COMPLETE', 50, False, False),
('RETIRING_ECOM', 60, False, False),
('ECOM_COMPLETE', 70, False, False),
('RETIRING_FORUMS', 80, False, False),
('FORUMS_COMPLETE', 90, False, False),
('RETIRING_EMAIL_LISTS', 100, False, False),
('EMAIL_LISTS_COMPLETE', 110, False, False),
('RETIRING_ENROLLMENTS', 120, False, False),
('ENROLLMENTS_COMPLETE', 130, False, False),
('RETIRING_NOTES', 140, False, False),
('NOTES_COMPLETE', 150, False, False),
('RETIRING_LMS', 160, False, False),
('LMS_COMPLETE', 170, False, False),
('ADDING_TO_PARTNER_QUEUE', 180, False, False),
('PARTNER_QUEUE_COMPLETE', 190, False, False),
('ERRORED', 200, True, True),
('ABORTED', 210, True, True),
('COMPLETE', 220, True, True),
]
for name, ex, dead, req in default_states:
RetirementState.objects.create(
state_name=name,
state_execution_order=ex,
is_dead_end_state=dead,
required=req
)
yield
RetirementState.objects.all().delete()
def create_retirement_status(state=None, create_datetime=None):
"""
Helper method to create a RetirementStatus with useful defaults.
Assumes that retirement states have been setup before calling.
"""
if create_datetime is None:
create_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=8)
user = UserFactory()
retirement = UserRetirementStatus.create_retirement(user)
if state:
retirement.current_state = state
retirement.last_state = state
retirement.created = create_datetime
retirement.modified = create_datetime
retirement.save()
return retirement
def _fake_logged_out_user(user):
# Simulate the initial logout retirement endpoint.
user.username = get_retired_username_by_username(user.username)
user.email = get_retired_email_by_email(user.email)
user.set_unusable_password()
user.save()
@pytest.fixture
def logged_out_retirement_request():
"""
Returns a UserRetirementStatus test fixture object that has been logged out and email-changed,
which is the first step which happens to a user being added to the retirement queue.
"""
retirement = create_retirement_status()
_fake_logged_out_user(retirement.user)
return retirement
@pytest.mark.usefixtures("setup_retirement_states")
class RetirementTestCase(TestCase):
"""
Test case with a helper methods for retirement
"""
@classmethod
def setUpClass(cls):
super(RetirementTestCase, cls).setUpClass()
cls.setup_states()
@staticmethod
def setup_states():
"""
Create basic states that mimic our current understanding of the retirement process
"""
default_states = [
('PENDING', 1, False, True),
('LOCKING_ACCOUNT', 20, False, False),
('LOCKING_COMPLETE', 30, False, False),
('RETIRING_CREDENTIALS', 40, False, False),
('CREDENTIALS_COMPLETE', 50, False, False),
('RETIRING_ECOM', 60, False, False),
('ECOM_COMPLETE', 70, False, False),
('RETIRING_FORUMS', 80, False, False),
('FORUMS_COMPLETE', 90, False, False),
('RETIRING_EMAIL_LISTS', 100, False, False),
('EMAIL_LISTS_COMPLETE', 110, False, False),
('RETIRING_ENROLLMENTS', 120, False, False),
('ENROLLMENTS_COMPLETE', 130, False, False),
('RETIRING_NOTES', 140, False, False),
('NOTES_COMPLETE', 150, False, False),
('RETIRING_LMS', 160, False, False),
('LMS_COMPLETE', 170, False, False),
('ADDING_TO_PARTNER_QUEUE', 180, False, False),
('PARTNER_QUEUE_COMPLETE', 190, False, False),
('ERRORED', 200, True, True),
('ABORTED', 210, True, True),
('COMPLETE', 220, True, True),
]
for name, ex, dead, req in default_states:
RetirementState.objects.create(
state_name=name,
state_execution_order=ex,
is_dead_end_state=dead,
required=req
)
def _create_retirement(self, state, create_datetime=None):
"""
Helper method to create a RetirementStatus with useful defaults
"""
if create_datetime is None:
create_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=8)
user = UserFactory()
return UserRetirementStatus.objects.create(
user=user,
original_username=user.username,
original_email=user.email,
original_name=user.profile.name,
retired_username=get_retired_username_by_username(user.username),
retired_email=get_retired_email_by_email(user.email),
current_state=state,
last_state=state,
responses="",
created=create_datetime,
modified=create_datetime
)
def _retirement_to_dict(self, retirement, all_fields=False):
"""
Return a dict format of this model to a consistent format for serialization, removing the long text field
@@ -131,7 +149,7 @@ class RetirementTestCase(TestCase):
return retirement_dict
def _create_users_all_states(self):
return [self._create_retirement(state) for state in RetirementState.objects.all()]
return [create_retirement_status(state) for state in RetirementState.objects.all()]
def _get_non_dead_end_states(self):
return [state for state in RetirementState.objects.filter(is_dead_end_state=False)]
@@ -140,7 +158,7 @@ class RetirementTestCase(TestCase):
return [state for state in RetirementState.objects.filter(is_dead_end_state=True)]
def fake_retirement(user):
def fake_completed_retirement(user):
"""
Makes an attempt to put user for the given user into a "COMPLETED"
retirement state by faking important parts of retirement.
@@ -151,12 +169,10 @@ def fake_retirement(user):
"""
# Deactivate / logout and hash username & email
UserSocialAuth.objects.filter(user_id=user.id).delete()
_fake_logged_out_user(user)
user.first_name = ''
user.last_name = ''
user.is_active = False
user.username = get_retired_username_by_username(user.username)
user.email = get_retired_email_by_email(user.email)
user.set_unusable_password()
user.save()
# Clear profile

View File

@@ -11,38 +11,13 @@ from openedx.core.djangoapps.user_api.models import (
)
from student.models import get_retired_email_by_email, get_retired_username_by_username
from student.tests.factories import UserFactory
from .retirement_helpers import setup_retirement_states # pylint: disable=unused-import
# Tell pytest it's ok to use the database
pytestmark = pytest.mark.django_db
@pytest.fixture
def setup_retirement_states():
"""
Pytest fixture to create some basic states for testing. Duplicates functionality of the
Django test runner in test_views.py unfortunately, but they're not compatible.
"""
default_states = [
('PENDING', 1, False, True),
('LOCKING_ACCOUNT', 20, False, False),
('LOCKING_COMPLETE', 30, False, False),
('RETIRING_LMS', 40, False, False),
('LMS_COMPLETE', 50, False, False),
('ERRORED', 60, True, True),
('ABORTED', 70, True, True),
('COMPLETE', 80, True, True),
]
for name, ex, dead, req in default_states:
RetirementState.objects.create(
state_name=name,
state_execution_order=ex,
is_dead_end_state=dead,
required=req
)
def _assert_retirementstatus_is_user(retirement, user):
"""
Helper function to compare a newly created UserRetirementStatus object to expected values for

View File

@@ -78,7 +78,12 @@ from student.tests.factories import (
from ..views import AccountRetirementView, USER_PROFILE_PII
from ...tests.factories import UserOrgTagFactory
from .retirement_helpers import RetirementTestCase, fake_retirement
from .retirement_helpers import ( # pylint: disable=unused-import
RetirementTestCase,
fake_completed_retirement,
create_retirement_status,
setup_retirement_states
)
def build_jwt_headers(user):
@@ -255,8 +260,7 @@ class TestAccountRetireMailings(RetirementTestCase):
# Should be created in parent setUpClass
retiring_email_lists = RetirementState.objects.get(state_name='RETIRING_EMAIL_LISTS')
self.retirement = self._create_retirement(retiring_email_lists)
self.retirement = create_retirement_status(retiring_email_lists)
self.test_user = self.retirement.user
self.url = reverse('accounts_retire_mailings')
@@ -456,7 +460,7 @@ class TestPartnerReportingPut(RetirementTestCase, ModuleStoreTestCase):
Checks the simple success case of creating a user, enrolling in a course, and doing the partner
report PUT. User should then have the appropriate row in UserRetirementPartnerReportingStatus
"""
retirement = self._create_retirement(self.partner_queue_state)
retirement = create_retirement_status(self.partner_queue_state)
for course in self.courses:
CourseEnrollment.enroll(user=retirement.user, course_key=course.id)
@@ -467,7 +471,7 @@ class TestPartnerReportingPut(RetirementTestCase, ModuleStoreTestCase):
"""
Runs the success test twice to make sure that re-running the step still succeeds.
"""
retirement = self._create_retirement(self.partner_queue_state)
retirement = create_retirement_status(self.partner_queue_state)
for course in self.courses:
CourseEnrollment.enroll(user=retirement.user, course_key=course.id)
@@ -475,7 +479,7 @@ class TestPartnerReportingPut(RetirementTestCase, ModuleStoreTestCase):
self.put_and_assert_status({'username': retirement.original_username})
# Do our basic other retirement step fakery
fake_retirement(retirement.user)
fake_completed_retirement(retirement.user)
# Try running our step again
self.put_and_assert_status({'username': retirement.original_username})
@@ -500,7 +504,7 @@ class TestPartnerReportingPut(RetirementTestCase, ModuleStoreTestCase):
the enrollment.course.org. We now just use the enrollment.course_id.org
since for this purpose we don't care if the course exists.
"""
retirement = self._create_retirement(self.partner_queue_state)
retirement = create_retirement_status(self.partner_queue_state)
user = retirement.user
enrollment = CourseEnrollment.enroll(user=user, course_key=CourseKey.from_string('edX/Test201/2018_Fall'))
@@ -717,7 +721,7 @@ class TestAccountRetirementList(RetirementTestCase):
Verify that users in dead end states are not returned
"""
for state in self._get_dead_end_states():
self._create_retirement(state)
create_retirement_status(state)
self.assert_status_and_user_list([], states_to_request=self._get_non_dead_end_states())
def test_users_retrieved_in_multiple_states(self):
@@ -726,7 +730,7 @@ class TestAccountRetirementList(RetirementTestCase):
"""
multiple_states = ['PENDING', 'FORUMS_COMPLETE']
for state in multiple_states:
self._create_retirement(RetirementState.objects.get(state_name=state))
create_retirement_status(RetirementState.objects.get(state_name=state))
data = {'cool_off_days': 0, 'states': multiple_states}
response = self.client.get(self.url, data, **self.headers)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -763,7 +767,7 @@ class TestAccountRetirementList(RetirementTestCase):
pending_state = RetirementState.objects.get(state_name='PENDING')
for days_back in range(1, days_back_to_test, -1):
create_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=days_back)
retirements.append(self._create_retirement(state=pending_state, create_datetime=create_datetime))
retirements.append(create_retirement_status(state=pending_state, create_datetime=create_datetime))
# Confirm we get the correct number and data back for each day we add to cool off days
# For each day we add to `cool_off_days` we expect to get one fewer retirement.
@@ -886,7 +890,7 @@ class TestAccountRetirementsByStatusAndDate(RetirementTestCase):
Verify that users in non-requested states are not returned
"""
state = RetirementState.objects.get(state_name='PENDING')
self._create_retirement(state=state)
create_retirement_status(state=state)
self.assert_status_and_user_list([])
def test_users_exist(self):
@@ -917,7 +921,7 @@ class TestAccountRetirementsByStatusAndDate(RetirementTestCase):
# Create retirements for the last 10 days
for days_back in range(0, 10):
create_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=days_back)
ret = self._create_retirement(state=complete_state, create_datetime=create_datetime)
ret = create_retirement_status(state=complete_state, create_datetime=create_datetime)
retirements.append(self._retirement_to_dict(ret))
# Go back in time adding days to the query, assert the correct retirements are present
@@ -1010,7 +1014,7 @@ class TestAccountRetirementRetrieve(RetirementTestCase):
retirements = []
for state in RetirementState.objects.all():
retirements.append(self._create_retirement(state))
retirements.append(create_retirement_status(state))
for retirement in retirements:
values = self._retirement_to_dict(retirement)
@@ -1021,7 +1025,7 @@ class TestAccountRetirementRetrieve(RetirementTestCase):
Simulate retrieving a retirement by the old username, after the name has been changed to the hashed one
"""
pending_state = RetirementState.objects.get(state_name='PENDING')
retirement = self._create_retirement(pending_state)
retirement = create_retirement_status(pending_state)
original_username = retirement.user.username
hashed_username = get_retired_username_by_username(original_username)
@@ -1044,7 +1048,7 @@ class TestAccountRetirementUpdate(RetirementTestCase):
self.pending_state = RetirementState.objects.get(state_name='PENDING')
self.locking_state = RetirementState.objects.get(state_name='LOCKING_ACCOUNT')
self.retirement = self._create_retirement(self.pending_state)
self.retirement = create_retirement_status(self.pending_state)
self.test_user = self.retirement.user
self.test_superuser = SuperuserFactory()
self.headers = build_jwt_headers(self.test_superuser)
@@ -1354,7 +1358,7 @@ class TestAccountRetirementPost(RetirementTestCase):
def test_retire_user_twice_idempotent(self):
data = {'username': self.original_username}
self.post_and_assert_status(data)
fake_retirement(self.test_user)
fake_completed_retirement(self.test_user)
self.post_and_assert_status(data)
def test_deletes_pii_from_user_profile(self):
@@ -1574,5 +1578,5 @@ class TestLMSAccountRetirementPost(RetirementTestCase, ModuleStoreTestCase):
# check that a second call to the retire_misc endpoint will work
data = {'username': self.original_username}
self.post_and_assert_status(data)
fake_retirement(self.test_user)
fake_completed_retirement(self.test_user)
self.post_and_assert_status(data)

View File

@@ -0,0 +1,59 @@
"""
Use this mgmt command when a user requests retirement mistakenly, then requests
for the retirement request to be cancelled. The command can't cancel a retirement
that has already commenced - only pending retirements.
"""
from __future__ import print_function
import logging
from django.core.management.base import BaseCommand, CommandError
from openedx.core.djangoapps.user_api.models import UserRetirementStatus
LOGGER = logging.getLogger(__name__)
class Command(BaseCommand):
"""
Implementation of the cancel_user_retirement_request command.
"""
help = 'Cancels the retirement of a user who has requested retirement - but has not yet been retired.'
def add_arguments(self, parser):
parser.add_argument('email_address',
help='Email address of user whose retirement request will be cancelled.')
def handle(self, *args, **options):
"""
Execute the command.
"""
email_address = options['email_address'].lower()
try:
# Load the user retirement status.
retirement_status = UserRetirementStatus.objects.select_related('current_state').select_related('user').get(
original_email=email_address
)
except UserRetirementStatus.DoesNotExist:
raise CommandError("No retirement request with email address '{}' exists.".format(email_address))
# Check if the user has started the retirement process -or- not.
if retirement_status.current_state.state_name != 'PENDING':
raise CommandError(
"Retirement requests can only be cancelled for users in the PENDING state."
" Current request state for '{}': {}".format(
email_address,
retirement_status.current_state.state_name
)
)
# Load the user record using the retired email address -and- change the email address back.
retirement_status.user.email = email_address
retirement_status.user.save()
# Delete the user retirement status record.
# No need to delete the accompanying "permanent" retirement request record - it gets done via Django signal.
retirement_status.delete()
print("Successfully cancelled retirement request for user with email address '{}'.")

View File

@@ -0,0 +1,50 @@
"""
Test the cancel_user_retirement_request management command
"""
import pytest
from django.contrib.auth.models import User
from django.core.management import CommandError, call_command
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import
logged_out_retirement_request,
setup_retirement_states
)
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementRequest, UserRetirementStatus
from student.tests.factories import UserFactory
pytestmark = pytest.mark.django_db
def test_successful_cancellation(setup_retirement_states, logged_out_retirement_request): # pylint: disable=redefined-outer-name, unused-argument
"""
Test a successfully cancelled retirement request.
"""
call_command('cancel_user_retirement_request', logged_out_retirement_request.original_email)
# Confirm that no retirement status exists for the user.
with pytest.raises(UserRetirementStatus.DoesNotExist):
UserRetirementStatus.objects.get(original_email=logged_out_retirement_request.user.email)
# Confirm that no retirement request exists for the user.
with pytest.raises(UserRetirementRequest.DoesNotExist):
UserRetirementRequest.objects.get(user=logged_out_retirement_request.user)
# Ensure user can be retrieved using the original email address.
User.objects.get(email=logged_out_retirement_request.original_email)
def test_cancellation_in_unrecoverable_state(setup_retirement_states, logged_out_retirement_request): # pylint: disable=redefined-outer-name, unused-argument
"""
Test a failed cancellation of a retirement request due to the retirement already beginning.
"""
retiring_lms_state = RetirementState.objects.get(state_name='RETIRING_LMS')
logged_out_retirement_request.current_state = retiring_lms_state
logged_out_retirement_request.save()
with pytest.raises(CommandError, match=r'Retirement requests can only be cancelled for users in the PENDING state'):
call_command('cancel_user_retirement_request', logged_out_retirement_request.original_email)
def test_cancellation_unknown_email_address(setup_retirement_states, logged_out_retirement_request): # pylint: disable=redefined-outer-name, unused-argument
"""
Test attempting to cancel a non-existent request of a user.
"""
user = UserFactory()
with pytest.raises(CommandError, match=r'No retirement request with email address'):
call_command('cancel_user_retirement_request', user.email)

View File

@@ -41,6 +41,7 @@ from ..accounts import (
NAME_MAX_LENGTH, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH,
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, USERNAME_BAD_LENGTH_MSG
)
from ..accounts.tests.retirement_helpers import setup_retirement_states # pylint: disable=unused-import
from ..accounts.api import get_account_settings
from ..models import UserOrgTag
from ..tests.factories import UserPreferenceFactory