feat: Django app to allow user retirement via API (#25800)
This adds a new django app to allow the GDPR user retirement via Open edX's REST API. Prior to this the only way to trigger the user retirement was either by the user themself clicking "Delete my account" in the account setting page or via creating a User Retirement request by admin. With these changes, the user retirement process can be triggered using REST API.
This commit is contained in:
0
lms/djangoapps/bulk_user_retirement/__init__.py
Normal file
0
lms/djangoapps/bulk_user_retirement/__init__.py
Normal file
121
lms/djangoapps/bulk_user_retirement/tests/test_views.py
Normal file
121
lms/djangoapps/bulk_user_retirement/tests/test_views.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Test cases for GDPR User Retirement Views
|
||||
"""
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APIClient, APITestCase
|
||||
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
|
||||
|
||||
class BulkUserRetirementViewTests(APITestCase):
|
||||
"""
|
||||
Tests the bulk user retirement api
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.user1 = UserFactory.create(
|
||||
username='testuser1',
|
||||
email='test1@example.com',
|
||||
password='test1_password',
|
||||
profile__name="Test User1"
|
||||
)
|
||||
self.client.login(username=self.user1.username, password='test1_password')
|
||||
self.user2 = UserFactory.create(
|
||||
username='testuser2',
|
||||
email='test2@example.com',
|
||||
password='test2_password',
|
||||
profile__name="Test User2"
|
||||
)
|
||||
self.client.login(username=self.user2.username, password='test2_password')
|
||||
self.user3 = UserFactory.create(
|
||||
username='testuser3',
|
||||
email='test3@example.com',
|
||||
password='test3_password',
|
||||
profile__name="Test User3"
|
||||
)
|
||||
self.user4 = UserFactory.create(
|
||||
username='testuser4',
|
||||
email='test4@example.com',
|
||||
password='test4_password',
|
||||
profile__name="Test User4"
|
||||
)
|
||||
RetirementState.objects.create(
|
||||
state_name='PENDING',
|
||||
state_execution_order=1,
|
||||
is_dead_end_state=False,
|
||||
required=True
|
||||
)
|
||||
self.pending_state = RetirementState.objects.get(state_name='PENDING')
|
||||
self.client.force_authenticate(user=self.user1)
|
||||
|
||||
def test_gdpr_user_retirement_api(self):
|
||||
user_retirement_url = reverse('bulk_retirement_api')
|
||||
expected_response = {
|
||||
'successful_user_retirements': [self.user2.username],
|
||||
'failed_user_retirements': []
|
||||
}
|
||||
with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username):
|
||||
response = self.client.post(user_retirement_url, {"usernames": self.user2.username})
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected_response
|
||||
|
||||
retirement_status = UserRetirementStatus.objects.get(user__username=self.user2.username)
|
||||
assert retirement_status.current_state == self.pending_state
|
||||
|
||||
def test_retirement_for_non_existing_users(self):
|
||||
user_retirement_url = reverse('bulk_retirement_api')
|
||||
expected_response = {
|
||||
'successful_user_retirements': [],
|
||||
'failed_user_retirements': ["non_existing_user"]
|
||||
}
|
||||
with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username):
|
||||
response = self.client.post(user_retirement_url, {"usernames": "non_existing_user"})
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected_response
|
||||
|
||||
def test_retirement_for_multiple_users(self):
|
||||
user_retirement_url = reverse('bulk_retirement_api')
|
||||
expected_response = {
|
||||
'successful_user_retirements': [self.user3.username, self.user4.username],
|
||||
'failed_user_retirements': []
|
||||
}
|
||||
with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username):
|
||||
response = self.client.post(user_retirement_url, {
|
||||
"usernames": '{user1},{user2}'.format(user1=self.user3.username, user2=self.user4.username)
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected_response
|
||||
|
||||
retirement_status_1 = UserRetirementStatus.objects.get(user__username=self.user3.username)
|
||||
assert retirement_status_1.current_state == self.pending_state
|
||||
|
||||
retirement_status_2 = UserRetirementStatus.objects.get(user__username=self.user4.username)
|
||||
assert retirement_status_2.current_state == self.pending_state
|
||||
|
||||
def test_retirement_for_multiple_users_with_some_nonexisting_users(self):
|
||||
user_retirement_url = reverse('bulk_retirement_api')
|
||||
expected_response = {
|
||||
'successful_user_retirements': [self.user3.username, self.user4.username],
|
||||
'failed_user_retirements': ['non_existing_user']
|
||||
}
|
||||
with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username):
|
||||
response = self.client.post(user_retirement_url, {
|
||||
"usernames": '{user1},{user2}, non_existing_user'.format(
|
||||
user1=self.user3.username,
|
||||
user2=self.user4.username
|
||||
)
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.data == expected_response
|
||||
|
||||
retirement_status_1 = UserRetirementStatus.objects.get(user__username=self.user3.username)
|
||||
assert retirement_status_1.current_state == self.pending_state
|
||||
|
||||
retirement_status_2 = UserRetirementStatus.objects.get(user__username=self.user4.username)
|
||||
assert retirement_status_2.current_state == self.pending_state
|
||||
|
||||
def test_retirement_for_unauthorized_users(self):
|
||||
user_retirement_url = reverse('bulk_retirement_api')
|
||||
response = self.client.post(user_retirement_url, {"usernames": self.user2.username})
|
||||
assert response.status_code == 403
|
||||
16
lms/djangoapps/bulk_user_retirement/urls.py
Normal file
16
lms/djangoapps/bulk_user_retirement/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Defines the URL route for this app.
|
||||
"""
|
||||
|
||||
from django.conf.urls import url
|
||||
|
||||
from .views import BulkUsersRetirementView
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
r'v1/accounts/bulk_retire_users$',
|
||||
BulkUsersRetirementView.as_view(),
|
||||
name='bulk_retirement_api'
|
||||
),
|
||||
]
|
||||
77
lms/djangoapps/bulk_user_retirement/views.py
Normal file
77
lms/djangoapps/bulk_user_retirement/views.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
An API for retiring user accounts.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser
|
||||
from openedx.core.djangoapps.user_api.accounts.utils import create_retirement_request_and_deactivate_account
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BulkUsersRetirementView(APIView):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
Implementation for Bulk User Retirement API. Creates a retirement request
|
||||
for one or more users.
|
||||
|
||||
**Example Request**
|
||||
|
||||
POST /v1/accounts/bulk_retire_users {
|
||||
"usernames": "test_user1, test_user2"
|
||||
}
|
||||
|
||||
**POST Parameters**
|
||||
|
||||
A POST request can include the following parameter.
|
||||
|
||||
* usernames: Comma separated strings of usernames that should be retired.
|
||||
"""
|
||||
authentication_classes = (JwtAuthentication, )
|
||||
permission_classes = (permissions.IsAuthenticated, CanRetireUser)
|
||||
|
||||
def post(self, request, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Initiates the bulk retirement process for the given users.
|
||||
"""
|
||||
request_usernames = request.data.get('usernames')
|
||||
|
||||
if request_usernames:
|
||||
usernames_to_retire = [each_username.strip() for each_username in request_usernames.split(',')]
|
||||
else:
|
||||
usernames_to_retire = []
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
successful_user_retirements, failed_user_retirements = [], []
|
||||
|
||||
for username in usernames_to_retire:
|
||||
try:
|
||||
user_to_retire = User.objects.get(username=username)
|
||||
with transaction.atomic():
|
||||
create_retirement_request_and_deactivate_account(user_to_retire)
|
||||
|
||||
except User.DoesNotExist:
|
||||
log.exception('The user "{}" does not exist.'.format(username))
|
||||
failed_user_retirements.append(username)
|
||||
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
log.exception('500 error retiring account {}'.format(exc))
|
||||
failed_user_retirements.append(username)
|
||||
|
||||
successful_user_retirements = list(set(usernames_to_retire).difference(failed_user_retirements))
|
||||
|
||||
return Response(
|
||||
status=status.HTTP_200_OK,
|
||||
data={
|
||||
"successful_user_retirements": successful_user_retirements,
|
||||
"failed_user_retirements": failed_user_retirements
|
||||
}
|
||||
)
|
||||
@@ -905,6 +905,18 @@ FEATURES = {
|
||||
# .. toggle_creation_date: 2021-01-27
|
||||
# .. toggle_tickets: https://openedx.atlassian.net/browse/ENT-4022
|
||||
'ALLOW_ADMIN_ENTERPRISE_COURSE_ENROLLMENT_DELETION': False,
|
||||
|
||||
# .. toggle_name: FEATURES['ENABLE_BULK_USER_RETIREMENT']
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Set to True to enable bulk user retirement through REST API. This is disabled by
|
||||
# default.
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_creation_date: 2021-03-11
|
||||
# .. toggle_target_removal_date: None
|
||||
# .. toggle_warnings: None
|
||||
# .. toggle_tickets: 'https://openedx.atlassian.net/browse/OSPR-5290'
|
||||
'ENABLE_BULK_USER_RETIREMENT': False,
|
||||
}
|
||||
|
||||
# Specifies extra XBlock fields that should available when requested via the Course Blocks API
|
||||
@@ -3069,6 +3081,9 @@ INSTALLED_APPS = [
|
||||
# Database-backed Organizations App (http://github.com/edx/edx-organizations)
|
||||
'organizations',
|
||||
|
||||
# Bulk User Retirement
|
||||
'lms.djangoapps.bulk_user_retirement',
|
||||
|
||||
# management of user-triggered async tasks (course import/export, etc.)
|
||||
# This is only used by Studio, but is being added here because the
|
||||
# app-permissions script that assigns users to Django admin roles only runs
|
||||
|
||||
@@ -81,6 +81,8 @@ FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True
|
||||
|
||||
FEATURES['ENABLE_BULK_ENROLLMENT_VIEW'] = True
|
||||
|
||||
FEATURES['ENABLE_BULK_USER_RETIREMENT'] = True
|
||||
|
||||
DEFAULT_MOBILE_AVAILABLE = True
|
||||
|
||||
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
|
||||
|
||||
@@ -998,3 +998,9 @@ urlpatterns += [
|
||||
urlpatterns += [
|
||||
url(r'^api/course_experience/', include('openedx.features.course_experience.api.v1.urls')),
|
||||
]
|
||||
|
||||
# Bulk User Retirement API urls
|
||||
if settings.FEATURES.get('ENABLE_BULK_USER_RETIREMENT'):
|
||||
urlpatterns += [
|
||||
url(r'', include('lms.djangoapps.bulk_user_retirement.urls')),
|
||||
]
|
||||
|
||||
@@ -197,7 +197,7 @@ class TestDeactivateLogout(RetirementTestCase):
|
||||
def build_post(self, password):
|
||||
return {'password': password}
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.views.retire_dot_oauth2_models')
|
||||
@mock.patch('openedx.core.djangoapps.user_api.accounts.utils.retire_dot_oauth2_models')
|
||||
def test_user_can_deactivate_self(self, mock_retire_dot):
|
||||
"""
|
||||
Verify a user calling the deactivation endpoint logs out the user, deletes all their SSO tokens,
|
||||
|
||||
@@ -11,14 +11,19 @@ from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH
|
||||
from completion.models import BlockCompletion
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
from social_django.models import UserSocialAuth
|
||||
|
||||
from common.djangoapps.third_party_auth.config.waffle import ENABLE_MULTIPLE_SSO_ACCOUNTS_ASSOCIATION_TO_SAML_USER
|
||||
from common.djangoapps.student.models import AccountRecovery, Registration, get_retired_email_by_email
|
||||
from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models
|
||||
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
|
||||
from openedx.core.djangoapps.theming.helpers import get_config_value_from_site_or_settings, get_current_site
|
||||
from openedx.core.djangoapps.user_api.config.waffle import ENABLE_MULTIPLE_USER_ENTERPRISES_FEATURE
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from ..models import UserRetirementStatus
|
||||
|
||||
ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature'
|
||||
|
||||
|
||||
@@ -206,3 +211,28 @@ def is_multiple_sso_accounts_association_to_saml_user_enabled():
|
||||
Boolean value representing switch status
|
||||
"""
|
||||
return ENABLE_MULTIPLE_SSO_ACCOUNTS_ASSOCIATION_TO_SAML_USER.is_enabled()
|
||||
|
||||
|
||||
def create_retirement_request_and_deactivate_account(user):
|
||||
"""
|
||||
Adds user to retirement queue, unlinks social auth accounts, changes user passwords
|
||||
and delete tokens and activation keys
|
||||
"""
|
||||
# Add user to retirement queue.
|
||||
UserRetirementStatus.create_retirement(user)
|
||||
|
||||
# Unlink LMS social auth accounts
|
||||
UserSocialAuth.objects.filter(user_id=user.id).delete()
|
||||
|
||||
# Change LMS password & email
|
||||
user.email = get_retired_email_by_email(user.email)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
# TODO: Unlink social accounts & change password on each IDA.
|
||||
# Remove the activation keys sent by email to the user for account activation.
|
||||
Registration.objects.filter(user=user).delete()
|
||||
|
||||
# Delete OAuth tokens associated with the user.
|
||||
retire_dot_oauth2_models(user)
|
||||
AccountRecovery.retire_recovery_email(user.id)
|
||||
|
||||
@@ -34,7 +34,6 @@ from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSet
|
||||
from social_django.models import UserSocialAuth
|
||||
from wiki.models import ArticleRevision
|
||||
from wiki.models.pluginbase import RevisionPluginRevision
|
||||
|
||||
@@ -48,7 +47,6 @@ from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
|
||||
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_authn.exceptions import AuthFailedError
|
||||
from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models
|
||||
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
|
||||
from openedx.core.lib.api.parsers import MergePatchParser
|
||||
from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable=unused-import
|
||||
@@ -81,6 +79,7 @@ from .api import get_account_settings, update_account_settings
|
||||
from .permissions import CanDeactivateUser, CanReplaceUsername, CanRetireUser
|
||||
from .serializers import UserRetirementPartnerReportSerializer, UserRetirementStatusSerializer
|
||||
from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS
|
||||
from .utils import create_retirement_request_and_deactivate_account
|
||||
|
||||
try:
|
||||
from coaching.api import has_ever_consented_to_coaching
|
||||
@@ -426,23 +425,8 @@ class DeactivateLogoutView(APIView):
|
||||
if verify_user_password_response.status_code != status.HTTP_204_NO_CONTENT:
|
||||
return verify_user_password_response
|
||||
with transaction.atomic():
|
||||
# Add user to retirement queue.
|
||||
UserRetirementStatus.create_retirement(request.user)
|
||||
# Unlink LMS social auth accounts
|
||||
UserSocialAuth.objects.filter(user_id=request.user.id).delete()
|
||||
# Change LMS password & email
|
||||
user_email = request.user.email
|
||||
request.user.email = get_retired_email_by_email(request.user.email)
|
||||
request.user.save()
|
||||
_set_unusable_password(request.user)
|
||||
|
||||
# TODO: Unlink social accounts & change password on each IDA.
|
||||
# Remove the activation keys sent by email to the user for account activation.
|
||||
Registration.objects.filter(user=request.user).delete()
|
||||
|
||||
# Delete OAuth tokens associated with the user.
|
||||
retire_dot_oauth2_models(request.user)
|
||||
AccountRecovery.retire_recovery_email(request.user.id)
|
||||
create_retirement_request_and_deactivate_account(request.user)
|
||||
|
||||
try:
|
||||
# Send notification email to user
|
||||
|
||||
Reference in New Issue
Block a user