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')),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user