From b2f4762dc465f308d198e76b663fedca176a4b73 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Fri, 25 May 2018 16:36:52 -0400 Subject: [PATCH] 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/. --- lms/djangoapps/edxnotes/api_urls.py | 10 +++ lms/djangoapps/edxnotes/helpers.py | 3 +- lms/djangoapps/edxnotes/tests.py | 118 +++++++++++++++++++++++++++- lms/djangoapps/edxnotes/views.py | 60 ++++++++++++++ lms/urls.py | 6 ++ 5 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 lms/djangoapps/edxnotes/api_urls.py diff --git a/lms/djangoapps/edxnotes/api_urls.py b/lms/djangoapps/edxnotes/api_urls.py new file mode 100644 index 0000000000..9b4b286402 --- /dev/null +++ b/lms/djangoapps/edxnotes/api_urls.py @@ -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"), +] diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index 68acdb3264..2826f6b053 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -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), diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 5e5a2b851b..e860f605a7 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -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 diff --git a/lms/djangoapps/edxnotes/views.py b/lms/djangoapps/edxnotes/views.py index 79d320bb70..5ef5284a21 100644 --- a/lms/djangoapps/edxnotes/views.py +++ b/lms/djangoapps/edxnotes/views.py @@ -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) diff --git a/lms/urls.py b/lms/urls.py index 65786b767d..bd5bad78e7 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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/',