Fix (re-implement) answer distribution report generation.

This restores functionality that has been broken since the introduction of
XModuleDescriptor/XModule proxying (part of the XBlock transition). It generates
a CSV of all answers for all content of type "problem" in a given course, with a
row per (problem part, answer). The format is:

url_name, display name, answer id, answer, count

Example values:
  url_name = "7f1b1523a55848cd9f5c93eb8cbabcf7"
  display name = "Problem 1: Something Hard"
  answer id = i4x-JediAcdmy-LTSB304-problem-7f1b1523a55848cd9f5c93eb8cbabcf7_2_1
  answer = "Use the Force"
  count = 1138

Since it only grabs things of type "problem", it will not return results for
things like self/peer-assessments. Any Loncapa problem types will show up (so
multiple choice, text input, numeric, etc.)

Instead of crawling the course tree and instantiating the appropriate CapaModule
objects to grab state, this version draws directly from StudentModule. This lets
us skip a lot of processing and most importantly lets us generate the answer
distribution without causing side-effects (since XBlocks auto-save state). It
also lets us take advantage of a read-replica database if one is available, to
minimize locking concerns.

There are minor changes to the legacy dashboard around CSV charset encoding and
a change to OptionResponseXMLFactory to make it more unicode friendly. Answer
distribution output is now also sorted, to group together answers for the same
content piece.

Note that this does not introduce celery into the process. Answer distributions
are still only available for small courses.

This was originally created to fix [LMS-922], but it also addresses [LMS-811] and
possibly other areas in the legacy dashboard where CSV downloads break due to
character encoding issues.
This commit is contained in:
David Ormsbee
2013-12-12 10:45:17 -05:00
parent 608aaf31e8
commit 0565fbbfab
6 changed files with 285 additions and 80 deletions

View File

