Add a model and associated API to help control the user retirement flow

This commit is contained in:
bmedx
2018-04-10 15:49:06 -04:00
parent dfe08245de
commit 63dcaa4695
9 changed files with 1013 additions and 18 deletions

View File

@@ -245,6 +245,17 @@ def get_all_retired_emails_by_email(email):
return user_util.get_all_retired_emails(email, settings.RETIRED_USER_SALTS, settings.RETIRED_EMAIL_FMT)
def get_potentially_retired_user_by_username(username):
"""
Attempt to return a User object based on the username, or if it
does not exist, then any hashed username salted with the historical
salts.
"""
locally_hashed_usernames = list(get_all_retired_usernames_by_username(username))
locally_hashed_usernames.append(username)
return User.objects.get(username__in=locally_hashed_usernames)
def get_potentially_retired_user_by_username_and_hash(username, hashed_username):
"""
To assist in the retirement process this method will:
@@ -259,13 +270,8 @@ def get_potentially_retired_user_by_username_and_hash(username, hashed_username)
if hashed_username not in locally_hashed_usernames:
raise Exception('Mismatched hashed_username, bad salt?')
try:
return User.objects.get(username=username)
except User.DoesNotExist:
# The 2nd DoesNotExist will bubble up from here if necessary,
# an assumption is being made here that our hashed username format
# is something that a user cannot create for themselves.
return User.objects.get(username__in=locally_hashed_usernames)
locally_hashed_usernames.append(username)
return User.objects.get(username__in=locally_hashed_usernames)
class UserStanding(models.Model):

View File

@@ -14,7 +14,7 @@ from six import text_type
from lms.djangoapps.badges.utils import badges_enabled
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api import errors
from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus, UserPreference
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
from student.models import UserProfile, LanguageProficiency, SocialLink
@@ -332,6 +332,48 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
return instance
class RetirementUserProfileSerializer(serializers.ModelSerializer):
"""
Serialize a small subset of UserProfile data for use in RetirementStatus APIs
"""
class Meta(object):
model = UserProfile
fields = ('id', 'name')
class RetirementUserSerializer(serializers.ModelSerializer):
"""
Serialize a small subset of User data for use in RetirementStatus APIs
"""
profile = RetirementUserProfileSerializer(read_only=True)
class Meta(object):
model = User
fields = ('id', 'username', 'email', 'profile')
class RetirementStateSerializer(serializers.ModelSerializer):
"""
Serialize a small subset of RetirementState data for use in RetirementStatus APIs
"""
class Meta(object):
model = RetirementState
fields = ('id', 'state_name', 'state_execution_order')
class UserRetirementStatusSerializer(serializers.ModelSerializer):
"""
Perform serialization for the RetirementStatus model
"""
user = RetirementUserSerializer(read_only=True)
current_state = RetirementStateSerializer(read_only=True)
last_state = RetirementStateSerializer(read_only=True)
class Meta(object):
model = UserRetirementStatus
exclude = ['responses', ]
def get_extended_profile(user_profile):
"""Returns the extended user profile fields stored in user_profile.meta"""

View File

@@ -0,0 +1,89 @@
"""
Model specific tests for user_api
"""
import pytest
from openedx.core.djangoapps.user_api.models import RetirementState, RetirementStateError, UserRetirementStatus
from student.models import get_retired_email_by_email, get_retired_username_by_username
from student.tests.factories import UserFactory
# 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
the given user.
"""
pending = RetirementState.objects.all().order_by('state_execution_order')[0]
retired_username = get_retired_username_by_username(user.username)
retired_email = get_retired_email_by_email(user.email)
assert retirement.user == user
assert retirement.original_username == user.username
assert retirement.original_email == user.email
assert retirement.original_name == user.profile.name
assert retirement.retired_username == retired_username
assert retirement.retired_email == retired_email
assert retirement.current_state == pending
assert retirement.last_state == pending
assert pending.state_name in retirement.responses
def test_retirement_create_success(setup_retirement_states): # pylint: disable=unused-argument, redefined-outer-name
"""
Basic test to make sure default creation succeeds
"""
user = UserFactory()
retirement = UserRetirementStatus.create_retirement(user)
_assert_retirementstatus_is_user(retirement, user)
def test_retirement_create_no_default_state():
"""
Confirm that if no states have been loaded we fail with a RetirementStateError
"""
user = UserFactory()
with pytest.raises(RetirementStateError):
UserRetirementStatus.create_retirement(user)
def test_retirement_create_already_retired(setup_retirement_states): # pylint: disable=unused-argument, redefined-outer-name
"""
Confirm the correct error bubbles up if the user already has a retirement row
"""
user = UserFactory()
retirement = UserRetirementStatus.create_retirement(user)
_assert_retirementstatus_is_user(retirement, user)
with pytest.raises(RetirementStateError):
UserRetirementStatus.create_retirement(user)

