From 47d1216894284e13c7fa4430bf437782db07ac25 Mon Sep 17 00:00:00 2001 From: Alie Langston Date: Mon, 4 Nov 2024 16:45:17 -0500 Subject: [PATCH 1/2] feat: add management command to delete exam attempts --- CHANGELOG.rst | 4 + edx_proctoring/__init__.py | 2 +- .../management/commands/reset_attempts.py | 76 +++++++++++++++++++ .../commands/tests/test_reset_attempts.py | 72 ++++++++++++++++++ package.json | 2 +- 5 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 edx_proctoring/management/commands/reset_attempts.py create mode 100644 edx_proctoring/management/commands/tests/test_reset_attempts.py 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..1759bd902b --- /dev/null +++ b/edx_proctoring/management/commands/reset_attempts.py @@ -0,0 +1,76 @@ +""" +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 csv +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..9e6bafd6fa --- /dev/null +++ b/edx_proctoring/management/commands/tests/test_reset_attempts.py @@ -0,0 +1,72 @@ +""" +Tests for the reset_attempts management command +""" + +import ddt +from tempfile import NamedTemporaryFile + +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 id in ids: + writing_file.write(str(id) + '\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" From 2884dc88540a1e1f9b10d8e25123effba25112aa Mon Sep 17 00:00:00 2001 From: Alie Langston Date: Mon, 4 Nov 2024 16:56:39 -0500 Subject: [PATCH 2/2] fix: quality --- edx_proctoring/management/commands/reset_attempts.py | 2 -- .../management/commands/tests/test_reset_attempts.py | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/edx_proctoring/management/commands/reset_attempts.py b/edx_proctoring/management/commands/reset_attempts.py index 1759bd902b..06d13fba67 100644 --- a/edx_proctoring/management/commands/reset_attempts.py +++ b/edx_proctoring/management/commands/reset_attempts.py @@ -3,7 +3,6 @@ to remove attempts that have not been started or completed, as it will not reset problem state or grade overrides. """ -import csv import logging import time @@ -72,5 +71,4 @@ def handle(self, *args, **options): 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 index 9e6bafd6fa..1933208bff 100644 --- a/edx_proctoring/management/commands/tests/test_reset_attempts.py +++ b/edx_proctoring/management/commands/tests/test_reset_attempts.py @@ -1,9 +1,9 @@ """ Tests for the reset_attempts management command """ +from tempfile import NamedTemporaryFile import ddt -from tempfile import NamedTemporaryFile from django.core.management import call_command @@ -58,8 +58,8 @@ def test_run_command(self, num_to_delete): with NamedTemporaryFile() as file: with open(file.name, 'w') as writing_file: - for id in ids: - writing_file.write(str(id) + '\n') + for num in ids: + writing_file.write(str(num) + '\n') call_command( 'reset_attempts',