diff --git a/lms/djangoapps/ora_staff_grader/ora_api.py b/lms/djangoapps/ora_staff_grader/ora_api.py index 9adcfd0646..852f1a707b 100644 --- a/lms/djangoapps/ora_staff_grader/ora_api.py +++ b/lms/djangoapps/ora_staff_grader/ora_api.py @@ -154,3 +154,19 @@ def delete_submission_lock(request, usage_id, submission_uuid): raise XBlockInternalError(context={"handler": handler_name}) return json.loads(response.content) + + +def batch_delete_submission_locks(request, usage_id, submission_uuids): + """ + Batch delete a list of submission locks. Limited only to those in the list the user owns. + + Returns: none + """ + handler_name = "batch_delete_submission_lock" + body = {"submission_uuids": submission_uuids} + + response = call_xblock_json_handler(request, usage_id, handler_name, body) + + # Errors should raise a blanket exception. Otherwise body is empty, 200 is implicit success + if response.status_code != 200: + raise XBlockInternalError(context={"handler": handler_name}) diff --git a/lms/djangoapps/ora_staff_grader/ora_staff_grader.postman_collection.json b/lms/djangoapps/ora_staff_grader/ora_staff_grader.postman_collection.json index 34b0fa027e..22a6573a99 100644 --- a/lms/djangoapps/ora_staff_grader/ora_staff_grader.postman_collection.json +++ b/lms/djangoapps/ora_staff_grader/ora_staff_grader.postman_collection.json @@ -1454,6 +1454,173 @@ } ] }, + { + "name": "Bulk Unlock", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-CSRFToken", + "value": "{{csrftoken}}", + "type": "text" + } + ], + "url": { + "raw": "{{protocol}}://{{lms_url}}/api/ora_staff_grader{{mock}}/submission/lock?oraLocation={{block_id_encoded}}&submissionUUID={{submission_id}}", + "protocol": "{{protocol}}", + "host": [ + "{{lms_url}}" + ], + "path": [ + "api", + "ora_staff_grader{{mock}}", + "submission", + "lock" + ], + "query": [ + { + "key": "oraLocation", + "value": "{{block_id_encoded}}", + "description": "ORA location" + }, + { + "key": "submissionUUID", + "value": "{{submission_id}}", + "description": "Individual submission UUID" + }, + { + "key": "oraLocation", + "value": "{{team_block_id_encoded}}", + "description": "Team ORA location", + "disabled": true + }, + { + "key": "submissionUUID", + "value": "{{team_submission_id}}", + "description": "Team submission UUID", + "disabled": true + } + ] + } + }, + "response": [ + { + "name": "Bulk Unlock Success", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "X-CSRFToken", + "value": "{{csrftoken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"submissionUUIDs\": [{{submission_id}}]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{lms_url}}/api/ora_staff_grader{{mock}}/submission/batch/unlock?oraLocation={{block_id_encoded}}", + "protocol": "{{protocol}}", + "host": [ + "{{lms_url}}" + ], + "path": [ + "api", + "ora_staff_grader{{mock}}", + "submission", + "batch", + "unlock" + ], + "query": [ + { + "key": "oraLocation", + "value": "{{block_id_encoded}}", + "description": "ORA location" + }, + { + "key": "oraLocation", + "value": "{{team_block_id_encoded}}", + "description": "Team ORA location", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Date", + "value": "Thu, 31 Mar 2022 20:52:57 GMT" + }, + { + "key": "Server", + "value": "WSGIServer/0.2 CPython/3.8.10" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "WWW-Authenticate", + "value": "JWT realm=\"api\"" + }, + { + "key": "Vary", + "value": "Accept, Accept-Language, Origin, Cookie" + }, + { + "key": "Allow", + "value": "GET, POST, HEAD, OPTIONS" + }, + { + "key": "Server-Timing", + "value": "TimerPanel_utime;dur=37.91900000000048;desc=\"User CPU time\", TimerPanel_stime;dur=1.1920000000000819;desc=\"System CPU time\", TimerPanel_total;dur=39.11100000000056;desc=\"Total CPU time\", TimerPanel_total_time;dur=65.8259391784668;desc=\"Elapsed time\", SQLPanel_sql_time;dur=0;desc=\"SQL 0 queries\"" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Language", + "value": "en" + }, + { + "key": "Content-Length", + "value": "58" + }, + { + "key": "Set-Cookie", + "value": "lms_sessionid=\"\"; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; SameSite=Lax" + } + ], + "cookie": [ + { + "expires": "Invalid Date" + } + ], + "body": "{}" + } + ] + }, { "name": "Update Grade Data", "event": [ diff --git a/lms/djangoapps/ora_staff_grader/tests/test_views.py b/lms/djangoapps/ora_staff_grader/tests/test_views.py index e50c56a1fb..41ebabd4bf 100644 --- a/lms/djangoapps/ora_staff_grader/tests/test_views.py +++ b/lms/djangoapps/ora_staff_grader/tests/test_views.py @@ -548,6 +548,99 @@ class TestSubmissionLockView(BaseViewTest): assert json.loads(response.content) == {"error": ERR_UNKNOWN} +class TestBatchSubmissionLockView(BaseViewTest): + """ + Tests for the /lock view, locking or unlocking a submission for grading + """ + + view_name = "ora-staff-grader:batch-unlock" + + test_submission_uuids = [str(uuid4()) for _ in range(3)] + test_anon_user_id = "anon-user-id" + test_other_anon_user_id = "anon-user-id-2" + test_timestamp = "2020-08-29T02:14:00-04:00" + + def setUp(self): + super().setUp() + + # Batch unlock includes the ORA location in the params... + self.test_request_params = { + PARAM_ORA_LOCATION: self.ora_usage_key, + } + + # and a list of submission UUIDs in the body + self.test_request_body = { + "submissionUUIDs": self.test_submission_uuids + } + + self.log_in() + + def batch_unlock(self, params, body): + """Wrapper for easier calling of 'batch_unlock'""" + return self.client.post(self.url_with_params(params), body, format="json") + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock_invalid_ora(self, mock_batch_delete): + """An invalid ORA returns a 400""" + self.test_request_params[PARAM_ORA_LOCATION] = "not_a_real_location" + + response = self.batch_unlock(self.test_request_params, self.test_request_body) + + assert response.status_code == 400 + assert json.loads(response.content) == {"error": ERR_BAD_ORA_LOCATION} + mock_batch_delete.assert_not_called() + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock_missing_submisison_list(self, mock_batch_delete): + """An invalid ORA returns a 400""" + + response = self.batch_unlock(self.test_request_params, {}) + + assert response.status_code == 400 + assert json.loads(response.content) == {"error": ERR_MISSING_PARAM} + mock_batch_delete.assert_not_called() + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock(self, mock_batch_delete): + """POST tries to delete a group of submission locks. Success returns empty 200""" + mock_batch_delete.return_value = None + + response = self.batch_unlock(self.test_request_params, self.test_request_body) + + assert response.status_code == 200 + assert json.loads(response.content) == {} + mock_batch_delete.assert_called() + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock_internal_error(self, mock_batch_delete): + """Any internal errors to this API get surfaced as an internal error""" + mock_batch_delete.side_effect = XBlockInternalError( + context={"handler": "batch_delete_submission_locks"} + ) + + response = self.batch_unlock(self.test_request_params, self.test_request_body) + + assert response.status_code == 500 + assert json.loads(response.content) == { + "error": ERR_INTERNAL, + "handler": "batch_delete_submission_locks", + } + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock_generic_exception( + self, + mock_batch_delete, + ): + """In the even more unlikely event of an unhandled error, shrug exuberantly""" + # Mock a generic error inside the API + mock_batch_delete.side_effect = Exception() + + response = self.batch_unlock(self.test_request_params, self.test_request_body) + + assert response.status_code == 500 + assert json.loads(response.content) == {"error": ERR_UNKNOWN} + + class TestUpdateGradeView(BaseViewTest): """ Tests for updating a grade for a submission diff --git a/lms/djangoapps/ora_staff_grader/urls.py b/lms/djangoapps/ora_staff_grader/urls.py index ed100114e0..c5e3a2a837 100644 --- a/lms/djangoapps/ora_staff_grader/urls.py +++ b/lms/djangoapps/ora_staff_grader/urls.py @@ -7,6 +7,7 @@ from django.urls import path from lms.djangoapps.ora_staff_grader.views import ( InitializeView, + SubmissionBatchUnlockView, SubmissionFetchView, SubmissionLockView, SubmissionStatusFetchView, @@ -20,6 +21,7 @@ app_name = "ora-staff-grader" urlpatterns += [ path("mock/", include("lms.djangoapps.ora_staff_grader.mock.urls")), path("initialize", InitializeView.as_view(), name="initialize"), + path("submission/batch/unlock", SubmissionBatchUnlockView.as_view(), name="batch-unlock"), path( "submission/status", SubmissionStatusFetchView.as_view(), diff --git a/lms/djangoapps/ora_staff_grader/views.py b/lms/djangoapps/ora_staff_grader/views.py index bdad314699..707350fd7b 100644 --- a/lms/djangoapps/ora_staff_grader/views.py +++ b/lms/djangoapps/ora_staff_grader/views.py @@ -30,10 +30,12 @@ from lms.djangoapps.ora_staff_grader.errors import ( InternalErrorResponse, LockContestedError, LockContestedResponse, + MissingParamResponse, UnknownErrorResponse, XBlockInternalError, ) from lms.djangoapps.ora_staff_grader.ora_api import ( + batch_delete_submission_locks, check_submission_lock, claim_submission_lock, delete_submission_lock, @@ -428,3 +430,52 @@ class SubmissionLockView(StaffGraderBaseView): except Exception as ex: log.exception(ex) return UnknownErrorResponse() + + +class SubmissionBatchUnlockView(StaffGraderBaseView): + """ + POST delete a group of submission locks, limited to just those in the list that the user owns. + + Params: + - ora_location (str/UsageID): ORA location for XBlock handling + + Body: + - submissionUUIDs (UUID): A list of submission/team submission UUIDS to lock/unlock + + Response: None + + Errors: + - MissingParamResponse (HTTP 400) for missing params + - XBlockInternalError (HTTP 500) for an issue within ORA + """ + + @require_params([PARAM_ORA_LOCATION]) + def post(self, request, ora_location, *args, **kwargs): + """Batch delete submission locks""" + try: + # Validate ORA location + UsageKey.from_string(ora_location) + + # Pull submission UUIDs list from request body + submission_uuids = request.data.get('submissionUUIDs') + if not isinstance(submission_uuids, list): + return MissingParamResponse() + batch_delete_submission_locks(request, ora_location, submission_uuids) + + # Return empty response + return Response({}) + + # Catch bad ORA location + except (InvalidKeyError, ItemNotFoundError): + log.error(f"Bad ORA location provided: {ora_location}") + return BadOraLocationResponse() + + # Issues with the XBlock handlers + except XBlockInternalError as ex: + log.error(ex) + return InternalErrorResponse(context=ex.context) + + # Blanket exception handling + except Exception as ex: + log.exception(ex) + return UnknownErrorResponse() diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 676315c03f..0c5a951d51 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -715,7 +715,7 @@ openedx-events==0.8.1 # via -r requirements/edx/base.in openedx-filters==0.5.0 # via -r requirements/edx/base.in -ora2==4.0.6 +ora2==4.1.0 # via -r requirements/edx/base.in packaging==21.3 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index ebce84fd53..6872172e98 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -953,7 +953,7 @@ openedx-events==0.8.1 # via -r requirements/edx/testing.txt openedx-filters==0.5.0 # via -r requirements/edx/testing.txt -ora2==4.0.6 +ora2==4.1.0 # via -r requirements/edx/testing.txt packaging==21.3 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 9298e491e7..90fb9c1686 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -901,7 +901,7 @@ openedx-events==0.8.1 # via -r requirements/edx/base.txt openedx-filters==0.5.0 # via -r requirements/edx/base.txt -ora2==4.0.6 +ora2==4.1.0 # via -r requirements/edx/base.txt packaging==21.3 # via