diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 7123b9758e..05832f8ebd 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -154,6 +154,7 @@ INSTRUCTOR_POST_ENDPOINTS = { 'change_due_date', 'export_ora2_data', 'export_ora2_submission_files', + 'export_ora2_summary', 'get_grading_config', 'get_problem_responses', 'get_proctored_exam_results', @@ -426,6 +427,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest ('get_problem_responses', {}), ('export_ora2_data', {}), ('export_ora2_submission_files', {}), + ('export_ora2_summary', {}), ('rescore_problem', {'problem_to_reset': self.problem_urlname, 'unique_student_identifier': self.user.email}), ('override_problem_score', @@ -2853,6 +2855,26 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment self.assertContains(response, already_running_status, status_code=400) + def test_get_ora2_summary_responses_success(self): + url = reverse('export_ora2_summary', kwargs={'course_id': str(self.course.id)}) + + with patch('lms.djangoapps.instructor_task.api.submit_export_ora2_summary') as mock_submit_ora2_task: + mock_submit_ora2_task.return_value = True + response = self.client.post(url, {}) + success_status = "The ORA summary report is being created." + self.assertContains(response, success_status) + + def test_get_ora2_summary_responses_already_running(self): + url = reverse('export_ora2_summary', kwargs={'course_id': str(self.course.id)}) + task_type = 'export_ora2_summary' + already_running_status = generate_already_running_error_message(task_type) + + with patch('lms.djangoapps.instructor_task.api.submit_export_ora2_summary') 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': str(self.course.id)}) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 0213c06cfc..2ee85149ad 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -2028,6 +2028,24 @@ 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_summary(request, course_id): + """ + Pushes a Celery task which will aggregate a summary students' progress in ora2 tasks for a course into a .csv + """ + course_key = CourseKey.from_string(course_id) + report_type = _('ORA summary') + task_api.submit_export_ora2_summary(request, course_key) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + + return JsonResponse({"status": success_status}) + + @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 b7cf4dfa10..8fc74f80df 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -53,6 +53,7 @@ urlpatterns = [ # Reports.. 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_summary', api.export_ora2_summary, name='export_ora2_summary'), url(r'^export_ora2_submission_files', api.export_ora2_submission_files, name='export_ora2_submission_files'), diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 9ee10ab442..3401724aa3 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -633,6 +633,7 @@ def _section_data_download(course, access): 'export_ora2_submission_files_url': reverse( 'export_ora2_submission_files', kwargs={'course_id': str(course_key)} ), + 'export_ora2_summary_url': reverse('export_ora2_summary', kwargs={'course_id': str(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 8870e25a34..2f2a963590 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -36,6 +36,7 @@ from lms.djangoapps.instructor_task.tasks import ( delete_problem_state, export_ora2_data, export_ora2_submission_files, + export_ora2_summary, generate_certificates, override_problem_score, proctored_exam_results_csv, @@ -463,6 +464,18 @@ def submit_export_ora2_submission_files(request, course_key): return submit_task(request, task_type, task_class, course_key, task_input, task_key) +def submit_export_ora2_summary(request, course_key): + """ + AlreadyRunningError is raised if an ora2 report is already being generated. + """ + task_type = 'export_ora2_summary' + task_class = export_ora2_summary + 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/tasks.py b/lms/djangoapps/instructor_task/tasks.py index 085684511d..93b2ee5f9c 100644 --- a/lms/djangoapps/instructor_task/tasks.py +++ b/lms/djangoapps/instructor_task/tasks.py @@ -38,6 +38,7 @@ from lms.djangoapps.instructor_task.tasks_helper.misc import ( upload_course_survey_report, upload_ora2_data, upload_ora2_submission_files, + upload_ora2_summary, upload_proctored_exam_results_report ) from lms.djangoapps.instructor_task.tasks_helper.module_state import ( @@ -316,3 +317,14 @@ def export_ora2_submission_files(entry_id, xmodule_instance_args): 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) + + +@shared_task(base=BaseInstructorTask) +@set_code_owner_attribute +def export_ora2_summary(entry_id, xmodule_instance_args): + """ + Generate a CSV of ora2/student summaries and push it to S3. + """ + action_name = ugettext_noop('generated') + task_fn = partial(upload_ora2_summary, 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 ad124b10d3..f9b0619239 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/misc.py +++ b/lms/djangoapps/instructor_task/tasks_helper/misc.py @@ -275,6 +275,32 @@ def upload_ora2_data( Collect ora2 responses and upload them to S3 as a CSV """ + return _upload_ora2_data_common( + _xmodule_instance_args, _entry_id, course_id, _task_input, action_name, + 'data', OraAggregateData.collect_ora2_data + ) + + +def upload_ora2_summary( + _xmodule_instance_args, _entry_id, course_id, _task_input, action_name +): + """ + Collect ora2/student summaries and upload them to file storage as a CSV + """ + + return _upload_ora2_data_common( + _xmodule_instance_args, _entry_id, course_id, _task_input, action_name, + 'summary', OraAggregateData.collect_ora2_summary + ) + + +def _upload_ora2_data_common( + _xmodule_instance_args, _entry_id, course_id, _task_input, action_name, + report_name, csv_gen_func +): + """ + Common code for uploading data or summary csv report. + """ start_date = datetime.now(UTC) start_time = time() @@ -304,8 +330,10 @@ def upload_ora2_data( task_progress.update_task_state(extra_meta=curr_step) try: - header, datarows = OraAggregateData.collect_ora2_data(course_id) - rows = [header] + [row for row in datarows] # lint-amnesty, pylint: disable=unnecessary-comprehension + header, datarows = csv_gen_func(course_id) + rows = [header] + for row in datarows: + rows.append(row) # Update progress to failed regardless of error type except Exception: # pylint: disable=broad-except TASK_LOG.exception('Failed to get ORA data.') @@ -326,9 +354,9 @@ def upload_ora2_data( ) task_progress.update_task_state(extra_meta=curr_step) - upload_csv_to_report_store(rows, 'ORA_data', course_id, start_date) + upload_csv_to_report_store(rows, 'ORA_{}'.format(report_name), course_id, start_date) - curr_step = {'step': 'Finalizing ORA data report'} + curr_step = {'step': 'Finalizing ORA {} report'.format(report_name)} task_progress.update_task_state(extra_meta=curr_step) TASK_LOG.info('%s, Task type: %s, Upload complete.', task_info_string, action_name) diff --git a/lms/djangoapps/instructor_task/tests/test_tasks.py b/lms/djangoapps/instructor_task/tests/test_tasks.py index 0d8c4d536c..dec7e0c84b 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks.py @@ -25,13 +25,12 @@ from lms.djangoapps.instructor_task.tasks import ( delete_problem_state, export_ora2_data, export_ora2_submission_files, + export_ora2_summary, generate_certificates, override_problem_score, rescore_problem, reset_problem_attempts ) -from lms.djangoapps.instructor_task.tasks_helper.misc import \ - upload_ora2_data # lint-amnesty, pylint: disable=unused-import from lms.djangoapps.instructor_task.tests.factories import InstructorTaskFactory from lms.djangoapps.instructor_task.tests.test_base import InstructorTaskModuleTestCase from xmodule.modulestore.exceptions import ItemNotFoundError @@ -715,3 +714,33 @@ class TestOra2ExportSubmissionFilesInstructorTask(TestInstructorTasks): assert args[0] == task_entry.id assert callable(args[1]) assert args[2] == action_name + + +class TestOra2SummaryInstructorTask(TestInstructorTasks): + """Tests instructor task that fetches ora2 response summary.""" + + def test_ora2_missing_current_task(self): + self._test_missing_current_task(export_ora2_summary) + + def test_ora2_with_failure(self): + self._test_run_with_failure(export_ora2_summary, 'We expected this to fail') + + def test_ora2_with_long_error_msg(self): + self._test_run_with_long_error_msg(export_ora2_summary) + + def test_ora2_with_short_error_msg(self): + self._test_run_with_short_error_msg(export_ora2_summary) + + 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_summary(task_entry.id, task_xmodule_args) + action_name = ugettext_noop('generated') + + 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 2a6c26fa57..dcec0f3fb1 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -50,7 +50,8 @@ 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_ora2_submission_files, + upload_ora2_summary ) from lms.djangoapps.instructor_task.tests.test_base import ( InstructorTaskCourseTestCase, @@ -2537,6 +2538,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): ] +@ddt.ddt class TestInstructorOra2Report(SharedModuleStoreTestCase): """ Tests that ORA2 response report generation works. @@ -2557,17 +2559,20 @@ class TestInstructorOra2Report(SharedModuleStoreTestCase): if os.path.exists(settings.GRADES_DOWNLOAD['ROOT_PATH']): shutil.rmtree(settings.GRADES_DOWNLOAD['ROOT_PATH']) - def test_report_fails_if_error(self): - with patch( - 'lms.djangoapps.instructor_task.tasks_helper.misc.OraAggregateData.collect_ora2_data' - ) as mock_collect_data: + @ddt.data( + ('lms.djangoapps.instructor_task.tasks_helper.misc.OraAggregateData.collect_ora2_data', upload_ora2_data), + ('lms.djangoapps.instructor_task.tasks_helper.misc.OraAggregateData.collect_ora2_summary', upload_ora2_summary), + ) + @ddt.unpack + def test_report_fails_if_error(self, data_collector_module, upload_func): + with patch(data_collector_module) as mock_collect_data: mock_collect_data.side_effect = KeyError with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task') as mock_current_task: mock_current_task.return_value = self.current_task - response = upload_ora2_data(None, None, self.course.id, None, 'generated') - assert response == UPDATE_STATUS_FAILED + response = upload_func(None, None, self.course.id, None, 'generated') + self.assertEqual(response, UPDATE_STATUS_FAILED) def test_report_stores_results(self): with ExitStack() as stack: @@ -2628,6 +2633,30 @@ class TestInstructorOra2AttachmentsExport(SharedModuleStoreTestCase): response = upload_ora2_submission_files(None, None, self.course.id, None, 'compressed') assert response == UPDATE_STATUS_FAILED + def test_summary_report_stores_results(self): + with freeze_time('2001-01-01 00:00:00'): + test_header = ['field1', 'field2'] + test_rows = [['row1_field1', 'row1_field2'], ['row2_field1', 'row2_field2']] + + 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_summary' + ) as mock_collect_summary: + mock_collect_summary.return_value = (test_header, test_rows) + with patch( + 'lms.djangoapps.instructor_task.models.DjangoStorageReportStore.store_rows' + ) as mock_store_rows: + return_val = upload_ora2_summary(None, None, self.course.id, None, 'generated') + + timestamp_str = datetime.now(UTC).strftime('%Y-%m-%d-%H%M') + course_id_string = quote(str(self.course.id).replace('/', '_')) + filename = '{}_ORA_summary_{}.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) + def test_export_fails_if_error_on_create_zip_step(self): with ExitStack() as stack: mock_current_task = stack.enter_context( diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index 4170763439..fefcfef668 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -95,6 +95,7 @@ from openedx.core.djangolib.markup import HTML, Text +

${_("Click to generate a ZIP file that contains all submission texts and attachments.")}