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:
Troy Sankey
2018-05-25 16:36:52 -04:00
parent 827ea3f089
commit b2f4762dc4
5 changed files with 194 additions and 3 deletions

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

View File

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

View File

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

View File

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

View File

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