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:
Pooja Kulkarni
2021-04-08 23:20:12 +05:30
committed by GitHub
parent 709b8217e1
commit 86bfcea19c
11 changed files with 270 additions and 19 deletions

View 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

View 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'
),
]

View 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
}
)

View File

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

View File

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

View File

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