diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1a16eb7c82..3871700173 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,10 @@ Change Log Unreleased ~~~~~~~~~~ +[4.18.3] - 2024-11-04 +~~~~~~~~~~~~~~~~~~~~~ +* add management command to delete attempts + [4.18.2] - 2024-10-03 ~~~~~~~~~~~~~~~~~~~~~ * fix various bugs related to exams configured with removed proctoring backend diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index f5cb65ad4b..3b21ed1a2c 100644 --- a/edx_proctoring/__init__.py +++ b/edx_proctoring/__init__.py @@ -3,4 +3,4 @@ """ # Be sure to update the version number in edx_proctoring/package.json -__version__ = '4.18.2' +__version__ = '4.18.3' diff --git a/edx_proctoring/management/commands/reset_attempts.py b/edx_proctoring/management/commands/reset_attempts.py new file mode 100644 index 0000000000..06d13fba67 --- /dev/null +++ b/edx_proctoring/management/commands/reset_attempts.py @@ -0,0 +1,74 @@ +""" +Django management command to delete attempts. This command should only be used +to remove attempts that have not been started or completed, as it will not +reset problem state or grade overrides. +""" +import logging +import time + +from django.core.management.base import BaseCommand + +from edx_proctoring.models import ProctoredExamStudentAttempt + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Django Management command to delete attempts. + """ + + def add_arguments(self, parser): + parser.add_argument( + '-p', + '--file_path', + metavar='file_path', + dest='file_path', + required=True, + help='Path to file.' + ) + parser.add_argument( + '--batch_size', + action='store', + dest='batch_size', + type=int, + default=300, + help='Maximum number of attempt_ids 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'] + file_path = options['file_path'] + + with open(file_path, 'r') as file: + ids_to_delete = file.readlines() + + total_deleted = 0 + + for i in range(0, len(ids_to_delete), batch_size): + batch_to_delete = ids_to_delete[i:i + batch_size] + + delete_queryset = ProctoredExamStudentAttempt.objects.filter( + id__in=batch_to_delete + ) + deleted_count, _ = delete_queryset.delete() + + total_deleted += deleted_count + + log.info(f'{deleted_count} attempts deleted.') + time.sleep(sleep_time) + + log.info(f'Job completed. {total_deleted} attempts deleted.') diff --git a/edx_proctoring/management/commands/tests/test_reset_attempts.py b/edx_proctoring/management/commands/tests/test_reset_attempts.py new file mode 100644 index 0000000000..1933208bff --- /dev/null +++ b/edx_proctoring/management/commands/tests/test_reset_attempts.py @@ -0,0 +1,72 @@ +""" +Tests for the reset_attempts management command +""" +from tempfile import NamedTemporaryFile + +import ddt + +from django.core.management import call_command + +from edx_proctoring.api import create_exam +from edx_proctoring.models import ProctoredExamStudentAttempt +from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus +from edx_proctoring.tests.utils import LoggedInTestCase + + +@ddt.ddt +class ResetAttemptsTests(LoggedInTestCase): + """ + Coverage of the reset_attempts.py file + """ + + def setUp(self): + """ + Build up test data + """ + super().setUp() + self.exam_id = create_exam( + course_id='a/b/c', + content_id='bar', + exam_name='Test Exam', + time_limit_mins=90 + ) + + self.num_attempts = 10 + + user_list = self.create_batch_users(self.num_attempts) + for user in user_list: + ProctoredExamStudentAttempt.objects.create( + proctored_exam_id=self.exam_id, + user_id=user.id, + external_id='foo', + status=ProctoredExamStudentAttemptStatus.created, + allowed_time_limit_mins=10, + taking_as_proctored=True, + is_sample_attempt=False + ) + + @ddt.data( + 5, + 7, + 10, + ) + def test_run_command(self, num_to_delete): + """ + Run the management command + """ + ids = list(ProctoredExamStudentAttempt.objects.all().values_list('id', flat=True))[:num_to_delete] + + with NamedTemporaryFile() as file: + with open(file.name, 'w') as writing_file: + for num in ids: + writing_file.write(str(num) + '\n') + + call_command( + 'reset_attempts', + batch_size=2, + sleep_time=0, + file_path=file.name, + ) + + attempts = ProctoredExamStudentAttempt.objects.all() + self.assertEqual(len(attempts), self.num_attempts - num_to_delete) diff --git a/package.json b/package.json index 56b2bade77..ede756b5cf 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.18.2", + "version": "4.18.3", "main": "edx_proctoring/static/index.js", "scripts": { "test": "gulp test"