Merge pull request #9147 from open-craft/OC-791-answer-export
Move problem responses export from legacy instructor dash to new instructor dash
This commit is contained in:
@@ -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': {},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -234,6 +242,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
|
||||
('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 = [
|
||||
@@ -286,6 +295,20 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
|
||||
"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.
|
||||
@@ -301,6 +324,11 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
|
||||
# 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,
|
||||
@@ -330,6 +358,11 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
|
||||
# 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,
|
||||
@@ -2288,6 +2321,78 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment
|
||||
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
|
||||
@@ -2593,16 +2698,21 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment
|
||||
|
||||
@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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>/csv)?$',
|
||||
|
||||
@@ -495,6 +495,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(
|
||||
|
||||
@@ -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 += "<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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -361,9 +361,8 @@ function goto( mode)
|
||||
|
||||
%if modeflag.get('Data'):
|
||||
<hr width="40%" style="align:left">
|
||||
<p> ${_("Problem urlname:")}
|
||||
<input type="text" name="problem_to_dump" size="40">
|
||||
<input type="submit" name="action" value="Download CSV of all responses to problem">
|
||||
<p class="is-deprecated">
|
||||
${_("To download a CSV listing student responses to a given problem, visit the Data Download section of the Instructor Dashboard.")}
|
||||
</p>
|
||||
|
||||
<p class="is-deprecated">
|
||||
|
||||
@@ -39,6 +39,19 @@
|
||||
<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>
|
||||
%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:
|
||||
<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>
|
||||
@@ -54,7 +67,7 @@
|
||||
%endif
|
||||
|
||||
<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>
|
||||
|
||||
<p><b>${_("Reports Available for Download")}</b></p>
|
||||
|
||||
Reference in New Issue
Block a user