Commit df5b2dad by Tim Krones

Move functionality for exporting a CSV of all student answers to a given

problem from legacy instructor dash to new instructor dash.
parent 19604a4a
...@@ -4,6 +4,7 @@ Unit tests for instructor.api methods. ...@@ -4,6 +4,7 @@ Unit tests for instructor.api methods.
""" """
import datetime import datetime
import ddt import ddt
import functools
import random import random
import pytz import pytz
import io import io
...@@ -28,6 +29,7 @@ from mock import Mock, patch ...@@ -28,6 +29,7 @@ from mock import Mock, patch
from nose.tools import raises from nose.tools import raises
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import UsageKey
from course_modes.models import CourseMode from course_modes.models import CourseMode
from courseware.models import StudentModule from courseware.models import StudentModule
...@@ -107,6 +109,12 @@ REPORTS_DATA = ( ...@@ -107,6 +109,12 @@ REPORTS_DATA = (
'instructor_api_endpoint': 'get_proctored_exam_results', 'instructor_api_endpoint': 'get_proctored_exam_results',
'task_api_endpoint': 'instructor_task.api.submit_proctored_exam_results_report', 'task_api_endpoint': 'instructor_task.api.submit_proctored_exam_results_report',
'extra_instructor_api_kwargs': {}, '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): ...@@ -230,6 +238,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
('get_students_who_may_enroll', {}), ('get_students_who_may_enroll', {}),
('get_exec_summary_report', {}), ('get_exec_summary_report', {}),
('get_proctored_exam_results', {}), ('get_proctored_exam_results', {}),
('get_problem_responses', {}),
] ]
# Endpoints that only Instructors can access # Endpoints that only Instructors can access
self.instructor_level_endpoints = [ self.instructor_level_endpoints = [
...@@ -282,6 +291,20 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -282,6 +291,20 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
"Student should not be allowed to access endpoint " + endpoint "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): def test_staff_level(self):
""" """
Ensure that a staff member can't access instructor endpoints. Ensure that a staff member can't access instructor endpoints.
...@@ -297,6 +320,11 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -297,6 +320,11 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
# TODO: make these work # TODO: make these work
if endpoint in ['update_forum_role_membership', 'list_forum_members']: if endpoint in ['update_forum_role_membership', 'list_forum_members']:
continue continue
elif endpoint == 'get_problem_responses':
self._access_problem_responses_endpoint(
"Staff member should be allowed to access endpoint " + endpoint
)
continue
self._access_endpoint( self._access_endpoint(
endpoint, endpoint,
args, args,
...@@ -326,6 +354,11 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -326,6 +354,11 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase):
# TODO: make these work # TODO: make these work
if endpoint in ['update_forum_role_membership']: if endpoint in ['update_forum_role_membership']:
continue continue
elif endpoint == 'get_problem_responses':
self._access_problem_responses_endpoint(
"Instructor should be allowed to access endpoint " + endpoint
)
continue
self._access_endpoint( self._access_endpoint(
endpoint, endpoint,
args, args,
...@@ -2268,6 +2301,78 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa ...@@ -2268,6 +2301,78 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
self.assertEqual(res['total_used_codes'], used_codes) self.assertEqual(res['total_used_codes'], used_codes)
self.assertEqual(res['total_codes'], 5) 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): def test_get_students_features(self):
""" """
Test that some minimum of information is formatted Test that some minimum of information is formatted
...@@ -2571,16 +2676,21 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa ...@@ -2571,16 +2676,21 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa
@ddt.data(*REPORTS_DATA) @ddt.data(*REPORTS_DATA)
@ddt.unpack @ddt.unpack
@valid_problem_location
def test_calculate_report_csv_success(self, report_type, instructor_api_endpoint, task_api_endpoint, extra_instructor_api_kwargs): 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 = {'course_id': unicode(self.course.id)}
kwargs.update(extra_instructor_api_kwargs) kwargs.update(extra_instructor_api_kwargs)
url = reverse(instructor_api_endpoint, kwargs=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) 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.data(*EXECUTIVE_SUMMARY_DATA)
@ddt.unpack @ddt.unpack
......
...@@ -35,7 +35,7 @@ from util.file import ( ...@@ -35,7 +35,7 @@ from util.file import (
store_uploaded_file, course_and_time_based_filename_generator, store_uploaded_file, course_and_time_based_filename_generator,
FileValidationException, UniversalNewlineIterator 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 instructor.views.instructor_task_helpers import extract_email_features, extract_task_features
from microsite_configuration import microsite from microsite_configuration import microsite
...@@ -107,7 +107,7 @@ from .tools import ( ...@@ -107,7 +107,7 @@ from .tools import (
bulk_email_is_enabled_for_course, bulk_email_is_enabled_for_course,
add_block_ids, 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.edx.locations import SlashSeparatedCourseKey
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted
...@@ -890,6 +890,51 @@ def list_course_role_members(request, course_id): ...@@ -890,6 +890,51 @@ def list_course_role_members(request, course_id):
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff') @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')
def get_grading_config(request, course_id): def get_grading_config(request, course_id):
""" """
Respond with json which contains a html formatted grade summary. Respond with json which contains a html formatted grade summary.
......
...@@ -17,6 +17,8 @@ urlpatterns = patterns( ...@@ -17,6 +17,8 @@ urlpatterns = patterns(
'instructor.views.api.modify_access', name="modify_access"), 'instructor.views.api.modify_access', name="modify_access"),
url(r'^bulk_beta_modify_access$', url(r'^bulk_beta_modify_access$',
'instructor.views.api.bulk_beta_modify_access', name="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$', url(r'^get_grading_config$',
'instructor.views.api.get_grading_config', name="get_grading_config"), 'instructor.views.api.get_grading_config', name="get_grading_config"),
url(r'^get_students_features(?P<csv>/csv)?$', url(r'^get_students_features(?P<csv>/csv)?$',
......
...@@ -489,6 +489,7 @@ def _section_data_download(course, access): ...@@ -489,6 +489,7 @@ def _section_data_download(course, access):
'section_display_name': _('Data Download'), 'section_display_name': _('Data Download'),
'access': access, 'access': access,
'show_generate_proctored_exam_report_button': settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False), '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_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_features_url': reverse('get_students_features', kwargs={'course_id': unicode(course_key)}),
'get_students_who_may_enroll_url': reverse( 'get_students_who_may_enroll_url': reverse(
......
...@@ -277,35 +277,6 @@ def instructor_dashboard(request, course_id): ...@@ -277,35 +277,6 @@ def instructor_dashboard(request, course_id):
msg += msg2 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 += "<font color='red'>{text}</font><pre>{err}</pre>".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 # enrollment
elif action == 'Enroll multiple students': elif action == 'Enroll multiple students':
......
...@@ -11,12 +11,14 @@ from shoppingcart.models import ( ...@@ -11,12 +11,14 @@ from shoppingcart.models import (
from django.db.models import Q from django.db.models import Q
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from opaque_keys.edx.keys import UsageKey
import xmodule.graders as xmgraders import xmodule.graders as xmgraders
from django.core.exceptions import ObjectDoesNotExist
from microsite_configuration import microsite from microsite_configuration import microsite
from student.models import CourseEnrollmentAllowed from student.models import CourseEnrollmentAllowed
from edx_proctoring.api import get_all_exam_attempts 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') STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email')
...@@ -317,6 +319,41 @@ def coupon_codes_features(features, coupons_list, course_id): ...@@ -317,6 +319,41 @@ def coupon_codes_features(features, coupons_list, course_id):
return [extract_coupon(coupon, features) for coupon in coupons_list] 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): def course_registration_features(features, registration_codes, csv_type):
""" """
Return list of Course Registration Codes as dictionaries. Return list of Course Registration Codes as dictionaries.
......
...@@ -2,27 +2,29 @@ ...@@ -2,27 +2,29 @@
Tests for instructor.basic Tests for instructor.basic
""" """
import json
import datetime import datetime
from django.db.models import Q import json
import pytz import pytz
from student.models import CourseEnrollment, CourseEnrollmentAllowed from mock import MagicMock, Mock, patch
from django.core.urlresolvers import reverse 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.roles import CourseSalesAdminRole
from student.tests.factories import UserFactory, CourseModeFactory from student.tests.factories import UserFactory, CourseModeFactory
from shoppingcart.models import ( from shoppingcart.models import (
CourseRegistrationCode, RegistrationCodeRedemption, Order, CourseRegistrationCode, RegistrationCodeRedemption, Order,
Invoice, Coupon, CourseRegCodeItem, CouponRedemption, CourseRegistrationCodeInvoiceItem 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.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from edx_proctoring.api import create_exam from edx_proctoring.api import create_exam
...@@ -51,6 +53,48 @@ class TestAnalyticsBasic(ModuleStoreTestCase): ...@@ -51,6 +53,48 @@ class TestAnalyticsBasic(ModuleStoreTestCase):
email=student.email, course_id=self.course_key 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): def test_enrolled_students_features_username(self):
self.assertIn('username', AVAILABLE_FEATURES) self.assertIn('username', AVAILABLE_FEATURES)
userreports = enrolled_students_features(self.course_key, ['username']) userreports = enrolled_students_features(self.course_key, ['username'])
......
...@@ -18,6 +18,7 @@ from instructor_task.tasks import ( ...@@ -18,6 +18,7 @@ from instructor_task.tasks import (
reset_problem_attempts, reset_problem_attempts,
delete_problem_state, delete_problem_state,
send_bulk_course_email, send_bulk_course_email,
calculate_problem_responses_csv,
calculate_grades_csv, calculate_grades_csv,
calculate_problem_grade_report, calculate_problem_grade_report,
calculate_students_features_csv, calculate_students_features_csv,
...@@ -328,6 +329,21 @@ def submit_bulk_course_email(request, course_key, email_id): ...@@ -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) 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): def submit_calculate_grades_csv(request, course_key):
""" """
AlreadyRunningError is raised if the course's grades are already being updated. AlreadyRunningError is raised if the course's grades are already being updated.
......
...@@ -34,6 +34,7 @@ from instructor_task.tasks_helper import ( ...@@ -34,6 +34,7 @@ from instructor_task.tasks_helper import (
rescore_problem_module_state, rescore_problem_module_state,
reset_attempts_module_state, reset_attempts_module_state,
delete_problem_module_state, delete_problem_module_state,
upload_problem_responses_csv,
upload_grades_csv, upload_grades_csv,
upload_problem_grade_report, upload_problem_grade_report,
upload_students_csv, upload_students_csv,
...@@ -146,6 +147,18 @@ def send_bulk_course_email(entry_id, _xmodule_instance_args): ...@@ -146,6 +147,18 @@ def send_bulk_course_email(entry_id, _xmodule_instance_args):
@task(base=BaseInstructorTask, routing_key=settings.GRADES_DOWNLOAD_ROUTING_KEY) # pylint: disable=not-callable @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): def calculate_grades_csv(entry_id, xmodule_instance_args):
""" """
Grade a course and push the results to an S3 bucket for download. Grade a course and push the results to an S3 bucket for download.
......
...@@ -4,6 +4,7 @@ running state of a course. ...@@ -4,6 +4,7 @@ running state of a course.
""" """
import json import json
import re
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime from datetime import datetime
from django.conf import settings from django.conf import settings
...@@ -46,7 +47,12 @@ from courseware.grades import iterate_grades_for ...@@ -46,7 +47,12 @@ from courseware.grades import iterate_grades_for
from courseware.models import StudentModule from courseware.models import StudentModule
from courseware.model_data import DjangoKeyValueStore, FieldDataCache from courseware.model_data import DjangoKeyValueStore, FieldDataCache
from courseware.module_render import get_module_for_descriptor_internal 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_analytics.csvs import format_dictlist
from instructor_task.models import ReportStore, InstructorTask, PROGRESS from instructor_task.models import ReportStore, InstructorTask, PROGRESS
from lms.djangoapps.lms_xblock.runtime import LmsPartitionService from lms.djangoapps.lms_xblock.runtime import LmsPartitionService
...@@ -849,6 +855,40 @@ def _order_problems(blocks): ...@@ -849,6 +855,40 @@ def _order_problems(blocks):
return problems 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): 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 Generate a CSV containing all students' problem grades within a given
......
...@@ -14,6 +14,7 @@ from instructor_task.api import ( ...@@ -14,6 +14,7 @@ from instructor_task.api import (
submit_reset_problem_attempts_for_all_students, submit_reset_problem_attempts_for_all_students,
submit_delete_problem_state_for_all_students, submit_delete_problem_state_for_all_students,
submit_bulk_course_email, submit_bulk_course_email,
submit_calculate_problem_responses_csv,
submit_calculate_students_features_csv, submit_calculate_students_features_csv,
submit_cohort_students, submit_cohort_students,
submit_detailed_enrollment_features_csv, submit_detailed_enrollment_features_csv,
...@@ -203,6 +204,14 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa ...@@ -203,6 +204,14 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
) )
self._test_resubmission(api_call) 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): def test_submit_calculate_students_features(self):
api_call = lambda: submit_calculate_students_features_csv( api_call = lambda: submit_calculate_students_features_csv(
self.create_task_request(self.instructor), self.create_task_request(self.instructor),
......
...@@ -33,6 +33,7 @@ from xmodule.partitions.partitions import Group, UserPartition ...@@ -33,6 +33,7 @@ from xmodule.partitions.partitions import Group, UserPartition
from instructor_task.models import ReportStore from instructor_task.models import ReportStore
from instructor_task.tasks_helper import ( from instructor_task.tasks_helper import (
cohort_students_and_upload, cohort_students_and_upload,
upload_problem_responses_csv,
upload_grades_csv, upload_grades_csv,
upload_problem_grade_report, upload_problem_grade_report,
upload_students_csv, upload_students_csv,
...@@ -277,6 +278,32 @@ class TestInstructorGradeReport(TestReportMixin, InstructorTaskCourseTestCase): ...@@ -277,6 +278,32 @@ class TestInstructorGradeReport(TestReportMixin, InstructorTaskCourseTestCase):
self.assertDictContainsSubset({'attempted': 1, 'succeeded': 1, 'failed': 0}, result) 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 @ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True}) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True})
class TestInstructorDetailedEnrollmentReport(TestReportMixin, InstructorTaskCourseTestCase): class TestInstructorDetailedEnrollmentReport(TestReportMixin, InstructorTaskCourseTestCase):
......
...@@ -22,6 +22,8 @@ class DataDownload ...@@ -22,6 +22,8 @@ class DataDownload
@$list_studs_csv_btn = @$section.find("input[name='list-profiles-csv']'") @$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_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_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']'") @$list_anon_btn = @$section.find("input[name='list-anon-ids']'")
@$grade_config_btn = @$section.find("input[name='dump-gradeconf']'") @$grade_config_btn = @$section.find("input[name='dump-gradeconf']'")
@$calculate_grades_csv_btn = @$section.find("input[name='calculate-grades-csv']'") @$calculate_grades_csv_btn = @$section.find("input[name='calculate-grades-csv']'")
...@@ -117,6 +119,22 @@ class DataDownload ...@@ -117,6 +119,22 @@ class DataDownload
grid = new Slick.Grid($table_placeholder, grid_data, columns, options) grid = new Slick.Grid($table_placeholder, grid_data, columns, options)
# grid.autosizeColumns() # 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) => @$list_may_enroll_csv_btn.click (e) =>
@clear_display() @clear_display()
......
...@@ -361,9 +361,8 @@ function goto( mode) ...@@ -361,9 +361,8 @@ function goto( mode)
%if modeflag.get('Data'): %if modeflag.get('Data'):
<hr width="40%" style="align:left"> <hr width="40%" style="align:left">
<p> ${_("Problem urlname:")} <p class="is-deprecated">
<input type="text" name="problem_to_dump" size="40"> ${_("To download a CSV listing student responses to a given problem, visit the Data Download section of the Instructor Dashboard.")}
<input type="submit" name="action" value="Download CSV of all responses to problem">
</p> </p>
<p class="is-deprecated"> <p class="is-deprecated">
......
...@@ -39,6 +39,19 @@ ...@@ -39,6 +39,19 @@
<p>${_("Click to generate a CSV file of all proctored exam results in this course.")}</p> <p>${_("Click to generate a CSV file of all proctored exam results in this course.")}</p>
<p><input type="button" name="proctored-exam-results-report" value="${_("Generate Proctored Exam Results Report")}" data-endpoint="${ section_data['list_proctored_results_url'] }"/></p> <p><input type="button" name="proctored-exam-results-report" value="${_("Generate Proctored Exam Results Report")}" data-endpoint="${ section_data['list_proctored_results_url'] }"/></p>
%endif %endif
<p>${_("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).")}</p>
<p>
<label>
<span>${_("Problem location: ")}</span>
<input type="text" name="problem-location" />
</label>
</p>
<p>
<input type="button" name="list-problem-responses-csv" value="${_("Download a CSV of problem responses")}" data-endpoint="${ section_data['get_problem_responses_url'] }" data-csv="true">
</p>
% if not disable_buttons: % if not disable_buttons:
<p>${_("For smaller courses, click to list profile information for enrolled students directly on this page:")}</p> <p>${_("For smaller courses, click to list profile information for enrolled students directly on this page:")}</p>
<p><input type="button" name="list-profiles" value="${_("List enrolled students' profile information")}" data-endpoint="${ section_data['get_students_features_url'] }"></p> <p><input type="button" name="list-profiles" value="${_("List enrolled students' profile information")}" data-endpoint="${ section_data['get_students_features_url'] }"></p>
...@@ -54,7 +67,7 @@ ...@@ -54,7 +67,7 @@
%endif %endif
<div class="request-response msg msg-confirm copy" id="report-request-response"></div> <div class="request-response msg msg-confirm copy" id="report-request-response"></div>
<div class="request-response-error msg msg-warning copy" id="report-request-response-error"></div> <div class="request-response-error msg msg-error copy" id="report-request-response-error"></div>
<br> <br>
<p><b>${_("Reports Available for Download")}</b></p> <p><b>${_("Reports Available for Download")}</b></p>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment