diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f78800e34fb..580ed2e751c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,12 @@ Change Log Unreleased ~~~~~~~~~~ +[3.14.0] - 2021-06-10 +~~~~~~~~~~~~~~~~~~~~~ +* When an exam attempt is finished for the first time, mark all completable children in the exam as complete + in the Completion Service using the Instructor Service. If the Completion Service is not enabled, nothing + will happen. + [3.13.2] - 2021-06-09 ~~~~~~~~~~~~~~~~~~~~~ * Extend exam attempt API to return total time left in the attempt diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index b5eb54f76ec..de9b3fc001c 100644 --- a/edx_proctoring/__init__.py +++ b/edx_proctoring/__init__.py @@ -3,6 +3,6 @@ """ # Be sure to update the version number in edx_proctoring/package.json -__version__ = '3.13.2' +__version__ = '3.14.0' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/signals.py b/edx_proctoring/signals.py index 3faa32b06ee..9787a89c64f 100644 --- a/edx_proctoring/signals.py +++ b/edx_proctoring/signals.py @@ -7,6 +7,7 @@ from edx_proctoring import api, constants, models from edx_proctoring.backends import get_backend_provider +from edx_proctoring.runtime import get_runtime_service from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus, SoftwareSecureReviewStatus from edx_proctoring.utils import emit_event, locate_attempt_by_attempt_code @@ -105,6 +106,18 @@ def on_attempt_changed(sender, instance, signal, **kwargs): # pylint: disable=u # to archive it original = sender.objects.get(id=instance.id) + # if the exam was finished for the first time, we want to mark it as + # complete in the Completion Service. + # This functionality was added because regardless of submission status + # on individual problems, we want to mark the entire exam as complete + # when the exam is finished since there are no more actions a learner can take. + if not original.completed_at and instance.completed_at: + instructor_service = get_runtime_service('instructor') + if instructor_service: + username = instance.user.username + content_id = instance.proctored_exam.content_id + instructor_service.complete_student_attempt(username, content_id) + if original.status != instance.status: instance = original else: diff --git a/edx_proctoring/tests/test_services.py b/edx_proctoring/tests/test_services.py index 9433d7d6824..f674eb53926 100644 --- a/edx_proctoring/tests/test_services.py +++ b/edx_proctoring/tests/test_services.py @@ -137,6 +137,12 @@ def delete_student_attempt(self, student_identifier, course_id, content_id, requ raise UserNotFoundException return True + def complete_student_attempt(self, user_identifier, content_id): + """ + Mock implementation + """ + return True + def is_course_staff(self, user, course_id): """ Mocked implementation of is_course_staff diff --git a/edx_proctoring/tests/test_signals.py b/edx_proctoring/tests/test_signals.py index c726b1e6ba3..fb4629e58d9 100644 --- a/edx_proctoring/tests/test_signals.py +++ b/edx_proctoring/tests/test_signals.py @@ -1,19 +1,23 @@ """ Tests for signals.py """ - from unittest.mock import patch +import ddt from httmock import HTTMock -from django.db.models.signals import pre_delete +from django.db.models.signals import pre_delete, pre_save +from edx_proctoring.api import update_attempt_status from edx_proctoring.models import ProctoredExam, ProctoredExamStudentAttempt from edx_proctoring.signals import on_attempt_changed +from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus +from edx_proctoring.tests.test_services import MockInstructorService from .utils import ProctoredExamTestCase +@ddt.ddt class SignalTests(ProctoredExamTestCase): """ Tests for signals.py @@ -22,26 +26,44 @@ def setUp(self): super().setUp() self.backend_name = 'software_secure' self.proctored_exam = ProctoredExam.objects.create( - course_id='x/y/z', content_id=self.content_id, exam_name=self.exam_name, - time_limit_mins=self.default_time_limit, external_id=self.external_id, - backend=self.backend_name + course_id='x/y/z', content_id=self.content_id, exam_name=self.exam_name, + time_limit_mins=self.default_time_limit, external_id=self.external_id, + backend=self.backend_name + ) + self.attempt = ProctoredExamStudentAttempt.objects.create( + proctored_exam=self.proctored_exam, user=self.user, attempt_code='12345', + external_id='abcde' ) pre_delete.connect(on_attempt_changed, sender=ProctoredExamStudentAttempt) + pre_save.connect(on_attempt_changed, sender=ProctoredExamStudentAttempt) def tearDown(self): super().tearDown() pre_delete.disconnect() + pre_save.disconnect() @patch('logging.Logger.error') def test_backend_fails_to_delete_attempt(self, logger_mock): - attempt = ProctoredExamStudentAttempt.objects.create( - proctored_exam=self.proctored_exam, user=self.user, attempt_code='12345', - external_id='abcde' - ) - # If there is no response from the backend, assert that it is logged correctly with HTTMock(None): - attempt.delete_exam_attempt() + self.attempt.delete_exam_attempt() log_format_string = ('Failed to remove attempt_id=%s from backend=%s') logger_mock.assert_any_call(log_format_string, 1, self.backend_name) + + @ddt.data(None, MockInstructorService()) + @patch('edx_proctoring.signals.get_runtime_service') + @patch('edx_proctoring.tests.test_services.MockInstructorService.complete_student_attempt') + def test_pre_save_complete_student_attempt( + self, runtime_return, mock_complete_student_attempt, mock_get_runtime_service + ): + """ + Ensures the complete_student_attempt function will not be called if the instructor service + is not found. + """ + mock_get_runtime_service.return_value = runtime_return + update_attempt_status(self.attempt.id, ProctoredExamStudentAttemptStatus.submitted) + if runtime_return: + mock_complete_student_attempt.assert_called_once_with(self.user.username, self.content_id) + else: + mock_complete_student_attempt.assert_not_called() diff --git a/package.json b/package.json index 93e1cb30f32..8b7e19ad28a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@edx/edx-proctoring", "//": "Be sure to update the version number in edx_proctoring/__init__.py", "//": "Note that the version format is slightly different than that of the Python version when using prereleases.", - "version": "3.13.2", + "version": "3.14.0", "main": "edx_proctoring/static/index.js", "scripts": { "test": "gulp test"