From 2b48649a9ee1ecb04ffdbf845a4f82f6836e865b Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Wed, 25 Apr 2018 16:12:32 -0400 Subject: [PATCH] EDUCATOR-2771 | Adds an LMS API endpoint to retire a user account. --- cms/envs/common.py | 3 + common/djangoapps/student/models.py | 23 +- .../student/tests/test_retirement.py | 45 ++- .../user_api/accounts/tests/test_views.py | 329 ++++++++++++++++-- .../djangoapps/user_api/accounts/views.py | 183 +++++++++- .../djangoapps/user_api/tests/factories.py | 2 +- openedx/core/djangoapps/user_api/urls.py | 16 +- 7 files changed, 551 insertions(+), 50 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index 778babf0b6..c3c48572d4 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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), ) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index dcd68dd0c8..7425bc8bd5 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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) diff --git a/common/djangoapps/student/tests/test_retirement.py b/common/djangoapps/student/tests/test_retirement.py index 4147aee8b8..48c592f6ac 100644 --- a/common/djangoapps/student/tests/test_retirement.py +++ b/common/djangoapps/student/tests/test_retirement.py @@ -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 diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 29fc871b58..a49946d2c5 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -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) diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 0f2d962393..a8d786d602 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -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='') diff --git a/openedx/core/djangoapps/user_api/tests/factories.py b/openedx/core/djangoapps/user_api/tests/factories.py index a4dcda6e76..bffcf76e39 100644 --- a/openedx/core/djangoapps/user_api/tests/factories.py +++ b/openedx/core/djangoapps/user_api/tests/factories.py @@ -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 diff --git a/openedx/core/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py index 2ac78c660d..d2bcb29fca 100644 --- a/openedx/core/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -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,