EDUCATOR-2771 | Adds an LMS API endpoint to retire a user account.
This commit is contained in:
committed by
Alex Dusenbery
parent
0ffb021c65
commit
2b48649a9e
@@ -1245,6 +1245,9 @@ OPTIONAL_APPS = (
|
||||
# Enterprise App (http://github.com/edx/edx-enterprise)
|
||||
('enterprise', None),
|
||||
('consent', None),
|
||||
('integrated_channels.integrated_channel', None),
|
||||
('integrated_channels.degreed', None),
|
||||
('integrated_channels.sap_success_factors', None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from urllib import urlencode
|
||||
|
||||
import analytics
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.contrib.auth.models import User
|
||||
@@ -216,15 +217,33 @@ def is_username_retired(username):
|
||||
|
||||
def get_retired_username_by_username(username):
|
||||
"""
|
||||
Returns a "retired username" hashed using the newest configured salt
|
||||
If a UserRetirementStatus object with an original_username matching the given username exists,
|
||||
returns that UserRetirementStatus.retired_username value. Otherwise, returns a "retired username"
|
||||
hashed using the newest configured salt.
|
||||
"""
|
||||
UserRetirementStatus = apps.get_model('user_api', 'UserRetirementStatus')
|
||||
try:
|
||||
status = UserRetirementStatus.objects.filter(original_username=username).order_by('-modified').first()
|
||||
if status:
|
||||
return status.retired_username
|
||||
except UserRetirementStatus.DoesNotExist:
|
||||
pass
|
||||
return user_util.get_retired_username(username, settings.RETIRED_USER_SALTS, settings.RETIRED_USERNAME_FMT)
|
||||
|
||||
|
||||
def get_retired_email_by_email(email):
|
||||
"""
|
||||
Returns a "retired email" hashed using the newest configured salt
|
||||
If a UserRetirementStatus object with an original_email matching the given email exists,
|
||||
returns that UserRetirementStatus.retired_email value. Otherwise, returns a "retired email"
|
||||
hashed using the newest configured salt.
|
||||
"""
|
||||
UserRetirementStatus = apps.get_model('user_api', 'UserRetirementStatus')
|
||||
try:
|
||||
status = UserRetirementStatus.objects.filter(original_email=email).order_by('-modified').first()
|
||||
if status:
|
||||
return status.retired_email
|
||||
except UserRetirementStatus.DoesNotExist:
|
||||
pass
|
||||
return user_util.get_retired_email(email, settings.RETIRED_USER_SALTS, settings.RETIRED_EMAIL_FMT)
|
||||
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ Test user retirement methods
|
||||
import json
|
||||
|
||||
import ddt
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
import pytest
|
||||
|
||||
from student.models import (
|
||||
@@ -33,6 +33,29 @@ assert "{}" in settings.RETIRED_USERNAME_FMT
|
||||
assert "{}@" in settings.RETIRED_EMAIL_FMT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def retirement_user():
|
||||
return UserFactory.create(username='test_user')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def retirement_status(retirement_user): # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Returns a UserRetirementStatus test fixture object.
|
||||
"""
|
||||
RetirementState = apps.get_model('user_api', 'RetirementState')
|
||||
UserRetirementStatus = apps.get_model('user_api', 'UserRetirementStatus')
|
||||
RetirementState.objects.create(
|
||||
state_name='RETIRING_LMS',
|
||||
state_execution_order=1,
|
||||
required=False,
|
||||
is_dead_end_state=False
|
||||
)
|
||||
status = UserRetirementStatus.create_retirement(retirement_user)
|
||||
status.save()
|
||||
return status
|
||||
|
||||
|
||||
def check_username_against_fmt(hashed_username):
|
||||
"""
|
||||
Checks that the given username is formatted correctly using our settings.
|
||||
@@ -60,6 +83,16 @@ def test_get_retired_username():
|
||||
check_username_against_fmt(hashed_username)
|
||||
|
||||
|
||||
def test_get_retired_username_status_exists(retirement_user, retirement_status): # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Checks that a retired username is gotten from a UserRetirementStatus
|
||||
object when one already exists for a user.
|
||||
"""
|
||||
hashed_username = get_retired_username_by_username(retirement_user.username)
|
||||
check_username_against_fmt(hashed_username)
|
||||
assert retirement_status.retired_username == hashed_username
|
||||
|
||||
|
||||
def test_get_all_retired_usernames_by_username():
|
||||
"""
|
||||
Check that all salts are used for this method and return expected
|
||||
@@ -108,6 +141,16 @@ def test_get_retired_email():
|
||||
check_email_against_fmt(hashed_email)
|
||||
|
||||
|
||||
def test_get_retired_email_status_exists(retirement_user, retirement_status): # pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Checks that a retired email is gotten from a UserRetirementStatus
|
||||
object when one already exists for a user.
|
||||
"""
|
||||
hashed_email = get_retired_email_by_email(retirement_user.email)
|
||||
check_email_against_fmt(hashed_email)
|
||||
assert retirement_status.retired_email == hashed_email
|
||||
|
||||
|
||||
def test_get_all_retired_email_by_email():
|
||||
"""
|
||||
Check that all salts are used for this method and return expected
|
||||
|
||||
@@ -2,37 +2,55 @@
|
||||
"""
|
||||
Test cases to cover Accounts-related behaviors of the User API application
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
from copy import deepcopy
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
|
||||
from consent.models import DataSharingConsent
|
||||
import ddt
|
||||
import pytest
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.cache import cache
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
from django.test.testcases import TransactionTestCase
|
||||
from django.test.utils import override_settings
|
||||
from mock import MagicMock, patch
|
||||
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 pytz import UTC
|
||||
import pytest
|
||||
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 entitlements.models import CourseEntitlementSupportDetail
|
||||
from entitlements.tests.factories import CourseEntitlementFactory
|
||||
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.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 student.models import PendingEmailChange, UserProfile, get_retired_username_by_username, get_retired_email_by_email
|
||||
from student.models import (
|
||||
PendingEmailChange,
|
||||
SocialLink,
|
||||
UserProfile,
|
||||
get_retired_username_by_username,
|
||||
get_retired_email_by_email,
|
||||
)
|
||||
from student.tests.factories import (
|
||||
TEST_PASSWORD,
|
||||
ContentTypeFactory,
|
||||
@@ -41,8 +59,9 @@ from student.tests.factories import (
|
||||
UserFactory
|
||||
)
|
||||
from .. import ALL_USERS_VISIBILITY, PRIVATE_VISIBILITY
|
||||
from ..views import AccountRetirementView, USER_PROFILE_PII
|
||||
|
||||
TEST_PROFILE_IMAGE_UPLOADED_AT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=UTC)
|
||||
TEST_PROFILE_IMAGE_UPLOADED_AT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=pytz.UTC)
|
||||
|
||||
|
||||
# this is used in one test to check the behavior of profile image url
|
||||
@@ -92,6 +111,7 @@ class UserAPITestCase(APITestCase):
|
||||
self.assertEqual(expected_status, response.status_code)
|
||||
return response
|
||||
|
||||
# pylint: disable=no-member
|
||||
def send_put(self, client, json_data, content_type="application/json", expected_status=204):
|
||||
"""
|
||||
Helper method for sending a PUT to the server. Verifies the expected status and returns the response.
|
||||
@@ -100,6 +120,7 @@ class UserAPITestCase(APITestCase):
|
||||
self.assertEqual(expected_status, response.status_code)
|
||||
return response
|
||||
|
||||
# pylint: disable=no-member
|
||||
def send_delete(self, client, expected_status=204):
|
||||
"""
|
||||
Helper method for sending a DELETE to the server. Verifies the expected status and returns the response.
|
||||
@@ -207,8 +228,8 @@ class TestOwnUsernameAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
|
||||
@ddt.ddt
|
||||
@skip_unless_lms
|
||||
@patch('openedx.core.djangoapps.user_api.accounts.image_helpers._PROFILE_IMAGE_SIZES', [50, 10])
|
||||
@patch.dict(
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.image_helpers._PROFILE_IMAGE_SIZES', [50, 10])
|
||||
@mock.patch.dict(
|
||||
'django.conf.settings.PROFILE_IMAGE_SIZES_MAP',
|
||||
{'full': 50, 'small': 10},
|
||||
clear=True
|
||||
@@ -306,7 +327,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
# Note: using getattr so that the patching works even if there is no configuration.
|
||||
# This is needed when testing CMS as the patching is still executed even though the
|
||||
# suite is skipped.
|
||||
@patch.dict(getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}), {"default_visibility": "all_users"})
|
||||
@mock.patch.dict(getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}), {"default_visibility": "all_users"})
|
||||
@pytest.mark.django111_expected_failure
|
||||
def test_get_account_different_user_visible(self):
|
||||
"""
|
||||
@@ -322,7 +343,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
# Note: using getattr so that the patching works even if there is no configuration.
|
||||
# This is needed when testing CMS as the patching is still executed even though the
|
||||
# suite is skipped.
|
||||
@patch.dict(getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}), {"default_visibility": "private"})
|
||||
@mock.patch.dict(getattr(settings, "ACCOUNT_VISIBILITY_CONFIGURATION", {}), {"default_visibility": "private"})
|
||||
@pytest.mark.django111_expected_failure
|
||||
def test_get_account_different_user_private(self):
|
||||
"""
|
||||
@@ -335,7 +356,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
response = self.send_get(self.different_client)
|
||||
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
@mock.patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
|
||||
@ddt.data(
|
||||
("client", "user", PRIVATE_VISIBILITY),
|
||||
("different_client", "different_user", PRIVATE_VISIBILITY),
|
||||
@@ -619,7 +640,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
verify_change_info(name_change_info[0], old_name, self.user.username, "Donald Duck",)
|
||||
verify_change_info(name_change_info[1], "Mickey Mouse", self.user.username, "Donald Duck")
|
||||
|
||||
@patch.dict(
|
||||
@mock.patch.dict(
|
||||
'django.conf.settings.PROFILE_IMAGE_SIZES_MAP',
|
||||
{'full': 50, 'medium': 30, 'small': 10},
|
||||
clear=True
|
||||
@@ -727,7 +748,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
|
||||
)
|
||||
)
|
||||
|
||||
@patch('openedx.core.djangoapps.user_api.accounts.serializers.AccountUserSerializer.save')
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.serializers.AccountUserSerializer.save')
|
||||
def test_patch_serializer_save_fails(self, serializer_save):
|
||||
"""
|
||||
Test that AccountUpdateErrors are passed through to the response.
|
||||
@@ -821,7 +842,7 @@ class TestAccountAPITransactions(TransactionTestCase):
|
||||
self.user = UserFactory.create(password=TEST_PASSWORD)
|
||||
self.url = reverse("accounts_api", kwargs={'username': self.user.username})
|
||||
|
||||
@patch('student.views.do_email_change_request')
|
||||
@mock.patch('student.views.do_email_change_request')
|
||||
def test_update_account_settings_rollback(self, mock_email_change):
|
||||
"""
|
||||
Verify that updating account settings is transactional when a failure happens.
|
||||
@@ -873,11 +894,11 @@ class TestAccountDeactivation(TestCase):
|
||||
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()) # pylint: disable=no-member
|
||||
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() # pylint: disable=no-member
|
||||
self.assertEqual(self.test_user.has_usable_password(), expected_activation_status) # pylint: disable=no-member
|
||||
self.test_user.refresh_from_db()
|
||||
self.assertEqual(self.test_user.has_usable_password(), expected_activation_status)
|
||||
|
||||
def test_superuser_deactivates_user(self):
|
||||
"""
|
||||
@@ -898,9 +919,9 @@ class TestAccountDeactivation(TestCase):
|
||||
app_label='student'
|
||||
)
|
||||
)
|
||||
user.user_permissions.add(permission) # pylint: disable=no-member
|
||||
user.user_permissions.add(permission)
|
||||
headers = self.build_jwt_headers(user)
|
||||
self.assertTrue(self.test_user.has_usable_password()) # pylint: disable=no-member
|
||||
self.assertTrue(self.test_user.has_usable_password())
|
||||
self.assert_activation_status(headers)
|
||||
|
||||
def test_unauthorized_rejection(self):
|
||||
@@ -918,7 +939,7 @@ class TestAccountDeactivation(TestCase):
|
||||
"""
|
||||
Verify users who are not JWT authenticated are rejected.
|
||||
"""
|
||||
user = UserFactory()
|
||||
UserFactory()
|
||||
self.assert_activation_status(
|
||||
{},
|
||||
expected_status=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -1164,9 +1185,6 @@ class TestAccountRetireMailings(RetirementTestCase):
|
||||
"""
|
||||
response = self.client.post(self.url, self.build_post(self.test_user), **headers)
|
||||
|
||||
if response.status_code != expected_status:
|
||||
print(response)
|
||||
|
||||
self.assertEqual(response.status_code, expected_status)
|
||||
|
||||
# Check that the expected number of tags with the correct value exist
|
||||
@@ -1206,7 +1224,7 @@ class TestAccountRetireMailings(RetirementTestCase):
|
||||
"""
|
||||
headers = self.build_jwt_headers(self.test_superuser)
|
||||
|
||||
mock_handler = MagicMock()
|
||||
mock_handler = mock.MagicMock()
|
||||
mock_handler.side_effect = Exception("Tango")
|
||||
|
||||
try:
|
||||
@@ -1591,3 +1609,260 @@ class TestAccountRetirementUpdate(RetirementTestCase):
|
||||
# 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.'
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
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)
|
||||
|
||||
@@ -5,39 +5,83 @@ For additional information and historical context, see:
|
||||
https://openedx.atlassian.net/wiki/display/TNL/User+API
|
||||
"""
|
||||
import datetime
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
import pytz
|
||||
from django.contrib.auth import get_user_model, authenticate, logout
|
||||
from consent.models import DataSharingConsent
|
||||
from django.contrib.auth import authenticate, get_user_model, logout
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.utils.translation import ugettext as _
|
||||
from edx_rest_framework_extensions.authentication import JwtAuthentication
|
||||
from rest_framework import permissions
|
||||
from rest_framework import status
|
||||
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser, PendingEnterpriseCustomerUser
|
||||
from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.parsers import JSONParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSet
|
||||
from six import text_type
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from entitlements.models import CourseEntitlement
|
||||
from openedx.core.djangoapps.profile_images.images import remove_profile_images
|
||||
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
|
||||
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
|
||||
from openedx.core.lib.api.authentication import (
|
||||
OAuth2AuthenticationAllowInactiveUser,
|
||||
SessionAuthenticationAllowInactiveUser
|
||||
)
|
||||
from openedx.core.lib.api.parsers import MergePatchParser
|
||||
from student.models import (
|
||||
User,
|
||||
UserProfile,
|
||||
get_potentially_retired_user_by_username,
|
||||
get_retired_email_by_email,
|
||||
get_potentially_retired_user_by_username
|
||||
get_retired_username_by_username,
|
||||
is_username_retired
|
||||
)
|
||||
from student.views.login import AuthFailedError, LoginFailures
|
||||
|
||||
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
|
||||
from openedx.core.lib.api.authentication import (
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
OAuth2AuthenticationAllowInactiveUser,
|
||||
)
|
||||
from openedx.core.lib.api.parsers import MergePatchParser
|
||||
from ..errors import AccountUpdateError, AccountValidationError, UserNotAuthorized, UserNotFound
|
||||
from ..models import RetirementState, RetirementStateError, UserOrgTag, UserRetirementStatus
|
||||
from .api import get_account_settings, update_account_settings
|
||||
from .permissions import CanDeactivateUser, CanRetireUser
|
||||
from .serializers import UserRetirementStatusSerializer
|
||||
from .signals import USER_RETIRE_MAILINGS
|
||||
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
|
||||
from ..models import UserOrgTag, RetirementState, RetirementStateError, UserRetirementStatus
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
USER_PROFILE_PII = {
|
||||
'name': '',
|
||||
'meta': '',
|
||||
'location': '',
|
||||
'year_of_birth': None,
|
||||
'gender': None,
|
||||
'mailing_address': None,
|
||||
'city': None,
|
||||
'country': None,
|
||||
'bio': None,
|
||||
}
|
||||
|
||||
|
||||
def request_requires_username(function):
|
||||
"""
|
||||
Requires that a ``username`` key containing a truthy value exists in
|
||||
the ``request.data`` attribute of the decorated function.
|
||||
"""
|
||||
@wraps(function)
|
||||
def wrapper(self, request): # pylint: disable=missing-docstring
|
||||
username = request.data.get('username', None)
|
||||
if not username:
|
||||
return Response(
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
data={'message': text_type('The user was not specified.')}
|
||||
)
|
||||
return function(self, request)
|
||||
return wrapper
|
||||
|
||||
|
||||
class AccountViewSet(ViewSet):
|
||||
@@ -423,18 +467,18 @@ def _set_unusable_password(user):
|
||||
user.save()
|
||||
|
||||
|
||||
class AccountRetirementView(ViewSet):
|
||||
class AccountRetirementStatusView(ViewSet):
|
||||
"""
|
||||
Provides API endpoints for managing the user retirement process.
|
||||
"""
|
||||
authentication_classes = (JwtAuthentication,)
|
||||
permission_classes = (permissions.IsAuthenticated, CanRetireUser, )
|
||||
parser_classes = (MergePatchParser, )
|
||||
permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
|
||||
parser_classes = (MergePatchParser,)
|
||||
serializer_class = UserRetirementStatusSerializer
|
||||
|
||||
def retirement_queue(self, request):
|
||||
"""
|
||||
GET /api/user/v1/accounts/accounts_to_retire/
|
||||
GET /api/user/v1/accounts/retirement_queue/
|
||||
{'cool_off_days': 7, 'states': ['PENDING', 'COMPLETE']}
|
||||
|
||||
Returns the list of RetirementStatus users in the given states that were
|
||||
@@ -489,6 +533,7 @@ class AccountRetirementView(ViewSet):
|
||||
except (UserRetirementStatus.DoesNotExist, User.DoesNotExist):
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@request_requires_username
|
||||
def partial_update(self, request):
|
||||
"""
|
||||
PATCH /api/user/v1/accounts/update_retirement_status/
|
||||
@@ -517,3 +562,109 @@ class AccountRetirementView(ViewSet):
|
||||
return Response(text_type(exc), status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
class AccountRetirementView(ViewSet):
|
||||
"""
|
||||
Provides API endpoint for retiring a user.
|
||||
"""
|
||||
authentication_classes = (JwtAuthentication,)
|
||||
permission_classes = (permissions.IsAuthenticated, CanRetireUser,)
|
||||
parser_classes = (JSONParser,)
|
||||
|
||||
@request_requires_username
|
||||
def post(self, request):
|
||||
"""
|
||||
POST /api/user/v1/accounts/retire/
|
||||
|
||||
{
|
||||
'username': 'user_to_retire'
|
||||
}
|
||||
|
||||
Retires the user with the given username. This includes
|
||||
retiring this username, the associates email address, and
|
||||
any other PII associated with this user.
|
||||
"""
|
||||
username = request.data['username']
|
||||
if is_username_retired(username):
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
retirement_status = UserRetirementStatus.get_retirement_for_retirement_action(username)
|
||||
user = retirement_status.user
|
||||
retired_username = retirement_status.retired_username or get_retired_username_by_username(username)
|
||||
retired_email = retirement_status.retired_email or get_retired_email_by_email(user.email)
|
||||
|
||||
self.clear_pii_from_userprofile(user)
|
||||
self.delete_users_profile_images(user)
|
||||
self.delete_users_country_cache(user)
|
||||
self.retire_users_data_sharing_consent(username, retired_username)
|
||||
self.retire_sapsf_data_transmission(user)
|
||||
self.retire_user_from_pending_enterprise_customer_user(user, retired_email)
|
||||
self.retire_entitlement_support_detail(user)
|
||||
# TODO: Password Reset links - https://openedx.atlassian.net/browse/PLAT-2104
|
||||
# TODO: Delete OAuth2 records - https://openedx.atlassian.net/browse/EDUCATOR-2703
|
||||
user.first_name = ''
|
||||
user.last_name = ''
|
||||
user.is_active = False
|
||||
user.username = retired_username
|
||||
user.save()
|
||||
except UserRetirementStatus.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
except RetirementStateError as exc:
|
||||
return Response(text_type(exc), status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
return Response(text_type(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@staticmethod
|
||||
def clear_pii_from_userprofile(user):
|
||||
"""
|
||||
For the given user, sets all of the user's profile fields to some retired value.
|
||||
This also deletes all ``SocialLink`` objects associated with this user's profile.
|
||||
"""
|
||||
for model_field, value_to_assign in USER_PROFILE_PII.iteritems():
|
||||
setattr(user.profile, model_field, value_to_assign)
|
||||
|
||||
user.profile.save()
|
||||
user.profile.social_links.all().delete()
|
||||
|
||||
@staticmethod
|
||||
def delete_users_profile_images(user):
|
||||
set_has_profile_image(user.username, False)
|
||||
names_of_profile_images = get_profile_image_names(user.username)
|
||||
remove_profile_images(names_of_profile_images)
|
||||
|
||||
@staticmethod
|
||||
def delete_users_country_cache(user):
|
||||
cache_key = UserProfile.country_cache_key_name(user.id)
|
||||
cache.delete(cache_key)
|
||||
|
||||
@staticmethod
|
||||
def retire_users_data_sharing_consent(username, retired_username):
|
||||
DataSharingConsent.objects.filter(username=username).update(username=retired_username)
|
||||
|
||||
@staticmethod
|
||||
def retire_sapsf_data_transmission(user):
|
||||
for ent_user in EnterpriseCustomerUser.objects.filter(user_id=user.id):
|
||||
for enrollment in EnterpriseCourseEnrollment.objects.filter(
|
||||
enterprise_customer_user=ent_user
|
||||
):
|
||||
audits = SapSuccessFactorsLearnerDataTransmissionAudit.objects.filter(
|
||||
enterprise_course_enrollment_id=enrollment.id
|
||||
)
|
||||
audits.update(sapsf_user_id='')
|
||||
|
||||
@staticmethod
|
||||
def retire_user_from_pending_enterprise_customer_user(user, retired_email):
|
||||
PendingEnterpriseCustomerUser.objects.filter(user_email=user.email).update(user_email=retired_email)
|
||||
|
||||
@staticmethod
|
||||
def retire_entitlement_support_detail(user):
|
||||
"""
|
||||
Updates all CourseEntitleSupportDetail records for the given
|
||||
user to have an empty ``comments`` field.
|
||||
"""
|
||||
for entitlement in CourseEntitlement.objects.filter(user_id=user.id):
|
||||
entitlement.courseentitlementsupportdetail_set.all().update(comments='')
|
||||
|
||||
@@ -4,7 +4,7 @@ from factory import SubFactory
|
||||
from student.tests.factories import UserFactory
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
from ..models import UserPreference, UserCourseTag, UserOrgTag
|
||||
from ..models import UserCourseTag, UserOrgTag, UserPreference
|
||||
|
||||
|
||||
# Factories are self documenting
|
||||
|
||||
@@ -9,6 +9,7 @@ from ..profile_images.views import ProfileImageView
|
||||
from .accounts.views import (
|
||||
AccountDeactivationView,
|
||||
AccountRetireMailingsView,
|
||||
AccountRetirementStatusView,
|
||||
AccountRetirementView,
|
||||
AccountViewSet,
|
||||
DeactivateLogoutView
|
||||
@@ -30,18 +31,22 @@ ACCOUNT_DETAIL = AccountViewSet.as_view({
|
||||
'patch': 'partial_update',
|
||||
})
|
||||
|
||||
RETIREMENT_QUEUE = AccountRetirementView.as_view({
|
||||
RETIREMENT_QUEUE = AccountRetirementStatusView.as_view({
|
||||
'get': 'retirement_queue'
|
||||
})
|
||||
|
||||
RETIREMENT_RETRIEVE = AccountRetirementView.as_view({
|
||||
RETIREMENT_RETRIEVE = AccountRetirementStatusView.as_view({
|
||||
'get': 'retrieve'
|
||||
})
|
||||
|
||||
RETIREMENT_UPDATE = AccountRetirementView.as_view({
|
||||
RETIREMENT_UPDATE = AccountRetirementStatusView.as_view({
|
||||
'patch': 'partial_update',
|
||||
})
|
||||
|
||||
RETIREMENT_POST = AccountRetirementView.as_view({
|
||||
'post': 'post',
|
||||
})
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
@@ -94,6 +99,11 @@ urlpatterns = [
|
||||
RETIREMENT_QUEUE,
|
||||
name='accounts_retirement_queue'
|
||||
),
|
||||
url(
|
||||
r'^v1/accounts/retire/$',
|
||||
RETIREMENT_POST,
|
||||
name='accounts_retire'
|
||||
),
|
||||
url(
|
||||
r'^v1/accounts/update_retirement_status/$',
|
||||
RETIREMENT_UPDATE,
|
||||
|
||||
Reference in New Issue
Block a user