EDUCATOR-2771 | Adds an LMS API endpoint to retire a user account.

This commit is contained in:
Alex Dusenbery
2018-04-25 16:12:32 -04:00
committed by Alex Dusenbery
parent 0ffb021c65
commit 2b48649a9e
7 changed files with 551 additions and 50 deletions

View File

@@ -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),
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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='')

View File

@@ -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

View File

@@ -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,