Merge pull request #2721 from rocha/rocha/add-answer-values
Add map to problem_check event with descriptions of student answers
This commit is contained in:
@@ -288,6 +288,14 @@ class InputTypeBase(object):
|
|||||||
html = self.capa_system.render_template(self.template, context)
|
html = self.capa_system.render_template(self.template, context)
|
||||||
return etree.XML(html)
|
return etree.XML(html)
|
||||||
|
|
||||||
|
def get_user_visible_answer(self, internal_answer):
|
||||||
|
"""
|
||||||
|
Given the internal representation of the answer provided by the user, return the representation of the answer
|
||||||
|
as the user saw it. Subclasses should override this method if and only if the internal represenation of the
|
||||||
|
answer is different from the answer that is displayed to the user.
|
||||||
|
"""
|
||||||
|
return internal_answer
|
||||||
|
|
||||||
|
|
||||||
#-----------------------------------------------------------------------------
|
#-----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -385,6 +393,7 @@ class ChoiceGroup(InputTypeBase):
|
|||||||
raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag))
|
raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag))
|
||||||
|
|
||||||
self.choices = self.extract_choices(self.xml)
|
self.choices = self.extract_choices(self.xml)
|
||||||
|
self._choices_map = dict(self.choices) # pylint: disable=attribute-defined-outside-init
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_attributes(cls):
|
def get_attributes(cls):
|
||||||
@@ -419,6 +428,12 @@ class ChoiceGroup(InputTypeBase):
|
|||||||
choices.append((choice.get("name"), stringify_children(choice)))
|
choices.append((choice.get("name"), stringify_children(choice)))
|
||||||
return choices
|
return choices
|
||||||
|
|
||||||
|
def get_user_visible_answer(self, internal_answer):
|
||||||
|
if isinstance(internal_answer, basestring):
|
||||||
|
return self._choices_map[internal_answer]
|
||||||
|
|
||||||
|
return [self._choices_map[i] for i in internal_answer]
|
||||||
|
|
||||||
|
|
||||||
#-----------------------------------------------------------------------------
|
#-----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -1021,7 +1036,7 @@ class ChemicalEquationInput(InputTypeBase):
|
|||||||
"""
|
"""
|
||||||
Can set size of text field.
|
Can set size of text field.
|
||||||
"""
|
"""
|
||||||
return [Attribute('size', '20'),
|
return [Attribute('size', '20'),
|
||||||
Attribute('label', ''),]
|
Attribute('label', ''),]
|
||||||
|
|
||||||
def _extra_context(self):
|
def _extra_context(self):
|
||||||
|
|||||||
@@ -866,7 +866,8 @@ class CapaMixin(CapaFields):
|
|||||||
event_info['problem_id'] = self.location.url()
|
event_info['problem_id'] = self.location.url()
|
||||||
|
|
||||||
answers = self.make_dict_of_responses(data)
|
answers = self.make_dict_of_responses(data)
|
||||||
event_info['answers'] = convert_files_to_filenames(answers)
|
answers_without_files = convert_files_to_filenames(answers)
|
||||||
|
event_info['answers'] = answers_without_files
|
||||||
|
|
||||||
_ = self.runtime.service(self, "i18n").ugettext
|
_ = self.runtime.service(self, "i18n").ugettext
|
||||||
|
|
||||||
@@ -944,6 +945,7 @@ class CapaMixin(CapaFields):
|
|||||||
event_info['correct_map'] = correct_map.get_dict()
|
event_info['correct_map'] = correct_map.get_dict()
|
||||||
event_info['success'] = success
|
event_info['success'] = success
|
||||||
event_info['attempts'] = self.attempts
|
event_info['attempts'] = self.attempts
|
||||||
|
event_info['submission'] = self.get_submission_metadata_safe(answers_without_files, correct_map)
|
||||||
self.runtime.track_function('problem_check', event_info)
|
self.runtime.track_function('problem_check', event_info)
|
||||||
|
|
||||||
if hasattr(self.runtime, 'psychometrics_handler'): # update PsychometricsData using callback
|
if hasattr(self.runtime, 'psychometrics_handler'): # update PsychometricsData using callback
|
||||||
@@ -957,6 +959,90 @@ class CapaMixin(CapaFields):
|
|||||||
'contents': html,
|
'contents': html,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_submission_metadata_safe(self, answers, correct_map):
|
||||||
|
"""
|
||||||
|
Ensures that no exceptions are thrown while generating input metadata summaries. Returns the
|
||||||
|
summary if it is successfully created, otherwise an empty dictionary.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.get_submission_metadata(answers, correct_map)
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
# NOTE: The above process requires deep inspection of capa structures that may break for some
|
||||||
|
# uncommon problem types. Ensure that it does not prevent answer submission in those
|
||||||
|
# cases. Any occurrences of errors in this block should be investigated and resolved.
|
||||||
|
log.exception('Unable to gather submission metadata, it will not be included in the event.')
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_submission_metadata(self, answers, correct_map):
|
||||||
|
"""
|
||||||
|
Return a map of inputs to their corresponding summarized metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A map whose keys are a unique identifier for the input (in this case a capa input_id) and
|
||||||
|
whose values are:
|
||||||
|
|
||||||
|
question (str): Is the prompt that was presented to the student. It corresponds to the
|
||||||
|
label of the input.
|
||||||
|
answer (mixed): Is the answer the student provided. This may be a rich structure,
|
||||||
|
however it must be json serializable.
|
||||||
|
response_type (str): The XML tag of the capa response type.
|
||||||
|
input_type (str): The XML tag of the capa input type.
|
||||||
|
correct (bool): Whether or not the provided answer is correct. Will be an empty
|
||||||
|
string if correctness could not be determined.
|
||||||
|
variant (str): In some cases the same question can have several different variants.
|
||||||
|
This string should uniquely identify the variant of the question that was answered.
|
||||||
|
In the capa context this corresponds to the `seed`.
|
||||||
|
|
||||||
|
This function attempts to be very conservative and make very few assumptions about the structure
|
||||||
|
of the problem. If problem related metadata cannot be located it should be replaced with empty
|
||||||
|
strings ''.
|
||||||
|
"""
|
||||||
|
|
||||||
|
input_metadata = {}
|
||||||
|
for input_id, internal_answer in answers.iteritems():
|
||||||
|
answer_input = self.lcp.inputs.get(input_id)
|
||||||
|
|
||||||
|
if answer_input is None:
|
||||||
|
log.warning('Input id %s is not mapped to an input type.', input_id)
|
||||||
|
|
||||||
|
answer_response = None
|
||||||
|
for response, responder in self.lcp.responders.iteritems():
|
||||||
|
for other_input_id in self.lcp.responder_answers[response]:
|
||||||
|
if other_input_id == input_id:
|
||||||
|
answer_response = responder
|
||||||
|
|
||||||
|
if answer_response is None:
|
||||||
|
log.warning('Answer responder could not be found for input_id %s.', input_id)
|
||||||
|
|
||||||
|
user_visible_answer = internal_answer
|
||||||
|
if hasattr(answer_input, 'get_user_visible_answer'):
|
||||||
|
user_visible_answer = answer_input.get_user_visible_answer(internal_answer)
|
||||||
|
|
||||||
|
# If this problem has rerandomize enabled, then it will generate N variants of the
|
||||||
|
# question, one per unique seed value. In this case we would like to know which
|
||||||
|
# variant was selected. Ideally it would be nice to have the exact question that
|
||||||
|
# was presented to the user, with values interpolated etc, but that can be done
|
||||||
|
# later if necessary.
|
||||||
|
variant = ''
|
||||||
|
if self.rerandomize != 'never':
|
||||||
|
variant = self.seed
|
||||||
|
|
||||||
|
is_correct = correct_map.is_correct(input_id)
|
||||||
|
if is_correct is None:
|
||||||
|
is_correct = ''
|
||||||
|
|
||||||
|
input_metadata[input_id] = {
|
||||||
|
'question': getattr(answer_input, 'loaded_attributes', {}).get('label', ''),
|
||||||
|
'answer': user_visible_answer,
|
||||||
|
'response_type': getattr(getattr(answer_response, 'xml', None), 'tag', ''),
|
||||||
|
'input_type': getattr(answer_input, 'tag', ''),
|
||||||
|
'correct': is_correct,
|
||||||
|
'variant': variant,
|
||||||
|
}
|
||||||
|
|
||||||
|
return input_metadata
|
||||||
|
|
||||||
def rescore_problem(self):
|
def rescore_problem(self):
|
||||||
"""
|
"""
|
||||||
Checks whether the existing answers to a problem are correct.
|
Checks whether the existing answers to a problem are correct.
|
||||||
|
|||||||
@@ -58,21 +58,22 @@ class CapaFactory(object):
|
|||||||
return cls.num
|
return cls.num
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def input_key(cls, input_num=2):
|
def input_key(cls, response_num=2, input_num=1):
|
||||||
"""
|
"""
|
||||||
Return the input key to use when passing GET parameters
|
Return the input key to use when passing GET parameters
|
||||||
"""
|
"""
|
||||||
return ("input_" + cls.answer_key(input_num))
|
return ("input_" + cls.answer_key(response_num, input_num))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def answer_key(cls, input_num=2):
|
def answer_key(cls, response_num=2, input_num=1):
|
||||||
"""
|
"""
|
||||||
Return the key stored in the capa problem answer dict
|
Return the key stored in the capa problem answer dict
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
"%s_%d_1" % (
|
"%s_%d_%d" % (
|
||||||
"-".join(['i4x', 'edX', 'capa_test', 'problem', 'SampleProblem%d' % cls.num]),
|
"-".join(['i4x', 'edX', 'capa_test', 'problem', 'SampleProblem%d' % cls.num]),
|
||||||
input_num,
|
response_num,
|
||||||
|
input_num
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -544,8 +545,8 @@ class CapaModuleTest(unittest.TestCase):
|
|||||||
|
|
||||||
# Create a request dictionary for check_problem.
|
# Create a request dictionary for check_problem.
|
||||||
get_request_dict = {
|
get_request_dict = {
|
||||||
CapaFactoryWithFiles.input_key(input_num=2): fileobjs,
|
CapaFactoryWithFiles.input_key(response_num=2): fileobjs,
|
||||||
CapaFactoryWithFiles.input_key(input_num=3): 'None',
|
CapaFactoryWithFiles.input_key(response_num=3): 'None',
|
||||||
}
|
}
|
||||||
|
|
||||||
module.check_problem(get_request_dict)
|
module.check_problem(get_request_dict)
|
||||||
@@ -594,8 +595,8 @@ class CapaModuleTest(unittest.TestCase):
|
|||||||
# Create a webob Request with the files uploaded.
|
# Create a webob Request with the files uploaded.
|
||||||
post_data = []
|
post_data = []
|
||||||
for fname, fileobj in zip(fnames, fileobjs):
|
for fname, fileobj in zip(fnames, fileobjs):
|
||||||
post_data.append((CapaFactoryWithFiles.input_key(input_num=2), (fname, fileobj)))
|
post_data.append((CapaFactoryWithFiles.input_key(response_num=2), (fname, fileobj)))
|
||||||
post_data.append((CapaFactoryWithFiles.input_key(input_num=3), 'None'))
|
post_data.append((CapaFactoryWithFiles.input_key(response_num=3), 'None'))
|
||||||
request = webob.Request.blank("/some/fake/url", POST=post_data, content_type='multipart/form-data')
|
request = webob.Request.blank("/some/fake/url", POST=post_data, content_type='multipart/form-data')
|
||||||
|
|
||||||
module.handle('xmodule_handler', request, 'problem_check')
|
module.handle('xmodule_handler', request, 'problem_check')
|
||||||
@@ -1402,3 +1403,222 @@ class ComplexEncoderTest(unittest.TestCase):
|
|||||||
expected_str = '1-1*j'
|
expected_str = '1-1*j'
|
||||||
json_str = json.dumps(complex_num, cls=ComplexEncoder)
|
json_str = json.dumps(complex_num, cls=ComplexEncoder)
|
||||||
self.assertEqual(expected_str, json_str[1:-1]) # ignore quotes
|
self.assertEqual(expected_str, json_str[1:-1]) # ignore quotes
|
||||||
|
|
||||||
|
|
||||||
|
class TestProblemCheckTracking(unittest.TestCase):
|
||||||
|
"""
|
||||||
|
Ensure correct tracking information is included in events emitted during problem checks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.maxDiff = None
|
||||||
|
|
||||||
|
def test_choice_answer_text(self):
|
||||||
|
factory = self.capa_factory_for_problem_xml("""\
|
||||||
|
<problem display_name="Multiple Choice Questions">
|
||||||
|
<p>What color is the open ocean on a sunny day?</p>
|
||||||
|
<optionresponse>
|
||||||
|
<optioninput options="('yellow','blue','green')" correct="blue" label="What color is the open ocean on a sunny day?"/>
|
||||||
|
</optionresponse>
|
||||||
|
<p>Which piece of furniture is built for sitting?</p>
|
||||||
|
<multiplechoiceresponse>
|
||||||
|
<choicegroup type="MultipleChoice">
|
||||||
|
<choice correct="false">
|
||||||
|
<text>a table</text>
|
||||||
|
</choice>
|
||||||
|
<choice correct="false">
|
||||||
|
<text>a desk</text>
|
||||||
|
</choice>
|
||||||
|
<choice correct="true">
|
||||||
|
<text>a chair</text>
|
||||||
|
</choice>
|
||||||
|
<choice correct="false">
|
||||||
|
<text>a bookshelf</text>
|
||||||
|
</choice>
|
||||||
|
</choicegroup>
|
||||||
|
</multiplechoiceresponse>
|
||||||
|
<p>Which of the following are musical instruments?</p>
|
||||||
|
<choiceresponse>
|
||||||
|
<checkboxgroup direction="vertical" label="Which of the following are musical instruments?">
|
||||||
|
<choice correct="true">a piano</choice>
|
||||||
|
<choice correct="false">a tree</choice>
|
||||||
|
<choice correct="true">a guitar</choice>
|
||||||
|
<choice correct="false">a window</choice>
|
||||||
|
</checkboxgroup>
|
||||||
|
</choiceresponse>
|
||||||
|
</problem>
|
||||||
|
""")
|
||||||
|
module = factory.create()
|
||||||
|
|
||||||
|
answer_input_dict = {
|
||||||
|
factory.input_key(2): 'blue',
|
||||||
|
factory.input_key(3): 'choice_0',
|
||||||
|
factory.input_key(4): ['choice_0', 'choice_1'],
|
||||||
|
}
|
||||||
|
|
||||||
|
event = self.get_event_for_answers(module, answer_input_dict)
|
||||||
|
|
||||||
|
self.assertEquals(event['submission'], {
|
||||||
|
factory.answer_key(2): {
|
||||||
|
'question': 'What color is the open ocean on a sunny day?',
|
||||||
|
'answer': 'blue',
|
||||||
|
'response_type': 'optionresponse',
|
||||||
|
'input_type': 'optioninput',
|
||||||
|
'correct': True,
|
||||||
|
'variant': '',
|
||||||
|
},
|
||||||
|
factory.answer_key(3): {
|
||||||
|
'question': '',
|
||||||
|
'answer': u'<text>a table</text>',
|
||||||
|
'response_type': 'multiplechoiceresponse',
|
||||||
|
'input_type': 'choicegroup',
|
||||||
|
'correct': False,
|
||||||
|
'variant': '',
|
||||||
|
},
|
||||||
|
factory.answer_key(4): {
|
||||||
|
'question': 'Which of the following are musical instruments?',
|
||||||
|
'answer': [u'a piano', u'a tree'],
|
||||||
|
'response_type': 'choiceresponse',
|
||||||
|
'input_type': 'checkboxgroup',
|
||||||
|
'correct': False,
|
||||||
|
'variant': '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def capa_factory_for_problem_xml(self, xml):
|
||||||
|
class CustomCapaFactory(CapaFactory):
|
||||||
|
"""
|
||||||
|
A factory for creating a Capa problem with arbitrary xml.
|
||||||
|
"""
|
||||||
|
sample_problem_xml = textwrap.dedent(xml)
|
||||||
|
|
||||||
|
return CustomCapaFactory
|
||||||
|
|
||||||
|
def get_event_for_answers(self, module, answer_input_dict):
|
||||||
|
with patch.object(module.runtime, 'track_function') as mock_track_function:
|
||||||
|
module.check_problem(answer_input_dict)
|
||||||
|
|
||||||
|
self.assertEquals(len(mock_track_function.mock_calls), 1)
|
||||||
|
mock_call = mock_track_function.mock_calls[0]
|
||||||
|
event = mock_call[1][1]
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
def test_numerical_textline(self):
|
||||||
|
factory = CapaFactory
|
||||||
|
module = factory.create()
|
||||||
|
|
||||||
|
answer_input_dict = {
|
||||||
|
factory.input_key(2): '3.14'
|
||||||
|
}
|
||||||
|
|
||||||
|
event = self.get_event_for_answers(module, answer_input_dict)
|
||||||
|
self.assertEquals(event['submission'], {
|
||||||
|
factory.answer_key(2): {
|
||||||
|
'question': '',
|
||||||
|
'answer': '3.14',
|
||||||
|
'response_type': 'numericalresponse',
|
||||||
|
'input_type': 'textline',
|
||||||
|
'correct': True,
|
||||||
|
'variant': '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_multiple_inputs(self):
|
||||||
|
factory = self.capa_factory_for_problem_xml("""\
|
||||||
|
<problem display_name="Multiple Inputs">
|
||||||
|
<p>Choose the correct color</p>
|
||||||
|
<optionresponse>
|
||||||
|
<p>What color is the sky?</p>
|
||||||
|
<optioninput options="('yellow','blue','green')" correct="blue"/>
|
||||||
|
<p>What color are pine needles?</p>
|
||||||
|
<optioninput options="('yellow','blue','green')" correct="green"/>
|
||||||
|
</optionresponse>
|
||||||
|
</problem>
|
||||||
|
""")
|
||||||
|
module = factory.create()
|
||||||
|
|
||||||
|
answer_input_dict = {
|
||||||
|
factory.input_key(2, 1): 'blue',
|
||||||
|
factory.input_key(2, 2): 'yellow',
|
||||||
|
}
|
||||||
|
|
||||||
|
event = self.get_event_for_answers(module, answer_input_dict)
|
||||||
|
self.assertEquals(event['submission'], {
|
||||||
|
factory.answer_key(2, 1): {
|
||||||
|
'question': '',
|
||||||
|
'answer': 'blue',
|
||||||
|
'response_type': 'optionresponse',
|
||||||
|
'input_type': 'optioninput',
|
||||||
|
'correct': True,
|
||||||
|
'variant': '',
|
||||||
|
},
|
||||||
|
factory.answer_key(2, 2): {
|
||||||
|
'question': '',
|
||||||
|
'answer': 'yellow',
|
||||||
|
'response_type': 'optionresponse',
|
||||||
|
'input_type': 'optioninput',
|
||||||
|
'correct': False,
|
||||||
|
'variant': '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_rerandomized_inputs(self):
|
||||||
|
factory = CapaFactory
|
||||||
|
module = factory.create(rerandomize='always')
|
||||||
|
|
||||||
|
answer_input_dict = {
|
||||||
|
factory.input_key(2): '3.14'
|
||||||
|
}
|
||||||
|
|
||||||
|
event = self.get_event_for_answers(module, answer_input_dict)
|
||||||
|
self.assertEquals(event['submission'], {
|
||||||
|
factory.answer_key(2): {
|
||||||
|
'question': '',
|
||||||
|
'answer': '3.14',
|
||||||
|
'response_type': 'numericalresponse',
|
||||||
|
'input_type': 'textline',
|
||||||
|
'correct': True,
|
||||||
|
'variant': module.seed,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_file_inputs(self):
|
||||||
|
fnames = ["prog1.py", "prog2.py", "prog3.py"]
|
||||||
|
fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames]
|
||||||
|
fileobjs = [open(fpath) for fpath in fpaths]
|
||||||
|
for fileobj in fileobjs:
|
||||||
|
self.addCleanup(fileobj.close)
|
||||||
|
|
||||||
|
factory = CapaFactoryWithFiles
|
||||||
|
module = factory.create()
|
||||||
|
|
||||||
|
# Mock the XQueueInterface.
|
||||||
|
xqueue_interface = XQueueInterface("http://example.com/xqueue", Mock())
|
||||||
|
xqueue_interface._http_post = Mock(return_value=(0, "ok")) # pylint: disable=protected-access
|
||||||
|
module.system.xqueue['interface'] = xqueue_interface
|
||||||
|
|
||||||
|
answer_input_dict = {
|
||||||
|
CapaFactoryWithFiles.input_key(response_num=2): fileobjs,
|
||||||
|
CapaFactoryWithFiles.input_key(response_num=3): 'None',
|
||||||
|
}
|
||||||
|
|
||||||
|
event = self.get_event_for_answers(module, answer_input_dict)
|
||||||
|
self.assertEquals(event['submission'], {
|
||||||
|
factory.answer_key(2): {
|
||||||
|
'question': '',
|
||||||
|
'answer': fpaths,
|
||||||
|
'response_type': 'coderesponse',
|
||||||
|
'input_type': 'filesubmission',
|
||||||
|
'correct': False,
|
||||||
|
'variant': '',
|
||||||
|
},
|
||||||
|
factory.answer_key(3): {
|
||||||
|
'answer': 'None',
|
||||||
|
'correct': True,
|
||||||
|
'question': '',
|
||||||
|
'response_type': 'customresponse',
|
||||||
|
'input_type': 'textline',
|
||||||
|
'variant': ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user