diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 3741e16f163414b53119bef96941141e3b5b0512..b2dbdef99586e1ef22b63f4ab2c78eabaf679a42 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -149,6 +149,7 @@ INSTRUCTOR_POST_ENDPOINTS = set([ 'calculate_grades_csv', 'change_due_date', 'export_ora2_data', + 'export_ora2_submission_files', 'get_grading_config', 'get_problem_responses', 'get_proctored_exam_results', @@ -428,6 +429,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest ('get_proctored_exam_results', {}), ('get_problem_responses', {}), ('export_ora2_data', {}), + ('export_ora2_submission_files', {}), ('rescore_problem', {'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email}), ('override_problem_score', @@ -2875,6 +2877,32 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment self.assertContains(response, already_running_status, status_code=400) + def test_get_ora2_submission_files_success(self): + url = reverse('export_ora2_submission_files', kwargs={'course_id': text_type(self.course.id)}) + + with patch( + 'lms.djangoapps.instructor_task.api.submit_export_ora2_submission_files' + ) as mock_submit_ora2_task: + mock_submit_ora2_task.return_value = True + response = self.client.post(url, {}) + + success_status = 'Attachments archive is being created.' + + self.assertContains(response, success_status) + + def test_get_ora2_submission_files_already_running(self): + url = reverse('export_ora2_submission_files', kwargs={'course_id': text_type(self.course.id)}) + task_type = 'export_ora2_submission_files' + already_running_status = generate_already_running_error_message(task_type) + + with patch( + 'lms.djangoapps.instructor_task.api.submit_export_ora2_submission_files' + ) as mock_submit_ora2_task: + mock_submit_ora2_task.side_effect = AlreadyRunningError(already_running_status) + response = self.client.post(url, {}) + + self.assertContains(response, already_running_status, status_code=400) + def test_get_student_progress_url(self): """ Test that progress_url is in the successful response. """ url = reverse('get_student_progress_url', kwargs={'course_id': text_type(self.course.id)}) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 640d27e46c5b6df934db8b829e5701f2eb1bd9ae..904d8dfe7df98ac3cf4431a9c94688839856da62 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -2049,6 +2049,28 @@ def export_ora2_data(request, course_id): return JsonResponse({"status": success_status}) +@transaction.non_atomic_requests +@require_POST +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_course_permission(permissions.CAN_RESEARCH) +@common_exceptions_400 +def export_ora2_submission_files(request, course_id): + """ + Pushes a Celery task which will download and compress all submission + files (texts, attachments) into a zip archive. + """ + course_key = CourseKey.from_string(course_id) + + task_api.submit_export_ora2_submission_files(request, course_key) + + return JsonResponse({ + "status": _( + "Attachments archive is being created." + ) + }) + + @transaction.non_atomic_requests @require_POST @ensure_csrf_cookie diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index b9f3442c381f4dbfa80e5e9ac3d8578d1f272b73..b7cf4dfa1099e806339c21fb5142a7e2b1c12713 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -54,6 +54,9 @@ urlpatterns = [ url(r'^get_course_survey_results$', api.get_course_survey_results, name='get_course_survey_results'), url(r'^export_ora2_data', api.export_ora2_data, name='export_ora2_data'), + url(r'^export_ora2_submission_files', api.export_ora2_submission_files, + name='export_ora2_submission_files'), + # spoc gradebook url(r'^gradebook$', gradebook_api.spoc_gradebook, name='spoc_gradebook'), diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 7b39156a8a244e82b86c6a825b467e6ca989a49d..9aa02d022f777a294b9c0313b3d06ad259ac7a53 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -628,6 +628,9 @@ def _section_data_download(course, access): 'get_course_survey_results', kwargs={'course_id': six.text_type(course_key)} ), 'export_ora2_data_url': reverse('export_ora2_data', kwargs={'course_id': six.text_type(course_key)}), + 'export_ora2_submission_files_url': reverse( + 'export_ora2_submission_files', kwargs={'course_id': six.text_type(course_key)} + ), } if not access.get('data_researcher'): section_data['is_hidden'] = True diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 013840cb81795c7b42781c802cc50f1aeef17f40..c9c2a84997f3deeaba7fdf0a7b7414466f51b26f 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -35,6 +35,7 @@ from lms.djangoapps.instructor_task.tasks import ( course_survey_report_csv, delete_problem_state, export_ora2_data, + export_ora2_submission_files, generate_certificates, override_problem_score, proctored_exam_results_csv, @@ -450,6 +451,19 @@ def submit_export_ora2_data(request, course_key): return submit_task(request, task_type, task_class, course_key, task_input, task_key) +def submit_export_ora2_submission_files(request, course_key): + """ + Submits a task to download and compress all submissions + files (texts, attachments) for given course. + """ + task_type = 'export_ora2_submission_files' + task_class = export_ora2_submission_files + task_input = {} + task_key = '' + + return submit_task(request, task_type, task_class, course_key, task_input, task_key) + + def generate_certificates_for_students(request, course_key, student_set=None, specific_student_id=None): """ Submits a task to generate certificates for given students enrolled in the course. diff --git a/lms/djangoapps/instructor_task/models.py b/lms/djangoapps/instructor_task/models.py index 9300b1b7d7dedcda9f3b17e743222c4cd762abc6..95a8d05d0ac509e616c60b40fd79af16669a80f2 100644 --- a/lms/djangoapps/instructor_task/models.py +++ b/lms/djangoapps/instructor_task/models.py @@ -280,9 +280,14 @@ class DjangoStorageReportStore(ReportStore): """ path = self.path_to(course_id, filename) # See https://github.com/boto/boto/issues/2868 - # Boto doesn't play nice with unicod in python3 + # Boto doesn't play nice with unicode in python3 if not six.PY2: - buff = ContentFile(buff.read().encode('utf-8')) + buff_contents = buff.read() + + if not isinstance(buff_contents, bytes): + buff_contents = buff_contents.encode('utf-8') + + buff = ContentFile(buff_contents) self.storage.save(path, buff) diff --git a/lms/djangoapps/instructor_task/tasks.py b/lms/djangoapps/instructor_task/tasks.py index d87627f079ac63b91faa7d7bcf059470b26aa248..bd7f428a643e0a5d226d2ea42fce8d2e08db60b6 100644 --- a/lms/djangoapps/instructor_task/tasks.py +++ b/lms/djangoapps/instructor_task/tasks.py @@ -40,6 +40,7 @@ from lms.djangoapps.instructor_task.tasks_helper.misc import ( cohort_students_and_upload, upload_course_survey_report, upload_ora2_data, + upload_ora2_submission_files, upload_proctored_exam_results_report ) from lms.djangoapps.instructor_task.tasks_helper.module_state import ( @@ -292,3 +293,14 @@ def export_ora2_data(entry_id, xmodule_instance_args): action_name = ugettext_noop('generated') task_fn = partial(upload_ora2_data, xmodule_instance_args) return run_main_task(entry_id, task_fn, action_name) + + +@task(base=BaseInstructorTask) +def export_ora2_submission_files(entry_id, xmodule_instance_args): + """ + Download all submission files, generate csv downloads list, + put all this into zip archive and push it to S3. + """ + action_name = ugettext_noop('compressed') + task_fn = partial(upload_ora2_submission_files, xmodule_instance_args) + return run_main_task(entry_id, task_fn, action_name) diff --git a/lms/djangoapps/instructor_task/tasks_helper/misc.py b/lms/djangoapps/instructor_task/tasks_helper/misc.py index b2906d01f9ad34d08c3477be000caa4b7292841f..b8477e0b6c3ce4df5b8439d7236949a7a98ac769 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/misc.py +++ b/lms/djangoapps/instructor_task/tasks_helper/misc.py @@ -7,16 +7,21 @@ running state of a course. import logging from collections import OrderedDict +from contextlib import contextmanager from datetime import datetime +from io import StringIO +from tempfile import TemporaryFile from time import time +from zipfile import ZipFile import csv +import os import unicodecsv import six from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.files.storage import DefaultStorage -from openassessment.data import OraAggregateData +from openassessment.data import OraAggregateData, OraDownloadData from pytz import UTC from lms.djangoapps.instructor_analytics.basic import get_proctored_exam_results @@ -27,7 +32,12 @@ from survey.models import SurveyAnswer from util.file import UniversalNewlineIterator from .runner import TaskProgress -from .utils import UPDATE_STATUS_FAILED, UPDATE_STATUS_SUCCEEDED, upload_csv_to_report_store +from .utils import ( + UPDATE_STATUS_FAILED, + UPDATE_STATUS_SUCCEEDED, + upload_csv_to_report_store, + upload_zip_to_report_store, +) # define different loggers for use within tasks and on client side TASK_LOG = logging.getLogger('edx.celery.task') @@ -340,3 +350,108 @@ def upload_ora2_data( TASK_LOG.info(u'%s, Task type: %s, Upload complete.', task_info_string, action_name) return UPDATE_STATUS_SUCCEEDED + + +def _task_step(task_progress, task_info_string, action_name): + """ + Returns a context manager, that logs error and updates TaskProgress + filures counter in case inner block throws an exception. + """ + + @contextmanager + def _step_context_manager(step_description, exception_text, step_error_description): + curr_step = {'step': step_description} + TASK_LOG.info( + '%s, Task type: %s, Current step: %s', + task_info_string, + action_name, + curr_step, + ) + + task_progress.update_task_state(extra_meta=curr_step) + + try: + yield + + # Update progress to failed regardless of error type + except Exception: # pylint: disable=broad-except + TASK_LOG.exception(exception_text) + task_progress.failed = 1 + + task_progress.update_task_state(extra_meta={'step': step_error_description}) + + return _step_context_manager + + +def upload_ora2_submission_files( + _xmodule_instance_args, _entry_id, course_id, _task_input, action_name +): + """ + Creates zip archive with submission files in three steps: + + 1. Collect all files information using ORA download helper. + 2. Download all submission attachments, put them in temporary zip + file along with submission texts and csv downloads list. + 3. Upload zip file into reports storage. + """ + + start_time = time() + start_date = datetime.now(UTC) + + num_attempted = 1 + num_total = 1 + + fmt = 'Task: {task_id}, InstructorTask ID: {entry_id}, Course: {course_id}, Input: {task_input}' + task_info_string = fmt.format( + task_id=_xmodule_instance_args.get('task_id') if _xmodule_instance_args is not None else None, + entry_id=_entry_id, + course_id=course_id, + task_input=_task_input + ) + TASK_LOG.info(u'%s, Task type: %s, Starting task execution', task_info_string, action_name) + + task_progress = TaskProgress(action_name, num_total, start_time) + task_progress.attempted = num_attempted + + step_manager = _task_step(task_progress, task_info_string, action_name) + + submission_files_data = None + with step_manager( + 'Collecting attachments data', + 'Failed to get ORA submissions attachments data.', + 'Error while collecting data', + ): + submission_files_data = OraDownloadData.collect_ora2_submission_files(course_id) + + if submission_files_data is None: + return UPDATE_STATUS_FAILED + + with TemporaryFile('rb+') as zip_file: + compressed = None + with step_manager( + 'Downloading and compressing attachments files', + 'Failed to download and compress submissions attachments.', + 'Error while downloading and compressing submissions attachments', + ): + compressed = OraDownloadData.create_zip_with_attachments(zip_file, course_id, submission_files_data) + + if compressed is None: + return UPDATE_STATUS_FAILED + + zip_filename = None + with step_manager( + 'Uploading zip file to storage', + 'Failed to upload zip file to storage.', + 'Error while uploading zip file to storage', + ): + zip_filename = upload_zip_to_report_store(zip_file, 'submission_files', course_id, start_date), + + if not zip_filename: + return UPDATE_STATUS_FAILED + + task_progress.succeeded = 1 + curr_step = {'step': 'Finalizing attachments extracting'} + task_progress.update_task_state(extra_meta=curr_step) + TASK_LOG.info(u'%s, Task type: %s, Upload complete.', task_info_string, action_name) + + return UPDATE_STATUS_SUCCEEDED diff --git a/lms/djangoapps/instructor_task/tasks_helper/utils.py b/lms/djangoapps/instructor_task/tasks_helper/utils.py index cb72ea4877f5c21cbcf57097bad1b69ada3443c4..607851f77f4502de61246fe071674cd8d9a281f7 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/utils.py +++ b/lms/djangoapps/instructor_task/tasks_helper/utils.py @@ -49,6 +49,23 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp, config_name return report_name, report_path +def upload_zip_to_report_store(file, zip_name, course_id, timestamp, config_name='GRADES_DOWNLOAD'): + """ + Upload given file buffer as a zip file using ReportStore. + """ + report_store = ReportStore.from_config(config_name) + + report_name = u"{course_prefix}_{zip_name}_{timestamp_str}.zip".format( + course_prefix=course_filename_prefix_generator(course_id), + zip_name=zip_name, + timestamp_str=timestamp.strftime("%Y-%m-%d-%H%M") + ) + + report_store.store(course_id, report_name, file) + tracker_emit(zip_name) + return report_name + + def tracker_emit(report_name): """ Emits a 'report.requested' event for the given report. diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index 9d10f2e0408e3f3d1083fb0d0d621021e050d40e..1e386aa20c2bd1993ff1a8eacae0ef28a49163e6 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -27,6 +27,7 @@ from lms.djangoapps.instructor_task.api import ( submit_delete_entrance_exam_state_for_student, submit_delete_problem_state_for_all_students, submit_export_ora2_data, + submit_export_ora2_submission_files, submit_override_score, submit_rescore_entrance_exam_for_student, submit_rescore_problem_for_all_students, @@ -36,7 +37,7 @@ from lms.djangoapps.instructor_task.api import ( ) from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, QueueConnectionError from lms.djangoapps.instructor_task.models import PROGRESS, InstructorTask -from lms.djangoapps.instructor_task.tasks import export_ora2_data +from lms.djangoapps.instructor_task.tasks import export_ora2_data, export_ora2_submission_files from lms.djangoapps.instructor_task.tests.test_base import ( TEST_COURSE_KEY, InstructorTaskCourseTestCase, @@ -282,6 +283,22 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa mock_submit_task.assert_called_once_with( request, 'export_ora2_data', export_ora2_data, self.course.id, {}, '') + def test_submit_export_ora2_submission_files(self): + request = self.create_task_request(self.instructor) + + with patch('lms.djangoapps.instructor_task.api.submit_task') as mock_submit_task: + mock_submit_task.return_value = MagicMock() + submit_export_ora2_submission_files(request, self.course.id) + + mock_submit_task.assert_called_once_with( + request, + 'export_ora2_submission_files', + export_ora2_submission_files, + self.course.id, + {}, + '' + ) + def test_submit_generate_certs_students(self): """ Tests certificates generation task submission api diff --git a/lms/djangoapps/instructor_task/tests/test_tasks.py b/lms/djangoapps/instructor_task/tests/test_tasks.py index 3f4fe877ac16d81d41430dcffa37e81fc9e98cd1..2f9a90630b1a6b31923b72764a8f6630e3bf3aba 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks.py @@ -25,6 +25,7 @@ from lms.djangoapps.instructor_task.models import InstructorTask from lms.djangoapps.instructor_task.tasks import ( delete_problem_state, export_ora2_data, + export_ora2_submission_files, generate_certificates, override_problem_score, rescore_problem, @@ -684,3 +685,33 @@ class TestOra2ResponsesInstructorTask(TestInstructorTasks): assert args[0] == task_entry.id assert callable(args[1]) assert args[2] == action_name + + +class TestOra2ExportSubmissionFilesInstructorTask(TestInstructorTasks): + """Tests instructor task that exports ora2 submission files archive.""" + + def test_ora2_missing_current_task(self): + self._test_missing_current_task(export_ora2_submission_files) + + def test_ora2_with_failure(self): + self._test_run_with_failure(export_ora2_submission_files, 'We expected this to fail') + + def test_ora2_with_long_error_msg(self): + self._test_run_with_long_error_msg(export_ora2_submission_files) + + def test_ora2_with_short_error_msg(self): + self._test_run_with_short_error_msg(export_ora2_submission_files) + + def test_ora2_runs_task(self): + task_entry = self._create_input_entry() + task_xmodule_args = self._get_xmodule_instance_args() + + with patch('lms.djangoapps.instructor_task.tasks.run_main_task') as mock_main_task: + export_ora2_submission_files(task_entry.id, task_xmodule_args) + action_name = ugettext_noop('compressed') + + assert mock_main_task.call_count == 1 + args = mock_main_task.call_args[0] + assert args[0] == task_entry.id + assert callable(args[1]) + assert args[2] == action_name diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 87761e0c773ca275f444997d728893991e532d6e..4e0e46e768fd9f046ae2ce89321edd03ce93f635 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -12,8 +12,10 @@ Unit tests for LMS instructor-initiated background tasks helper functions. import os import shutil import tempfile -from contextlib import contextmanager +from contextlib import contextmanager, ExitStack from datetime import datetime, timedelta +from io import BytesIO +from zipfile import ZipFile import ddt import unicodecsv @@ -57,7 +59,8 @@ from lms.djangoapps.instructor_task.tasks_helper.grades import ( from lms.djangoapps.instructor_task.tasks_helper.misc import ( cohort_students_and_upload, upload_course_survey_report, - upload_ora2_data + upload_ora2_data, + upload_ora2_submission_files ) from lms.djangoapps.instructor_task.tests.test_base import ( InstructorTaskCourseTestCase, @@ -2539,25 +2542,126 @@ class TestInstructorOra2Report(SharedModuleStoreTestCase): self.assertEqual(response, UPDATE_STATUS_FAILED) def test_report_stores_results(self): - with freeze_time('2001-01-01 00:00:00'): + with ExitStack() as stack: + stack.enter_context(freeze_time('2001-01-01 00:00:00')) + + mock_current_task = stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task') + ) + mock_collect_data = stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.misc.OraAggregateData.collect_ora2_data') + ) + mock_store_rows = stack.enter_context( + patch('lms.djangoapps.instructor_task.models.DjangoStorageReportStore.store_rows') + ) + + mock_current_task.return_value = self.current_task + test_header = ['field1', 'field2'] test_rows = [['row1_field1', 'row1_field2'], ['row2_field1', 'row2_field2']] + mock_collect_data.return_value = (test_header, test_rows) + + return_val = upload_ora2_data(None, None, self.course.id, None, 'generated') + + timestamp_str = datetime.now(UTC).strftime('%Y-%m-%d-%H%M') + course_id_string = quote(text_type(self.course.id).replace('/', '_')) + filename = u'{}_ORA_data_{}.csv'.format(course_id_string, timestamp_str) + + self.assertEqual(return_val, UPDATE_STATUS_SUCCEEDED) + mock_store_rows.assert_called_once_with(self.course.id, filename, [test_header] + test_rows) + + +class TestInstructorOra2AttachmentsExport(SharedModuleStoreTestCase): + """ + Tests that ORA2 submission files export works. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super().setUp() + + self.current_task = Mock() + self.current_task.update_state = Mock() + + def test_export_fails_if_error_on_collect_step(self): with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task') as mock_current_task: mock_current_task.return_value = self.current_task with patch( - 'lms.djangoapps.instructor_task.tasks_helper.misc.OraAggregateData.collect_ora2_data' + 'lms.djangoapps.instructor_task.tasks_helper.misc.OraDownloadData.collect_ora2_submission_files' ) as mock_collect_data: - mock_collect_data.return_value = (test_header, test_rows) - with patch( - 'lms.djangoapps.instructor_task.models.DjangoStorageReportStore.store_rows' - ) as mock_store_rows: - return_val = upload_ora2_data(None, None, self.course.id, None, 'generated') - - timestamp_str = datetime.now(UTC).strftime('%Y-%m-%d-%H%M') - course_id_string = quote(text_type(self.course.id).replace('/', '_')) - filename = u'{}_ORA_data_{}.csv'.format(course_id_string, timestamp_str) - - self.assertEqual(return_val, UPDATE_STATUS_SUCCEEDED) - mock_store_rows.assert_called_once_with(self.course.id, filename, [test_header] + test_rows) + mock_collect_data.side_effect = KeyError + + response = upload_ora2_submission_files(None, None, self.course.id, None, 'compressed') + self.assertEqual(response, UPDATE_STATUS_FAILED) + + def test_export_fails_if_error_on_create_zip_step(self): + with ExitStack() as stack: + mock_current_task = stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task') + ) + mock_current_task.return_value = self.current_task + + stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.misc.OraDownloadData.collect_ora2_submission_files') + ) + create_zip_mock = stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.misc.OraDownloadData.create_zip_with_attachments') + ) + + create_zip_mock.side_effect = KeyError + + response = upload_ora2_submission_files(None, None, self.course.id, None, 'compressed') + self.assertEqual(response, UPDATE_STATUS_FAILED) + + def test_export_fails_if_error_on_upload_step(self): + with ExitStack() as stack: + mock_current_task = stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task') + ) + mock_current_task.return_value = self.current_task + + stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.misc.OraDownloadData.collect_ora2_submission_files') + ) + stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.misc.OraDownloadData.create_zip_with_attachments') + ) + upload_mock = stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.misc.upload_zip_to_report_store') + ) + + upload_mock.side_effect = KeyError + + response = upload_ora2_submission_files(None, None, self.course.id, None, 'compressed') + self.assertEqual(response, UPDATE_STATUS_FAILED) + + def test_task_stores_zip_with_attachments(self): + with ExitStack() as stack: + mock_current_task = stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task') + ) + mock_collect_files = stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.misc.OraDownloadData.collect_ora2_submission_files') + ) + mock_create_zip = stack.enter_context( + patch('lms.djangoapps.instructor_task.tasks_helper.misc.OraDownloadData.create_zip_with_attachments') + ) + mock_store = stack.enter_context( + patch('lms.djangoapps.instructor_task.models.DjangoStorageReportStore.store') + ) + + mock_current_task.return_value = self.current_task + + response = upload_ora2_submission_files(None, None, self.course.id, None, 'compressed') + + mock_collect_files.assert_called_once() + mock_create_zip.assert_called_once() + mock_store.assert_called_once() + + self.assertEqual(response, UPDATE_STATUS_SUCCEEDED) diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index 051bb48ad179396d7d7ec2100024bf96d6f6e3ab..67bc40dba005bdf64c275c971c2e83b62cdf8728 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -94,6 +94,11 @@ from openedx.core.djangolib.markup import HTML, Text <input type="button" name="problem-grade-report" class="async-report-btn" value="${_("Generate Problem Grade Report")}" data-endpoint="${ section_data['problem_grade_report_url'] }"/> <input type="button" name="export-ora2-data" class="async-report-btn" value="${_("Generate ORA Data Report")}" data-endpoint="${ section_data['export_ora2_data_url'] }"/> </p> + + <p>${_("Click to generate a ZIP file that contains all submission texts and attachments.")}</p> + + <p><input type="button" name="export-ora2-data" class="async-report-btn" value="${_("Generate Submission Files Archive")}" data-endpoint="${ section_data['export_ora2_submission_files_url'] }"/></p> + %endif <div class="request-response msg msg-confirm copy" id="report-request-response"></div>