@@ -1,6 +1,8 @@
"""Integration tests for submitting problem responses and getting grades."""
# text processing dependancies
# -*- coding: utf-8 -*-
"""
Integration tests for submitting problem responses and getting grades.
"""
# text processing dependencies
import json
from textwrap import dedent
@@ -12,6 +14,7 @@ from django.test.utils import override_settings
# Need access to internal func to put users in the right group
from courseware import grades
from courseware.model_data import FieldDataCache
from courseware.models import StudentModule
from xmodule.modulestore.django import modulestore, editable_modulestore
@@ -22,6 +25,7 @@ from capa.tests.response_xml_factory import OptionResponseXMLFactory, CustomResp
from courseware.tests.helpers import LoginEnrollmentTestCase
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from lms.lib.xblock.runtime import quote_slashes
from student.tests.factories import UserFactory
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
@@ -134,7 +138,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase):
question_text='The correct answer is Correct',
num_inputs=num_inputs,
weight=num_inputs,
options=['Correct', 'Incorrect'],
options=['Correct', 'Incorrect', u'ⓤⓝⓘⓒⓞⓓⓔ'],
correct_option='Correct'
)
@@ -793,3 +797,156 @@ class TestPythonGradedResponse(TestSubmittingProblems):
name = 'computed_answer'
self.computed_answer_setup(name)
self._check_ireset(name)
class TestAnswerDistributions(TestSubmittingProblems):
"""Check that we can pull answer distributions for problems."""
def setUp(self):
"""Set up a simple course with four problems."""
super(TestAnswerDistributions, self).setUp()
self.homework = self.add_graded_section_to_course('homework')
self.add_dropdown_to_section(self.homework.location, 'p1', 1)
self.add_dropdown_to_section(self.homework.location, 'p2', 1)
self.add_dropdown_to_section(self.homework.location, 'p3', 1)
self.refresh_course()
def test_empty(self):
# Just make sure we can process this without errors.
empty_distribution = grades.answer_distributions(self.course.id)
self.assertFalse(empty_distribution) # should be empty
def test_one_student(self):
# Basic test to make sure we have simple behavior right for a student
# Throw in a non-ASCII answer
self.submit_question_answer('p1', {'2_1': u'ⓤⓝⓘⓒⓞⓓⓔ'})
self.submit_question_answer('p2', {'2_1': 'Correct'})
distributions = grades.answer_distributions(self.course.id)
self.assertEqual(
distributions,
{
('p1', 'p1', 'i4x-MITx-100-problem-p1_2_1'): {
u'ⓤⓝⓘⓒⓞⓓⓔ': 1
},
('p2', 'p2', 'i4x-MITx-100-problem-p2_2_1'): {
'Correct': 1
}
}
)
def test_multiple_students(self):
# Our test class is based around making requests for a particular user,
# so we're going to cheat by creating another user and copying and
# modifying StudentModule entries to make them from other users. It's
# a little hacky, but it seemed the simpler way to do this.
self.submit_question_answer('p1', {'2_1': u'Correct'})
self.submit_question_answer('p2', {'2_1': u'Incorrect'})
self.submit_question_answer('p3', {'2_1': u'Correct'})
# Make the above submissions owned by user2
user2 = UserFactory.create()
problems = StudentModule.objects.filter(
course_id=self.course.id,
student_id=self.student_user.id
)
for problem in problems:
problem.student_id = user2.id
problem.save()
# Now make more submissions by our original user
self.submit_question_answer('p1', {'2_1': u'Correct'})
self.submit_question_answer('p2', {'2_1': u'Correct'})
self.assertEqual(
grades.answer_distributions(self.course.id),
{
('p1', 'p1', 'i4x-MITx-100-problem-p1_2_1'): {
'Correct': 2
},
('p2', 'p2', 'i4x-MITx-100-problem-p2_2_1'): {
'Correct': 1,
'Incorrect': 1
},
('p3', 'p3', 'i4x-MITx-100-problem-p3_2_1'): {
'Correct': 1
}
}
)
def test_other_data_types(self):
# We'll submit one problem, and then muck with the student_answers
# dict inside its state to try different data types (str, int, float,
# none)
self.submit_question_answer('p1', {'2_1': u'Correct'})
# Now fetch the state entry for that problem.
student_module = StudentModule.objects.get(
course_id=self.course.id,
student_id=self.student_user.id
)
for val in ('Correct', True, False, 0, 0.0, 1, 1.0, None):
state = json.loads(student_module.state)
state["student_answers"]['i4x-MITx-100-problem-p1_2_1'] = val
student_module.state = json.dumps(state)
student_module.save()
self.assertEqual(
grades.answer_distributions(self.course.id),
{
('p1', 'p1', 'i4x-MITx-100-problem-p1_2_1'): {
str(val): 1
},
}
)
def test_missing_content(self):
# If there's a StudentModule entry for content that no longer exists,
# we just quietly ignore it (because we can't display a meaningful url
# or name for it).
self.submit_question_answer('p1', {'2_1': 'Incorrect'})
# Now fetch the state entry for that problem and alter it so it points
# to a non-existent problem.
student_module = StudentModule.objects.get(
course_id=self.course.id,
student_id=self.student_user.id
)
student_module.module_state_key += "_fake"
student_module.save()
# It should be empty (ignored)
empty_distribution = grades.answer_distributions(self.course.id)
self.assertFalse(empty_distribution) # should be empty
def test_broken_state(self):
# Missing or broken state for a problem should be skipped without
# causing the whole answer_distribution call to explode.
# Submit p1
self.submit_question_answer('p1', {'2_1': u'Correct'})
# Now fetch the StudentModule entry for p1 so we can corrupt its state
prb1 = StudentModule.objects.get(
course_id=self.course.id,
student_id=self.student_user.id
)
# Submit p2
self.submit_question_answer('p2', {'2_1': u'Incorrect'})
for new_p1_state in ('{"student_answers": {}}', "invalid json!", None):
prb1.state = new_p1_state
prb1.save()
# p1 won't show up, but p2 should still work
self.assertEqual(
grades.answer_distributions(self.course.id),
{
('p2', 'p2', 'i4x-MITx-100-problem-p2_2_1'): {
'Incorrect': 1
},
}
)