diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 582436fa311..09dc5d074b1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,10 @@ Change Log Unreleased ~~~~~~~~~~ +[3.24.5] - 2021-09-02 +~~~~~~~~~~~~~~~~~~~~~ +* Add management command for updating an attempt status based on its associated review + [3.24.4] - 2021-09-02 ~~~~~~~~~~~~~~~~~~~~~ * Add testing for exam attempt email failure and related logging diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index faac82d02e8..022584675d7 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.24.4' +__version__ = '3.24.5' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/management/commands/tests/test_update_attempt_status_from_review.py b/edx_proctoring/management/commands/tests/test_update_attempt_status_from_review.py new file mode 100644 index 00000000000..43cc9131694 --- /dev/null +++ b/edx_proctoring/management/commands/tests/test_update_attempt_status_from_review.py @@ -0,0 +1,107 @@ +""" +Tests for the update_attempt_status_from_review management command +""" +from datetime import datetime, timedelta + +import pytz + +from django.core.management import call_command + +from edx_proctoring.api import create_exam, create_exam_attempt, get_exam_attempt_by_id +from edx_proctoring.models import ProctoredExamSoftwareSecureReview +from edx_proctoring.runtime import set_runtime_service +from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus, SoftwareSecureReviewStatus +from edx_proctoring.tests.test_services import MockCertificateService, MockCreditService, MockGradesService +from edx_proctoring.tests.utils import LoggedInTestCase + + +class SetAttemptActiveFieldTests(LoggedInTestCase): + """ + Coverage of the update_attempt_status_from_review.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()) + self.first_exam_id = create_exam( + course_id='foo', + content_id='bar', + exam_name='Test Exam 1', + time_limit_mins=90 + ) + self.second_exam_id = create_exam( + course_id='foo', + content_id='baz', + exam_name='Test Exam 2', + time_limit_mins=90 + ) + + # create a first attempt and review + self.first_attempt_id = create_exam_attempt( + self.first_exam_id, + self.user.id, + taking_as_proctored=True + ) + self.first_attempt = get_exam_attempt_by_id(self.first_attempt_id) + self.first_attempt_review_object = ProctoredExamSoftwareSecureReview.objects.create( + attempt_code=self.first_attempt['attempt_code'], + exam_id=self.first_exam_id, + student_id=self.user.id, + ) + + # create a second attempt and review + self.second_attempt_id = create_exam_attempt( + self.second_exam_id, + self.user.id, + taking_as_proctored=True + ) + self.second_attempt = get_exam_attempt_by_id(self.second_attempt_id) + self.second_attempt_review_object = ProctoredExamSoftwareSecureReview.objects.create( + attempt_code=self.second_attempt['attempt_code'], + exam_id=self.second_exam_id, + student_id=self.user.id, + ) + + # update both reviews, and use update instead of save to avoid triggering a post_save signal + ProctoredExamSoftwareSecureReview.objects.filter(id=self.first_attempt_review_object.id).update( + review_status=SoftwareSecureReviewStatus.clean + ) + ProctoredExamSoftwareSecureReview.objects.filter(id=self.second_attempt_review_object.id).update( + review_status=SoftwareSecureReviewStatus.suspicious + ) + + def test_run_command(self): + """ + Run the management command + """ + # check status of attempts + self.assertEqual(self.first_attempt['status'], ProctoredExamStudentAttemptStatus.created) + self.assertEqual(self.second_attempt['status'], ProctoredExamStudentAttemptStatus.created) + # check status of reviews + first_review = ProctoredExamSoftwareSecureReview.objects.get(id=self.first_attempt_review_object.id) + self.assertEqual(first_review.review_status, SoftwareSecureReviewStatus.clean) + second_review = ProctoredExamSoftwareSecureReview.objects.get(id=self.second_attempt_review_object.id) + self.assertEqual(second_review.review_status, SoftwareSecureReviewStatus.suspicious) + + start_time = datetime.now(pytz.UTC) - timedelta(minutes=60) + end_time = datetime.now(pytz.UTC) + timedelta(minutes=60) + + # run command + call_command( + 'update_attempt_status_from_review', + batch_size=2, + sleep_time=0, + start_date_time=start_time.strftime('%Y-%m-%d %H:%M:%S'), + end_date_time=end_time.strftime('%Y-%m-%d %H:%M:%S') + ) + + # check that attempt statuses has been updated + updated_first_attempt = get_exam_attempt_by_id(self.first_attempt_id) + self.assertEqual(updated_first_attempt['status'], ProctoredExamStudentAttemptStatus.verified) + updated_second_attempt = get_exam_attempt_by_id(self.second_attempt_id) + self.assertEqual(updated_second_attempt['status'], ProctoredExamStudentAttemptStatus.second_review_required) diff --git a/edx_proctoring/management/commands/update_attempt_status_from_review.py b/edx_proctoring/management/commands/update_attempt_status_from_review.py new file mode 100644 index 00000000000..8c453420cad --- /dev/null +++ b/edx_proctoring/management/commands/update_attempt_status_from_review.py @@ -0,0 +1,80 @@ +""" +Django management command to update the status of a ProctoredExamStudentAttempt +based on the status of its corresponding ProctoredExamSoftwareSecureReview +""" +import datetime +import logging +import time + +from django.core.management.base import BaseCommand + +from edx_proctoring.models import ProctoredExamSoftwareSecureReview + +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( + '--start_date_time', + action='store', + dest='start_date_time', + type=str, + help='First date time for reviews that we want to consider. Should be formatted as 2020-12-02 00:00:00.' + ) + parser.add_argument( + '--end_date_time', + action='store', + dest='end_date_time', + type=str, + help='Last date time for reviews that we want to consider. Should be formatted as 2020-12-02 00:00:00.' + ) + parser.add_argument( + '--batch_size', + action='store', + dest='batch_size', + type=int, + default=300, + help='Maximum number of attempt_codes 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' + ) + + 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'] + review_start_date = datetime.datetime.strptime(options['start_date_time'], '%Y-%m-%d %H:%M:%S') + review_end_date = datetime.datetime.strptime(options['end_date_time'], '%Y-%m-%d %H:%M:%S') + + reviews_in_date_range = ProctoredExamSoftwareSecureReview.objects.filter( + modified__range=[review_start_date, review_end_date] + ) + review_count = 0 + + for review in reviews_in_date_range: + review_id = review.id + attempt_code = review.attempt_code + log.info('Saving review_id={review_id} for corresponding attempt_code={attempt_code}'.format( + review_id=review_id, + attempt_code=attempt_code + )) + review.save() + review_count += 1 + + if review_count == batch_size: + review_count = 0 + time.sleep(sleep_time) diff --git a/package.json b/package.json index 25c9a2a584e..dc4a5e5c9a7 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": "3.24.4", + "version": "3.24.5", "main": "edx_proctoring/static/index.js", "scripts": { "test": "gulp test"