Add retirement partner reporting queue and APIs
This commit is contained in:
@@ -14,7 +14,11 @@ 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 RetirementState, UserRetirementStatus, UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import (
|
||||
RetirementState,
|
||||
UserPreference,
|
||||
UserRetirementStatus
|
||||
)
|
||||
from openedx.core.djangoapps.user_api.serializers import ReadOnlyFieldsSerializerMixin
|
||||
from student.models import UserProfile, LanguageProficiency, SocialLink
|
||||
|
||||
@@ -209,7 +213,9 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
|
||||
return new_name
|
||||
|
||||
def validate_language_proficiencies(self, value):
|
||||
""" Enforce all languages are unique. """
|
||||
"""
|
||||
Enforce all languages are unique.
|
||||
"""
|
||||
language_proficiencies = [language for language in value]
|
||||
unique_language_proficiencies = set(language["code"] for language in language_proficiencies)
|
||||
if len(language_proficiencies) != len(unique_language_proficiencies):
|
||||
@@ -217,7 +223,9 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
|
||||
return value
|
||||
|
||||
def validate_social_links(self, value):
|
||||
""" Enforce only one entry for a particular social platform. """
|
||||
"""
|
||||
Enforce only one entry for a particular social platform.
|
||||
"""
|
||||
social_links = [social_link for social_link in value]
|
||||
unique_social_links = set(social_link["platform"] for social_link in social_links)
|
||||
if len(social_links) != len(unique_social_links):
|
||||
@@ -225,29 +233,41 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
|
||||
return value
|
||||
|
||||
def transform_gender(self, user_profile, value): # pylint: disable=unused-argument
|
||||
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
|
||||
"""
|
||||
Converts empty string to None, to indicate not set. Replaced by to_representation in version 3.
|
||||
"""
|
||||
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
|
||||
|
||||
def transform_country(self, user_profile, value): # pylint: disable=unused-argument
|
||||
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
|
||||
"""
|
||||
Converts empty string to None, to indicate not set. Replaced by to_representation in version 3.
|
||||
"""
|
||||
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
|
||||
|
||||
def transform_level_of_education(self, user_profile, value): # pylint: disable=unused-argument
|
||||
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
|
||||
"""
|
||||
Converts empty string to None, to indicate not set. Replaced by to_representation in version 3.
|
||||
"""
|
||||
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
|
||||
|
||||
def transform_bio(self, user_profile, value): # pylint: disable=unused-argument
|
||||
""" Converts empty string to None, to indicate not set. Replaced by to_representation in version 3. """
|
||||
"""
|
||||
Converts empty string to None, to indicate not set. Replaced by to_representation in version 3.
|
||||
"""
|
||||
return AccountLegacyProfileSerializer.convert_empty_to_None(value)
|
||||
|
||||
@staticmethod
|
||||
def convert_empty_to_None(value):
|
||||
""" Helper method to convert empty string to None (other values pass through). """
|
||||
"""
|
||||
Helper method to convert empty string to None (other values pass through).
|
||||
"""
|
||||
return None if value == "" else value
|
||||
|
||||
@staticmethod
|
||||
def get_profile_image(user_profile, user, request=None):
|
||||
""" Returns metadata about a user's profile image. """
|
||||
"""
|
||||
Returns metadata about a user's profile image.
|
||||
"""
|
||||
data = {'has_image': user_profile.has_profile_image}
|
||||
urls = get_profile_image_urls_for_user(user, request)
|
||||
data.update({
|
||||
@@ -257,7 +277,9 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea
|
||||
return data
|
||||
|
||||
def get_requires_parental_consent(self, user_profile):
|
||||
""" Returns a boolean representing whether the user requires parental controls. """
|
||||
"""
|
||||
Returns a boolean representing whether the user requires parental controls.
|
||||
"""
|
||||
return user_profile.requires_parental_consent()
|
||||
|
||||
def _get_profile_image(self, user_profile):
|
||||
@@ -374,8 +396,27 @@ class UserRetirementStatusSerializer(serializers.ModelSerializer):
|
||||
exclude = ['responses', ]
|
||||
|
||||
|
||||
class UserRetirementPartnerReportSerializer(serializers.Serializer):
|
||||
"""
|
||||
Perform serialization for the UserRetirementPartnerReportingStatus model
|
||||
"""
|
||||
original_username = serializers.CharField()
|
||||
original_email = serializers.EmailField()
|
||||
original_name = serializers.CharField()
|
||||
orgs = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
# Required overrides of abstract base class methods, but we don't use them
|
||||
def create(self, validated_data):
|
||||
pass
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
pass
|
||||
|
||||
|
||||
def get_extended_profile(user_profile):
|
||||
"""Returns the extended user profile fields stored in user_profile.meta"""
|
||||
"""
|
||||
Returns the extended user profile fields stored in user_profile.meta
|
||||
"""
|
||||
|
||||
# pick the keys from the site configuration
|
||||
extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', [])
|
||||
@@ -395,7 +436,9 @@ def get_extended_profile(user_profile):
|
||||
|
||||
|
||||
def get_profile_visibility(user_profile, user, configuration=None):
|
||||
"""Returns the visibility level for the specified user profile."""
|
||||
"""
|
||||
Returns the visibility level for the specified user profile.
|
||||
"""
|
||||
if user_profile.requires_parental_consent():
|
||||
return PRIVATE_VISIBILITY
|
||||
|
||||
|
||||
@@ -0,0 +1,1478 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test cases to cover account retirement views
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from consent.models import DataSharingConsent
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
from enterprise.models import (
|
||||
EnterpriseCustomer,
|
||||
EnterpriseCustomerUser,
|
||||
EnterpriseCourseEnrollment,
|
||||
PendingEnterpriseCustomerUser,
|
||||
)
|
||||
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 text_type
|
||||
from social_django.models import UserSocialAuth
|
||||
from wiki.models import ArticleRevision, Article
|
||||
from wiki.models.pluginbase import RevisionPluginRevision, RevisionPlugin
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from entitlements.models import CourseEntitlementSupportDetail
|
||||
from entitlements.tests.factories import CourseEntitlementFactory
|
||||
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
|
||||
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
|
||||
from openedx.core.djangoapps.credit.models import (
|
||||
CreditRequirementStatus, CreditRequest, CreditCourse, CreditProvider, CreditRequirement
|
||||
)
|
||||
from openedx.core.djangoapps.course_groups.models import CourseUserGroup, UnregisteredLearnerCohortAssignments
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_MAILINGS
|
||||
from openedx.core.djangoapps.user_api.models import (
|
||||
RetirementState,
|
||||
UserRetirementStatus,
|
||||
UserRetirementPartnerReportingStatus,
|
||||
UserOrgTag
|
||||
)
|
||||
from openedx.core.lib.token_utils import JwtBuilder
|
||||
from student.models import (
|
||||
CourseEnrollment,
|
||||
CourseEnrollmentAllowed,
|
||||
ManualEnrollmentAudit,
|
||||
PasswordHistory,
|
||||
PendingEmailChange,
|
||||
PendingNameChange,
|
||||
Registration,
|
||||
SocialLink,
|
||||
UserProfile,
|
||||
get_retired_username_by_username,
|
||||
get_retired_email_by_email,
|
||||
)
|
||||
from student.tests.factories import (
|
||||
ContentTypeFactory,
|
||||
CourseEnrollmentAllowedFactory,
|
||||
PendingEmailChangeFactory,
|
||||
PermissionFactory,
|
||||
SuperuserFactory,
|
||||
UserFactory
|
||||
)
|
||||
from survey.models import SurveyAnswer
|
||||
|
||||
from ..views import AccountRetirementView, USER_PROFILE_PII
|
||||
from ...tests.factories import UserOrgTagFactory
|
||||
|
||||
|
||||
def build_jwt_headers(user):
|
||||
"""
|
||||
Helper function for creating headers for the JWT authentication.
|
||||
"""
|
||||
token = JwtBuilder(user).build_token([])
|
||||
headers = {
|
||||
'HTTP_AUTHORIZATION': 'JWT ' + token
|
||||
}
|
||||
return headers
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestAccountDeactivation(TestCase):
|
||||
"""
|
||||
Tests the account deactivation endpoint.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestAccountDeactivation, self).setUp()
|
||||
self.test_user = UserFactory()
|
||||
self.url = reverse('accounts_deactivation', kwargs={'username': self.test_user.username})
|
||||
|
||||
def assert_activation_status(self, headers, expected_status=status.HTTP_200_OK, expected_activation_status=False):
|
||||
"""
|
||||
Helper function for making a request to the deactivation endpoint, and asserting the status.
|
||||
|
||||
Args:
|
||||
expected_status(int): Expected request's response status.
|
||||
expected_activation_status(bool): Expected user has_usable_password attribute value.
|
||||
"""
|
||||
self.assertTrue(self.test_user.has_usable_password())
|
||||
response = self.client.post(self.url, **headers)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
self.test_user.refresh_from_db()
|
||||
self.assertEqual(self.test_user.has_usable_password(), expected_activation_status)
|
||||
|
||||
def test_superuser_deactivates_user(self):
|
||||
"""
|
||||
Verify a user is deactivated when a superuser posts to the deactivation endpoint.
|
||||
"""
|
||||
superuser = SuperuserFactory()
|
||||
headers = build_jwt_headers(superuser)
|
||||
self.assert_activation_status(headers)
|
||||
|
||||
def test_user_with_permission_deactivates_user(self):
|
||||
"""
|
||||
Verify a user is deactivated when a user with permission posts to the deactivation endpoint.
|
||||
"""
|
||||
user = UserFactory()
|
||||
permission = PermissionFactory(
|
||||
codename='can_deactivate_users',
|
||||
content_type=ContentTypeFactory(
|
||||
app_label='student'
|
||||
)
|
||||
)
|
||||
user.user_permissions.add(permission)
|
||||
headers = build_jwt_headers(user)
|
||||
self.assertTrue(self.test_user.has_usable_password())
|
||||
self.assert_activation_status(headers)
|
||||
|
||||
def test_unauthorized_rejection(self):
|
||||
"""
|
||||
Verify unauthorized users cannot deactivate accounts.
|
||||
"""
|
||||
headers = build_jwt_headers(self.test_user)
|
||||
self.assert_activation_status(
|
||||
headers,
|
||||
expected_status=status.HTTP_403_FORBIDDEN,
|
||||
expected_activation_status=True
|
||||
)
|
||||
|
||||
def test_on_jwt_headers_rejection(self):
|
||||
"""
|
||||
Verify users who are not JWT authenticated are rejected.
|
||||
"""
|
||||
UserFactory()
|
||||
self.assert_activation_status(
|
||||
{},
|
||||
expected_status=status.HTTP_401_UNAUTHORIZED,
|
||||
expected_activation_status=True
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
('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 _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 TestDeactivateLogout(RetirementTestCase):
|
||||
"""
|
||||
Tests the account deactivation/logout endpoint.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestDeactivateLogout, self).setUp()
|
||||
self.test_password = 'password'
|
||||
self.test_user = UserFactory(password=self.test_password)
|
||||
UserSocialAuth.objects.create(
|
||||
user=self.test_user,
|
||||
provider='some_provider_name',
|
||||
uid='xyz@gmail.com'
|
||||
)
|
||||
UserSocialAuth.objects.create(
|
||||
user=self.test_user,
|
||||
provider='some_other_provider_name',
|
||||
uid='xyz@gmail.com'
|
||||
)
|
||||
|
||||
Registration().register(self.test_user)
|
||||
|
||||
self.url = reverse('deactivate_logout')
|
||||
|
||||
def build_post(self, password):
|
||||
return {'password': password}
|
||||
|
||||
@mock.patch('openedx.core.djangolib.oauth2_retirement_utils')
|
||||
def test_user_can_deactivate_self(self, retirement_utils_mock):
|
||||
"""
|
||||
Verify a user calling the deactivation endpoint logs out the user, deletes all their SSO tokens,
|
||||
and creates a user retirement row.
|
||||
"""
|
||||
self.client.login(username=self.test_user.username, password=self.test_password)
|
||||
headers = build_jwt_headers(self.test_user)
|
||||
response = self.client.post(self.url, self.build_post(self.test_password), **headers)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
# make sure the user model is as expected
|
||||
updated_user = User.objects.get(id=self.test_user.id)
|
||||
self.assertEqual(get_retired_email_by_email(self.test_user.email), updated_user.email)
|
||||
self.assertFalse(updated_user.has_usable_password())
|
||||
self.assertEqual(list(UserSocialAuth.objects.filter(user=self.test_user)), [])
|
||||
self.assertEqual(list(Registration.objects.filter(user=self.test_user)), [])
|
||||
self.assertEqual(len(UserRetirementStatus.objects.filter(user_id=self.test_user.id)), 1)
|
||||
# these retirement utils are tested elsewhere; just make sure we called them
|
||||
retirement_utils_mock.retire_dop_oauth2_models.assertCalledWith(self.test_user)
|
||||
retirement_utils_mock.retire_dot_oauth2_models.assertCalledWith(self.test_user)
|
||||
# make sure the user cannot log in
|
||||
self.assertFalse(self.client.login(username=self.test_user.username, password=self.test_password))
|
||||
# make sure that an email has been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
# ensure that it's been sent to the correct email address
|
||||
self.assertIn(self.test_user.email, mail.outbox[0].to)
|
||||
|
||||
def test_password_mismatch(self):
|
||||
"""
|
||||
Verify that the user submitting a mismatched password results in
|
||||
a rejection.
|
||||
"""
|
||||
self.client.login(username=self.test_user.username, password=self.test_password)
|
||||
headers = build_jwt_headers(self.test_user)
|
||||
response = self.client.post(self.url, self.build_post(self.test_password + "xxxx"), **headers)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_called_twice(self):
|
||||
"""
|
||||
Verify a user calling the deactivation endpoint a second time results in a "forbidden"
|
||||
error, as the user will be logged out.
|
||||
"""
|
||||
self.client.login(username=self.test_user.username, password=self.test_password)
|
||||
headers = build_jwt_headers(self.test_user)
|
||||
response = self.client.post(self.url, self.build_post(self.test_password), **headers)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.client.login(username=self.test_user.username, password=self.test_password)
|
||||
headers = build_jwt_headers(self.test_user)
|
||||
response = self.client.post(self.url, self.build_post(self.test_password), **headers)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestAccountRetireMailings(RetirementTestCase):
|
||||
"""
|
||||
Tests the account retire mailings endpoint.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestAccountRetireMailings, self).setUp()
|
||||
self.test_superuser = SuperuserFactory()
|
||||
self.test_service_user = UserFactory()
|
||||
|
||||
# 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.test_user = self.retirement.user
|
||||
|
||||
UserOrgTag.objects.create(user=self.test_user, key='email-optin', org="foo", value="True")
|
||||
UserOrgTag.objects.create(user=self.test_user, key='email-optin', org="bar", value="True")
|
||||
|
||||
self.url = reverse('accounts_retire_mailings')
|
||||
|
||||
def build_post(self, user):
|
||||
return {'username': user.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):
|
||||
"""
|
||||
Helper function for making a request to the retire subscriptions endpoint, and asserting the status.
|
||||
"""
|
||||
response = self.client.post(self.url, self.build_post(self.test_user), **headers)
|
||||
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
|
||||
# Check that the expected number of tags with the correct value exist
|
||||
tag_count = UserOrgTag.objects.filter(user=self.test_user, value=expected_tag_value).count()
|
||||
self.assertEqual(tag_count, expected_tag_count)
|
||||
|
||||
if expected_content:
|
||||
self.assertEqual(response.content.strip('"'), expected_content)
|
||||
|
||||
def test_superuser_retires_user_subscriptions(self):
|
||||
"""
|
||||
Verify a user's subscriptions are retired when a superuser posts to the retire subscriptions endpoint.
|
||||
"""
|
||||
headers = build_jwt_headers(self.test_superuser)
|
||||
self.assert_status_and_tag_count(headers)
|
||||
|
||||
def test_superuser_retires_user_subscriptions_no_orgtags(self):
|
||||
"""
|
||||
Verify the call succeeds when the user doesn't have any org tags.
|
||||
"""
|
||||
UserOrgTag.objects.all().delete()
|
||||
headers = build_jwt_headers(self.test_superuser)
|
||||
self.assert_status_and_tag_count(headers, expected_tag_count=0)
|
||||
|
||||
def test_unauthorized_rejection(self):
|
||||
"""
|
||||
Verify unauthorized users cannot retire subscriptions.
|
||||
"""
|
||||
headers = build_jwt_headers(self.test_user)
|
||||
|
||||
# User should still have 2 "True" subscriptions.
|
||||
self.assert_status_and_tag_count(headers, expected_status=status.HTTP_403_FORBIDDEN, expected_tag_value="True")
|
||||
|
||||
def test_signal_failure(self):
|
||||
"""
|
||||
Verify that if a signal fails the transaction is rolled back and a proper error message is returned.
|
||||
"""
|
||||
headers = build_jwt_headers(self.test_superuser)
|
||||
|
||||
mock_handler = mock.MagicMock()
|
||||
mock_handler.side_effect = Exception("Tango")
|
||||
|
||||
try:
|
||||
USER_RETIRE_MAILINGS.connect(mock_handler)
|
||||
|
||||
# User should still have 2 "True" subscriptions.
|
||||
self.assert_status_and_tag_count(
|
||||
headers,
|
||||
expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
expected_tag_value="True",
|
||||
expected_content="Tango"
|
||||
)
|
||||
finally:
|
||||
USER_RETIRE_MAILINGS.disconnect(mock_handler)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestPartnerReportingCleanup(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the partner reporting cleanup endpoint.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestPartnerReportingCleanup, self).setUp()
|
||||
self.test_superuser = SuperuserFactory()
|
||||
self.course = CourseFactory()
|
||||
self.course_awesome_org = CourseFactory(org='awesome_org')
|
||||
self.headers = build_jwt_headers(self.test_superuser)
|
||||
self.headers['content_type'] = "application/json"
|
||||
self.url = reverse('accounts_retirement_partner_report')
|
||||
self.maxDiff = None
|
||||
|
||||
def create_partner_reporting_statuses(self, is_being_processed=True, num=2):
|
||||
"""
|
||||
Creates and returnes the given number of test users and UserRetirementPartnerReportingStatuses
|
||||
with the given is_being_processed value.
|
||||
"""
|
||||
statuses = []
|
||||
for _ in range(num):
|
||||
user = UserFactory()
|
||||
reporting_status = UserRetirementPartnerReportingStatus.objects.create(
|
||||
user=user,
|
||||
original_username=user.username,
|
||||
original_email=user.email,
|
||||
original_name=user.first_name + ' ' + user.last_name,
|
||||
is_being_processed=is_being_processed
|
||||
)
|
||||
|
||||
statuses.append(reporting_status)
|
||||
|
||||
return statuses
|
||||
|
||||
def assert_status_and_count(self, statuses, remaining_count, expected_status=status.HTTP_204_NO_CONTENT):
|
||||
"""
|
||||
Performs a test client DELETE against the retirement reporting cleanup endpoint. It generates
|
||||
the JSON of usernames to clean up based on the given list of UserRetirementPartnerReportingStatuses,
|
||||
asserts that the given number of UserRetirementPartnerReportingStatus rows are still in the database
|
||||
after the operation, and asserts that the given expected_status HTTP status code is returned.
|
||||
"""
|
||||
usernames = [{'original_username': u.original_username} for u in statuses]
|
||||
|
||||
data = json.dumps(usernames)
|
||||
response = self.client.delete(self.url, data=data, **self.headers)
|
||||
print(response)
|
||||
print(response.content)
|
||||
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
self.assertEqual(UserRetirementPartnerReportingStatus.objects.all().count(), remaining_count)
|
||||
|
||||
def test_success(self):
|
||||
"""
|
||||
A basic test that newly created UserRetirementPartnerReportingStatus rows are all deleted as expected.
|
||||
"""
|
||||
statuses = self.create_partner_reporting_statuses()
|
||||
self.assert_status_and_count(statuses, remaining_count=0)
|
||||
|
||||
def test_no_usernames(self):
|
||||
"""
|
||||
Checks that if no usernames are passed in we will get a 400 back.
|
||||
"""
|
||||
statuses = self.create_partner_reporting_statuses()
|
||||
self.assert_status_and_count([], len(statuses), expected_status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_username_does_not_exist(self):
|
||||
"""
|
||||
Checks that if a username is passed in that does not have a UserRetirementPartnerReportingStatus row
|
||||
we will get a 400 back.
|
||||
"""
|
||||
statuses = self.create_partner_reporting_statuses()
|
||||
orig_count = len(statuses)
|
||||
|
||||
# Create a bogus user that has a non-saved row. This user doesn't exist in the database, so
|
||||
# it should trigger the "incorrect number of rows" error.
|
||||
user = UserFactory()
|
||||
statuses.append(
|
||||
UserRetirementPartnerReportingStatus(
|
||||
user=user,
|
||||
original_username=user.username,
|
||||
original_email=user.email,
|
||||
original_name=user.first_name + ' ' + user.last_name,
|
||||
is_being_processed=True
|
||||
)
|
||||
)
|
||||
self.assert_status_and_count(statuses, orig_count, expected_status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_username_in_wrong_status(self):
|
||||
"""
|
||||
Checks that if an username passed in has the wrong "is_being_processed" value we will get a 400 error.
|
||||
"""
|
||||
# Create some status rows in the expected status
|
||||
statuses = self.create_partner_reporting_statuses()
|
||||
|
||||
# Create some users in the wrong processing status, should trigger the "incorrect number of rows" error.
|
||||
statuses += self.create_partner_reporting_statuses(is_being_processed=False)
|
||||
|
||||
self.assert_status_and_count(statuses, len(statuses), expected_status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_does_not_delete_users_in_process(self):
|
||||
"""
|
||||
Checks that with mixed "is_being_processed" values in the table only the usernames passed in will
|
||||
be deleted.
|
||||
"""
|
||||
statuses = self.create_partner_reporting_statuses()
|
||||
|
||||
self.create_partner_reporting_statuses(is_being_processed=False)
|
||||
self.assert_status_and_count(statuses, len(statuses))
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestPartnerReportingList(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the partner reporting list endpoint
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestPartnerReportingList, self).setUp()
|
||||
self.test_superuser = SuperuserFactory()
|
||||
self.course = CourseFactory()
|
||||
self.course_awesome_org = CourseFactory(org='awesome_org')
|
||||
self.courses = (self.course, self.course_awesome_org)
|
||||
self.headers = build_jwt_headers(self.test_superuser)
|
||||
self.url = reverse('accounts_retirement_partner_report')
|
||||
self.maxDiff = None
|
||||
|
||||
@staticmethod
|
||||
def get_user_dict(user, enrollments):
|
||||
"""
|
||||
Emulate the DRF serialization to create a dict we can compare against the partner
|
||||
reporting list endpoint results. If this breaks in testing the serialization will
|
||||
have changed and clients of this endpoint will need to be updates as well.
|
||||
"""
|
||||
return {
|
||||
'original_username': user.username,
|
||||
'original_email': user.email,
|
||||
'original_name': user.first_name + ' ' + user.last_name,
|
||||
'orgs': [enrollment.course.org for enrollment in enrollments]
|
||||
}
|
||||
|
||||
def create_partner_reporting_statuses(self, is_being_processed=False, num=2, courses=None):
|
||||
"""
|
||||
Create the given number of test users and UserRetirementPartnerReportingStatus rows,
|
||||
enroll them in the given course (or the default test course if none given), and set
|
||||
their processing state to "is_being_processed".
|
||||
|
||||
Returns a list of user dicts representing what we would expect back from the
|
||||
endpoint for the given user / enrollment.
|
||||
"""
|
||||
user_dicts = []
|
||||
courses = self.courses if courses is None else courses
|
||||
|
||||
for _ in range(num):
|
||||
user = UserFactory()
|
||||
UserRetirementPartnerReportingStatus.objects.create(
|
||||
user=user,
|
||||
original_username=user.username,
|
||||
original_email=user.email,
|
||||
original_name=user.first_name + ' ' + user.last_name,
|
||||
is_being_processed=is_being_processed
|
||||
)
|
||||
|
||||
enrollments = []
|
||||
for course in courses:
|
||||
enrollments.append(CourseEnrollment.enroll(user=user, course_key=course.id))
|
||||
|
||||
user_dicts.append(
|
||||
self.get_user_dict(user, enrollments)
|
||||
)
|
||||
|
||||
return user_dicts
|
||||
|
||||
def assert_status_and_user_list(self, expected_users, expected_status=status.HTTP_200_OK):
|
||||
"""
|
||||
Makes the partner reporting list GET and asserts that the given users are
|
||||
in the returned list, as well as asserting the expected HTTP status code
|
||||
is returned.
|
||||
"""
|
||||
response = self.client.post(self.url, **self.headers)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
|
||||
returned_users = response.json()
|
||||
print(returned_users)
|
||||
print(expected_users)
|
||||
|
||||
self.assertEqual(len(expected_users), len(returned_users))
|
||||
|
||||
# These sub-lists will fail assertCountEqual if they're out of order
|
||||
for expected_user in expected_users:
|
||||
expected_user['orgs'].sort()
|
||||
|
||||
for returned_user in returned_users:
|
||||
returned_user['orgs'].sort()
|
||||
|
||||
self.assertCountEqual(returned_users, expected_users)
|
||||
|
||||
def test_success(self):
|
||||
"""
|
||||
Basic test to make sure that users in two different orgs are returned.
|
||||
"""
|
||||
users = self.create_partner_reporting_statuses()
|
||||
users += self.create_partner_reporting_statuses(courses=(self.course_awesome_org,))
|
||||
|
||||
self.assert_status_and_user_list(users)
|
||||
|
||||
def test_success_multiple_statuses(self):
|
||||
"""
|
||||
Checks that only users in the correct is_being_processed state (False) are returned.
|
||||
"""
|
||||
users = self.create_partner_reporting_statuses()
|
||||
|
||||
# These should not come back
|
||||
self.create_partner_reporting_statuses(courses=(self.course_awesome_org,), is_being_processed=True)
|
||||
|
||||
self.assert_status_and_user_list(users)
|
||||
|
||||
def test_no_users(self):
|
||||
"""
|
||||
Checks that the call returns a success code and empty list if no users are found.
|
||||
"""
|
||||
self.assert_status_and_user_list([])
|
||||
|
||||
def test_only_users_in_processing(self):
|
||||
"""
|
||||
Checks that the call returns a success code and empty list if only users with
|
||||
"is_being_processed=True" are in the database.
|
||||
"""
|
||||
self.create_partner_reporting_statuses(is_being_processed=True)
|
||||
self.assert_status_and_user_list([])
|
||||
|
||||
def test_state_update(self):
|
||||
"""
|
||||
Checks that users are progressed to "is_being_processed" True upon being returned
|
||||
from this call.
|
||||
"""
|
||||
users = self.create_partner_reporting_statuses()
|
||||
|
||||
# First time through we should get the users
|
||||
self.assert_status_and_user_list(users)
|
||||
|
||||
# Second time they should be updated to is_being_processed=True
|
||||
self.assert_status_and_user_list([])
|
||||
|
||||
|
||||
@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 = build_jwt_headers(self.test_superuser)
|
||||
self.url = reverse('accounts_retirement_queue')
|
||||
self.maxDiff = None
|
||||
|
||||
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': 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_retrieved_in_multiple_states(self):
|
||||
"""
|
||||
Verify that if multiple states are requested, learners in each state are returned.
|
||||
"""
|
||||
multiple_states = ['PENDING', 'FORUMS_COMPLETE']
|
||||
for state in multiple_states:
|
||||
self._create_retirement(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)
|
||||
self.assertEqual(len(response.json()), 2)
|
||||
|
||||
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 = build_jwt_headers(self.test_superuser)
|
||||
self.maxDiff = None
|
||||
|
||||
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.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 = build_jwt_headers(self.test_superuser)
|
||||
self.headers['content_type'] = "application/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)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestAccountRetirementPost(RetirementTestCase):
|
||||
"""
|
||||
Tests the account retirement endpoint.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestAccountRetirementPost, self).setUp()
|
||||
|
||||
self.test_user = UserFactory()
|
||||
self.test_superuser = SuperuserFactory()
|
||||
self.original_username = self.test_user.username
|
||||
self.original_email = self.test_user.email
|
||||
self.retired_username = get_retired_username_by_username(self.original_username)
|
||||
self.retired_email = get_retired_email_by_email(self.original_email)
|
||||
|
||||
retirement_state = RetirementState.objects.get(state_name='RETIRING_LMS')
|
||||
self.retirement_status = UserRetirementStatus.create_retirement(self.test_user)
|
||||
self.retirement_status.current_state = retirement_state
|
||||
self.retirement_status.last_state = retirement_state
|
||||
self.retirement_status.save()
|
||||
|
||||
SocialLink.objects.create(
|
||||
user_profile=self.test_user.profile,
|
||||
platform='Facebook',
|
||||
social_link='www.facebook.com'
|
||||
).save()
|
||||
|
||||
self.cache_key = UserProfile.country_cache_key_name(self.test_user.id)
|
||||
cache.set(self.cache_key, 'Timor-leste')
|
||||
|
||||
# Enterprise model setup
|
||||
self.course_id = 'course-v1:edX+DemoX.1+2T2017'
|
||||
self.enterprise_customer = EnterpriseCustomer.objects.create(
|
||||
name='test_enterprise_customer',
|
||||
site=SiteFactory.create()
|
||||
)
|
||||
self.enterprise_user = EnterpriseCustomerUser.objects.create(
|
||||
enterprise_customer=self.enterprise_customer,
|
||||
user_id=self.test_user.id,
|
||||
)
|
||||
self.enterprise_enrollment = EnterpriseCourseEnrollment.objects.create(
|
||||
enterprise_customer_user=self.enterprise_user,
|
||||
course_id=self.course_id
|
||||
)
|
||||
self.pending_enterprise_user = PendingEnterpriseCustomerUser.objects.create(
|
||||
enterprise_customer_id=self.enterprise_user.enterprise_customer_id,
|
||||
user_email=self.test_user.email
|
||||
)
|
||||
self.sapsf_audit = SapSuccessFactorsLearnerDataTransmissionAudit.objects.create(
|
||||
sapsf_user_id=self.test_user.id,
|
||||
enterprise_course_enrollment_id=self.enterprise_enrollment.id,
|
||||
completed_timestamp=1,
|
||||
)
|
||||
self.consent = DataSharingConsent.objects.create(
|
||||
username=self.test_user.username,
|
||||
enterprise_customer=self.enterprise_customer,
|
||||
)
|
||||
|
||||
# Entitlement model setup
|
||||
self.entitlement = CourseEntitlementFactory.create(user=self.test_user)
|
||||
self.entitlement_support_detail = CourseEntitlementSupportDetail.objects.create(
|
||||
entitlement=self.entitlement,
|
||||
support_user=UserFactory(),
|
||||
comments='A comment containing potential PII.'
|
||||
)
|
||||
|
||||
# Misc. setup
|
||||
self.photo_verification = SoftwareSecurePhotoVerificationFactory.create(user=self.test_user)
|
||||
PendingEmailChangeFactory.create(user=self.test_user)
|
||||
UserOrgTagFactory.create(user=self.test_user, key='foo', value='bar')
|
||||
UserOrgTagFactory.create(user=self.test_user, key='cat', value='dog')
|
||||
|
||||
CourseEnrollmentAllowedFactory.create(email=self.original_email)
|
||||
|
||||
self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course')
|
||||
self.cohort = CourseUserGroup.objects.create(
|
||||
name="TestCohort",
|
||||
course_id=self.course_key,
|
||||
group_type=CourseUserGroup.COHORT
|
||||
)
|
||||
self.cohort_assignment = UnregisteredLearnerCohortAssignments.objects.create(
|
||||
course_user_group=self.cohort,
|
||||
course_id=self.course_key,
|
||||
email=self.original_email
|
||||
)
|
||||
|
||||
# setup for doing POST from test client
|
||||
self.headers = build_jwt_headers(self.test_superuser)
|
||||
self.headers['content_type'] = "application/json"
|
||||
self.url = reverse('accounts_retire')
|
||||
|
||||
def post_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.
|
||||
"""
|
||||
response = self.client.post(self.url, json.dumps(data), **self.headers)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
return response
|
||||
|
||||
def test_user_profile_pii_has_expected_values(self):
|
||||
expected_user_profile_pii = {
|
||||
'name': '',
|
||||
'meta': '',
|
||||
'location': '',
|
||||
'year_of_birth': None,
|
||||
'gender': None,
|
||||
'mailing_address': None,
|
||||
'city': None,
|
||||
'country': None,
|
||||
'bio': None,
|
||||
}
|
||||
self.assertEqual(expected_user_profile_pii, USER_PROFILE_PII)
|
||||
|
||||
def test_retire_user_where_user_does_not_exist(self):
|
||||
path = 'openedx.core.djangoapps.user_api.accounts.views.is_username_retired'
|
||||
with mock.patch(path, return_value=False) as mock_retired_username:
|
||||
data = {'username': 'not_a_user'}
|
||||
response = self.post_and_assert_status(data, status.HTTP_404_NOT_FOUND)
|
||||
self.assertFalse(response.content)
|
||||
mock_retired_username.assert_called_once_with('not_a_user')
|
||||
|
||||
def test_retire_user_server_error_is_raised(self):
|
||||
path = 'openedx.core.djangoapps.user_api.models.UserRetirementStatus.get_retirement_for_retirement_action'
|
||||
with mock.patch(path, side_effect=Exception('Unexpected Exception')) as mock_get_retirement:
|
||||
data = {'username': self.test_user.username}
|
||||
response = self.post_and_assert_status(data, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
self.assertEqual('Unexpected Exception', text_type(response.json()))
|
||||
mock_get_retirement.assert_called_once_with(self.original_username)
|
||||
|
||||
def test_retire_user_where_user_already_retired(self):
|
||||
path = 'openedx.core.djangoapps.user_api.accounts.views.is_username_retired'
|
||||
with mock.patch(path, return_value=True) as mock_is_username_retired:
|
||||
data = {'username': self.test_user.username}
|
||||
response = self.post_and_assert_status(data, status.HTTP_404_NOT_FOUND)
|
||||
self.assertFalse(response.content)
|
||||
mock_is_username_retired.assert_called_once_with(self.original_username)
|
||||
|
||||
def test_retire_user_where_username_not_provided(self):
|
||||
response = self.post_and_assert_status({}, status.HTTP_404_NOT_FOUND)
|
||||
expected_response_message = {'message': text_type('The user was not specified.')}
|
||||
self.assertEqual(expected_response_message, response.json())
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.get_profile_image_names')
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.remove_profile_images')
|
||||
def test_retire_user(self, mock_remove_profile_images, mock_get_profile_image_names):
|
||||
data = {'username': self.original_username}
|
||||
self.post_and_assert_status(data)
|
||||
|
||||
self.test_user.refresh_from_db()
|
||||
self.test_user.profile.refresh_from_db() # pylint: disable=no-member
|
||||
|
||||
expected_user_values = {
|
||||
'first_name': '',
|
||||
'last_name': '',
|
||||
'is_active': False,
|
||||
'username': self.retired_username,
|
||||
}
|
||||
for field, expected_value in expected_user_values.iteritems():
|
||||
self.assertEqual(expected_value, getattr(self.test_user, field))
|
||||
|
||||
for field, expected_value in USER_PROFILE_PII.iteritems():
|
||||
self.assertEqual(expected_value, getattr(self.test_user.profile, field))
|
||||
|
||||
self.assertIsNone(self.test_user.profile.profile_image_uploaded_at)
|
||||
mock_get_profile_image_names.assert_called_once_with(self.original_username)
|
||||
mock_remove_profile_images.assert_called_once_with(
|
||||
mock_get_profile_image_names.return_value
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
SocialLink.objects.filter(user_profile=self.test_user.profile).exists()
|
||||
)
|
||||
|
||||
self.assertIsNone(cache.get(self.cache_key))
|
||||
|
||||
self._data_sharing_consent_assertions()
|
||||
self._sapsf_audit_assertions()
|
||||
self._pending_enterprise_customer_user_assertions()
|
||||
self._entitlement_support_detail_assertions()
|
||||
|
||||
self._photo_verification_assertions()
|
||||
self.assertFalse(PendingEmailChange.objects.filter(user=self.test_user).exists())
|
||||
self.assertFalse(UserOrgTag.objects.filter(user=self.test_user).exists())
|
||||
|
||||
self.assertFalse(CourseEnrollmentAllowed.objects.filter(email=self.original_email).exists())
|
||||
self.assertFalse(UnregisteredLearnerCohortAssignments.objects.filter(email=self.original_email).exists())
|
||||
|
||||
def test_deletes_pii_from_user_profile(self):
|
||||
for model_field, value_to_assign in USER_PROFILE_PII.iteritems():
|
||||
if value_to_assign == '':
|
||||
value = 'foo'
|
||||
else:
|
||||
value = mock.Mock()
|
||||
setattr(self.test_user.profile, model_field, value)
|
||||
|
||||
AccountRetirementView.clear_pii_from_userprofile(self.test_user)
|
||||
|
||||
for model_field, value_to_assign in USER_PROFILE_PII.iteritems():
|
||||
self.assertEqual(value_to_assign, getattr(self.test_user.profile, model_field))
|
||||
|
||||
social_links = SocialLink.objects.filter(
|
||||
user_profile=self.test_user.profile
|
||||
)
|
||||
self.assertFalse(social_links.exists())
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.get_profile_image_names')
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.remove_profile_images')
|
||||
def test_removes_user_profile_images(
|
||||
self, mock_remove_profile_images, mock_get_profile_image_names
|
||||
):
|
||||
test_datetime = datetime.datetime(2018, 1, 1)
|
||||
self.test_user.profile.profile_image_uploaded_at = test_datetime
|
||||
|
||||
AccountRetirementView.delete_users_profile_images(self.test_user)
|
||||
|
||||
self.test_user.profile.refresh_from_db() # pylint: disable=no-member
|
||||
|
||||
self.assertIsNone(self.test_user.profile.profile_image_uploaded_at)
|
||||
mock_get_profile_image_names.assert_called_once_with(self.test_user.username)
|
||||
mock_remove_profile_images.assert_called_once_with(
|
||||
mock_get_profile_image_names.return_value
|
||||
)
|
||||
|
||||
def test_can_delete_user_profiles_country_cache(self):
|
||||
AccountRetirementView.delete_users_country_cache(self.test_user)
|
||||
self.assertIsNone(cache.get(self.cache_key))
|
||||
|
||||
def test_can_retire_users_datasharingconsent(self):
|
||||
AccountRetirementView.retire_users_data_sharing_consent(self.test_user.username, self.retired_username)
|
||||
self._data_sharing_consent_assertions()
|
||||
|
||||
def _data_sharing_consent_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``DataSharingConsent`` objects are retired.
|
||||
"""
|
||||
self.consent.refresh_from_db()
|
||||
self.assertEqual(self.retired_username, self.consent.username)
|
||||
test_users_data_sharing_consent = DataSharingConsent.objects.filter(
|
||||
username=self.original_username
|
||||
)
|
||||
self.assertFalse(test_users_data_sharing_consent.exists())
|
||||
|
||||
def test_can_retire_users_sap_success_factors_audits(self):
|
||||
AccountRetirementView.retire_sapsf_data_transmission(self.test_user)
|
||||
self._sapsf_audit_assertions()
|
||||
|
||||
def _sapsf_audit_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``SapSuccessFactorsLearnerDataTransmissionAudit`` objects are retired.
|
||||
"""
|
||||
self.sapsf_audit.refresh_from_db()
|
||||
self.assertEqual('', self.sapsf_audit.sapsf_user_id)
|
||||
audits_for_original_user_id = SapSuccessFactorsLearnerDataTransmissionAudit.objects.filter(
|
||||
sapsf_user_id=self.test_user.id,
|
||||
)
|
||||
self.assertFalse(audits_for_original_user_id.exists())
|
||||
|
||||
def test_can_retire_user_from_pendingenterprisecustomeruser(self):
|
||||
AccountRetirementView.retire_user_from_pending_enterprise_customer_user(self.test_user, self.retired_email)
|
||||
self._pending_enterprise_customer_user_assertions()
|
||||
|
||||
def _pending_enterprise_customer_user_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``PendingEnterpriseCustomerUser`` objects are retired.
|
||||
"""
|
||||
self.pending_enterprise_user.refresh_from_db()
|
||||
self.assertEqual(self.retired_email, self.pending_enterprise_user.user_email)
|
||||
pending_enterprise_users = PendingEnterpriseCustomerUser.objects.filter(
|
||||
user_email=self.original_email
|
||||
)
|
||||
self.assertFalse(pending_enterprise_users.exists())
|
||||
|
||||
def test_course_entitlement_support_detail_comments_are_retired(self):
|
||||
AccountRetirementView.retire_entitlement_support_detail(self.test_user)
|
||||
self._entitlement_support_detail_assertions()
|
||||
|
||||
def _entitlement_support_detail_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``CourseEntitleSupportDetail`` objects are retired.
|
||||
"""
|
||||
self.entitlement_support_detail.refresh_from_db()
|
||||
self.assertEqual('', self.entitlement_support_detail.comments)
|
||||
|
||||
def _photo_verification_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``SoftwareSecurePhotoVerification`` objects are retired.
|
||||
"""
|
||||
self.photo_verification.refresh_from_db()
|
||||
self.assertEqual(self.test_user, self.photo_verification.user)
|
||||
for field in ('name', 'face_image_url', 'photo_id_image_url', 'photo_id_key'):
|
||||
self.assertEqual('', getattr(self.photo_verification, field))
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestLMSAccountRetirementPost(RetirementTestCase, ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the LMS account retirement (GDPR P2) endpoint.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestLMSAccountRetirementPost, self).setUp()
|
||||
self.pii_standin = 'PII here'
|
||||
self.course = CourseFactory()
|
||||
self.test_user = UserFactory()
|
||||
self.test_superuser = SuperuserFactory()
|
||||
self.original_username = self.test_user.username
|
||||
self.original_email = self.test_user.email
|
||||
self.retired_username = get_retired_username_by_username(self.original_username)
|
||||
self.retired_email = get_retired_email_by_email(self.original_email)
|
||||
|
||||
retirement_state = RetirementState.objects.get(state_name='RETIRING_LMS')
|
||||
self.retirement_status = UserRetirementStatus.create_retirement(self.test_user)
|
||||
self.retirement_status.current_state = retirement_state
|
||||
self.retirement_status.last_state = retirement_state
|
||||
self.retirement_status.save()
|
||||
|
||||
# wiki data setup
|
||||
rp = RevisionPlugin.objects.create(article_id=0)
|
||||
RevisionPluginRevision.objects.create(
|
||||
revision_number=1,
|
||||
ip_address="ipaddresss",
|
||||
plugin=rp,
|
||||
user=self.test_user,
|
||||
)
|
||||
article = Article.objects.create()
|
||||
ArticleRevision.objects.create(ip_address="ipaddresss", user=self.test_user, article=article)
|
||||
|
||||
# ManualEnrollmentAudit setup
|
||||
course_enrollment = CourseEnrollment.enroll(user=self.test_user, course_key=self.course.id)
|
||||
ManualEnrollmentAudit.objects.create(
|
||||
enrollment=course_enrollment, reason=self.pii_standin, enrolled_email=self.pii_standin
|
||||
)
|
||||
|
||||
# CreditRequest and CreditRequirementStatus setup
|
||||
provider = CreditProvider.objects.create(provider_id="Hogwarts")
|
||||
credit_course = CreditCourse.objects.create(course_key=self.course.id)
|
||||
CreditRequest.objects.create(
|
||||
username=self.test_user.username,
|
||||
course=credit_course,
|
||||
provider_id=provider.id,
|
||||
parameters={self.pii_standin},
|
||||
)
|
||||
req = CreditRequirement.objects.create(course_id=credit_course.id)
|
||||
CreditRequirementStatus.objects.create(username=self.test_user.username, requirement=req)
|
||||
|
||||
# ApiAccessRequest setup
|
||||
site = Site.objects.create()
|
||||
ApiAccessRequest.objects.create(
|
||||
user=self.test_user,
|
||||
site=site,
|
||||
website=self.pii_standin,
|
||||
company_address=self.pii_standin,
|
||||
company_name=self.pii_standin,
|
||||
reason=self.pii_standin,
|
||||
)
|
||||
|
||||
# SurveyAnswer setup
|
||||
SurveyAnswer.objects.create(user=self.test_user, field_value=self.pii_standin, form_id=0)
|
||||
|
||||
# other setup
|
||||
PendingNameChange.objects.create(user=self.test_user, new_name=self.pii_standin, rationale=self.pii_standin)
|
||||
PasswordHistory.objects.create(user=self.test_user, password=self.pii_standin)
|
||||
|
||||
# setup for doing POST from test client
|
||||
self.headers = build_jwt_headers(self.test_superuser)
|
||||
self.headers['content_type'] = "application/json"
|
||||
self.url = reverse('accounts_retire_misc')
|
||||
|
||||
def post_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.
|
||||
"""
|
||||
response = self.client.post(self.url, json.dumps(data), **self.headers)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
return response
|
||||
|
||||
def test_retire_user(self):
|
||||
# check that rows that will not exist after retirement exist now
|
||||
self.assertTrue(CreditRequest.objects.filter(username=self.test_user.username).exists())
|
||||
self.assertTrue(CreditRequirementStatus.objects.filter(username=self.test_user.username).exists())
|
||||
self.assertTrue(PendingNameChange.objects.filter(user=self.test_user).exists())
|
||||
|
||||
retirement = UserRetirementStatus.get_retirement_for_retirement_action(self.test_user.username)
|
||||
data = {'username': self.original_username}
|
||||
self.post_and_assert_status(data)
|
||||
|
||||
self.test_user.refresh_from_db()
|
||||
self.test_user.profile.refresh_from_db() # pylint: disable=no-member
|
||||
self.assertEqual(RevisionPluginRevision.objects.get(user=self.test_user).ip_address, None)
|
||||
self.assertEqual(ArticleRevision.objects.get(user=self.test_user).ip_address, None)
|
||||
self.assertFalse(PendingNameChange.objects.filter(user=self.test_user).exists())
|
||||
self.assertEqual(PasswordHistory.objects.get(user=self.test_user).password, '')
|
||||
|
||||
self.assertEqual(
|
||||
ManualEnrollmentAudit.objects.get(
|
||||
enrollment=CourseEnrollment.objects.get(user=self.test_user)
|
||||
).enrolled_email,
|
||||
retirement.retired_email
|
||||
)
|
||||
self.assertFalse(CreditRequest.objects.filter(username=self.test_user.username).exists())
|
||||
self.assertTrue(CreditRequest.objects.filter(username=retirement.retired_username).exists())
|
||||
self.assertEqual(CreditRequest.objects.get(username=retirement.retired_username).parameters, {})
|
||||
|
||||
self.assertFalse(CreditRequirementStatus.objects.filter(username=self.test_user.username).exists())
|
||||
self.assertTrue(CreditRequirementStatus.objects.filter(username=retirement.retired_username).exists())
|
||||
self.assertEqual(CreditRequirementStatus.objects.get(username=retirement.retired_username).reason, {})
|
||||
|
||||
retired_api_access_request = ApiAccessRequest.objects.get(user=self.test_user)
|
||||
self.assertEqual(retired_api_access_request.website, '')
|
||||
self.assertEqual(retired_api_access_request.company_address, '')
|
||||
self.assertEqual(retired_api_access_request.company_name, '')
|
||||
self.assertEqual(retired_api_access_request.reason, '')
|
||||
self.assertEqual(SurveyAnswer.objects.get(user=self.test_user).field_value, '')
|
||||
@@ -6,57 +6,22 @@ from copy import deepcopy
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from consent.models import DataSharingConsent
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.testcases import TransactionTestCase
|
||||
from django.test.utils import override_settings
|
||||
from enterprise.models import (
|
||||
EnterpriseCustomer,
|
||||
EnterpriseCustomerUser,
|
||||
EnterpriseCourseEnrollment,
|
||||
PendingEnterpriseCustomerUser,
|
||||
)
|
||||
from integrated_channels.sap_success_factors.models import (
|
||||
SapSuccessFactorsLearnerDataTransmissionAudit
|
||||
)
|
||||
|
||||
import mock
|
||||
from nose.plugins.attrib import attr
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
import pytz
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient, APITestCase
|
||||
from six import text_type
|
||||
from social_django.models import UserSocialAuth
|
||||
from wiki.models import ArticleRevision, Article
|
||||
from wiki.models.pluginbase import RevisionPluginRevision, RevisionPlugin
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from entitlements.models import CourseEntitlementSupportDetail
|
||||
from entitlements.tests.factories import CourseEntitlementFactory
|
||||
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
|
||||
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
|
||||
from openedx.core.djangoapps.credit.models import (
|
||||
CreditRequirementStatus, CreditRequest, CreditCourse, CreditProvider, CreditRequirement
|
||||
)
|
||||
from openedx.core.djangoapps.course_groups.models import CourseUserGroup, UnregisteredLearnerCohortAssignments
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
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 RetirementState, UserRetirementStatus, UserPreference, UserOrgTag
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
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
|
||||
from survey.models import SurveyAnswer
|
||||
from student.models import (
|
||||
CourseEnrollment,
|
||||
CourseEnrollmentAllowed,
|
||||
@@ -81,8 +46,7 @@ from student.tests.factories import (
|
||||
)
|
||||
|
||||
from .. import ALL_USERS_VISIBILITY, PRIVATE_VISIBILITY
|
||||
from ..views import AccountRetirementView, USER_PROFILE_PII
|
||||
from ...tests.factories import UserOrgTagFactory
|
||||
|
||||
|
||||
TEST_PROFILE_IMAGE_UPLOADED_AT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=pytz.UTC)
|
||||
|
||||
@@ -882,1174 +846,3 @@ class TestAccountAPITransactions(TransactionTestCase):
|
||||
data = response.data
|
||||
self.assertEqual(old_email, data["email"])
|
||||
self.assertEqual(u"m", data["gender"])
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestAccountDeactivation(TestCase):
|
||||
"""
|
||||
Tests the account deactivation endpoint.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestAccountDeactivation, self).setUp()
|
||||
self.test_user = UserFactory()
|
||||
self.url = reverse('accounts_deactivation', kwargs={'username': self.test_user.username})
|
||||
|
||||
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 assert_activation_status(self, headers, expected_status=status.HTTP_200_OK, expected_activation_status=False):
|
||||
"""
|
||||
Helper function for making a request to the deactivation endpoint, and asserting the status.
|
||||
|
||||
Args:
|
||||
expected_status(int): Expected request's response status.
|
||||
expected_activation_status(bool): Expected user has_usable_password attribute value.
|
||||
"""
|
||||
self.assertTrue(self.test_user.has_usable_password())
|
||||
response = self.client.post(self.url, **headers)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
self.test_user.refresh_from_db()
|
||||
self.assertEqual(self.test_user.has_usable_password(), expected_activation_status)
|
||||
|
||||
def test_superuser_deactivates_user(self):
|
||||
"""
|
||||
Verify a user is deactivated when a superuser posts to the deactivation endpoint.
|
||||
"""
|
||||
superuser = SuperuserFactory()
|
||||
headers = self.build_jwt_headers(superuser)
|
||||
self.assert_activation_status(headers)
|
||||
|
||||
def test_user_with_permission_deactivates_user(self):
|
||||
"""
|
||||
Verify a user is deactivated when a user with permission posts to the deactivation endpoint.
|
||||
"""
|
||||
user = UserFactory()
|
||||
permission = PermissionFactory(
|
||||
codename='can_deactivate_users',
|
||||
content_type=ContentTypeFactory(
|
||||
app_label='student'
|
||||
)
|
||||
)
|
||||
user.user_permissions.add(permission)
|
||||
headers = self.build_jwt_headers(user)
|
||||
self.assertTrue(self.test_user.has_usable_password())
|
||||
self.assert_activation_status(headers)
|
||||
|
||||
def test_unauthorized_rejection(self):
|
||||
"""
|
||||
Verify unauthorized users cannot deactivate accounts.
|
||||
"""
|
||||
headers = self.build_jwt_headers(self.test_user)
|
||||
self.assert_activation_status(
|
||||
headers,
|
||||
expected_status=status.HTTP_403_FORBIDDEN,
|
||||
expected_activation_status=True
|
||||
)
|
||||
|
||||
def test_on_jwt_headers_rejection(self):
|
||||
"""
|
||||
Verify users who are not JWT authenticated are rejected.
|
||||
"""
|
||||
UserFactory()
|
||||
self.assert_activation_status(
|
||||
{},
|
||||
expected_status=status.HTTP_401_UNAUTHORIZED,
|
||||
expected_activation_status=True
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
('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 TestDeactivateLogout(RetirementTestCase):
|
||||
"""
|
||||
Tests the account deactivation/logout endpoint.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestDeactivateLogout, self).setUp()
|
||||
self.test_password = 'password'
|
||||
self.test_user = UserFactory(password=self.test_password)
|
||||
UserSocialAuth.objects.create(
|
||||
user=self.test_user,
|
||||
provider='some_provider_name',
|
||||
uid='xyz@gmail.com'
|
||||
)
|
||||
UserSocialAuth.objects.create(
|
||||
user=self.test_user,
|
||||
provider='some_other_provider_name',
|
||||
uid='xyz@gmail.com'
|
||||
)
|
||||
|
||||
Registration().register(self.test_user)
|
||||
|
||||
self.url = reverse('deactivate_logout')
|
||||
|
||||
def build_post(self, password):
|
||||
return {'password': password}
|
||||
|
||||
@mock.patch('openedx.core.djangolib.oauth2_retirement_utils')
|
||||
def test_user_can_deactivate_self(self, retirement_utils_mock):
|
||||
"""
|
||||
Verify a user calling the deactivation endpoint logs out the user, deletes all their SSO tokens,
|
||||
and creates a user retirement row.
|
||||
"""
|
||||
self.client.login(username=self.test_user.username, password=self.test_password)
|
||||
headers = self.build_jwt_headers(self.test_user)
|
||||
response = self.client.post(self.url, self.build_post(self.test_password), **headers)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
# make sure the user model is as expected
|
||||
updated_user = User.objects.get(id=self.test_user.id)
|
||||
self.assertEqual(get_retired_email_by_email(self.test_user.email), updated_user.email)
|
||||
self.assertFalse(updated_user.has_usable_password())
|
||||
self.assertEqual(list(UserSocialAuth.objects.filter(user=self.test_user)), [])
|
||||
self.assertEqual(list(Registration.objects.filter(user=self.test_user)), [])
|
||||
self.assertEqual(len(UserRetirementStatus.objects.filter(user_id=self.test_user.id)), 1)
|
||||
# these retirement utils are tested elsewhere; just make sure we called them
|
||||
retirement_utils_mock.retire_dop_oauth2_models.assertCalledWith(self.test_user)
|
||||
retirement_utils_mock.retire_dot_oauth2_models.assertCalledWith(self.test_user)
|
||||
# make sure the user cannot log in
|
||||
self.assertFalse(self.client.login(username=self.test_user.username, password=self.test_password))
|
||||
# make sure that an email has been sent
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
# ensure that it's been sent to the correct email address
|
||||
self.assertIn(self.test_user.email, mail.outbox[0].to)
|
||||
|
||||
def test_password_mismatch(self):
|
||||
"""
|
||||
Verify that the user submitting a mismatched password results in
|
||||
a rejection.
|
||||
"""
|
||||
self.client.login(username=self.test_user.username, password=self.test_password)
|
||||
headers = self.build_jwt_headers(self.test_user)
|
||||
response = self.client.post(self.url, self.build_post(self.test_password + "xxxx"), **headers)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_called_twice(self):
|
||||
"""
|
||||
Verify a user calling the deactivation endpoint a second time results in a "forbidden"
|
||||
error, as the user will be logged out.
|
||||
"""
|
||||
self.client.login(username=self.test_user.username, password=self.test_password)
|
||||
headers = self.build_jwt_headers(self.test_user)
|
||||
response = self.client.post(self.url, self.build_post(self.test_password), **headers)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.client.login(username=self.test_user.username, password=self.test_password)
|
||||
headers = self.build_jwt_headers(self.test_user)
|
||||
response = self.client.post(self.url, self.build_post(self.test_password), **headers)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestAccountRetireMailings(RetirementTestCase):
|
||||
"""
|
||||
Tests the account retire mailings endpoint.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestAccountRetireMailings, self).setUp()
|
||||
self.test_superuser = SuperuserFactory()
|
||||
self.test_service_user = UserFactory()
|
||||
|
||||
# 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.test_user = self.retirement.user
|
||||
|
||||
UserOrgTag.objects.create(user=self.test_user, key='email-optin', org="foo", value="True")
|
||||
UserOrgTag.objects.create(user=self.test_user, key='email-optin', org="bar", value="True")
|
||||
|
||||
self.url = reverse('accounts_retire_mailings')
|
||||
|
||||
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 build_post(self, user):
|
||||
return {'username': user.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):
|
||||
"""
|
||||
Helper function for making a request to the retire subscriptions endpoint, and asserting the status.
|
||||
"""
|
||||
response = self.client.post(self.url, self.build_post(self.test_user), **headers)
|
||||
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
|
||||
# Check that the expected number of tags with the correct value exist
|
||||
tag_count = UserOrgTag.objects.filter(user=self.test_user, value=expected_tag_value).count()
|
||||
self.assertEqual(tag_count, expected_tag_count)
|
||||
|
||||
if expected_content:
|
||||
self.assertEqual(response.content.strip('"'), expected_content)
|
||||
|
||||
def test_superuser_retires_user_subscriptions(self):
|
||||
"""
|
||||
Verify a user's subscriptions are retired when a superuser posts to the retire subscriptions endpoint.
|
||||
"""
|
||||
headers = self.build_jwt_headers(self.test_superuser)
|
||||
self.assert_status_and_tag_count(headers)
|
||||
|
||||
def test_superuser_retires_user_subscriptions_no_orgtags(self):
|
||||
"""
|
||||
Verify the call succeeds when the user doesn't have any org tags.
|
||||
"""
|
||||
UserOrgTag.objects.all().delete()
|
||||
headers = self.build_jwt_headers(self.test_superuser)
|
||||
self.assert_status_and_tag_count(headers, expected_tag_count=0)
|
||||
|
||||
def test_unauthorized_rejection(self):
|
||||
"""
|
||||
Verify unauthorized users cannot retire subscriptions.
|
||||
"""
|
||||
headers = self.build_jwt_headers(self.test_user)
|
||||
|
||||
# User should still have 2 "True" subscriptions.
|
||||
self.assert_status_and_tag_count(headers, expected_status=status.HTTP_403_FORBIDDEN, expected_tag_value="True")
|
||||
|
||||
def test_signal_failure(self):
|
||||
"""
|
||||
Verify that if a signal fails the transaction is rolled back and a proper error message is returned.
|
||||
"""
|
||||
headers = self.build_jwt_headers(self.test_superuser)
|
||||
|
||||
mock_handler = mock.MagicMock()
|
||||
mock_handler.side_effect = Exception("Tango")
|
||||
|
||||
try:
|
||||
USER_RETIRE_MAILINGS.connect(mock_handler)
|
||||
|
||||
# User should still have 2 "True" subscriptions.
|
||||
self.assert_status_and_tag_count(
|
||||
headers,
|
||||
expected_status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
expected_tag_value="True",
|
||||
expected_content="Tango"
|
||||
)
|
||||
finally:
|
||||
USER_RETIRE_MAILINGS.disconnect(mock_handler)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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': 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_retrieved_in_multiple_states(self):
|
||||
"""
|
||||
Verify that if multiple states are requested, learners in each state are returned.
|
||||
"""
|
||||
multiple_states = ['PENDING', 'FORUMS_COMPLETE']
|
||||
for state in multiple_states:
|
||||
self._create_retirement(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)
|
||||
self.assertEqual(len(response.json()), 2)
|
||||
|
||||
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
|
||||
|
||||
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.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/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)
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestAccountRetirementPost(RetirementTestCase):
|
||||
"""
|
||||
Tests the account retirement endpoint.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestAccountRetirementPost, self).setUp()
|
||||
|
||||
self.test_user = UserFactory()
|
||||
self.test_superuser = SuperuserFactory()
|
||||
self.original_username = self.test_user.username
|
||||
self.original_email = self.test_user.email
|
||||
self.retired_username = get_retired_username_by_username(self.original_username)
|
||||
self.retired_email = get_retired_email_by_email(self.original_email)
|
||||
|
||||
retirement_state = RetirementState.objects.get(state_name='RETIRING_LMS')
|
||||
self.retirement_status = UserRetirementStatus.create_retirement(self.test_user)
|
||||
self.retirement_status.current_state = retirement_state
|
||||
self.retirement_status.last_state = retirement_state
|
||||
self.retirement_status.save()
|
||||
|
||||
SocialLink.objects.create(
|
||||
user_profile=self.test_user.profile,
|
||||
platform='Facebook',
|
||||
social_link='www.facebook.com'
|
||||
).save()
|
||||
|
||||
self.cache_key = UserProfile.country_cache_key_name(self.test_user.id)
|
||||
cache.set(self.cache_key, 'Timor-leste')
|
||||
|
||||
# Enterprise model setup
|
||||
self.course_id = 'course-v1:edX+DemoX.1+2T2017'
|
||||
self.enterprise_customer = EnterpriseCustomer.objects.create(
|
||||
name='test_enterprise_customer',
|
||||
site=SiteFactory.create()
|
||||
)
|
||||
self.enterprise_user = EnterpriseCustomerUser.objects.create(
|
||||
enterprise_customer=self.enterprise_customer,
|
||||
user_id=self.test_user.id,
|
||||
)
|
||||
self.enterprise_enrollment = EnterpriseCourseEnrollment.objects.create(
|
||||
enterprise_customer_user=self.enterprise_user,
|
||||
course_id=self.course_id
|
||||
)
|
||||
self.pending_enterprise_user = PendingEnterpriseCustomerUser.objects.create(
|
||||
enterprise_customer_id=self.enterprise_user.enterprise_customer_id,
|
||||
user_email=self.test_user.email
|
||||
)
|
||||
self.sapsf_audit = SapSuccessFactorsLearnerDataTransmissionAudit.objects.create(
|
||||
sapsf_user_id=self.test_user.id,
|
||||
enterprise_course_enrollment_id=self.enterprise_enrollment.id,
|
||||
completed_timestamp=1,
|
||||
)
|
||||
self.consent = DataSharingConsent.objects.create(
|
||||
username=self.test_user.username,
|
||||
enterprise_customer=self.enterprise_customer,
|
||||
)
|
||||
|
||||
# Entitlement model setup
|
||||
self.entitlement = CourseEntitlementFactory.create(user=self.test_user)
|
||||
self.entitlement_support_detail = CourseEntitlementSupportDetail.objects.create(
|
||||
entitlement=self.entitlement,
|
||||
support_user=UserFactory(),
|
||||
comments='A comment containing potential PII.'
|
||||
)
|
||||
|
||||
# Misc. setup
|
||||
self.photo_verification = SoftwareSecurePhotoVerificationFactory.create(user=self.test_user)
|
||||
PendingEmailChangeFactory.create(user=self.test_user)
|
||||
UserOrgTagFactory.create(user=self.test_user, key='foo', value='bar')
|
||||
UserOrgTagFactory.create(user=self.test_user, key='cat', value='dog')
|
||||
|
||||
CourseEnrollmentAllowedFactory.create(email=self.original_email)
|
||||
|
||||
self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course')
|
||||
self.cohort = CourseUserGroup.objects.create(
|
||||
name="TestCohort",
|
||||
course_id=self.course_key,
|
||||
group_type=CourseUserGroup.COHORT
|
||||
)
|
||||
self.cohort_assignment = UnregisteredLearnerCohortAssignments.objects.create(
|
||||
course_user_group=self.cohort,
|
||||
course_id=self.course_key,
|
||||
email=self.original_email
|
||||
)
|
||||
|
||||
# setup for doing POST from test client
|
||||
self.headers = self.build_jwt_headers(self.test_superuser)
|
||||
self.headers['content_type'] = "application/json"
|
||||
self.url = reverse('accounts_retire')
|
||||
|
||||
def post_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.
|
||||
"""
|
||||
response = self.client.post(self.url, json.dumps(data), **self.headers)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
return response
|
||||
|
||||
def test_user_profile_pii_has_expected_values(self):
|
||||
expected_user_profile_pii = {
|
||||
'name': '',
|
||||
'meta': '',
|
||||
'location': '',
|
||||
'year_of_birth': None,
|
||||
'gender': None,
|
||||
'mailing_address': None,
|
||||
'city': None,
|
||||
'country': None,
|
||||
'bio': None,
|
||||
}
|
||||
self.assertEqual(expected_user_profile_pii, USER_PROFILE_PII)
|
||||
|
||||
def test_retire_user_where_user_does_not_exist(self):
|
||||
path = 'openedx.core.djangoapps.user_api.accounts.views.is_username_retired'
|
||||
with mock.patch(path, return_value=False) as mock_retired_username:
|
||||
data = {'username': 'not_a_user'}
|
||||
response = self.post_and_assert_status(data, status.HTTP_404_NOT_FOUND)
|
||||
self.assertFalse(response.content)
|
||||
mock_retired_username.assert_called_once_with('not_a_user')
|
||||
|
||||
def test_retire_user_server_error_is_raised(self):
|
||||
path = 'openedx.core.djangoapps.user_api.models.UserRetirementStatus.get_retirement_for_retirement_action'
|
||||
with mock.patch(path, side_effect=Exception('Unexpected Exception')) as mock_get_retirement:
|
||||
data = {'username': self.test_user.username}
|
||||
response = self.post_and_assert_status(data, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
self.assertEqual('Unexpected Exception', text_type(response.json()))
|
||||
mock_get_retirement.assert_called_once_with(self.original_username)
|
||||
|
||||
def test_retire_user_where_user_already_retired(self):
|
||||
path = 'openedx.core.djangoapps.user_api.accounts.views.is_username_retired'
|
||||
with mock.patch(path, return_value=True) as mock_is_username_retired:
|
||||
data = {'username': self.test_user.username}
|
||||
response = self.post_and_assert_status(data, status.HTTP_404_NOT_FOUND)
|
||||
self.assertFalse(response.content)
|
||||
mock_is_username_retired.assert_called_once_with(self.original_username)
|
||||
|
||||
def test_retire_user_where_username_not_provided(self):
|
||||
response = self.post_and_assert_status({}, status.HTTP_404_NOT_FOUND)
|
||||
expected_response_message = {'message': text_type('The user was not specified.')}
|
||||
self.assertEqual(expected_response_message, response.json())
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.get_profile_image_names')
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.remove_profile_images')
|
||||
def test_retire_user(self, mock_remove_profile_images, mock_get_profile_image_names):
|
||||
data = {'username': self.original_username}
|
||||
self.post_and_assert_status(data)
|
||||
|
||||
self.test_user.refresh_from_db()
|
||||
self.test_user.profile.refresh_from_db() # pylint: disable=no-member
|
||||
|
||||
expected_user_values = {
|
||||
'first_name': '',
|
||||
'last_name': '',
|
||||
'is_active': False,
|
||||
'username': self.retired_username,
|
||||
}
|
||||
for field, expected_value in expected_user_values.iteritems():
|
||||
self.assertEqual(expected_value, getattr(self.test_user, field))
|
||||
|
||||
for field, expected_value in USER_PROFILE_PII.iteritems():
|
||||
self.assertEqual(expected_value, getattr(self.test_user.profile, field))
|
||||
|
||||
self.assertIsNone(self.test_user.profile.profile_image_uploaded_at)
|
||||
mock_get_profile_image_names.assert_called_once_with(self.original_username)
|
||||
mock_remove_profile_images.assert_called_once_with(
|
||||
mock_get_profile_image_names.return_value
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
SocialLink.objects.filter(user_profile=self.test_user.profile).exists()
|
||||
)
|
||||
|
||||
self.assertIsNone(cache.get(self.cache_key))
|
||||
|
||||
self._data_sharing_consent_assertions()
|
||||
self._sapsf_audit_assertions()
|
||||
self._pending_enterprise_customer_user_assertions()
|
||||
self._entitlement_support_detail_assertions()
|
||||
|
||||
self._photo_verification_assertions()
|
||||
self.assertFalse(PendingEmailChange.objects.filter(user=self.test_user).exists())
|
||||
self.assertFalse(UserOrgTag.objects.filter(user=self.test_user).exists())
|
||||
|
||||
self.assertFalse(CourseEnrollmentAllowed.objects.filter(email=self.original_email).exists())
|
||||
self.assertFalse(UnregisteredLearnerCohortAssignments.objects.filter(email=self.original_email).exists())
|
||||
|
||||
def test_deletes_pii_from_user_profile(self):
|
||||
for model_field, value_to_assign in USER_PROFILE_PII.iteritems():
|
||||
if value_to_assign == '':
|
||||
value = 'foo'
|
||||
else:
|
||||
value = mock.Mock()
|
||||
setattr(self.test_user.profile, model_field, value)
|
||||
|
||||
AccountRetirementView.clear_pii_from_userprofile(self.test_user)
|
||||
|
||||
for model_field, value_to_assign in USER_PROFILE_PII.iteritems():
|
||||
self.assertEqual(value_to_assign, getattr(self.test_user.profile, model_field))
|
||||
|
||||
social_links = SocialLink.objects.filter(
|
||||
user_profile=self.test_user.profile
|
||||
)
|
||||
self.assertFalse(social_links.exists())
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.get_profile_image_names')
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.remove_profile_images')
|
||||
def test_removes_user_profile_images(
|
||||
self, mock_remove_profile_images, mock_get_profile_image_names
|
||||
):
|
||||
test_datetime = datetime.datetime(2018, 1, 1)
|
||||
self.test_user.profile.profile_image_uploaded_at = test_datetime
|
||||
|
||||
AccountRetirementView.delete_users_profile_images(self.test_user)
|
||||
|
||||
self.test_user.profile.refresh_from_db() # pylint: disable=no-member
|
||||
|
||||
self.assertIsNone(self.test_user.profile.profile_image_uploaded_at)
|
||||
mock_get_profile_image_names.assert_called_once_with(self.test_user.username)
|
||||
mock_remove_profile_images.assert_called_once_with(
|
||||
mock_get_profile_image_names.return_value
|
||||
)
|
||||
|
||||
def test_can_delete_user_profiles_country_cache(self):
|
||||
AccountRetirementView.delete_users_country_cache(self.test_user)
|
||||
self.assertIsNone(cache.get(self.cache_key))
|
||||
|
||||
def test_can_retire_users_datasharingconsent(self):
|
||||
AccountRetirementView.retire_users_data_sharing_consent(self.test_user.username, self.retired_username)
|
||||
self._data_sharing_consent_assertions()
|
||||
|
||||
def _data_sharing_consent_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``DataSharingConsent`` objects are retired.
|
||||
"""
|
||||
self.consent.refresh_from_db()
|
||||
self.assertEqual(self.retired_username, self.consent.username)
|
||||
test_users_data_sharing_consent = DataSharingConsent.objects.filter(
|
||||
username=self.original_username
|
||||
)
|
||||
self.assertFalse(test_users_data_sharing_consent.exists())
|
||||
|
||||
def test_can_retire_users_sap_success_factors_audits(self):
|
||||
AccountRetirementView.retire_sapsf_data_transmission(self.test_user)
|
||||
self._sapsf_audit_assertions()
|
||||
|
||||
def _sapsf_audit_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``SapSuccessFactorsLearnerDataTransmissionAudit`` objects are retired.
|
||||
"""
|
||||
self.sapsf_audit.refresh_from_db()
|
||||
self.assertEqual('', self.sapsf_audit.sapsf_user_id)
|
||||
audits_for_original_user_id = SapSuccessFactorsLearnerDataTransmissionAudit.objects.filter(
|
||||
sapsf_user_id=self.test_user.id,
|
||||
)
|
||||
self.assertFalse(audits_for_original_user_id.exists())
|
||||
|
||||
def test_can_retire_user_from_pendingenterprisecustomeruser(self):
|
||||
AccountRetirementView.retire_user_from_pending_enterprise_customer_user(self.test_user, self.retired_email)
|
||||
self._pending_enterprise_customer_user_assertions()
|
||||
|
||||
def _pending_enterprise_customer_user_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``PendingEnterpriseCustomerUser`` objects are retired.
|
||||
"""
|
||||
self.pending_enterprise_user.refresh_from_db()
|
||||
self.assertEqual(self.retired_email, self.pending_enterprise_user.user_email)
|
||||
pending_enterprise_users = PendingEnterpriseCustomerUser.objects.filter(
|
||||
user_email=self.original_email
|
||||
)
|
||||
self.assertFalse(pending_enterprise_users.exists())
|
||||
|
||||
def test_course_entitlement_support_detail_comments_are_retired(self):
|
||||
AccountRetirementView.retire_entitlement_support_detail(self.test_user)
|
||||
self._entitlement_support_detail_assertions()
|
||||
|
||||
def _entitlement_support_detail_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``CourseEntitleSupportDetail`` objects are retired.
|
||||
"""
|
||||
self.entitlement_support_detail.refresh_from_db()
|
||||
self.assertEqual('', self.entitlement_support_detail.comments)
|
||||
|
||||
def _photo_verification_assertions(self):
|
||||
"""
|
||||
Helper method for asserting that ``SoftwareSecurePhotoVerification`` objects are retired.
|
||||
"""
|
||||
self.photo_verification.refresh_from_db()
|
||||
self.assertEqual(self.test_user, self.photo_verification.user)
|
||||
for field in ('name', 'face_image_url', 'photo_id_image_url', 'photo_id_key'):
|
||||
self.assertEqual('', getattr(self.photo_verification, field))
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Account APIs are only supported in LMS')
|
||||
class TestLMSAccountRetirementPost(RetirementTestCase, ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the LMS account retirement (GDPR P2) endpoint.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestLMSAccountRetirementPost, self).setUp()
|
||||
self.pii_standin = 'PII here'
|
||||
self.course = CourseFactory()
|
||||
self.test_user = UserFactory()
|
||||
self.test_superuser = SuperuserFactory()
|
||||
self.original_username = self.test_user.username
|
||||
self.original_email = self.test_user.email
|
||||
self.retired_username = get_retired_username_by_username(self.original_username)
|
||||
self.retired_email = get_retired_email_by_email(self.original_email)
|
||||
|
||||
retirement_state = RetirementState.objects.get(state_name='RETIRING_LMS')
|
||||
self.retirement_status = UserRetirementStatus.create_retirement(self.test_user)
|
||||
self.retirement_status.current_state = retirement_state
|
||||
self.retirement_status.last_state = retirement_state
|
||||
self.retirement_status.save()
|
||||
|
||||
# wiki data setup
|
||||
rp = RevisionPlugin.objects.create(article_id=0)
|
||||
RevisionPluginRevision.objects.create(
|
||||
revision_number=1,
|
||||
ip_address="ipaddresss",
|
||||
plugin=rp,
|
||||
user=self.test_user,
|
||||
)
|
||||
article = Article.objects.create()
|
||||
ArticleRevision.objects.create(ip_address="ipaddresss", user=self.test_user, article=article)
|
||||
|
||||
# ManualEnrollmentAudit setup
|
||||
course_enrollment = CourseEnrollment.enroll(user=self.test_user, course_key=self.course.id)
|
||||
ManualEnrollmentAudit.objects.create(
|
||||
enrollment=course_enrollment, reason=self.pii_standin, enrolled_email=self.pii_standin
|
||||
)
|
||||
|
||||
# CreditRequest and CreditRequirementStatus setup
|
||||
provider = CreditProvider.objects.create(provider_id="Hogwarts")
|
||||
credit_course = CreditCourse.objects.create(course_key=self.course.id)
|
||||
CreditRequest.objects.create(
|
||||
username=self.test_user.username,
|
||||
course=credit_course,
|
||||
provider_id=provider.id,
|
||||
parameters={self.pii_standin},
|
||||
)
|
||||
req = CreditRequirement.objects.create(course_id=credit_course.id)
|
||||
CreditRequirementStatus.objects.create(username=self.test_user.username, requirement=req)
|
||||
|
||||
# ApiAccessRequest setup
|
||||
site = Site.objects.create()
|
||||
ApiAccessRequest.objects.create(
|
||||
user=self.test_user,
|
||||
site=site,
|
||||
website=self.pii_standin,
|
||||
company_address=self.pii_standin,
|
||||
company_name=self.pii_standin,
|
||||
reason=self.pii_standin,
|
||||
)
|
||||
|
||||
# SurveyAnswer setup
|
||||
SurveyAnswer.objects.create(user=self.test_user, field_value=self.pii_standin, form_id=0)
|
||||
|
||||
# other setup
|
||||
PendingNameChange.objects.create(user=self.test_user, new_name=self.pii_standin, rationale=self.pii_standin)
|
||||
PasswordHistory.objects.create(user=self.test_user, password=self.pii_standin)
|
||||
|
||||
# setup for doing POST from test client
|
||||
self.headers = self.build_jwt_headers(self.test_superuser)
|
||||
self.headers['content_type'] = "application/json"
|
||||
self.url = reverse('accounts_retire_misc')
|
||||
|
||||
def post_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.
|
||||
"""
|
||||
response = self.client.post(self.url, json.dumps(data), **self.headers)
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
return response
|
||||
|
||||
def test_retire_user(self):
|
||||
# check that rows that will not exist after retirement exist now
|
||||
self.assertTrue(CreditRequest.objects.filter(username=self.test_user.username).exists())
|
||||
self.assertTrue(CreditRequirementStatus.objects.filter(username=self.test_user.username).exists())
|
||||
self.assertTrue(PendingNameChange.objects.filter(user=self.test_user).exists())
|
||||
|
||||
retirement = UserRetirementStatus.get_retirement_for_retirement_action(self.test_user.username)
|
||||
data = {'username': self.original_username}
|
||||
self.post_and_assert_status(data)
|
||||
|
||||
self.test_user.refresh_from_db()
|
||||
self.test_user.profile.refresh_from_db() # pylint: disable=no-member
|
||||
self.assertEqual(RevisionPluginRevision.objects.get(user=self.test_user).ip_address, None)
|
||||
self.assertEqual(ArticleRevision.objects.get(user=self.test_user).ip_address, None)
|
||||
self.assertFalse(PendingNameChange.objects.filter(user=self.test_user).exists())
|
||||
self.assertEqual(PasswordHistory.objects.get(user=self.test_user).password, '')
|
||||
|
||||
self.assertEqual(
|
||||
ManualEnrollmentAudit.objects.get(
|
||||
enrollment=CourseEnrollment.objects.get(user=self.test_user)
|
||||
).enrolled_email,
|
||||
retirement.retired_email
|
||||
)
|
||||
self.assertFalse(CreditRequest.objects.filter(username=self.test_user.username).exists())
|
||||
self.assertTrue(CreditRequest.objects.filter(username=retirement.retired_username).exists())
|
||||
self.assertEqual(CreditRequest.objects.get(username=retirement.retired_username).parameters, {})
|
||||
|
||||
self.assertFalse(CreditRequirementStatus.objects.filter(username=self.test_user.username).exists())
|
||||
self.assertTrue(CreditRequirementStatus.objects.filter(username=retirement.retired_username).exists())
|
||||
self.assertEqual(CreditRequirementStatus.objects.get(username=retirement.retired_username).reason, {})
|
||||
|
||||
retired_api_access_request = ApiAccessRequest.objects.get(user=self.test_user)
|
||||
self.assertEqual(retired_api_access_request.website, '')
|
||||
self.assertEqual(retired_api_access_request.company_address, '')
|
||||
self.assertEqual(retired_api_access_request.company_name, '')
|
||||
self.assertEqual(retired_api_access_request.reason, '')
|
||||
self.assertEqual(SurveyAnswer.objects.get(user=self.test_user).field_value, '')
|
||||
|
||||
@@ -66,10 +66,16 @@ from student.models import (
|
||||
from student.views.login import AuthFailedError, LoginFailures
|
||||
|
||||
from ..errors import AccountUpdateError, AccountValidationError, UserNotAuthorized, UserNotFound
|
||||
from ..models import RetirementState, RetirementStateError, UserOrgTag, UserRetirementStatus
|
||||
from ..models import (
|
||||
RetirementState,
|
||||
RetirementStateError,
|
||||
UserOrgTag,
|
||||
UserRetirementPartnerReportingStatus,
|
||||
UserRetirementStatus
|
||||
)
|
||||
from .api import get_account_settings, update_account_settings
|
||||
from .permissions import CanDeactivateUser, CanRetireUser
|
||||
from .serializers import UserRetirementStatusSerializer
|
||||
from .serializers import UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer
|
||||
from .signals import USER_RETIRE_MAILINGS
|
||||
from ..message_types import DeletionNotificationMessage
|
||||
|
||||
@@ -515,6 +521,87 @@ def _set_unusable_password(user):
|
||||
user.save()
|
||||
|
||||
|
||||
class AccountRetirementPartnerReportView(ViewSet):
|
||||
"""
|
||||
Provides API endpoints for managing partner reporting of retired
|
||||
users.
|
||||
"""
|
||||
authentication_classes = (JwtAuthentication,)
|
||||
permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
|
||||
parser_classes = (JSONParser,)
|
||||
serializer_class = UserRetirementStatusSerializer
|
||||
|
||||
def _get_orgs_for_user(self, user):
|
||||
"""
|
||||
Returns a set of orgs that the user has enrollments with
|
||||
"""
|
||||
orgs = set()
|
||||
for enrollment in user.courseenrollment_set.all():
|
||||
org = enrollment.course.org
|
||||
|
||||
# Org can concievably be blank or this bogus default value
|
||||
if org and org != 'outdated_entry':
|
||||
orgs.add(enrollment.course.org)
|
||||
return orgs
|
||||
|
||||
def retirement_partner_report(self, request): # pylint: disable=unused-argument
|
||||
"""
|
||||
POST /api/user/v1/accounts/retirement_partner_report/
|
||||
|
||||
Returns the list of UserRetirementPartnerReportingStatus users
|
||||
that are not already being processed and updates their status
|
||||
to indicate they are currently being processed.
|
||||
"""
|
||||
retirement_statuses = UserRetirementPartnerReportingStatus.objects.filter(
|
||||
is_being_processed=False
|
||||
).order_by('id')
|
||||
|
||||
retirements = [
|
||||
{
|
||||
'original_username': retirement.original_username,
|
||||
'original_email': retirement.original_email,
|
||||
'original_name': retirement.original_name,
|
||||
'orgs': self._get_orgs_for_user(retirement.user)
|
||||
}
|
||||
for retirement in retirement_statuses
|
||||
]
|
||||
|
||||
serializer = UserRetirementPartnerReportSerializer(retirements, many=True)
|
||||
|
||||
retirement_statuses.update(is_being_processed=True)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def retirement_partner_cleanup(self, request):
|
||||
"""
|
||||
DELETE /api/user/v1/accounts/retirement_partner_report/
|
||||
|
||||
[{'original_username': 'user1'}, {'original_username': 'user2'}, ...]
|
||||
|
||||
Deletes UserRetirementPartnerReportingStatus objects for a list of users
|
||||
that have been reported on.
|
||||
"""
|
||||
usernames = [u['original_username'] for u in request.data]
|
||||
|
||||
if not usernames:
|
||||
return Response('No original_usernames given.', status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
retirement_statuses = UserRetirementPartnerReportingStatus.objects.filter(
|
||||
is_being_processed=True,
|
||||
original_username__in=usernames
|
||||
)
|
||||
|
||||
if len(usernames) != len(retirement_statuses):
|
||||
return Response(
|
||||
'{} original_usernames given, only {} found!'.format(len(usernames), len(retirement_statuses)),
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
retirement_statuses.delete()
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class AccountRetirementStatusView(ViewSet):
|
||||
"""
|
||||
Provides API endpoints for managing the user retirement process.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.13 on 2018-06-13 20:54
|
||||
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', '0003_userretirementrequest'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserRetirementPartnerReportingStatus',
|
||||
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)),
|
||||
('is_being_processed', models.BooleanField(default=False)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User Retirement Reporting Status',
|
||||
'verbose_name_plural': 'User Retirement Reporting Statuses',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -167,6 +167,31 @@ class RetirementState(models.Model):
|
||||
return cls.objects.all().values_list('state_name', flat=True)
|
||||
|
||||
|
||||
class UserRetirementPartnerReportingStatus(TimeStampedModel):
|
||||
"""
|
||||
When a user has been retired from LMS it will still need to be reported out to
|
||||
partners so they can forget the user also. This process happens on a very different,
|
||||
and asynchronous, timeline than LMS retirement and only impacts a subset of learners
|
||||
so it maintains a queue. This queue is populated as part of the LMS retirement
|
||||
process.
|
||||
"""
|
||||
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)
|
||||
is_being_processed = models.BooleanField(default=False)
|
||||
|
||||
class Meta(object):
|
||||
verbose_name = 'User Retirement Reporting Status'
|
||||
verbose_name_plural = 'User Retirement Reporting Statuses'
|
||||
|
||||
def __unicode__(self):
|
||||
return u'UserRetirementPartnerReportingStatus: {} is being processed: {}'.format(
|
||||
self.user,
|
||||
self.is_being_processed
|
||||
)
|
||||
|
||||
|
||||
class UserRetirementRequest(TimeStampedModel):
|
||||
"""
|
||||
Records and perists every user retirement request.
|
||||
|
||||
@@ -20,7 +20,7 @@ from six import text_type
|
||||
from social_django.models import UserSocialAuth, Partial
|
||||
|
||||
from django_comment_common import models
|
||||
from openedx.core.djangoapps.user_api.accounts.tests.test_views import RetirementTestCase
|
||||
from openedx.core.djangoapps.user_api.accounts.tests.test_retirement_views import RetirementTestCase
|
||||
from openedx.core.djangoapps.user_api.models import UserRetirementStatus
|
||||
from openedx.core.djangoapps.site_configuration.helpers import get_value
|
||||
from openedx.core.lib.api.test_utils import ApiTestCase, TEST_API_KEY
|
||||
|
||||
@@ -9,6 +9,7 @@ from ..profile_images.views import ProfileImageView
|
||||
from .accounts.views import (
|
||||
AccountDeactivationView,
|
||||
AccountRetireMailingsView,
|
||||
AccountRetirementPartnerReportView,
|
||||
AccountRetirementStatusView,
|
||||
AccountRetirementView,
|
||||
AccountViewSet,
|
||||
@@ -32,6 +33,11 @@ ACCOUNT_DETAIL = AccountViewSet.as_view({
|
||||
'patch': 'partial_update',
|
||||
})
|
||||
|
||||
PARTNER_REPORT = AccountRetirementPartnerReportView.as_view({
|
||||
'post': 'retirement_partner_report',
|
||||
'delete': 'retirement_partner_cleanup'
|
||||
})
|
||||
|
||||
RETIREMENT_QUEUE = AccountRetirementStatusView.as_view({
|
||||
'get': 'retirement_queue'
|
||||
})
|
||||
@@ -98,6 +104,11 @@ urlpatterns = [
|
||||
RETIREMENT_RETRIEVE,
|
||||
name='accounts_retirement_retrieve'
|
||||
),
|
||||
url(
|
||||
r'^v1/accounts/retirement_partner_report/$',
|
||||
PARTNER_REPORT,
|
||||
name='accounts_retirement_partner_report'
|
||||
),
|
||||
url(
|
||||
r'^v1/accounts/retirement_queue/$',
|
||||
RETIREMENT_QUEUE,
|
||||
|
||||
Reference in New Issue
Block a user