diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ce58406a795..adffe7dfd05 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,10 @@ Change Log Unreleased ~~~~~~~~~~ +[2.5.10] - 2021-01-15 +~~~~~~~~~~~~~~~~~~~~~ +* Added management command to update `is_attempt_active` field on review models + [2.5.9] - 2021-01-13 ~~~~~~~~~~~~~~~~~~~~ * Added `is_attempt_active` field to ProctoredExamSoftwareSecureReview and diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index 7c94843bfd4..38eab731856 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__ = '2.5.9' +__version__ = '2.5.10' default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name diff --git a/edx_proctoring/management/commands/set_is_attempt_active.py b/edx_proctoring/management/commands/set_is_attempt_active.py new file mode 100644 index 00000000000..b0f14b4d690 --- /dev/null +++ b/edx_proctoring/management/commands/set_is_attempt_active.py @@ -0,0 +1,104 @@ +""" +Django management command to update the is_attempt_active field on +ProctoredExamSoftwareSecureReview and ProctoredExamSoftwareSecureReviewHistory models +""" +import logging +import time + +from django.core.management.base import BaseCommand + +from edx_proctoring.models import ( + ProctoredExamSoftwareSecureReview, + ProctoredExamSoftwareSecureReviewHistory, + ProctoredExamStudentAttempt +) + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Django Management command to update is_attempt_active field on review models + """ + update_field_count = 0 + update_attempt_codes = [] + distinct_attempt_codes = set() + + def add_arguments(self, parser): + 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 locking 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'] + + log.info('Updating is_attempt_active field for current reviews.') + for review in ProctoredExamSoftwareSecureReview.objects.filter(is_attempt_active=True): + self.check_and_update(review, batch_size, sleep_time) + + log.info('Updating is_attempt_active field for archived reviews.') + for archived_review in ProctoredExamSoftwareSecureReviewHistory.objects.filter(is_attempt_active=True): + self.check_and_update(archived_review, batch_size, sleep_time, only_update_archives=True) + + if self.update_attempt_codes: + log.info('Updating {} reviews'.format(len(self.update_attempt_codes))) + self.bulk_update(self.update_attempt_codes, False) + + def check_and_update(self, review_object, size, sleep_time, only_update_archives=False): + """ + Function to check if a review object should be updated, and updates accordingly + """ + if review_object.attempt_code not in self.distinct_attempt_codes: + if self.should_update(review_object): + self.distinct_attempt_codes.add(review_object.attempt_code) + self.update_attempt_codes.append(review_object.attempt_code) + self.update_field_count += 1 + log.info('Adding review {} to be updated'.format(review_object.id)) + + if self.update_field_count == size: + log.info('Updating {} reviews'.format(size)) + self.bulk_update(self.update_attempt_codes, only_update_archives) + self.update_field_count = 0 + self.update_attempt_codes = [] + time.sleep(sleep_time) + + def bulk_update(self, attempt_codes, only_update_archive): + """ + Updates the is_attempt_active fields for all reviews who have an attempt code in attempt_codes + """ + if not only_update_archive: + reviews = ProctoredExamSoftwareSecureReview.objects.filter(attempt_code__in=attempt_codes) + reviews.update(is_attempt_active=False) + + archived_reviews = ProctoredExamSoftwareSecureReviewHistory.objects.filter(attempt_code__in=attempt_codes) + archived_reviews.update(is_attempt_active=False) + + def should_update(self, review_object): + """ + Returns a boolean based on whether an attempt exists in the ProctoredExamStudentAttempt model + """ + attempt_code = review_object.attempt_code + try: + ProctoredExamStudentAttempt.objects.get(attempt_code=attempt_code) + return False + except ProctoredExamStudentAttempt.DoesNotExist: + return True diff --git a/edx_proctoring/management/commands/tests/test_set_is_attempt_active.py b/edx_proctoring/management/commands/tests/test_set_is_attempt_active.py new file mode 100644 index 00000000000..92815953372 --- /dev/null +++ b/edx_proctoring/management/commands/tests/test_set_is_attempt_active.py @@ -0,0 +1,85 @@ +""" +Tests for the set_is_attempt_active management command +""" + +from django.core.management import call_command + +from edx_proctoring.api import create_exam, create_exam_attempt, get_exam_attempt_by_id, remove_exam_attempt +from edx_proctoring.models import ProctoredExamSoftwareSecureReview, ProctoredExamSoftwareSecureReviewHistory +from edx_proctoring.runtime import set_runtime_service +from edx_proctoring.tests.test_services import MockCertificateService, MockCreditService, MockGradesService +from edx_proctoring.tests.utils import LoggedInTestCase + + +class SetAttemptActiveFieldTests(LoggedInTestCase): + """ + Coverage of the set_attempt_status.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.exam_id = create_exam( + course_id='foo', + content_id='bar', + exam_name='Test Exam', + time_limit_mins=90 + ) + + self.attempt_id = create_exam_attempt( + self.exam_id, + self.user.id, + taking_as_proctored=True + ) + + self.attempt = get_exam_attempt_by_id(self.attempt_id) + + ProctoredExamSoftwareSecureReview.objects.create( + attempt_code=self.attempt['attempt_code'], + exam_id=self.exam_id, + student_id=self.user.id, + ) + + def test_run_command(self): + """ + Run the management command + """ + + # check that review is there + reviews = ProctoredExamSoftwareSecureReview.objects.all() + self.assertEqual(len(reviews), 1) + + archive_reviews = ProctoredExamSoftwareSecureReviewHistory.objects.all() + self.assertEqual(len(archive_reviews), 0) + + # archive attempt + remove_exam_attempt(self.attempt_id, requesting_user=self.user) + + # check that field is false + review = ProctoredExamSoftwareSecureReview.objects.get(attempt_code=self.attempt['attempt_code']) + self.assertFalse(review.is_attempt_active) + + # change field back to true for testing + review.is_attempt_active = True + review.save() + + # expect there to be two archived reviews, one from removing the attempt, and one because we changed a field + archive_reviews = ProctoredExamSoftwareSecureReviewHistory.objects.all() + self.assertEqual(len(archive_reviews), 2) + + call_command( + 'set_is_attempt_active', + batch_size=5, + sleep_time=0 + ) + + review = ProctoredExamSoftwareSecureReview.objects.get(attempt_code=self.attempt['attempt_code']) + self.assertFalse(review.is_attempt_active) + + archive_reviews = ProctoredExamSoftwareSecureReviewHistory.objects.filter(is_attempt_active=False) + self.assertEqual(len(archive_reviews), 2) diff --git a/package.json b/package.json index 2864daf261d..f6cff0cbda9 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": "2.5.9", + "version": "2.5.10", "main": "edx_proctoring/static/index.js", "repository": { "type": "git",