View File

@@ -10,6 +10,7 @@ from copy import deepcopy
import ddt
import pytest
import pytz
from django.conf import settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
@@ -25,7 +26,7 @@ from social_django.models import UserSocialAuth
from openedx.core.djangoapps.user_api.accounts import ACCOUNT_VISIBILITY_PREF_KEY
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_MAILINGS
from openedx.core.djangoapps.user_api.models import UserPreference, UserOrgTag
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus, UserPreference, UserOrgTag
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from openedx.core.lib.token_utils import JwtBuilder
@@ -940,6 +941,10 @@ class TestAccountRetireMailings(TestCase):
self.url = reverse('accounts_retire_mailings', kwargs={'username': self.test_user.username})
def build_post(self, user):
retired_username = get_retired_username_by_username(user.username)
return {'retired_username': retired_username}
def build_jwt_headers(self, user):
"""
Helper function for creating headers for the JWT authentication.
@@ -950,10 +955,6 @@ class TestAccountRetireMailings(TestCase):
}
return headers
def build_post(self, user):
retired_username = get_retired_username_by_username(user.username)
return {'retired_username': retired_username}
def assert_status_and_tag_count(self, headers, expected_status=status.HTTP_204_NO_CONTENT, expected_tag_count=2,
expected_tag_value="False", expected_content=None):
"""
@@ -1088,3 +1089,488 @@ class TestDeactivateLogout(TestCase):
headers = self.build_jwt_headers(self.test_superuser)
response = self.client.post(self.url, self.build_post(""), **headers)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
class RetirementTestCase(TestCase):
"""
Test case with a helper methods for retirement
"""
def setup_states(self):
"""
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),
('NOTIFYING_PARTNERS', 160, False, False),
('PARTNERS_NOTIFIED', 170, False, False),
('RETIRING_LMS', 180, False, False),
('LMS_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 build_jwt_headers(self, user):
"""
Helper function for creating headers for the JWT authentication.
"""
token = JwtBuilder(user).build_token([])
headers = {
'HTTP_AUTHORIZATION': 'JWT ' + token
}
return headers
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
`responses` for performance reasons.
"""
retirement_dict = {
u'id': retirement.id,
u'user': {
u'id': retirement.user.id,
u'username': retirement.user.username,
u'email': retirement.user.email,
u'profile': {
u'id': retirement.user.profile.id,
u'name': retirement.user.profile.name
},
},
u'original_username': retirement.original_username,
u'original_email': retirement.original_email,
u'original_name': retirement.original_name,
u'retired_username': retirement.retired_username,
u'retired_email': retirement.retired_email,
u'current_state': {
u'id': retirement.current_state.id,
u'state_name': retirement.current_state.state_name,
u'state_execution_order': retirement.current_state.state_execution_order,
},
u'last_state': {
u'id': retirement.last_state.id,
u'state_name': retirement.last_state.state_name,
u'state_execution_order': retirement.last_state.state_execution_order,
},
u'created': retirement.created,
u'modified': retirement.modified
}
if all_fields:
retirement_dict['responses'] = retirement.responses
return retirement_dict
def _create_users_all_states(self):
return [self._create_retirement(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)]
def _get_dead_end_states(self):
return [state for state in RetirementState.objects.filter(is_dead_end_state=True)]
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
class TestAccountRetirementList(RetirementTestCase):
"""
Tests the account retirement endpoint.
"""
def setUp(self):
super(TestAccountRetirementList, self).setUp()
self.test_superuser = SuperuserFactory()
self.headers = self.build_jwt_headers(self.test_superuser)
self.url = reverse('accounts_retirement_queue')
self.maxDiff = None
self.setup_states()
def assert_status_and_user_list(
self,
expected_data,
expected_status=status.HTTP_200_OK,
states_to_request=None,
cool_off_days=7
):
"""
Helper function for making a request to the retire subscriptions endpoint, asserting the status, and
optionally asserting data returned.
"""
if states_to_request is None:
# These are just a couple of random states that should be used in any implementation
states_to_request = ['PENDING', 'LOCKING_ACCOUNT']
else:
# Can pass in RetirementState objects or strings here
try:
states_to_request = [s.state_name for s in states_to_request]
except AttributeError:
states_to_request = states_to_request
data = {'cool_off_days': cool_off_days, 'states': ','.join(states_to_request)}
response = self.client.get(self.url, data, **self.headers)
self.assertEqual(response.status_code, expected_status)
response_data = response.json()
if expected_data:
# These datetimes won't match up due to serialization, but they're inherited fields tested elsewhere
for data in (response_data, expected_data):
for retirement in data:
del retirement['created']
del retirement['modified']
self.assertItemsEqual(response_data, expected_data)
def test_empty(self):
"""
Verify that an empty array is returned if no users are awaiting retirement
"""
self.assert_status_and_user_list([])
def test_users_exist_none_in_correct_status(self):
"""
Verify that users in dead end states are not returned
"""
for state in self._get_dead_end_states():
self._create_retirement(state)
self.assert_status_and_user_list([], states_to_request=self._get_non_dead_end_states())
def test_users_exist(self):
"""
Verify users in different states are returned with correct data or filtered out
"""
self.maxDiff = None
retirement_values = []
states_to_request = []
dead_end_states = self._get_dead_end_states()
for retirement in self._create_users_all_states():
if retirement.current_state not in dead_end_states:
states_to_request.append(retirement.current_state)
retirement_values.append(self._retirement_to_dict(retirement))
self.assert_status_and_user_list(retirement_values, states_to_request=self._get_non_dead_end_states())
def test_date_filter(self):
"""
Verifies the functionality of the `cool_off_days` parameter by creating 1 retirement per day for
10 days. Then requests different 1-10 `cool_off_days` to confirm the correct retirements are returned.
"""
retirements = []
days_back_to_test = 10
# Create a retirement per day for the last 10 days, from oldest date to newest. We want these all created
# before we start checking, thus the two loops.
# retirements = [2018-04-10..., 2018-04-09..., 2018-04-08...]
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))
# 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.
for cool_off_days in range(1, days_back_to_test):
# Start with 9 days back
req_days_back = days_back_to_test - cool_off_days
retirement_dicts = [self._retirement_to_dict(ret) for ret in retirements[:cool_off_days]]
self.assert_status_and_user_list(
retirement_dicts,
cool_off_days=req_days_back
)
def test_bad_cool_off_days(self):
"""
Check some bad inputs to make sure we get back the expected status
"""
self.assert_status_and_user_list(None, expected_status=status.HTTP_400_BAD_REQUEST, cool_off_days=-1)
self.assert_status_and_user_list(None, expected_status=status.HTTP_400_BAD_REQUEST, cool_off_days='ABCDERTP')
def test_bad_states(self):
"""
Check some bad inputs to make sure we get back the expected status
"""
self.assert_status_and_user_list(
None,
expected_status=status.HTTP_400_BAD_REQUEST,
states_to_request=['TUNA', 'TACO'])
self.assert_status_and_user_list(None, expected_status=status.HTTP_400_BAD_REQUEST, states_to_request=[])
def test_missing_params(self):
"""
All params are required, make sure that is enforced
"""
response = self.client.get(self.url, **self.headers)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
response = self.client.get(self.url, {}, **self.headers)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
response = self.client.get(self.url, {'cool_off_days': 7}, **self.headers)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
RetirementState.objects.get(state_name='PENDING')
response = self.client.get(self.url, {'states': ['PENDING']}, **self.headers)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
class TestAccountRetirementRetrieve(RetirementTestCase):
"""
Tests the account retirement retrieval endpoint.
"""
def setUp(self):
super(TestAccountRetirementRetrieve, self).setUp()
self.test_user = UserFactory()
self.test_superuser = SuperuserFactory()
self.url = reverse('accounts_retirement_retrieve', kwargs={'username': self.test_user.username})
self.headers = self.build_jwt_headers(self.test_superuser)
self.maxDiff = None
self.setup_states()
def assert_status_and_user_data(self, expected_data, expected_status=status.HTTP_200_OK, username_to_find=None):
"""
Helper function for making a request to the retire subscriptions endpoint, asserting the status,
and optionally asserting the expected data.
"""
if username_to_find is not None:
self.url = reverse('accounts_retirement_retrieve', kwargs={'username': username_to_find})
response = self.client.get(self.url, **self.headers)
self.assertEqual(response.status_code, expected_status)
if expected_data is not None:
response_data = response.json()
# These won't match up due to serialization, but they're inherited fields tested elsewhere
for data in (expected_data, response_data):
del data['created']
del data['modified']
self.assertDictEqual(response_data, expected_data)
return response_data
def test_no_retirement(self):
"""
Confirm we get a 404 if a retirement for the user can be found
"""
self.assert_status_and_user_data(None, status.HTTP_404_NOT_FOUND)
def test_retirements_all_states(self):
"""
Create a bunch of retirements and confirm we get back the correct data for each
"""
retirements = []
for state in RetirementState.objects.all():
retirements.append(self._create_retirement(state))
for retirement in retirements:
values = self._retirement_to_dict(retirement)
self.assert_status_and_user_data(values, username_to_find=values['user']['username'])
def test_retrieve_by_old_username(self):
"""
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)
original_username = retirement.user.username
hashed_username = get_retired_username_by_username(original_username)
retirement.user.username = hashed_username
retirement.user.save()
values = self._retirement_to_dict(retirement)
self.assert_status_and_user_data(values, username_to_find=original_username)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
class TestAccountRetirementUpdate(RetirementTestCase):
"""
Tests the account retirement endpoint.
"""
def setUp(self):
super(TestAccountRetirementUpdate, self).setUp()
self.setup_states()
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.test_user = self.retirement.user
self.test_superuser = SuperuserFactory()
self.headers = self.build_jwt_headers(self.test_superuser)
self.headers['content_type'] = "application/merge-patch+json"
self.url = reverse('accounts_retirement_update')
def update_and_assert_status(self, data, expected_status=status.HTTP_204_NO_CONTENT):
"""
Helper function for making a request to the retire subscriptions endpoint, and asserting the status.
"""
if 'username' not in data:
data['username'] = self.test_user.username
response = self.client.patch(self.url, json.dumps(data), **self.headers)
self.assertEqual(response.status_code, expected_status)
def test_single_update(self):
"""
Basic test to confirm changing state works and saves the given response
"""
data = {'new_state': 'LOCKING_ACCOUNT', 'response': 'this should succeed'}
self.update_and_assert_status(data)
# Refresh the retirment object and confirm the messages and state are correct
retirement = UserRetirementStatus.objects.get(id=self.retirement.id)
self.assertEqual(retirement.current_state, RetirementState.objects.get(state_name='LOCKING_ACCOUNT'))
self.assertEqual(retirement.last_state, RetirementState.objects.get(state_name='PENDING'))
self.assertIn('this should succeed', retirement.responses)
def test_move_through_process(self):
"""
Simulate moving a retirement through the process and confirm they end up in the
correct state, with all relevant response messages logged.
"""
fake_retire_process = [
{'new_state': 'LOCKING_ACCOUNT', 'response': 'accountlockstart'},
{'new_state': 'LOCKING_COMPLETE', 'response': 'accountlockcomplete'},
{'new_state': 'RETIRING_CREDENTIALS', 'response': 'retiringcredentials'},
{'new_state': 'CREDENTIALS_COMPLETE', 'response': 'credentialsretired'},
{'new_state': 'COMPLETE', 'response': 'accountretirementcomplete'},
]
for update_data in fake_retire_process:
self.update_and_assert_status(update_data)
# Refresh the retirment object and confirm the messages and state are correct
retirement = UserRetirementStatus.objects.get(id=self.retirement.id)
self.assertEqual(retirement.current_state, RetirementState.objects.get(state_name='COMPLETE'))
self.assertEqual(retirement.last_state, RetirementState.objects.get(state_name='CREDENTIALS_COMPLETE'))
self.assertIn('accountlockstart', retirement.responses)
self.assertIn('accountlockcomplete', retirement.responses)
self.assertIn('retiringcredentials', retirement.responses)
self.assertIn('credentialsretired', retirement.responses)
self.assertIn('accountretirementcomplete', retirement.responses)
def test_unknown_state(self):
"""
Test that trying to set to an unknown state fails with a 400
"""
data = {'new_state': 'BOGUS_STATE', 'response': 'this should fail'}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)
def test_bad_vars(self):
"""
Test various ways of sending the wrong variables to make sure they all fail correctly
"""
# No `new_state`
data = {'response': 'this should fail'}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)
# No `response`
data = {'new_state': 'COMPLETE'}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)
# Unknown `new_state`
data = {'new_state': 'BOGUS_STATE', 'response': 'this should fail'}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)
# No `new_state` or `response`
data = {}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)
# Unexpected param `should_not_exist`
data = {'should_not_exist': 'bad', 'new_state': 'COMPLETE', 'response': 'this should fail'}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)
def test_no_retirement(self):
"""
Confirm that trying to operate on a non-existent retirement for an existing user 404s
"""
# Delete the only retirement, created in setUp
UserRetirementStatus.objects.all().delete()
data = {'new_state': 'LOCKING_ACCOUNT', 'response': 'this should fail'}
self.update_and_assert_status(data, status.HTTP_404_NOT_FOUND)
def test_no_user(self):
"""
Confirm that trying to operate on a non-existent user 404s
"""
data = {'new_state': 'LOCKING_ACCOUNT', 'response': 'this should fail', 'username': 'does not exist'}
self.update_and_assert_status(data, status.HTTP_404_NOT_FOUND)
def test_move_from_dead_end(self):
"""
Confirm that trying to move from a dead end state to any other state fails
"""
retirement = UserRetirementStatus.objects.get(id=self.retirement.id)
retirement.current_state = RetirementState.objects.filter(is_dead_end_state=True)[0]
retirement.save()
data = {'new_state': 'LOCKING_ACCOUNT', 'response': 'this should fail'}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)
def test_move_backward(self):
"""
Confirm that trying to move to an earlier step in the process fails
"""
retirement = UserRetirementStatus.objects.get(id=self.retirement.id)
retirement.current_state = RetirementState.objects.get(state_name='COMPLETE')
retirement.save()
data = {'new_state': 'PENDING', 'response': 'this should fail'}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)
def test_move_same(self):
"""
Confirm that trying to move to the same step in the process fails
"""
# Should already be in 'PENDING'
data = {'new_state': 'PENDING', 'response': 'this should fail'}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)

View File

@@ -4,6 +4,7 @@ An API for retrieving user account information.
For additional information and historical context, see:
https://openedx.atlassian.net/wiki/display/TNL/User+API
"""
import datetime
from django.contrib.auth import get_user_model
from django.db import transaction
@@ -15,6 +16,7 @@ from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
from six import text_type
from social_django.models import UserSocialAuth
import pytz
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
from openedx.core.lib.api.authentication import (
@@ -22,13 +24,19 @@ from openedx.core.lib.api.authentication import (
OAuth2AuthenticationAllowInactiveUser,
)
from openedx.core.lib.api.parsers import MergePatchParser
from student.models import User, get_potentially_retired_user_by_username_and_hash, get_retired_email_by_email
from student.models import (
User,
get_retired_email_by_email,
get_potentially_retired_user_by_username_and_hash,
get_potentially_retired_user_by_username
)
from .api import get_account_settings, update_account_settings
from .permissions import CanDeactivateUser, CanRetireUser
from .serializers import UserRetirementStatusSerializer
from .signals import USER_RETIRE_MAILINGS
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
from ..models import UserOrgTag
from ..models import UserOrgTag, RetirementState, RetirementStateError, UserRetirementStatus
class AccountViewSet(ViewSet):
@@ -374,3 +382,97 @@ def _set_unusable_password(user):
"""
user.set_unusable_password()
user.save()
class AccountRetirementView(ViewSet):
"""
Provides API endpoints for managing the user retirement process.
"""
authentication_classes = (JwtAuthentication,)
permission_classes = (permissions.IsAuthenticated, CanRetireUser, )
parser_classes = (MergePatchParser, )
serializer_class = UserRetirementStatusSerializer
def retirement_queue(self, request):
"""
GET /api/user/v1/accounts/accounts_to_retire/
{'cool_off_days': 7, 'states': ['PENDING', 'COMPLETE']}
Returns the list of RetirementStatus users in the given states that were
created in the retirement queue at least `cool_off_days` ago.
"""
try:
cool_off_days = int(request.GET['cool_off_days'])
states = request.GET['states'].split(',')
if cool_off_days < 0:
raise RetirementStateError('Invalid argument for cool_off_days, must be greater than 0.')
state_objs = RetirementState.objects.filter(state_name__in=states)
if state_objs.count() != len(states):
found = [s.state_name for s in state_objs]
raise RetirementStateError('Unknown state. Requested: {} Found: {}'.format(states, found))
earliest_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=cool_off_days)
retirements = UserRetirementStatus.objects.select_related(
'user', 'current_state', 'last_state'
).filter(
current_state__in=state_objs, created__lt=earliest_datetime
).order_by(
'id'
)
serializer = UserRetirementStatusSerializer(retirements, many=True)
return Response(serializer.data)
# This should only occur on the int() converstion of cool_off_days at this point
except ValueError:
return Response('Invalid cool_off_days, should be integer.', status=status.HTTP_400_BAD_REQUEST)
except KeyError as exc:
return Response('Missing required parameter: {}'.format(text_type(exc)), status=status.HTTP_400_BAD_REQUEST)
except RetirementStateError as exc:
return Response(text_type(exc), status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, username): # pylint: disable=unused-argument
"""
GET /api/user/v1/accounts/{username}/retirement_status/
Returns the RetirementStatus of a given user, or 404 if that row
doesn't exist.
"""
try:
user = get_potentially_retired_user_by_username(username)
retirement = UserRetirementStatus.objects.select_related(
'user', 'current_state', 'last_state'
).get(user=user)
serializer = UserRetirementStatusSerializer(instance=retirement)
return Response(serializer.data)
except (UserRetirementStatus.DoesNotExist, User.DoesNotExist):
return Response(status=status.HTTP_404_NOT_FOUND)
def partial_update(self, request):
"""
PATCH /api/user/v1/accounts/update_retirement_status/
{
'username': 'user_to_retire',
'new_state': 'LOCKING_COMPLETE',
'response': 'User account locked and logged out.'
}
Updates the RetirementStatus row for the given user to the new
status, and append any messages to the message log.
Note that this implementation is the "merge patch" implementation proposed in
https://tools.ietf.org/html/rfc7396. The content_type must be "application/merge-patch+json" or
else an error response with status code 415 will be returned.
"""
try:
username = request.data['username']
retirement = UserRetirementStatus.objects.get(user__username=username)
retirement.update_state(request.data)
return Response(status=status.HTTP_204_NO_CONTENT)
except UserRetirementStatus.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
except RetirementStateError as exc:
return Response(text_type(exc), status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # pylint: disable=broad-except
return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -0,0 +1,33 @@
"""
Django admin configuration pages for the user_api app
"""
from django.contrib import admin
from .models import RetirementState, UserRetirementStatus
@admin.register(RetirementState)
class RetirementStateAdmin(admin.ModelAdmin):
"""
Admin interface for the RetirementState model.
"""
list_display = ('state_name', 'state_execution_order', 'is_dead_end_state', 'required',)
list_filter = ('is_dead_end_state', 'required',)
search_fields = ('state_name',)
class Meta(object):
model = RetirementState
@admin.register(UserRetirementStatus)
class UserRetirementStatusAdmin(admin.ModelAdmin):
"""
Admin interface for the UserRetirementStatusAdmin model.
"""
list_display = ('user', 'original_username', 'current_state', 'modified')
list_filter = ('current_state',)
raw_id_fields = ('user',)
search_fields = ('original_username', 'retired_username', 'original_email', 'retired_email', 'original_name')
class Meta(object):
model = UserRetirementStatus

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.12 on 2018-04-19 17:55
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('user_api', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='RetirementState',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('state_name', models.CharField(max_length=30, unique=True)),
('state_execution_order', models.SmallIntegerField(unique=True)),
('is_dead_end_state', models.BooleanField(db_index=True, default=False)),
('required', models.BooleanField(default=False)),
],
options={
'ordering': ('state_execution_order',),
},
),
migrations.CreateModel(
name='UserRetirementStatus',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('original_username', models.CharField(db_index=True, max_length=150)),
('original_email', models.EmailField(db_index=True, max_length=254)),
('original_name', models.CharField(blank=True, db_index=True, max_length=255)),
('retired_username', models.CharField(db_index=True, max_length=150)),
('retired_email', models.EmailField(db_index=True, max_length=254)),
('responses', models.TextField()),
('current_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='current_state', to='user_api.RetirementState')),
('last_state', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='last_state', to='user_api.RetirementState')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User Retirement Status',
'verbose_name_plural': 'User Retirement Statuses',
},
),
]

View File

@@ -4,7 +4,7 @@ Django ORM model specifications for the User API application
from django.contrib.auth.models import User
from django.core.validators import RegexValidator
from django.db import models
from django.db.models.signals import post_delete, post_save, pre_save
from django.db.models.signals import post_delete, post_save, pre_delete, pre_save
from django.dispatch import receiver
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
@@ -15,9 +15,22 @@ from opaque_keys.edx.django.models import CourseKeyField
# but currently the rest of the system assumes that "student" defines
# certain models. For now we will leave the models in "student" and
# create an alias in "user_api".
# pylint: disable=unused-import
from student.models import (
PendingEmailChange,
Registration,
UserProfile,
get_retired_email_by_email,
get_retired_username_by_username
)
from util.model_utils import emit_setting_changed_event, get_changed_fields_dict
class RetirementStateError(Exception):
pass
class UserPreference(models.Model):
"""A user's preference, stored as generic text to be processed by client"""
KEY_REGEX = r"[-_a-zA-Z0-9]+"
@@ -109,7 +122,8 @@ class UserCourseTag(models.Model):
class UserOrgTag(TimeStampedModel):
""" Per-Organization user tags.
"""
Per-Organization user tags.
Allows settings to be configured at an organization level.
@@ -121,3 +135,138 @@ class UserOrgTag(TimeStampedModel):
class Meta(object):
unique_together = ("user", "org", "key")
class RetirementState(models.Model):
"""
Stores the list and ordering of the steps of retirement, this should almost never change
as updating it can break the retirement process of users already in the queue.
"""
state_name = models.CharField(max_length=30, unique=True)
state_execution_order = models.SmallIntegerField(unique=True)
is_dead_end_state = models.BooleanField(default=False, db_index=True)
required = models.BooleanField(default=False)
def __unicode__(self):
return u'{} (step {})'.format(self.state_name, self.state_execution_order)
class Meta(object):
ordering = ('state_execution_order',)
@classmethod
def get_dead_end_states(cls):
return cls.objects.filter(is_dead_end_state=True)
@classmethod
def get_dead_end_state_names_list(cls):
return cls.objects.filter(is_dead_end_state=True).values_list('state_name', flat=True)
@classmethod
def get_state_names_list(cls):
return cls.objects.all().values_list('state_name', flat=True)
@receiver(pre_delete, sender=RetirementState)
def retirementstate_pre_delete_callback(_, **kwargs):
"""
Event changes to user preferences.
"""
state = kwargs["instance"]
if state.required:
raise Exception('Required RetirementStates cannot be deleted.')
class UserRetirementStatus(TimeStampedModel):
"""
Tracks the progress of a user's retirement request
"""
user = models.OneToOneField(User)
original_username = models.CharField(max_length=150, db_index=True)
original_email = models.EmailField(db_index=True)
original_name = models.CharField(max_length=255, blank=True, db_index=True)
retired_username = models.CharField(max_length=150, db_index=True)
retired_email = models.EmailField(db_index=True)
current_state = models.ForeignKey(RetirementState, related_name='current_state')
last_state = models.ForeignKey(RetirementState, blank=True, related_name='last_state')
responses = models.TextField()
class Meta(object):
verbose_name = 'User Retirement Status'
verbose_name_plural = 'User Retirement Statuses'
def _validate_state_update(self, new_state):
"""
Confirm that the state move that's trying to be made is allowed
"""
dead_end_states = list(RetirementState.get_dead_end_state_names_list())
states = list(RetirementState.get_state_names_list())
if self.current_state in dead_end_states:
raise RetirementStateError('RetirementStatus: Unable to move user from {}'.format(self.current_state))
try:
new_state_index = states.index(new_state)
if new_state_index <= states.index(self.current_state.state_name):
raise ValueError()
except ValueError:
err = '{} does not exist or is an eariler state than current state {}'.format(new_state, self.current_state)
raise RetirementStateError(err)
def _validate_update_data(self, data):
"""
Confirm that the data passed in is properly formatted
"""
required_keys = ('username', 'new_state', 'response')
for required_key in required_keys:
if required_key not in data:
raise RetirementStateError('RetirementStatus: Required key {} missing from update'.format(required_key))
for key in data:
if key not in required_keys:
raise RetirementStateError('RetirementStatus: Unknown key {} in update'.format(key))
@classmethod
def create_retirement(cls, user):
"""
Creates a UserRetirementStatus for the given user, in the correct initial state. Will
fail if the user already has a UserRetirementStatus row or if states are not yet populated.
"""
try:
pending = RetirementState.objects.all().order_by('state_execution_order')[0]
except IndexError:
raise RetirementStateError('Default state does not exist! Populate retirement states to retire users.')
if cls.objects.filter(user=user).exists():
raise RetirementStateError('User {} already has a retirement row!'.format(user))
retired_username = get_retired_username_by_username(user.username)
retired_email = get_retired_email_by_email(user.email)
return cls.objects.create(
user=user,
original_username=user.username,
original_email=user.email,
original_name=user.profile.name,
retired_username=retired_username,
retired_email=retired_email,
current_state=pending,
last_state=pending,
responses='Created in state {} by create_retirement'.format(pending)
)
def update_state(self, update):
"""
Perform the necessary checks for a state change and update the state and response if passed
or throw a RetirementStateError with a useful error message
"""
self._validate_update_data(update)
self._validate_state_update(update['new_state'])
old_state = self.current_state
self.current_state = RetirementState.objects.get(state_name=update['new_state'])
self.last_state = old_state
self.responses += "\n Moved from {} to {}:\n{}\n".format(old_state, self.current_state, update['response'])
self.save()
def __unicode__(self):
return u'User: {} State: {} Last Updated: {}'.format(self.user.id, self.current_state, self.modified)

View File

@@ -6,7 +6,13 @@ from django.conf import settings
from django.conf.urls import url
from ..profile_images.views import ProfileImageView
from .accounts.views import AccountDeactivationView, AccountRetireMailingsView, AccountViewSet, DeactivateLogoutView
from .accounts.views import (
AccountDeactivationView,
AccountRetireMailingsView,
AccountRetirementView,
AccountViewSet,
DeactivateLogoutView
)
from .preferences.views import PreferencesDetailView, PreferencesView
from .verification_api.views import PhotoVerificationStatusView
from .validation.views import RegistrationValidationView
@@ -24,6 +30,19 @@ ACCOUNT_DETAIL = AccountViewSet.as_view({
'patch': 'partial_update',
})
RETIREMENT_QUEUE = AccountRetirementView.as_view({
'get': 'retirement_queue'
})
RETIREMENT_RETRIEVE = AccountRetirementView.as_view({
'get': 'retrieve'
})
RETIREMENT_UPDATE = AccountRetirementView.as_view({
'patch': 'partial_update',
})
urlpatterns = [
url(
r'^v1/me$',
@@ -65,6 +84,21 @@ urlpatterns = [
PhotoVerificationStatusView.as_view(),
name='verification_status'
),
url(
r'^v1/accounts/{}/retirement_status/$'.format(settings.USERNAME_PATTERN),
RETIREMENT_RETRIEVE,
name='accounts_retirement_retrieve'
),
url(
r'^v1/accounts/retirement_queue/$',
RETIREMENT_QUEUE,
name='accounts_retirement_queue'
),
url(
r'^v1/accounts/update_retirement_status/$',
RETIREMENT_UPDATE,
name='accounts_retirement_update'
),
url(
r'^v1/validation/registration$',
RegistrationValidationView.as_view(),