diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 602e80ddded..1729bf51de1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,10 @@ Change Log Unreleased ~~~~~~~~~~ +[4.1.3] - 2021-10-15 +~~~~~~~~~~~~~~~~~~~~ +* Always allow practice attempts to trigger grade/credit/certificate updates + [4.1.2] - 2021-10-07 ~~~~~~~~~~~~~~~~~~~~ * Instructor dashboard view should redirect to review url for PSI exam attempts diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index aff079fa43c..014b071b8d2 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__ = '4.1.2' +__version__ = '4.1.3' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/api.py b/edx_proctoring/api.py index ad4c57735d9..5d149877ebf 100644 --- a/edx_proctoring/api.py +++ b/edx_proctoring/api.py @@ -202,7 +202,6 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None 'is_proctored': is_proctored, 'is_practice_exam': is_practice_exam, 'external_id': external_id, - 'is_active': is_active, 'hide_after_due': hide_after_due, } ) @@ -1271,14 +1270,19 @@ def is_state_transition_legal(from_status, to_status, attempt_obj): return True -def can_update_credit_grades_and_email(attempts, to_status): +def can_update_credit_grades_and_email(attempts, to_status, exam): """ Determine and return as a boolean whether an attempt should trigger an update to credit and grades Arguments: attempts: a list of all currently active attempts for a given user_id and exam_id to_status: future status of a proctored exam attempt + exam: dict representation of exam object """ + # if the exam is a practice exam, always allow updates + if exam['is_practice_exam']: + return True + statuses = [attempt['status'] for attempt in attempts] if len(statuses) == 1: # if there is only one attempt for a user in an exam, it can be responsible for updates to credits and grades @@ -1426,7 +1430,7 @@ def update_attempt_status(attempt_id, to_status, all_attempts = get_user_attempts_by_exam_id(user_id, exam_id) - if can_update_credit_grades_and_email(all_attempts, to_status): + if can_update_credit_grades_and_email(all_attempts, to_status, exam): # see if the status transition this changes credit requirement status if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status): diff --git a/edx_proctoring/management/commands/tests/test_update_attempts_for_exam.py b/edx_proctoring/management/commands/tests/test_update_attempts_for_exam.py new file mode 100644 index 00000000000..73460112bfa --- /dev/null +++ b/edx_proctoring/management/commands/tests/test_update_attempts_for_exam.py @@ -0,0 +1,62 @@ +""" +Tests for the update_attempts_for_exam management command +""" + +from mock import patch + +from django.contrib.auth import get_user_model +from django.core.management import call_command + +from edx_proctoring.api import create_exam, create_exam_attempt, update_attempt_status +from edx_proctoring.models import ProctoredExamStudentAttempt +from edx_proctoring.runtime import set_runtime_service +from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus +from edx_proctoring.tests.test_services import MockCertificateService, MockCreditService, MockGradesService +from edx_proctoring.tests.utils import LoggedInTestCase + +User = get_user_model() + + +class TestUpdateAttemptsForExam(LoggedInTestCase): + """ + Coverage of the update_attempts_for_exam.py file + """ + + def setUp(self): + """ + Build up test data + """ + super().setUp() + set_runtime_service('credit', MockCreditService()) + set_runtime_service('grades', MockGradesService()) + set_runtime_service('certificates', MockCertificateService()) + + def test_run_command(self): + """ + Run the management command + """ + exam_id = create_exam( + course_id='foo', + content_id='bar', + exam_name='Test Exam 1', + time_limit_mins=90 + ) + + # create three users and three exam attempts + for i in range(3): + other_user = User.objects.create(username='otheruser'+str(i), password='test') + attempt_id = create_exam_attempt(exam_id, other_user.id, taking_as_proctored=True) + update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.verified) + + with patch.object(MockCreditService, 'set_credit_requirement_status') as mock_credit: + call_command( + 'update_attempts_for_exam', + batch_size=2, + sleep_time=0, + exam_id=exam_id + ) + mock_credit.assert_called() + + # make sure status stays the same + attempts = ProctoredExamStudentAttempt.objects.filter(status=ProctoredExamStudentAttemptStatus.verified) + self.assertEqual(len(attempts), 3) diff --git a/edx_proctoring/management/commands/update_attempts_for_exam.py b/edx_proctoring/management/commands/update_attempts_for_exam.py new file mode 100644 index 00000000000..45fcd40aaf1 --- /dev/null +++ b/edx_proctoring/management/commands/update_attempts_for_exam.py @@ -0,0 +1,78 @@ +""" +Django management command to re-trigger the status update of a ProctoredExamStudentAttempt +for a specific proctored exam. +""" + +import logging +import time + +from django.core.management.base import BaseCommand + +from edx_proctoring.api import update_attempt_status +from edx_proctoring.models import ProctoredExamStudentAttempt + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Django Management command to update is_attempt_active field on review models + """ + + def add_arguments(self, parser): + parser.add_argument( + '--batch_size', + action='store', + dest='batch_size', + type=int, + default=300, + help='Maximum number of attempts to process. ' + 'This helps avoid overloading the database while updating large amount of data.' + ) + parser.add_argument( + '--sleep_time', + action='store', + dest='sleep_time', + type=int, + default=10, + help='Sleep time in seconds between update of batches' + ) + + parser.add_argument( + '--exam_id', + action='store', + dest='exam_id', + type=int, + help='Exam ID to process attempts for.' + ) + + def handle(self, *args, **options): + """ + Management command entry point, simply call into the signal firing + """ + + batch_size = options['batch_size'] + sleep_time = options['sleep_time'] + exam_id = options['exam_id'] + + # get all attempts for specific exam id + exam_attempts = ProctoredExamStudentAttempt.objects.filter(proctored_exam_id=exam_id) + + attempt_count = 0 + + # for each of those attempts, get id and status + for attempt in exam_attempts: + current_status = attempt.status + current_id = attempt.id + + log.info( + 'Triggering attempt status update for attempt_id=%(attempt_id)s with status=%(status)s', + {'attempt_id': current_id, 'status': current_status} + ) + # need to use update_attempt_status because this function will trigger grade + credit updates + update_attempt_status(current_id, current_status) + attempt_count += 1 + + if attempt_count == batch_size: + attempt_count = 0 + time.sleep(sleep_time) diff --git a/edx_proctoring/tests/test_api.py b/edx_proctoring/tests/test_api.py index ff7044f4c80..455f557f13f 100644 --- a/edx_proctoring/tests/test_api.py +++ b/edx_proctoring/tests/test_api.py @@ -3279,6 +3279,76 @@ def test_grade_certificate_release_with_multiple_attempts( else: self.assertEqual(override, None) + def test_grade_certificate_override_practice_exam(self): + """ + Test that if a user has multiple attempts in a practice exam, grades/certificates/emails will + be updated for each attempt status update. + """ + set_runtime_service('grades', MockGradesService()) + # create first attempt, and reset attempt + first_attempt = self._create_exam_attempt( + self.onboarding_exam_id, + status=ProctoredExamStudentAttemptStatus.error, + is_practice_exam=True, + ) + reset_practice_exam(self.onboarding_exam_id, self.user_id, self.user) + first_attempt.refresh_from_db() + self.assertEqual(first_attempt.status, ProctoredExamStudentAttemptStatus.onboarding_reset) + # that should create a second attempt, set second attempt to rejected + second_attempt = ProctoredExamStudentAttempt.objects.get_current_exam_attempt( + self.onboarding_exam_id, self.user.id + ) + + credit_service = get_runtime_service('credit') + grades_service = get_runtime_service('grades') + content_id = first_attempt.proctored_exam.content_id + + grades_service.init_grade( + user_id=self.user.id, + course_key_or_id=self.course_id, + usage_key_or_id=content_id, + earned_all=5.0, + earned_graded=5.0 + ) + + # set status to rejected, credit should be failed, email should be sent, + # grades should have override + update_attempt_status(second_attempt.id, ProctoredExamStudentAttemptStatus.rejected) + credit_status = credit_service.get_credit_state(self.user.id, self.course_id) + self.assertEqual(len(credit_status['credit_requirement_status']), 1) + self.assertEqual( + credit_status['credit_requirement_status'][0]['status'], + 'failed' + ) + override = grades_service.get_subsection_grade_override( + user_id=self.user.id, + course_key_or_id=self.course_id, + usage_key_or_id=content_id + ) + self.assertDictEqual({ + 'earned_all': override.earned_all_override, + 'earned_graded': override.earned_graded_override + }, { + 'earned_all': 0.0, + 'earned_graded': 0.0 + }) + + # set status to verified, credit should be satisfied, email should be sent, + # grades should not have override + update_attempt_status(second_attempt.id, ProctoredExamStudentAttemptStatus.verified) + credit_status = credit_service.get_credit_state(self.user.id, self.course_id) + self.assertEqual(len(credit_status['credit_requirement_status']), 1) + self.assertEqual( + credit_status['credit_requirement_status'][0]['status'], + 'satisfied' + ) + override = grades_service.get_subsection_grade_override( + user_id=self.user.id, + course_key_or_id=self.course_id, + usage_key_or_id=content_id + ) + self.assertEqual(override, None) + def test_create_exam_attempt_empty_string(self): """ Assert that exam attempt creation does not fail if the user's profile name is an diff --git a/package.json b/package.json index 4ed8acee3f0..6f98b4a5476 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@edx/edx-proctoring", "//": "Note that the version format is slightly different than that of the Python version when using prereleases.", - "version": "4.1.2", + "version": "4.1.3", "main": "edx_proctoring/static/index.js", "scripts": { "test": "gulp test"