diff --git a/common/test/acceptance/pages/lms/instructor_dashboard.py b/common/test/acceptance/pages/lms/instructor_dashboard.py index e9bac24289..9bf3c53741 100644 --- a/common/test/acceptance/pages/lms/instructor_dashboard.py +++ b/common/test/acceptance/pages/lms/instructor_dashboard.py @@ -912,6 +912,13 @@ class DataDownloadPage(PageObject): """ return self.q(css="#report-downloads-table .file-download-link>a") + @property + def generate_ora2_response_report_button(self): + """ + Returns the ORA2 response download button for the current page. + """ + return self.q(css='input[name=export-ora2-data]') + def wait_for_available_report(self): """ Waits for a downloadable report to be available. diff --git a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py index 9205cdf700..dd79a53310 100644 --- a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py +++ b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py @@ -636,6 +636,20 @@ class DataDownloadsTest(BaseInstructorDashboardTest): self.verify_report_requested_event(report_name) self.verify_report_download(report_name) + def test_ora2_response_report_download(self): + """ + Scenario: Verify that an instructor can download an ORA2 grade report + + Given that I am an instructor + And I visit the instructor dashboard's "Data Downloads" tab + And I click on the "Download ORA2 Responses" button + Then a report should be generated + """ + report_name = u"ORA_data" + self.data_download_section.generate_ora2_response_report_button.click() + self.data_download_section.wait_for_available_report() + self.verify_report_download(report_name) + @attr('shard_7') class CertificatesTest(BaseInstructorDashboardTest): diff --git a/lms/djangoapps/instructor/features/common.py b/lms/djangoapps/instructor/features/common.py index 4aa73e3c00..3f6534d446 100644 --- a/lms/djangoapps/instructor/features/common.py +++ b/lms/djangoapps/instructor/features/common.py @@ -92,7 +92,7 @@ def click_a_button(step, button): # pylint: disable=unused-argument # Expect to see a message that grade report is being generated expected_msg = "The grade report is being created." \ " To view the status of the report, see" \ - " Pending Instructor Tasks below." + " Pending Tasks below." world.wait_for_visible('#report-request-response') assert_in( expected_msg, world.css_text('#report-request-response'), diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index ebfe42c322..96d6e35d23 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -244,6 +244,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest ('get_exec_summary_report', {}), ('get_proctored_exam_results', {}), ('get_problem_responses', {}), + ('export_ora2_data', {}), ] # Endpoints that only Instructors can access self.instructor_level_endpoints = [ @@ -322,6 +323,8 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest # update_forum_role(self.course.id, staff_member, FORUM_ROLE_ADMINISTRATOR, 'allow') for endpoint, args in self.staff_level_endpoints: + expected_status = 200 + # TODO: make these work if endpoint in ['update_forum_role_membership', 'list_forum_members']: continue @@ -333,7 +336,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest self._access_endpoint( endpoint, args, - 200, + expected_status, "Staff member should be allowed to access endpoint " + endpoint ) @@ -356,6 +359,8 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest self.client.login(username=inst.username, password='test') for endpoint, args in self.staff_level_endpoints: + expected_status = 200 + # TODO: make these work if endpoint in ['update_forum_role_membership']: continue @@ -367,18 +372,20 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest self._access_endpoint( endpoint, args, - 200, + expected_status, "Instructor should be allowed to access endpoint " + endpoint ) for endpoint, args in self.instructor_level_endpoints: + expected_status = 200 + # TODO: make this work if endpoint in ['rescore_problem']: continue self._access_endpoint( endpoint, args, - 200, + expected_status, "Instructor should be allowed to access endpoint " + endpoint ) @@ -2866,8 +2873,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment response = self.client.get(url, {}) success_status = "The {report_type} report is being created." \ " To view the status of the report, see Pending" \ - " Instructor Tasks" \ - " below".format(report_type=report_type) + " Tasks below".format(report_type=report_type) self.assertIn(success_status, response.content) @ddt.data(*EXECUTIVE_SUMMARY_DATA) @@ -2888,12 +2894,30 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment mock.side_effect = AlreadyRunningError() response = self.client.get(url, {}) already_running_status = "The {report_type} report is currently being created." \ - " To view the status of the report, see Pending Instructor Tasks below." \ + " To view the status of the report, see Pending Tasks below." \ " You will be able to download the report" \ " when it is" \ " complete.".format(report_type=report_type) self.assertIn(already_running_status, response.content) + def test_get_ora2_responses_success(self): + url = reverse('export_ora2_data', kwargs={'course_id': unicode(self.course.id)}) + + with patch('instructor_task.api.submit_export_ora2_data') as mock_submit_ora2_task: + mock_submit_ora2_task.return_value = True + response = self.client.get(url, {}) + success_status = "The ORA data report is being generated." + self.assertIn(success_status, response.content) + + def test_get_ora2_responses_already_running(self): + url = reverse('export_ora2_data', kwargs={'course_id': unicode(self.course.id)}) + + with patch('instructor_task.api.submit_export_ora2_data') as mock_submit_ora2_task: + mock_submit_ora2_task.side_effect = AlreadyRunningError() + response = self.client.get(url, {}) + already_running_status = "An ORA data report generation task is already in progress." + self.assertIn(already_running_status, response.content) + 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': self.course.id.to_deprecated_string()}) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index f8e2154da4..695ea7e670 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1290,12 +1290,12 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red try: instructor_task.api.submit_calculate_students_features_csv(request, course_key, query_features) success_status = _("The enrolled learner profile report is being created." - " To view the status of the report, see Pending Instructor Tasks below.") + " To view the status of the report, see Pending Tasks below.") return JsonResponse({"status": success_status}) except AlreadyRunningError: already_running_status = _( "This enrollment report is currently being created." - " To view the status of the report, see Pending Instructor Tasks below." + " To view the status of the report, see Pending Tasks below." " You will be able to download the report when it is complete.") return JsonResponse({"status": already_running_status}) @@ -1320,13 +1320,13 @@ def get_students_who_may_enroll(request, course_id): success_status = _( "The enrollment report is being created. This report contains" " information about learners who can enroll in the course." - " To view the status of the report, see Pending Instructor Tasks below." + " To view the status of the report, see Pending Tasks below." ) return JsonResponse({"status": success_status}) except AlreadyRunningError: already_running_status = _( "This enrollment report is currently being created." - " To view the status of the report, see Pending Instructor Tasks below." + " To view the status of the report, see Pending Tasks below." " You will be able to download the report when it is complete." ) return JsonResponse({"status": already_running_status}) @@ -1420,11 +1420,11 @@ def get_enrollment_report(request, course_id): try: instructor_task.api.submit_detailed_enrollment_features_csv(request, course_key) success_status = _("The detailed enrollment report is being created." - " To view the status of the report, see Pending Instructor Tasks below.") + " To view the status of the report, see Pending Tasks below.") return JsonResponse({"status": success_status}) except AlreadyRunningError: already_running_status = _("The detailed enrollment report is being created." - " To view the status of the report, see Pending Instructor Tasks below." + " To view the status of the report, see Pending Tasks below." " You will be able to download the report when it is complete.") return JsonResponse({ "status": already_running_status @@ -1444,11 +1444,11 @@ def get_exec_summary_report(request, course_id): try: instructor_task.api.submit_executive_summary_report(request, course_key) status_response = _("The executive summary report is being created." - " To view the status of the report, see Pending Instructor Tasks below.") + " To view the status of the report, see Pending Tasks below.") except AlreadyRunningError: status_response = _( "The executive summary report is currently being created." - " To view the status of the report, see Pending Instructor Tasks below." + " To view the status of the report, see Pending Tasks below." " You will be able to download the report when it is complete." ) return JsonResponse({ @@ -1468,11 +1468,11 @@ def get_course_survey_results(request, course_id): try: instructor_task.api.submit_course_survey_report(request, course_key) status_response = _("The survey report is being created." - " To view the status of the report, see Pending Instructor Tasks below.") + " To view the status of the report, see Pending Tasks below.") except AlreadyRunningError: status_response = _( "The survey report is currently being created." - " To view the status of the report, see Pending Instructor Tasks below." + " To view the status of the report, see Pending Tasks below." " You will be able to download the report when it is complete." ) return JsonResponse({ @@ -1503,11 +1503,11 @@ def get_proctored_exam_results(request, course_id): try: instructor_task.api.submit_proctored_exam_results_report(request, course_key, query_features) status_response = _("The proctored exam results report is being created." - " To view the status of the report, see Pending Instructor Tasks below.") + " To view the status of the report, see Pending Tasks below.") except AlreadyRunningError: status_response = _( "The proctored exam results report is currently being created." - " To view the status of the report, see Pending Instructor Tasks below." + " To view the status of the report, see Pending Tasks below." " You will be able to download the report when it is complete." ) return JsonResponse({ @@ -2327,6 +2327,31 @@ def list_financial_report_downloads(_request, course_id): return JsonResponse(response_payload) +@transaction.non_atomic_requests +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +def export_ora2_data(request, course_id): + """ + Pushes a Celery task which will aggregate ora2 responses for a course into a .csv + """ + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + try: + instructor_task.api.submit_export_ora2_data(request, course_key) + success_status = _("The ORA data report is being generated.") + + return JsonResponse({"status": success_status}) + except AlreadyRunningError: + already_running_status = _( + "An ORA data report generation task is already in " + "progress. Check the 'Pending Tasks' table " + "for the status of the task. When completed, the report " + "will be available for download in the table below." + ) + + return JsonResponse({"status": already_running_status}) + + @transaction.non_atomic_requests @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @@ -2339,15 +2364,13 @@ def calculate_grades_csv(request, course_id): try: instructor_task.api.submit_calculate_grades_csv(request, course_key) success_status = _("The grade report is being created." - " To view the status of the report, see Pending Instructor Tasks below.") + " To view the status of the report, see Pending Tasks below.") return JsonResponse({"status": success_status}) except AlreadyRunningError: already_running_status = _("The grade report is currently being created." - " To view the status of the report, see Pending Instructor Tasks below." + " To view the status of the report, see Pending Tasks below." " You will be able to download the report when it is complete.") - return JsonResponse({ - "status": already_running_status - }) + return JsonResponse({"status": already_running_status}) @transaction.non_atomic_requests @@ -2366,11 +2389,11 @@ def problem_grade_report(request, course_id): try: instructor_task.api.submit_problem_grade_report(request, course_key) success_status = _("The problem grade report is being created." - " To view the status of the report, see Pending Instructor Tasks below.") + " To view the status of the report, see Pending Tasks below.") return JsonResponse({"status": success_status}) except AlreadyRunningError: already_running_status = _("A problem grade report is already being generated." - " To view the status of the report, see Pending Instructor Tasks below." + " To view the status of the report, see Pending Tasks below." " You will be able to download the report when it is complete.") return JsonResponse({ "status": already_running_status diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 0c9469b899..ccec3c2a18 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -117,6 +117,8 @@ urlpatterns = patterns( 'instructor.views.api.get_exec_summary_report', name="get_exec_summary_report"), url(r'get_course_survey_results$', 'instructor.views.api.get_course_survey_results', name="get_course_survey_results"), + url(r'export_ora2_data', + 'instructor.views.api.export_ora2_data', name="export_ora2_data"), # Coupon Codes.. url(r'get_coupon_codes', diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index f9acbd89c0..f90368c3aa 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -567,6 +567,7 @@ def _section_data_download(course, access): 'problem_grade_report_url': reverse('problem_grade_report', kwargs={'course_id': unicode(course_key)}), 'course_has_survey': True if course.course_survey_name else False, 'course_survey_results_url': reverse('get_course_survey_results', kwargs={'course_id': unicode(course_key)}), + 'export_ora2_data_url': reverse('export_ora2_data', kwargs={'course_id': unicode(course_key)}), } return section_data diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index a58973dbb7..1f7b50847f 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -28,7 +28,8 @@ from instructor_task.tasks import ( exec_summary_report_csv, course_survey_report_csv, generate_certificates, - proctored_exam_results_csv + proctored_exam_results_csv, + export_ora2_data, ) from certificates.models import CertificateGenerationHistory @@ -424,6 +425,18 @@ def submit_cohort_students(request, course_key, file_name): return submit_task(request, task_type, task_class, course_key, task_input, task_key) +def submit_export_ora2_data(request, course_key): + """ + AlreadyRunningError is raised if an ora2 report is already being generated. + """ + task_type = 'export_ora2_data' + task_class = export_ora2_data + 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, students=None): # pylint: disable=invalid-name """ Submits a task to generate certificates for given students enrolled in the course or diff --git a/lms/djangoapps/instructor_task/tasks.py b/lms/djangoapps/instructor_task/tasks.py index a5ac19e5a5..237d98c820 100644 --- a/lms/djangoapps/instructor_task/tasks.py +++ b/lms/djangoapps/instructor_task/tasks.py @@ -44,7 +44,8 @@ from instructor_task.tasks_helper import ( upload_exec_summary_report, upload_course_survey_report, generate_students_certificates, - upload_proctored_exam_results_report + upload_proctored_exam_results_report, + upload_ora2_data, ) @@ -290,3 +291,13 @@ def cohort_students(entry_id, xmodule_instance_args): action_name = ugettext_noop('cohorted') task_fn = partial(cohort_students_and_upload, xmodule_instance_args) return run_main_task(entry_id, task_fn, action_name) + + +@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable +def export_ora2_data(entry_id, xmodule_instance_args): + """ + Generate a CSV of ora2 responses and push it to S3. + """ + action_name = ugettext_noop('generated') + task_fn = partial(upload_ora2_data, xmodule_instance_args) + return run_main_task(entry_id, task_fn, action_name) diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index 08a1c0e929..a4f50953ad 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -57,6 +57,7 @@ from instructor_analytics.basic import ( list_problem_responses ) from instructor_analytics.csvs import format_dictlist +from openassessment.data import OraAggregateData from instructor_task.models import ReportStore, InstructorTask, PROGRESS from lms.djangoapps.lms_xblock.runtime import LmsPartitionService from openedx.core.djangoapps.course_groups.cohorts import get_cohort @@ -1599,3 +1600,70 @@ def invalidate_generated_certificates(course_id, enrolled_students, certificate_ download_url='', grade='', ) + + +def upload_ora2_data( + _xmodule_instance_args, _entry_id, course_id, _task_input, action_name +): + """ + Collect ora2 responses and upload them to S3 as a CSV + """ + + start_date = datetime.now(UTC) + start_time = time() + + num_attempted = 1 + num_total = 1 + + fmt = u'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 + + curr_step = {'step': "Collecting responses"} + TASK_LOG.info( + u'%s, Task type: %s, Current step: %s for all submissions', + task_info_string, + action_name, + curr_step, + ) + + 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] + # Update progress to failed regardless of error type + except Exception: # pylint: disable=broad-except + TASK_LOG.exception('Failed to get ORA data.') + task_progress.failed = 1 + curr_step = {'step': "Error while collecting data"} + + task_progress.update_task_state(extra_meta=curr_step) + + return UPDATE_STATUS_FAILED + + task_progress.succeeded = 1 + curr_step = {'step': "Uploading CSV"} + TASK_LOG.info( + u'%s, Task type: %s, Current step: %s', + task_info_string, + action_name, + curr_step, + ) + task_progress.update_task_state(extra_meta=curr_step) + + upload_csv_to_report_store(rows, 'ORA_data', course_id, start_date) + + curr_step = {'step': 'Finalizing ORA data report'} + 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/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index 6dbcb71abf..5dcd09769a 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -1,7 +1,7 @@ """ Test for LMS instructor background task queue management """ -from mock import patch, Mock +from mock import patch, Mock, MagicMock from bulk_email.models import CourseEmail, SEND_TO_ALL from courseware.tests.factories import UserFactory from xmodule.modulestore.exceptions import ItemNotFoundError @@ -22,16 +22,20 @@ from instructor_task.api import ( submit_executive_summary_report, submit_course_survey_report, generate_certificates_for_students, - regenerate_certificates + regenerate_certificates, + submit_export_ora2_data, ) from instructor_task.api_helper import AlreadyRunningError from instructor_task.models import InstructorTask, PROGRESS -from instructor_task.tests.test_base import (InstructorTaskTestCase, - InstructorTaskCourseTestCase, - InstructorTaskModuleTestCase, - TestReportMixin, - TEST_COURSE_KEY) +from instructor_task.tasks import export_ora2_data +from instructor_task.tests.test_base import ( + InstructorTaskTestCase, + InstructorTaskCourseTestCase, + InstructorTaskModuleTestCase, + TestReportMixin, + TEST_COURSE_KEY, +) from certificates.models import CertificateStatuses, CertificateGenerationHistory @@ -256,6 +260,16 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa ) self._test_resubmission(api_call) + def test_submit_ora2_request_task(self): + request = self.create_task_request(self.instructor) + + with patch('instructor_task.api.submit_task') as mock_submit_task: + mock_submit_task.return_value = MagicMock() + submit_export_ora2_data(request, self.course.id) + + mock_submit_task.assert_called_once_with( + request, 'export_ora2_data', export_ora2_data, 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 2ec7ef7ec0..f2562b3632 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks.py @@ -11,6 +11,8 @@ from uuid import uuid4 from mock import Mock, MagicMock, patch from celery.states import SUCCESS, FAILURE +from django.utils.translation import ugettext_noop +from functools import partial from xmodule.modulestore.exceptions import ItemNotFoundError from opaque_keys.edx.locations import i4xEncoder @@ -27,8 +29,12 @@ from instructor_task.tasks import ( reset_problem_attempts, delete_problem_state, generate_certificates, + export_ora2_data, +) +from instructor_task.tasks_helper import ( + UpdateProblemModuleStateError, + upload_ora2_data, ) -from instructor_task.tasks_helper import UpdateProblemModuleStateError PROBLEM_URL_NAME = "test_urlname" @@ -471,3 +477,31 @@ class TestCertificateGenerationnstructorTask(TestInstructorTasks): expected_attempted=1, expected_total=1 ) + + +class TestOra2ResponsesInstructorTask(TestInstructorTasks): + """Tests instructor task that fetches ora2 response data.""" + + def test_ora2_missing_current_task(self): + self._test_missing_current_task(export_ora2_data) + + def test_ora2_with_failure(self): + self._test_run_with_failure(export_ora2_data, 'We expected this to fail') + + def test_ora2_with_long_error_msg(self): + self._test_run_with_long_error_msg(export_ora2_data) + + def test_ora2_with_short_error_msg(self): + self._test_run_with_short_error_msg(export_ora2_data) + + def test_ora2_runs_task(self): + task_entry = self._create_input_entry() + task_xmodule_args = self._get_xmodule_instance_args() + + with patch('instructor_task.tasks.run_main_task') as mock_main_task: + export_ora2_data(task_entry.id, task_xmodule_args) + + action_name = ugettext_noop('generated') + task_fn = partial(upload_ora2_data, task_xmodule_args) + + mock_main_task.assert_called_once_with_args(task_entry.id, task_fn, 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 e50e756a05..72a354652d 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -3,10 +3,18 @@ """ Unit tests for LMS instructor-initiated background tasks helper functions. -Tests that CSV grade report generation works with unicode emails. +- Tests that CSV grade report generation works with unicode emails. +- Tests all of the existing reports. """ + +import os +import shutil +from datetime import datetime +import urllib + import ddt +from freezegun import freeze_time from mock import Mock, patch import tempfile import json @@ -22,6 +30,15 @@ from course_modes.models import CourseMode from courseware.tests.factories import InstructorFactory from instructor_task.tests.test_base import InstructorTaskCourseTestCase, TestReportMixin, InstructorTaskModuleTestCase from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup, CohortMembership +from django.conf import settings +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from pytz import UTC + +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory +from student.models import CourseEnrollment +from xmodule.partitions.partitions import Group, UserPartition + from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory import openedx.core.djangoapps.user_api.course_tag.api as course_tag_api from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme @@ -45,6 +62,9 @@ from instructor_task.tasks_helper import ( upload_exec_summary_report, upload_course_survey_report, generate_students_certificates, + upload_ora2_data, + UPDATE_STATUS_FAILED, + UPDATE_STATUS_SUCCEEDED, ) from instructor_analytics.basic import UNAVAILABLE from openedx.core.djangoapps.util.testing import ContentGroupTestCase, TestConditionalContent @@ -2012,3 +2032,56 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): }, result ) + + +class TestInstructorOra2Report(SharedModuleStoreTestCase): + """ + Tests that ORA2 response report generation works. + """ + @classmethod + def setUpClass(cls): + super(TestInstructorOra2Report, cls).setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super(TestInstructorOra2Report, self).setUp() + + self.current_task = Mock() + self.current_task.update_state = Mock() + + def tearDown(self): + super(TestInstructorOra2Report, self).tearDown() + 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('instructor_task.tasks_helper.OraAggregateData.collect_ora2_data') as mock_collect_data: + mock_collect_data.side_effect = KeyError + + with patch('instructor_task.tasks_helper._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') + self.assertEqual(response, UPDATE_STATUS_FAILED) + + @freeze_time('2001-01-01 00:00:00') + def test_report_stores_results(self): + test_header = ['field1', 'field2'] + test_rows = [['row1_field1', 'row1_field2'], ['row2_field1', 'row2_field2']] + + with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task: + mock_current_task.return_value = self.current_task + + with patch('instructor_task.tasks_helper.OraAggregateData.collect_ora2_data') as mock_collect_data: + mock_collect_data.return_value = (test_header, test_rows) + + with patch('instructor_task.models.LocalFSReportStore.store_rows') as mock_store_rows: + return_val = upload_ora2_data(None, None, self.course.id, None, 'generated') + + # pylint: disable=maybe-no-member + timestamp_str = datetime.now(UTC).strftime('%Y-%m-%d-%H%M') + course_id_string = urllib.quote(self.course.id.to_deprecated_string().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) diff --git a/lms/envs/common.py b/lms/envs/common.py index de98686bce..2b8e298fcd 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2230,6 +2230,8 @@ BADGR_BASE_URL = "http://localhost:8005" BADGR_ISSUER_SLUG = "example-issuer" ###################### Grade Downloads ###################### +# These keys are used for all of our asynchronous downloadable files, including +# the ones that contain information other than grades. GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE GRADES_DOWNLOAD = { @@ -2244,7 +2246,6 @@ FINANCIAL_REPORTS = { 'ROOT_PATH': '/tmp/edx-s3/financial_reports', } - #### PASSWORD POLICY SETTINGS ##### PASSWORD_MIN_LENGTH = 8 PASSWORD_MAX_LENGTH = None diff --git a/lms/static/coffee/src/instructor_dashboard/data_download.coffee b/lms/static/coffee/src/instructor_dashboard/data_download.coffee index 032ac9e02c..b9ad8f6c50 100644 --- a/lms/static/coffee/src/instructor_dashboard/data_download.coffee +++ b/lms/static/coffee/src/instructor_dashboard/data_download.coffee @@ -83,6 +83,7 @@ class DataDownload @$grade_config_btn = @$section.find("input[name='dump-gradeconf']'") @$calculate_grades_csv_btn = @$section.find("input[name='calculate-grades-csv']'") @$problem_grade_report_csv_btn = @$section.find("input[name='problem-grade-report']'") + @$async_report_btn = @$section.find("input[class='async-report-btn']'") # response areas @$download = @$section.find '.data-download-container' @@ -236,27 +237,26 @@ class DataDownload @clear_display() @$download_display_text.html data['grading_config_summary'] - @$calculate_grades_csv_btn.click (e) => - @onClickGradeDownload @$calculate_grades_csv_btn, gettext("Error generating grades. Please try again.") - - @$problem_grade_report_csv_btn.click (e) => - @onClickGradeDownload @$problem_grade_report_csv_btn, gettext("Error generating problem grade report. Please try again.") - - onClickGradeDownload: (button, errorMessage) -> - # Clear any CSS styling from the request-response areas - #$(".msg-confirm").css({"display":"none"}) - #$(".msg-error").css({"display":"none"}) - @clear_display() - url = button.data 'endpoint' - $.ajax - dataType: 'json' - url: url - error: (std_ajax_err) => - @$reports_request_response_error.text errorMessage - $(".msg-error").css({"display":"block"}) - success: (data) => - @$reports_request_response.text data['status'] - $(".msg-confirm").css({"display":"block"}) + @$async_report_btn.click (e) => + # Clear any CSS styling from the request-response areas + #$(".msg-confirm").css({"display":"none"}) + #$(".msg-error").css({"display":"none"}) + @clear_display() + url = $(e.target).data 'endpoint' + $.ajax + dataType: 'json' + url: url + error: std_ajax_err => + if e.target.name == 'calculate-grades-csv' + @$grades_request_response_error.text gettext("Error generating grades. Please try again.") + else if e.target.name == 'problem-grade-report' + @$grades_request_response_error.text gettext("Error generating problem grade report. Please try again.") + else if e.target.name == 'export-ora2-data' + @$grades_request_response_error.text gettext("Error generating ORA data report. Please try again.") + $(".msg-error").css({"display":"block"}) + success: (data) => + @$reports_request_response.text data['status'] + $(".msg-confirm").css({"display":"block"}) # handler for when the section title is clicked. onClickTitle: -> diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index 882a3cda74..fd57f99669 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -75,10 +75,11 @@ %if settings.FEATURES.get('ALLOW_COURSE_STAFF_GRADE_DOWNLOADS') or section_data['access']['admin']:
${_("Click to generate a CSV grade report for all currently enrolled students.")}
- - - - ++ + + +
%endif diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index a16b19e5b8..195ee02ed7 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -76,8 +76,8 @@ git+https://github.com/edx/XBlock.git@xblock-0.4.5#egg=XBlock==0.4.5 -e git+https://github.com/edx/event-tracking.git@0.2.1#egg=event-tracking==0.2.1 -e git+https://github.com/edx/django-splash.git@v0.2#egg=django-splash==0.2 -e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock -git+https://github.com/edx/edx-ora2.git@1.0.1#egg=ora2==1.0.1 --e git+https://github.com/edx/edx-submissions.git@1.0.0#egg=edx-submissions==1.0.0 +git+https://github.com/edx/edx-ora2.git@1.1.0#egg=ora2==1.1.0 +-e git+https://github.com/edx/edx-submissions.git@1.1.0#egg=edx-submissions==1.1.0 git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3 git+https://github.com/edx/i18n-tools.git@v0.2#egg=i18n-tools==v0.2 git+https://github.com/edx/edx-val.git@0.0.9#egg=edxval==0.0.9