Merge pull request #18591 from edx/bmedx/force_status_update

PLAT-2186 - allow retirement states to be forced by driver script
This commit is contained in:
Brian Mesick
2018-07-20 14:40:22 -04:00
committed by GitHub
4 changed files with 232 additions and 29 deletions

View File

@@ -8,6 +8,9 @@ import datetime
import json
import unittest
import ddt
import pytz
import mock
from consent.models import DataSharingConsent
from django.conf import settings
from django.contrib.auth.models import User
@@ -25,9 +28,7 @@ from enterprise.models import (
from integrated_channels.sap_success_factors.models import (
SapSuccessFactorsLearnerDataTransmissionAudit
)
import mock
from opaque_keys.edx.keys import CourseKey
import pytz
from rest_framework import status
from six import iteritems, text_type
from social_django.models import UserSocialAuth
@@ -51,7 +52,6 @@ from openedx.core.djangoapps.user_api.models import (
UserRetirementPartnerReportingStatus,
UserOrgTag
)
from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_retirement
from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementPartnerReportView
from openedx.core.lib.token_utils import JwtBuilder
from student.models import (
@@ -810,6 +810,155 @@ class TestAccountRetirementList(RetirementTestCase):
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
class TestAccountRetirementsByStatusAndDate(RetirementTestCase):
"""
Tests the retirements_by_status_and_date endpoint
"""
def setUp(self):
super(TestAccountRetirementsByStatusAndDate, self).setUp()
self.test_superuser = SuperuserFactory()
self.headers = build_jwt_headers(self.test_superuser)
self.url = reverse('accounts_retirements_by_status_and_date')
self.maxDiff = None
def assert_status_and_user_list(
self,
expected_data,
expected_status=status.HTTP_200_OK,
state_to_request=None,
start_date=None,
end_date=None
):
"""
Helper function for making a request to the endpoint, asserting the status, and
optionally asserting data returned. Will try to convert datetime start and end dates
to the correct string formatting.
"""
if state_to_request is None:
state_to_request = 'COMPLETE'
if start_date is None:
start_date = datetime.datetime.now().date().strftime('%Y-%m-%d')
else:
start_date = start_date.date().strftime('%Y-%m-%d')
if end_date is None:
end_date = datetime.datetime.now().date().strftime('%Y-%m-%d')
else:
end_date = end_date.date().strftime('%Y-%m-%d')
data = {'start_date': start_date, 'end_date': end_date, 'state': state_to_request}
response = self.client.get(self.url, data, **self.headers)
print(response.status_code)
print(response)
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:
# These may have been deleted in a previous pass
try:
del retirement['created']
del retirement['modified']
except KeyError:
pass
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_state(self):
"""
Verify that users in non-requested states are not returned
"""
state = RetirementState.objects.get(state_name='PENDING')
self._create_retirement(state=state)
self.assert_status_and_user_list([])
def test_users_exist(self):
"""
Verify correct user is returned when users in different states exist
"""
# Stores the user we expect to get back
retirement_values = None
for retirement in self._create_users_all_states():
if retirement.current_state == 'COMPLETE':
retirement_values.append(self._retirement_to_dict(retirement))
self.assert_status_and_user_list(retirement_values)
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, state_to_request='TACO')
def test_date_filter(self):
"""
Verifies the functionality of the start and end date filters
"""
retirements = []
complete_state = RetirementState.objects.get(state_name='COMPLETE')
# 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)
retirements.append(self._retirement_to_dict(ret))
# Go back in time adding days to the query, assert the correct retirements are present
end_date = datetime.datetime.now(pytz.UTC)
for days_back in range(1, 11):
retirement_dicts = retirements[:days_back]
start_date = end_date - datetime.timedelta(days=days_back - 1)
self.assert_status_and_user_list(
retirement_dicts,
start_date=start_date,
end_date=end_date
)
def test_bad_dates(self):
"""
Check some bad inputs to make sure we get back the expected status
"""
good_date = '2018-01-01'
for bad_param, good_param in (('start_date', 'end_date'), ('end_date', 'start_date')):
for bad_date in ('10/21/2001', '2118-01-01', '2018-14-25', 'toast', 5):
data = {
bad_param: bad_date,
good_param: good_date,
'state': 'COMPLETE'
}
response = self.client.get(self.url, data, **self.headers)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@ddt.data(
{},
{'start_date': '2018-01-01'},
{'end_date': '2018-01-01'},
{'state': 'PENDING'},
{'start_date': '2018-01-01', 'state': 'PENDING'},
{'end_date': '2018-01-01', 'state': 'PENDING'},
)
def test_missing_params(self, request_data):
"""
All params are required, make sure that is enforced
"""
response = self.client.get(self.url, request_data, **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):
"""
@@ -881,6 +1030,7 @@ class TestAccountRetirementRetrieve(RetirementTestCase):
self.assert_status_and_user_data(values, username_to_find=original_username)
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
class TestAccountRetirementUpdate(RetirementTestCase):
"""
@@ -994,35 +1144,31 @@ class TestAccountRetirementUpdate(RetirementTestCase):
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
"""
@ddt.data(
# Test moving backward from intermediate state
('LOCKING_ACCOUNT', 'PENDING', False, status.HTTP_400_BAD_REQUEST),
('LOCKING_ACCOUNT', 'PENDING', True, status.HTTP_204_NO_CONTENT),
# Test moving backward from dead end state
('COMPLETE', 'PENDING', False, status.HTTP_400_BAD_REQUEST),
('COMPLETE', 'PENDING', True, status.HTTP_204_NO_CONTENT),
# Test moving to the same state
('LOCKING_ACCOUNT', 'LOCKING_ACCOUNT', False, status.HTTP_400_BAD_REQUEST),
('LOCKING_ACCOUNT', 'LOCKING_ACCOUNT', True, status.HTTP_204_NO_CONTENT),
)
@ddt.unpack
def test_moves(self, start_state, move_to_state, force, expected_response_code):
retirement = UserRetirementStatus.objects.get(id=self.retirement.id)
retirement.current_state = RetirementState.objects.filter(is_dead_end_state=True)[0]
retirement.current_state = RetirementState.objects.get(state_name=start_state)
retirement.save()
data = {'new_state': 'LOCKING_ACCOUNT', 'response': 'this should fail'}
self.update_and_assert_status(data, status.HTTP_400_BAD_REQUEST)
data = {'new_state': move_to_state, 'response': 'foo'}
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()
if force:
data['force'] = True
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)
self.update_and_assert_status(data, expected_response_code)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')

View File

@@ -683,6 +683,49 @@ class AccountRetirementStatusView(ViewSet):
except RetirementStateError as exc:
return Response(text_type(exc), status=status.HTTP_400_BAD_REQUEST)
def retirements_by_status_and_date(self, request):
"""
GET /api/user/v1/accounts/retirements_by_status_and_date/
?start_date=2018-09-05&end_date=2018-09-07&state=COMPLETE
Returns a list of UserRetirementStatusSerializer serialized
RetirementStatus rows in the given state that were created in the
retirement queue between the dates given. Date range is inclusive,
so to get one day you would set both dates to that day.
"""
try:
start_date = datetime.datetime.strptime(request.GET['start_date'], '%Y-%m-%d')
end_date = datetime.datetime.strptime(request.GET['end_date'], '%Y-%m-%d')
now = datetime.datetime.now()
if start_date > now or end_date > now or start_date > end_date:
raise RetirementStateError('Dates must be today or earlier, and start must be earlier than end.')
# Add a day to make sure we get all the way to 23:59:59.999, this is compared "lt" in the query
# not "lte".
end_date += datetime.timedelta(days=1)
state = request.GET['state']
state_obj = RetirementState.objects.get(state_name=state)
retirements = UserRetirementStatus.objects.select_related(
'user', 'current_state', 'last_state'
).filter(
current_state=state_obj, created__lt=end_date, created__gte=start_date
).order_by(
'id'
)
serializer = UserRetirementStatusSerializer(retirements, many=True)
return Response(serializer.data)
# This should only occur on the datetime conversion of the start / end dates.
except ValueError as exc:
return Response('Invalid start or end date: {}'.format(text_type(exc)), 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 RetirementState.DoesNotExist:
return Response('Unknown retirement state.', 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/

View File

@@ -264,13 +264,15 @@ class UserRetirementStatus(TimeStampedModel):
Confirm that the data passed in is properly formatted
"""
required_keys = ('username', 'new_state', 'response')
optional_keys = ('force', )
known_keys = required_keys + optional_keys
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:
if key not in known_keys:
raise RetirementStateError('RetirementStatus: Unknown key {} in update'.format(key))
@classmethod
@@ -310,7 +312,10 @@ class UserRetirementStatus(TimeStampedModel):
or throw a RetirementStateError with a useful error message
"""
self._validate_update_data(update)
self._validate_state_update(update['new_state'])
force = update.get('force', False)
if not force:
self._validate_state_update(update['new_state'])
old_state = self.current_state
self.current_state = RetirementState.objects.get(state_name=update['new_state'])

View File

@@ -43,6 +43,10 @@ RETIREMENT_QUEUE = AccountRetirementStatusView.as_view({
'get': 'retirement_queue'
})
RETIREMENT_LIST_BY_STATUS_AND_DATE = AccountRetirementStatusView.as_view({
'get': 'retirements_by_status_and_date'
})
RETIREMENT_RETRIEVE = AccountRetirementStatusView.as_view({
'get': 'retrieve'
})
@@ -115,6 +119,11 @@ urlpatterns = [
RETIREMENT_QUEUE,
name='accounts_retirement_queue'
),
url(
r'^v1/accounts/retirements_by_status_and_date/$',
RETIREMENT_LIST_BY_STATUS_AND_DATE,
name='accounts_retirements_by_status_and_date'
),
url(
r'^v1/accounts/retire/$',
RETIREMENT_POST,