From df5b2dadda636184116fa394105ea37e3ab3b867 Mon Sep 17 00:00:00 2001 From: Tim Krones Date: Wed, 22 Jul 2015 16:14:57 +0200 Subject: [PATCH] Move functionality for exporting a CSV of all student answers to a given problem from legacy instructor dash to new instructor dash. --- lms/djangoapps/instructor/tests/test_api.py | 120 +++++++++++++++++- lms/djangoapps/instructor/views/api.py | 49 ++++++- lms/djangoapps/instructor/views/api_urls.py | 2 + .../instructor/views/instructor_dashboard.py | 1 + lms/djangoapps/instructor/views/legacy.py | 29 ----- lms/djangoapps/instructor_analytics/basic.py | 41 +++++- .../instructor_analytics/tests/test_basic.py | 68 ++++++++-- lms/djangoapps/instructor_task/api.py | 16 +++ lms/djangoapps/instructor_task/tasks.py | 13 ++ .../instructor_task/tasks_helper.py | 42 +++++- .../instructor_task/tests/test_api.py | 9 ++ .../tests/test_tasks_helper.py | 27 ++++ .../instructor_dashboard/data_download.coffee | 18 +++ .../legacy_instructor_dashboard.html | 5 +- .../instructor_dashboard_2/data_download.html | 15 ++- 15 files changed, 400 insertions(+), 55 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 1703ae7960..54a63321d5 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -4,6 +4,7 @@ Unit tests for instructor.api methods. """ import datetime import ddt +import functools import random import pytz import io @@ -28,6 +29,7 @@ from mock import Mock, patch from nose.tools import raises from nose.plugins.attrib import attr from opaque_keys.edx.locations import SlashSeparatedCourseKey +from opaque_keys.edx.locator import UsageKey from course_modes.models import CourseMode from courseware.models import StudentModule @@ -107,6 +109,12 @@ REPORTS_DATA = ( 'instructor_api_endpoint': 'get_proctored_exam_results', 'task_api_endpoint': 'instructor_task.api.submit_proctored_exam_results_report', 'extra_instructor_api_kwargs': {}, + }, + { + 'report_type': 'problem responses', + 'instructor_api_endpoint': 'get_problem_responses', + 'task_api_endpoint': 'instructor_task.api.submit_calculate_problem_responses_csv', + 'extra_instructor_api_kwargs': {}, } ) @@ -230,6 +238,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): ('get_students_who_may_enroll', {}), ('get_exec_summary_report', {}), ('get_proctored_exam_results', {}), + ('get_problem_responses', {}), ] # Endpoints that only Instructors can access self.instructor_level_endpoints = [ @@ -282,6 +291,20 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): "Student should not be allowed to access endpoint " + endpoint ) + def _access_problem_responses_endpoint(self, msg): + """ + Access endpoint for problem responses report, ensuring that + UsageKey.from_string returns a problem key that the endpoint + can work with. + + msg: message to display if assertion fails. + """ + mock_problem_key = Mock(return_value=u'') + mock_problem_key.course_key = self.course.id + with patch.object(UsageKey, 'from_string') as patched_method: + patched_method.return_value = mock_problem_key + self._access_endpoint('get_problem_responses', {}, 200, msg) + def test_staff_level(self): """ Ensure that a staff member can't access instructor endpoints. @@ -297,6 +320,11 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): # TODO: make these work if endpoint in ['update_forum_role_membership', 'list_forum_members']: continue + elif endpoint == 'get_problem_responses': + self._access_problem_responses_endpoint( + "Staff member should be allowed to access endpoint " + endpoint + ) + continue self._access_endpoint( endpoint, args, @@ -326,6 +354,11 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): # TODO: make these work if endpoint in ['update_forum_role_membership']: continue + elif endpoint == 'get_problem_responses': + self._access_problem_responses_endpoint( + "Instructor should be allowed to access endpoint " + endpoint + ) + continue self._access_endpoint( endpoint, args, @@ -2268,6 +2301,78 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa self.assertEqual(res['total_used_codes'], used_codes) self.assertEqual(res['total_codes'], 5) + def test_get_problem_responses_invalid_location(self): + """ + Test whether get_problem_responses returns an appropriate status + message when users submit an invalid problem location. + """ + url = reverse( + 'get_problem_responses', + kwargs={'course_id': unicode(self.course.id)} + ) + problem_location = '' + + response = self.client.get(url, {'problem_location': problem_location}) + res_json = json.loads(response.content) + self.assertEqual(res_json, 'Could not find problem with this location.') + + def valid_problem_location(test): # pylint: disable=no-self-argument + """ + Decorator for tests that target get_problem_responses endpoint and + need to pretend user submitted a valid problem location. + """ + @functools.wraps(test) + def wrapper(self, *args, **kwargs): + """ + Run `test` method, ensuring that UsageKey.from_string returns a + problem key that the get_problem_responses endpoint can + work with. + """ + mock_problem_key = Mock(return_value=u'') + mock_problem_key.course_key = self.course.id + with patch.object(UsageKey, 'from_string') as patched_method: + patched_method.return_value = mock_problem_key + test(self, *args, **kwargs) + return wrapper + + @valid_problem_location + def test_get_problem_responses_successful(self): + """ + Test whether get_problem_responses returns an appropriate status + message if CSV generation was started successfully. + """ + url = reverse( + 'get_problem_responses', + kwargs={'course_id': unicode(self.course.id)} + ) + problem_location = '' + + response = self.client.get(url, {'problem_location': problem_location}) + res_json = json.loads(response.content) + self.assertIn('status', res_json) + status = res_json['status'] + self.assertIn('is being created', status) + self.assertNotIn('already in progress', status) + + @valid_problem_location + def test_get_problem_responses_already_running(self): + """ + Test whether get_problem_responses returns an appropriate status + message if CSV generation is already in progress. + """ + url = reverse( + 'get_problem_responses', + kwargs={'course_id': unicode(self.course.id)} + ) + + with patch('instructor_task.api.submit_calculate_problem_responses_csv') as submit_task_function: + error = AlreadyRunningError() + submit_task_function.side_effect = error + response = self.client.get(url, {}) + res_json = json.loads(response.content) + self.assertIn('status', res_json) + self.assertIn('already in progress', res_json['status']) + def test_get_students_features(self): """ Test that some minimum of information is formatted @@ -2571,16 +2676,21 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa @ddt.data(*REPORTS_DATA) @ddt.unpack + @valid_problem_location def test_calculate_report_csv_success(self, report_type, instructor_api_endpoint, task_api_endpoint, extra_instructor_api_kwargs): kwargs = {'course_id': unicode(self.course.id)} kwargs.update(extra_instructor_api_kwargs) url = reverse(instructor_api_endpoint, kwargs=kwargs) - - CourseFinanceAdminRole(self.course.id).add_users(self.instructor) - with patch(task_api_endpoint): - response = self.client.get(url, {}) success_status = "The {report_type} report is being created.".format(report_type=report_type) - self.assertIn(success_status, response.content) + if report_type == 'problem responses': + with patch(task_api_endpoint): + response = self.client.get(url, {'problem_location': ''}) + self.assertIn(success_status, response.content) + else: + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) + with patch(task_api_endpoint): + response = self.client.get(url, {}) + self.assertIn(success_status, response.content) @ddt.data(*EXECUTIVE_SUMMARY_DATA) @ddt.unpack diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 712b9e26fe..caf85b552b 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -35,7 +35,7 @@ from util.file import ( store_uploaded_file, course_and_time_based_filename_generator, FileValidationException, UniversalNewlineIterator ) -from util.json_request import JsonResponse +from util.json_request import JsonResponse, JsonResponseBadRequest from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features from microsite_configuration import microsite @@ -107,7 +107,7 @@ from .tools import ( bulk_email_is_enabled_for_course, add_block_ids, ) -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys import InvalidKeyError from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted @@ -887,6 +887,51 @@ def list_course_role_members(request, course_id): return JsonResponse(response_payload) +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +def get_problem_responses(request, course_id): + """ + Initiate generation of a CSV file containing all student answers + to a given problem. + + Responds with JSON + {"status": "... status message ..."} + + if initiation is successful (or generation task is already running). + + Responds with BadRequest if problem location is faulty. + """ + course_key = CourseKey.from_string(course_id) + problem_location = request.GET.get('problem_location', '') + + try: + problem_key = UsageKey.from_string(problem_location) + # Are we dealing with an "old-style" problem location? + run = getattr(problem_key, 'run') + if not run: + problem_key = course_key.make_usage_key_from_deprecated_string(problem_location) + if problem_key.course_key != course_key: + raise InvalidKeyError(type(problem_key), problem_key) + except InvalidKeyError: + return JsonResponseBadRequest(_("Could not find problem with this location.")) + + try: + instructor_task.api.submit_calculate_problem_responses_csv(request, course_key, problem_location) + success_status = _( + "The problem responses report is being created." + " To view the status of the report, see Pending Tasks below." + ) + return JsonResponse({"status": success_status}) + except AlreadyRunningError: + already_running_status = _( + "A problem responses 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}) + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 2d2afeb55b..f24367a159 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -17,6 +17,8 @@ urlpatterns = patterns( 'instructor.views.api.modify_access', name="modify_access"), url(r'^bulk_beta_modify_access$', 'instructor.views.api.bulk_beta_modify_access', name="bulk_beta_modify_access"), + url(r'^get_problem_responses$', + 'instructor.views.api.get_problem_responses', name="get_problem_responses"), url(r'^get_grading_config$', 'instructor.views.api.get_grading_config', name="get_grading_config"), url(r'^get_students_features(?P/csv)?$', diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index a544a7d6ec..d2c351d691 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -489,6 +489,7 @@ def _section_data_download(course, access): 'section_display_name': _('Data Download'), 'access': access, 'show_generate_proctored_exam_report_button': settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False), + 'get_problem_responses_url': reverse('get_problem_responses', kwargs={'course_id': unicode(course_key)}), 'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': unicode(course_key)}), 'get_students_features_url': reverse('get_students_features', kwargs={'course_id': unicode(course_key)}), 'get_students_who_may_enroll_url': reverse( diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index b9118e4ec1..ac9e07261c 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -276,35 +276,6 @@ def instructor_dashboard(request, course_id): msg2, __ = _do_remote_gradebook(request.user, course, 'post-grades', files=files) msg += msg2 - #---------------------------------------- - # DataDump - - elif 'Download CSV of all responses to problem' in action: - problem_to_dump = request.POST.get('problem_to_dump', '') - - if problem_to_dump[-4:] == ".xml": - problem_to_dump = problem_to_dump[:-4] - try: - module_state_key = course_key.make_usage_key_from_deprecated_string(problem_to_dump) - smdat = StudentModule.objects.filter( - course_id=course_key, - module_state_key=module_state_key - ) - smdat = smdat.order_by('student') - msg += _("Found {num} records to dump.").format(num=smdat) - except Exception as err: # pylint: disable=broad-except - msg += "{text}
{err}
".format( - text=_("Couldn't find module with that urlname."), - err=escape(err) - ) - smdat = [] - - if smdat: - datatable = {'header': ['username', 'state']} - datatable['data'] = [[x.student.username, x.state] for x in smdat] - datatable['title'] = _('Student state for problem {problem}').format(problem=problem_to_dump) - return return_csv('student_state_from_{problem}.csv'.format(problem=problem_to_dump), datatable) - #---------------------------------------- # enrollment diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 59af446680..8624d27b26 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -11,12 +11,14 @@ from shoppingcart.models import ( from django.db.models import Q from django.conf import settings from django.contrib.auth.models import User -from django.core.urlresolvers import reverse -import xmodule.graders as xmgraders from django.core.exceptions import ObjectDoesNotExist +from django.core.urlresolvers import reverse +from opaque_keys.edx.keys import UsageKey +import xmodule.graders as xmgraders from microsite_configuration import microsite from student.models import CourseEnrollmentAllowed from edx_proctoring.api import get_all_exam_attempts +from courseware.models import StudentModule STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email') @@ -317,6 +319,41 @@ def coupon_codes_features(features, coupons_list, course_id): return [extract_coupon(coupon, features) for coupon in coupons_list] +def list_problem_responses(course_key, problem_location): + """ + Return responses to a given problem as a dict. + + list_problem_responses(course_key, problem_location) + + would return [ + {'username': u'user1', 'state': u'...'}, + {'username': u'user2', 'state': u'...'}, + {'username': u'user3', 'state': u'...'}, + ] + + where `state` represents a student's response to the problem + identified by `problem_location`. + """ + problem_key = UsageKey.from_string(problem_location) + # Are we dealing with an "old-style" problem location? + run = getattr(problem_key, 'run') + if not run: + problem_key = course_key.make_usage_key_from_deprecated_string(problem_location) + if problem_key.course_key != course_key: + return [] + + smdat = StudentModule.objects.filter( + course_id=course_key, + module_state_key=problem_key + ) + smdat = smdat.order_by('student') + + return [ + {'username': response.student.username, 'state': response.state} + for response in smdat + ] + + def course_registration_features(features, registration_codes, csv_type): """ Return list of Course Registration Codes as dictionaries. diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 80b8cfaaff..d03180a8a2 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -2,27 +2,29 @@ Tests for instructor.basic """ -import json import datetime -from django.db.models import Q +import json import pytz -from student.models import CourseEnrollment, CourseEnrollmentAllowed +from mock import MagicMock, Mock, patch from django.core.urlresolvers import reverse -from mock import patch +from django.db.models import Q + +from course_modes.models import CourseMode +from courseware.tests.factories import InstructorFactory +from instructor_analytics.basic import ( + StudentModule, sale_record_features, sale_order_record_features, enrolled_students_features, + course_registration_features, coupon_codes_features, get_proctored_exam_results, list_may_enroll, + list_problem_responses, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES +) +from opaque_keys.edx.locator import UsageKey +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.roles import CourseSalesAdminRole from student.tests.factories import UserFactory, CourseModeFactory from shoppingcart.models import ( CourseRegistrationCode, RegistrationCodeRedemption, Order, Invoice, Coupon, CourseRegCodeItem, CouponRedemption, CourseRegistrationCodeInvoiceItem ) -from course_modes.models import CourseMode -from instructor_analytics.basic import ( - sale_record_features, sale_order_record_features, enrolled_students_features, - course_registration_features, coupon_codes_features, list_may_enroll, - AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES, - get_proctored_exam_results) -from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory -from courseware.tests.factories import InstructorFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from edx_proctoring.api import create_exam @@ -51,6 +53,48 @@ class TestAnalyticsBasic(ModuleStoreTestCase): email=student.email, course_id=self.course_key ) + def test_list_problem_responses(self): + def result_factory(result_id): + """ + Return a dummy StudentModule object that can be queried for + relevant info (student.username and state). + """ + result = Mock(spec=['student', 'state']) + result.student.username.return_value = u'user{}'.format(result_id) + result.state.return_value = u'state{}'.format(result_id) + return result + + # Ensure that UsageKey.from_string returns a problem key that list_problem_responses can work with + # (even when called with a dummy location): + mock_problem_key = Mock(return_value=u'') + mock_problem_key.course_key = self.course_key + with patch.object(UsageKey, 'from_string') as patched_from_string: + patched_from_string.return_value = mock_problem_key + + # Ensure that StudentModule.objects.filter returns a result set that list_problem_responses can work with + # (this keeps us from having to create fixtures for this test): + mock_results = MagicMock(return_value=[result_factory(n) for n in range(5)]) + with patch.object(StudentModule, 'objects') as patched_manager: + patched_manager.filter.return_value = mock_results + + mock_problem_location = '' + problem_responses = list_problem_responses(self.course_key, problem_location=mock_problem_location) + + # Check if list_problem_responses called UsageKey.from_string to look up problem key: + patched_from_string.assert_called_once_with(mock_problem_location) + # Check if list_problem_responses called StudentModule.objects.filter to obtain relevant records: + patched_manager.filter.assert_called_once_with( + course_id=self.course_key, module_state_key=mock_problem_key + ) + + # Check if list_problem_responses returned expected results: + self.assertEqual(len(problem_responses), len(mock_results)) + for mock_result in mock_results: + self.assertTrue( + {'username': mock_result.student.username, 'state': mock_result.state} in + problem_responses + ) + def test_enrolled_students_features_username(self): self.assertIn('username', AVAILABLE_FEATURES) userreports = enrolled_students_features(self.course_key, ['username']) diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index de4c14f8c2..a1bb562536 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -18,6 +18,7 @@ from instructor_task.tasks import ( reset_problem_attempts, delete_problem_state, send_bulk_course_email, + calculate_problem_responses_csv, calculate_grades_csv, calculate_problem_grade_report, calculate_students_features_csv, @@ -328,6 +329,21 @@ def submit_bulk_course_email(request, course_key, email_id): return submit_task(request, task_type, task_class, course_key, task_input, task_key) +def submit_calculate_problem_responses_csv(request, course_key, problem_location): # pylint: disable=invalid-name + """ + Submits a task to generate a CSV file containing all student + answers to a given problem. + + Raises AlreadyRunningError if said file is already being updated. + """ + task_type = 'problem_responses_csv' + task_class = calculate_problem_responses_csv + task_input = {'problem_location': problem_location} + task_key = "" + + return submit_task(request, task_type, task_class, course_key, task_input, task_key) + + def submit_calculate_grades_csv(request, course_key): """ AlreadyRunningError is raised if the course's grades are already being updated. diff --git a/lms/djangoapps/instructor_task/tasks.py b/lms/djangoapps/instructor_task/tasks.py index de5ab34f86..7f61b8ba52 100644 --- a/lms/djangoapps/instructor_task/tasks.py +++ b/lms/djangoapps/instructor_task/tasks.py @@ -34,6 +34,7 @@ from instructor_task.tasks_helper import ( rescore_problem_module_state, reset_attempts_module_state, delete_problem_module_state, + upload_problem_responses_csv, upload_grades_csv, upload_problem_grade_report, upload_students_csv, @@ -145,6 +146,18 @@ def send_bulk_course_email(entry_id, _xmodule_instance_args): return run_main_task(entry_id, visit_fcn, action_name) +@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable +def calculate_problem_responses_csv(entry_id, xmodule_instance_args): + """ + Compute student answers to a given problem and upload the CSV to + an S3 bucket for download. + """ + # Translators: This is a past-tense verb that is inserted into task progress messages as {action}. + action_name = ugettext_noop('generated') + task_fn = partial(upload_problem_responses_csv, 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 calculate_grades_csv(entry_id, xmodule_instance_args): """ diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index 0d9f72d68d..59c4463614 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -4,6 +4,7 @@ running state of a course. """ import json +import re from collections import OrderedDict from datetime import datetime from django.conf import settings @@ -46,7 +47,12 @@ from courseware.grades import iterate_grades_for from courseware.models import StudentModule from courseware.model_data import DjangoKeyValueStore, FieldDataCache from courseware.module_render import get_module_for_descriptor_internal -from instructor_analytics.basic import enrolled_students_features, list_may_enroll, get_proctored_exam_results +from instructor_analytics.basic import ( + enrolled_students_features, + get_proctored_exam_results, + list_may_enroll, + list_problem_responses +) from instructor_analytics.csvs import format_dictlist from instructor_task.models import ReportStore, InstructorTask, PROGRESS from lms.djangoapps.lms_xblock.runtime import LmsPartitionService @@ -849,6 +855,40 @@ def _order_problems(blocks): return problems +def upload_problem_responses_csv(_xmodule_instance_args, _entry_id, course_id, task_input, action_name): + """ + For a given `course_id`, generate a CSV file containing + all student answers to a given problem, and store using a `ReportStore`. + """ + start_time = time() + start_date = datetime.now(UTC) + num_reports = 1 + task_progress = TaskProgress(action_name, num_reports, start_time) + current_step = {'step': 'Calculating students answers to problem'} + task_progress.update_task_state(extra_meta=current_step) + + # Compute result table and format it + problem_location = task_input.get('problem_location') + student_data = list_problem_responses(course_id, problem_location) + features = ['username', 'state'] + header, rows = format_dictlist(student_data, features) + + task_progress.attempted = task_progress.succeeded = len(rows) + task_progress.skipped = task_progress.total - task_progress.attempted + + rows.insert(0, header) + + current_step = {'step': 'Uploading CSV'} + task_progress.update_task_state(extra_meta=current_step) + + # Perform the upload + problem_location = re.sub(r'[:/]', '_', problem_location) + csv_name = 'student_state_from_{}'.format(problem_location) + upload_csv_to_report_store(rows, csv_name, course_id, start_date) + + return task_progress.update_task_state(extra_meta=current_step) + + def upload_problem_grade_report(_xmodule_instance_args, _entry_id, course_id, _task_input, action_name): """ Generate a CSV containing all students' problem grades within a given diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index 3145272484..113aeeb440 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -14,6 +14,7 @@ from instructor_task.api import ( submit_reset_problem_attempts_for_all_students, submit_delete_problem_state_for_all_students, submit_bulk_course_email, + submit_calculate_problem_responses_csv, submit_calculate_students_features_csv, submit_cohort_students, submit_detailed_enrollment_features_csv, @@ -203,6 +204,14 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa ) self._test_resubmission(api_call) + def test_submit_calculate_problem_responses(self): + api_call = lambda: submit_calculate_problem_responses_csv( + self.create_task_request(self.instructor), + self.course.id, + problem_location='' + ) + self._test_resubmission(api_call) + def test_submit_calculate_students_features(self): api_call = lambda: submit_calculate_students_features_csv( self.create_task_request(self.instructor), diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 12210c4260..c6701ebc55 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -33,6 +33,7 @@ from xmodule.partitions.partitions import Group, UserPartition from instructor_task.models import ReportStore from instructor_task.tasks_helper import ( cohort_students_and_upload, + upload_problem_responses_csv, upload_grades_csv, upload_problem_grade_report, upload_students_csv, @@ -277,6 +278,32 @@ class TestInstructorGradeReport(TestReportMixin, InstructorTaskCourseTestCase): self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result) +class TestProblemResponsesReport(TestReportMixin, InstructorTaskCourseTestCase): + """ + Tests that generation of CSV files listing student answers to a + given problem works. + """ + def setUp(self): + super(TestProblemResponsesReport, self).setUp() + self.course = CourseFactory.create() + + def test_success(self): + task_input = {'problem_location': ''} + with patch('instructor_task.tasks_helper._get_current_task'): + with patch('instructor_task.tasks_helper.list_problem_responses') as patched_data_source: + patched_data_source.return_value = [ + {'username': 'user0', 'state': u'state0'}, + {'username': 'user1', 'state': u'state1'}, + {'username': 'user2', 'state': u'state2'}, + ] + result = upload_problem_responses_csv(None, None, self.course.id, task_input, 'calculated') + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') + links = report_store.links_for(self.course.id) + + self.assertEquals(len(links), 1) + self.assertDictContainsSubset({'attempted': 3, 'succeeded': 3, 'failed': 0}, result) + + @ddt.ddt @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) class TestInstructorDetailedEnrollmentReport(TestReportMixin, InstructorTaskCourseTestCase): diff --git a/lms/static/coffee/src/instructor_dashboard/data_download.coffee b/lms/static/coffee/src/instructor_dashboard/data_download.coffee index 85badfc316..55e5f40562 100644 --- a/lms/static/coffee/src/instructor_dashboard/data_download.coffee +++ b/lms/static/coffee/src/instructor_dashboard/data_download.coffee @@ -22,6 +22,8 @@ class DataDownload @$list_studs_csv_btn = @$section.find("input[name='list-profiles-csv']'") @$list_proctored_exam_results_csv_btn = @$section.find("input[name='proctored-exam-results-report']'") @$list_may_enroll_csv_btn = @$section.find("input[name='list-may-enroll-csv']") + @$list_problem_responses_csv_input = @$section.find("input[name='problem-location']") + @$list_problem_responses_csv_btn = @$section.find("input[name='list-problem-responses-csv']") @$list_anon_btn = @$section.find("input[name='list-anon-ids']'") @$grade_config_btn = @$section.find("input[name='dump-gradeconf']'") @$calculate_grades_csv_btn = @$section.find("input[name='calculate-grades-csv']'") @@ -117,6 +119,22 @@ class DataDownload grid = new Slick.Grid($table_placeholder, grid_data, columns, options) # grid.autosizeColumns() + @$list_problem_responses_csv_btn.click (e) => + @clear_display() + + url = @$list_problem_responses_csv_btn.data 'endpoint' + $.ajax + dataType: 'json' + url: url + data: + problem_location: @$list_problem_responses_csv_input.val() + error: (std_ajax_err) => + @$reports_request_response_error.text JSON.parse(std_ajax_err['responseText']) + $(".msg-error").css({"display":"block"}) + success: (data) => + @$reports_request_response.text data['status'] + $(".msg-confirm").css({"display":"block"}) + @$list_may_enroll_csv_btn.click (e) => @clear_display() diff --git a/lms/templates/courseware/legacy_instructor_dashboard.html b/lms/templates/courseware/legacy_instructor_dashboard.html index 2aa0376a9e..28724da62d 100644 --- a/lms/templates/courseware/legacy_instructor_dashboard.html +++ b/lms/templates/courseware/legacy_instructor_dashboard.html @@ -361,9 +361,8 @@ function goto( mode) %if modeflag.get('Data'):
-

${_("Problem urlname:")} - - +

+ ${_("To download a CSV listing student responses to a given problem, visit the Data Download section of the Instructor Dashboard.")}

diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index a38ba21aac..36f897be16 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -39,6 +39,19 @@

${_("Click to generate a CSV file of all proctored exam results in this course.")}

%endif + +

${_("To generate a CSV file that lists all student answers to a given problem, enter the location of the problem (from its Staff Debug Info).")}

+ +

+ +

+

+ +

+ % if not disable_buttons:

${_("For smaller courses, click to list profile information for enrolled students directly on this page:")}

@@ -54,7 +67,7 @@ %endif
-
+

${_("Reports Available for Download")}