Add API endpoint for retiring a user from EdxNotes (edx-notes-api)
This also creates an appropriate course-agnostic location for notes APIs, under /api/edxnotes/v1/.
This commit is contained in:
10
lms/djangoapps/edxnotes/api_urls.py
Normal file
10
lms/djangoapps/edxnotes/api_urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
API URLs for EdxNotes
|
||||
"""
|
||||
from django.conf.urls import url
|
||||
|
||||
from edxnotes import views
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^retire_user$", views.RetireUserView.as_view(), name="edxnotes_retire_user"),
|
||||
]
|
||||
@@ -129,9 +129,8 @@ def delete_all_notes_for_user(user, user_id):
|
||||
:return: response (requests) object
|
||||
|
||||
Raises:
|
||||
RequestException - when notes api is not found/misconfigured.
|
||||
EdxNotesServiceUnavailable - when notes api is not found/misconfigured.
|
||||
"""
|
||||
# TODO:PLAT-2001 add to master LMS endpoint.
|
||||
url = get_internal_endpoint()
|
||||
headers = {
|
||||
"x-annotator-auth-token": get_edxnotes_id_token(user),
|
||||
|
||||
@@ -29,7 +29,9 @@ from edxnotes import helpers
|
||||
from edxnotes.decorators import edxnotes
|
||||
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
|
||||
from edxnotes.plugins import EdxNotesTab
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus
|
||||
from openedx.core.lib.token_utils import JwtBuilder
|
||||
from student.tests.factories import CourseEnrollmentFactory, SuperuserFactory, UserFactory
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
@@ -1147,6 +1149,120 @@ class EdxNotesViewsTest(ModuleStoreTestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
@attr(shard=3)
|
||||
class EdxNotesRetireAPITest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for EdxNotes retirement API.
|
||||
"""
|
||||
def setUp(self):
|
||||
ClientFactory(name="edx-notes")
|
||||
super(EdxNotesRetireAPITest, self).setUp()
|
||||
|
||||
# setup relevant states
|
||||
RetirementState.objects.create(state_name='PENDING', state_execution_order=1)
|
||||
self.retire_notes_state = RetirementState.objects.create(state_name='RETIRING_NOTES', state_execution_order=11)
|
||||
self.something_complete_state = RetirementState.objects.create(
|
||||
state_name='SOMETHING_COMPLETE',
|
||||
state_execution_order=22,
|
||||
)
|
||||
|
||||
# setup retired user with retirement status
|
||||
self.retired_user = UserFactory()
|
||||
self.retirement = UserRetirementStatus.create_retirement(self.retired_user)
|
||||
self.retirement.current_state = self.retire_notes_state
|
||||
self.retirement.save()
|
||||
|
||||
# setup another normal user which should not be allowed to retire any notes
|
||||
self.normal_user = UserFactory()
|
||||
|
||||
# setup superuser for making API calls
|
||||
self.superuser = SuperuserFactory()
|
||||
|
||||
self.retire_user_url = reverse("edxnotes_retire_user")
|
||||
|
||||
def _build_jwt_headers(self, user):
|
||||
"""
|
||||
Helper function for creating headers for the JWT authentication.
|
||||
"""
|
||||
token = JwtBuilder(user).build_token([])
|
||||
headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
|
||||
return headers
|
||||
|
||||
@patch("edxnotes.helpers.requests.delete", autospec=True)
|
||||
def test_retire_user_success(self, mock_get):
|
||||
"""
|
||||
Tests that 204 response is received on success.
|
||||
"""
|
||||
mock_get.return_value.content = ''
|
||||
mock_get.return_value.status_code = 204
|
||||
headers = self._build_jwt_headers(self.superuser)
|
||||
response = self.client.post(
|
||||
self.retire_user_url,
|
||||
data=json.dumps({'username': self.retired_user.username}),
|
||||
content_type='application/json',
|
||||
**headers
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
def test_retire_user_normal_user_not_allowed(self):
|
||||
"""
|
||||
Tests that 403 response is received when the requester is not allowed to call the retirement endpoint.
|
||||
"""
|
||||
headers = self._build_jwt_headers(self.normal_user)
|
||||
response = self.client.post(
|
||||
self.retire_user_url,
|
||||
data=json.dumps({'username': self.retired_user.username}),
|
||||
content_type='application/json',
|
||||
**headers
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_retire_user_status_not_found(self):
|
||||
"""
|
||||
Tests that 404 response is received if the retirement user status is not found.
|
||||
"""
|
||||
headers = self._build_jwt_headers(self.superuser)
|
||||
response = self.client.post(
|
||||
self.retire_user_url,
|
||||
data=json.dumps({'username': 'username_does_not_exist'}),
|
||||
content_type='application/json',
|
||||
**headers
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_retire_user_wrong_state(self):
|
||||
"""
|
||||
Tests that 405 response is received if the retirement user status is currently in a state which cannot be acted
|
||||
on.
|
||||
"""
|
||||
# Set state to the _COMPLETE version of an arbitrary "SOMETHING" state.
|
||||
self.retirement.current_state = self.something_complete_state
|
||||
self.retirement.save()
|
||||
headers = self._build_jwt_headers(self.superuser)
|
||||
response = self.client.post(
|
||||
self.retire_user_url,
|
||||
data=json.dumps({'username': self.retired_user.username}),
|
||||
content_type='application/json',
|
||||
**headers
|
||||
)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
@patch("edxnotes.helpers.delete_all_notes_for_user", autospec=True)
|
||||
def test_retire_user_downstream_unavailable(self, mock_delete_all_notes_for_user):
|
||||
"""
|
||||
Tests that 500 response is received if the downstream (i.e. the EdxNotes IDA) is unavailable.
|
||||
"""
|
||||
mock_delete_all_notes_for_user.side_effect = EdxNotesServiceUnavailable
|
||||
headers = self._build_jwt_headers(self.superuser)
|
||||
response = self.client.post(
|
||||
self.retire_user_url,
|
||||
data=json.dumps({'username': self.retired_user.username}),
|
||||
content_type='application/json',
|
||||
**headers
|
||||
)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
|
||||
@attr(shard=3)
|
||||
@skipUnless(settings.FEATURES["ENABLE_EDXNOTES"], "EdxNotes feature needs to be enabled.")
|
||||
@ddt.ddt
|
||||
|
||||
@@ -11,23 +11,32 @@ from django.http import Http404, HttpResponse
|
||||
from django.views.decorators.http import require_GET
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from six import text_type
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
from courseware.courses import get_course_with_access
|
||||
from courseware.model_data import FieldDataCache
|
||||
from courseware.module_render import get_module_for_descriptor
|
||||
from edx_rest_framework_extensions.authentication import JwtAuthentication
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
|
||||
from edxnotes.helpers import (
|
||||
DEFAULT_PAGE,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
NoteJSONEncoder,
|
||||
delete_all_notes_for_user,
|
||||
get_course_position,
|
||||
get_edxnotes_id_token,
|
||||
get_notes,
|
||||
is_feature_enabled,
|
||||
)
|
||||
from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser
|
||||
from openedx.core.djangoapps.user_api.models import RetirementStateError, UserRetirementStatus
|
||||
from util.json_request import JsonResponse, JsonResponseBadRequest
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -205,3 +214,54 @@ def edxnotes_visibility(request, course_id):
|
||||
"Could not decode request body as JSON and find a boolean visibility field: '%s'", request.body
|
||||
)
|
||||
return JsonResponseBadRequest()
|
||||
|
||||
|
||||
class RetireUserView(APIView):
|
||||
"""
|
||||
**Use Cases**
|
||||
|
||||
A superuser or the user with the username specified by settings.RETIREMENT_SERVICE_WORKER_USERNAME can "retire"
|
||||
the user's data from the edx-notes-api (aka. Edxnotes) service, which will delete all notes (aka. annotations)
|
||||
the user has made.
|
||||
|
||||
**Example Requests**
|
||||
|
||||
* POST /api/edxnotes/v1/retire_user/
|
||||
{
|
||||
"username": "an_original_username"
|
||||
}
|
||||
|
||||
**Example Response**
|
||||
|
||||
* HTTP 204 with empty body, indicating success.
|
||||
|
||||
* HTTP 404 with empty body. This can happen when:
|
||||
- The requested user does not exist in the retirement queue.
|
||||
|
||||
* HTTP 405 (Method Not Allowed) with error message. This can happen when:
|
||||
- RetirementStateError is thrown: the user is currently in a retirement state which cannot be acted on, such
|
||||
as a terminal or completed state.
|
||||
|
||||
* HTTP 500 with error message. This can happen when:
|
||||
- EdxNotesServiceUnavailable is thrown: the edx-notes-api IDA is not available.
|
||||
"""
|
||||
|
||||
authentication_classes = (JwtAuthentication,)
|
||||
permission_classes = (permissions.IsAuthenticated, CanRetireUser)
|
||||
|
||||
def post(self, request):
|
||||
"""
|
||||
Implements the retirement endpoint.
|
||||
"""
|
||||
username = request.data['username']
|
||||
try:
|
||||
retirement = UserRetirementStatus.get_retirement_for_retirement_action(username)
|
||||
delete_all_notes_for_user(retirement.user, retirement.user.id)
|
||||
except UserRetirementStatus.DoesNotExist:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
except RetirementStateError as exc:
|
||||
return Response(text_type(exc), status=status.HTTP_405_METHOD_NOT_ALLOWED)
|
||||
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)
|
||||
|
||||
@@ -626,6 +626,12 @@ urlpatterns += [
|
||||
name='edxnotes_endpoints',
|
||||
),
|
||||
|
||||
# Student Notes API
|
||||
url(
|
||||
r'^api/edxnotes/v1/',
|
||||
include('edxnotes.api_urls'),
|
||||
),
|
||||
|
||||
# Branding API
|
||||
url(
|
||||
r'^api/branding/v1/',
|
||||
|
||||
Reference in New Issue
Block a user