Commit 7b3c7969 by ichuang

second pass in capa cleanup:

  - each response can now render its own xhtml
  - cleaned up LoncapaProblem.extract_html
parent 7b3ad553
...@@ -23,7 +23,6 @@ import scipy ...@@ -23,7 +23,6 @@ import scipy
import struct import struct
from lxml import etree from lxml import etree
from lxml.etree import Element
from xml.sax.saxutils import unescape from xml.sax.saxutils import unescape
from util import contextualize_text from util import contextualize_text
...@@ -36,6 +35,7 @@ import eia ...@@ -36,6 +35,7 @@ import eia
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# dict of tagname, Response Class -- this should come from auto-registering
response_types = {'numericalresponse': NumericalResponse, response_types = {'numericalresponse': NumericalResponse,
'formularesponse': FormulaResponse, 'formularesponse': FormulaResponse,
'customresponse': CustomResponse, 'customresponse': CustomResponse,
...@@ -47,20 +47,13 @@ response_types = {'numericalresponse': NumericalResponse, ...@@ -47,20 +47,13 @@ response_types = {'numericalresponse': NumericalResponse,
'optionresponse': OptionResponse, 'optionresponse': OptionResponse,
'symbolicresponse': SymbolicResponse, 'symbolicresponse': SymbolicResponse,
} }
entry_types = ['textline', 'schematic', 'choicegroup', 'textbox', 'imageinput', 'optioninput'] entry_types = ['textline', 'schematic', 'choicegroup', 'textbox', 'imageinput', 'optioninput']
solution_types = ['solution'] # extra things displayed after "show answers" is pressed solution_types = ['solution'] # extra things displayed after "show answers" is pressed
response_properties = ["responseparam", "answer"] # these get captured as student responses response_properties = ["responseparam", "answer"] # these get captured as student responses
# How to convert from original XML to HTML # special problem tags which should be turned into innocuous HTML
# We should do this with xlst later
html_transforms = {'problem': {'tag': 'div'}, html_transforms = {'problem': {'tag': 'div'},
"numericalresponse": {'tag': 'span'},
"customresponse": {'tag': 'span'},
"externalresponse": {'tag': 'span'},
"schematicresponse": {'tag': 'span'},
"formularesponse": {'tag': 'span'},
"symbolicresponse": {'tag': 'span'},
"multiplechoiceresponse": {'tag': 'span'},
"text": {'tag': 'span'}, "text": {'tag': 'span'},
"math": {'tag': 'span'}, "math": {'tag': 'span'},
} }
...@@ -74,18 +67,6 @@ global_context = {'random': random, ...@@ -74,18 +67,6 @@ global_context = {'random': random,
# These should be removed from HTML output, including all subelements # These should be removed from HTML output, including all subelements
html_problem_semantics = ["responseparam", "answer", "script"] html_problem_semantics = ["responseparam", "answer", "script"]
# These should be removed from HTML output, but keeping subelements
html_skip = ["numericalresponse", "customresponse", "schematicresponse", "formularesponse", "text", "externalresponse", 'symbolicresponse']
# removed in MC
## These should be transformed
#html_special_response = {"textline":inputtypes.textline.render,
# "schematic":inputtypes.schematic.render,
# "textbox":inputtypes.textbox.render,
# "formulainput":inputtypes.jstextline.render,
# "solution":inputtypes.solution.render,
# }
class LoncapaProblem(object): class LoncapaProblem(object):
''' '''
...@@ -142,7 +123,8 @@ class LoncapaProblem(object): ...@@ -142,7 +123,8 @@ class LoncapaProblem(object):
self.context = self.extract_context(self.tree, seed=self.seed) self.context = self.extract_context(self.tree, seed=self.seed)
# pre-parse the XML tree: modifies it to add ID's and perform some in-place transformations # pre-parse the XML tree: modifies it to add ID's and perform some in-place transformations
# this also creates the list (self.responders) of Response instances for each question in the problem # this also creates the dict (self.responders) of Response instances for each question in the problem.
# the dict has keys = xml subtree of Response, values = Response instance
self.preprocess_problem(self.tree, correct_map=self.correct_map, answer_map=self.student_answers) self.preprocess_problem(self.tree, correct_map=self.correct_map, answer_map=self.student_answers)
def __unicode__(self): def __unicode__(self):
...@@ -166,7 +148,7 @@ class LoncapaProblem(object): ...@@ -166,7 +148,7 @@ class LoncapaProblem(object):
used to give complex problems (eg programming questions) multiple points. used to give complex problems (eg programming questions) multiple points.
''' '''
maxscore = 0 maxscore = 0
for responder in self.responders: for responder in self.responders.values():
if hasattr(responder,'get_max_score'): if hasattr(responder,'get_max_score'):
try: try:
maxscore += responder.get_max_score() maxscore += responder.get_max_score()
...@@ -182,6 +164,10 @@ class LoncapaProblem(object): ...@@ -182,6 +164,10 @@ class LoncapaProblem(object):
return maxscore return maxscore
def get_score(self): def get_score(self):
'''
Compute score for this problem. The score is the number of points awarded.
Returns an integer, from 0 to get_max_score().
'''
correct = 0 correct = 0
for key in self.correct_map: for key in self.correct_map:
if self.correct_map[key] == u'correct': if self.correct_map[key] == u'correct':
...@@ -206,7 +192,7 @@ class LoncapaProblem(object): ...@@ -206,7 +192,7 @@ class LoncapaProblem(object):
self.student_answers = answers self.student_answers = answers
self.correct_map = dict() self.correct_map = dict()
log.info('%s: in grade_answers, answers=%s' % (self,answers)) log.info('%s: in grade_answers, answers=%s' % (self,answers))
for responder in self.responders: for responder in self.responders.values():
results = responder.get_score(answers) # call the responsetype instance to do the actual grading results = responder.get_score(answers) # call the responsetype instance to do the actual grading
self.correct_map.update(results) self.correct_map.update(results)
return self.correct_map return self.correct_map
...@@ -218,24 +204,14 @@ class LoncapaProblem(object): ...@@ -218,24 +204,14 @@ class LoncapaProblem(object):
(see capa_module) (see capa_module)
""" """
answer_map = dict() answer_map = dict()
for responder in self.responders: for responder in self.responders.values():
results = responder.get_answers() results = responder.get_answers()
answer_map.update(results) # dict of (id,correct_answer) answer_map.update(results) # dict of (id,correct_answer)
# This should be handled in each responsetype, not here.
# example for the following: <textline size="5" correct_answer="saturated" />
for responder in self.responders:
for entry in responder.inputfields:
answer = entry.get('correct_answer') # correct answer, when specified elsewhere, eg in a textline
if answer:
answer_map[entry.get('id')] = contextualize_text(answer, self.context)
# include solutions from <solution>...</solution> stanzas # include solutions from <solution>...</solution> stanzas
# Tentative merge; we should figure out how we want to handle hints and solutions
for entry in self.tree.xpath("//" + "|//".join(solution_types)): for entry in self.tree.xpath("//" + "|//".join(solution_types)):
answer = etree.tostring(entry) answer = etree.tostring(entry)
if answer: if answer: answer_map[entry.get('id')] = answer
answer_map[entry.get('id')] = answer
return answer_map return answer_map
...@@ -244,7 +220,7 @@ class LoncapaProblem(object): ...@@ -244,7 +220,7 @@ class LoncapaProblem(object):
the dicts returned by grade_answers and get_question_answers. (Though the dicts returned by grade_answers and get_question_answers. (Though
get_question_answers may only return a subset of these.""" get_question_answers may only return a subset of these."""
answer_ids = [] answer_ids = []
for responder in self.responders: for responder in self.responders.values():
answer_ids.append(responder.get_answers().keys()) answer_ids.append(responder.get_answers().keys())
return answer_ids return answer_ids
...@@ -252,7 +228,7 @@ class LoncapaProblem(object): ...@@ -252,7 +228,7 @@ class LoncapaProblem(object):
''' '''
Main method called externally to get the HTML to be rendered for this capa Problem. Main method called externally to get the HTML to be rendered for this capa Problem.
''' '''
return contextualize_text(etree.tostring(self.extract_html(self.tree)[0]), self.context) return contextualize_text(etree.tostring(self.extract_html(self.tree)), self.context)
# ======= Private ======== # ======= Private ========
def extract_context(self, tree, seed=struct.unpack('i', os.urandom(4))[0]): # private def extract_context(self, tree, seed=struct.unpack('i', os.urandom(4))[0]): # private
...@@ -269,7 +245,6 @@ class LoncapaProblem(object): ...@@ -269,7 +245,6 @@ class LoncapaProblem(object):
context['__builtins__'] = globals()['__builtins__'] # put globals there also context['__builtins__'] = globals()['__builtins__'] # put globals there also
context['the_lcp'] = self # pass instance of LoncapaProblem in context['the_lcp'] = self # pass instance of LoncapaProblem in
#for script in tree.xpath('/problem/script'):
for script in tree.findall('.//script'): for script in tree.findall('.//script'):
stype = script.get('type') stype = script.get('type')
if stype: if stype:
...@@ -288,16 +263,20 @@ class LoncapaProblem(object): ...@@ -288,16 +263,20 @@ class LoncapaProblem(object):
return context return context
def extract_html(self, problemtree): # private def extract_html(self, problemtree): # private
''' Helper function for get_html. Recursively converts XML tree to HTML '''
Main (private) function which converts Problem XML tree to HTML.
Calls itself recursively.
Returns Element tree of XHTML representation of problemtree.
Calls render_html of Response instances to render responses into XHTML.
Used by get_html.
''' '''
if problemtree.tag in html_problem_semantics: if problemtree.tag in html_problem_semantics:
return return
problemid = problemtree.get('id') # my ID problemid = problemtree.get('id') # my ID
# used to be
# if problemtree.tag in html_special_response:
if problemtree.tag in inputtypes.get_input_xml_tags(): if problemtree.tag in inputtypes.get_input_xml_tags():
# status is currently the answer for the problem ID for the input element, # status is currently the answer for the problem ID for the input element,
# but it will turn into a dict containing both the answer and any associated message # but it will turn into a dict containing both the answer and any associated message
...@@ -334,31 +313,25 @@ class LoncapaProblem(object): ...@@ -334,31 +313,25 @@ class LoncapaProblem(object):
use='capa_input') use='capa_input')
return render_object.get_html() # function(problemtree, value, status, msg) # render the special response (textline, schematic,...) return render_object.get_html() # function(problemtree, value, status, msg) # render the special response (textline, schematic,...)
tree = Element(problemtree.tag) if problemtree in self.responders: # let each Response render itself
return self.responders[problemtree].render_html(self.extract_html)
tree = etree.Element(problemtree.tag)
for item in problemtree: for item in problemtree:
subitems = self.extract_html(item) item_xhtml = self.extract_html(item) # nothing special: recurse
if subitems is not None: if item_xhtml is not None:
for subitem in subitems: tree.append(item_xhtml)
tree.append(subitem)
for (key, value) in problemtree.items(): if tree.tag in html_transforms:
tree.tag = html_transforms[problemtree.tag]['tag']
else:
for (key, value) in problemtree.items(): # copy attributes over if not innocufying
tree.set(key, value) tree.set(key, value)
tree.text = problemtree.text tree.text = problemtree.text
tree.tail = problemtree.tail tree.tail = problemtree.tail
if problemtree.tag in html_transforms: return tree
tree.tag = html_transforms[problemtree.tag]['tag']
# Reset attributes. Otherwise, we get metadata in HTML
# (e.g. answers)
# TODO: We should remove and not zero them.
# I'm not sure how to do that quickly with lxml
for k in tree.keys():
tree.set(k, "")
# TODO: Fix. This loses Element().tail
#if problemtree.tag in html_skip:
# return tree
return [tree]
def preprocess_problem(self, tree, correct_map=dict(), answer_map=dict()): # private def preprocess_problem(self, tree, correct_map=dict(), answer_map=dict()): # private
''' '''
...@@ -370,7 +343,7 @@ class LoncapaProblem(object): ...@@ -370,7 +343,7 @@ class LoncapaProblem(object):
Also create capa Response instances for each responsetype and save as self.responders Also create capa Response instances for each responsetype and save as self.responders
''' '''
response_id = 1 response_id = 1
self.responders = [] self.responders = {}
for response in tree.xpath('//' + "|//".join(response_types)): for response in tree.xpath('//' + "|//".join(response_types)):
response_id_str = self.problem_id + "_" + str(response_id) response_id_str = self.problem_id + "_" + str(response_id)
response.attrib['id'] = response_id_str # create and save ID for this response response.attrib['id'] = response_id_str # create and save ID for this response
...@@ -389,7 +362,7 @@ class LoncapaProblem(object): ...@@ -389,7 +362,7 @@ class LoncapaProblem(object):
answer_id = answer_id + 1 answer_id = answer_id + 1
responder = response_types[response.tag](response, inputfields, self.context, self.system) # instantiate capa Response responder = response_types[response.tag](response, inputfields, self.context, self.system) # instantiate capa Response
self.responders.append(responder) # save in list in self self.responders[response] = responder # save in list in self
# <solution>...</solution> may not be associated with any specific response; give IDs for those separately # <solution>...</solution> may not be associated with any specific response; give IDs for those separately
# TODO: We should make the namespaces consistent and unique (e.g. %s_problem_%i). # TODO: We should make the namespaces consistent and unique (e.g. %s_problem_%i).
......
...@@ -33,27 +33,18 @@ def get_input_xml_tags(): ...@@ -33,27 +33,18 @@ def get_input_xml_tags():
class SimpleInput():# XModule class SimpleInput():# XModule
''' Type for simple inputs -- plain HTML with a form element ''' Type for simple inputs -- plain HTML with a form element
State is a dictionary with optional keys: State is a dictionary with optional keys:
* Value * Value
* ID * ID
* Status (answered, unanswered, unsubmitted) * Status (answered, unanswered, unsubmitted)
* Feedback (dictionary containing keys for hints, errors, or other * Feedback (dictionary containing keys for hints, errors, or other
feedback from previous attempt) feedback from previous attempt)
''' '''
xml_tags = {} ## Maps tags to functions xml_tags = {} ## Maps tags to functions
@classmethod
def get_xml_tags(c):
return c.xml_tags.keys()
@classmethod
def get_uses(c):
return ['capa_input', 'capa_transform']
def get_html(self):
return self.xml_tags[self.tag](self.xml, self.value, self.status, self.system.render_template, self.msg)
def __init__(self, system, xml, item_id = None, track_url=None, state=None, use = 'capa_input'): def __init__(self, system, xml, item_id = None, track_url=None, state=None, use = 'capa_input'):
self.xml = xml self.xml = xml
self.tag = xml.tag self.tag = xml.tag
...@@ -83,49 +74,16 @@ class SimpleInput():# XModule ...@@ -83,49 +74,16 @@ class SimpleInput():# XModule
if 'status' in state: if 'status' in state:
self.status = state['status'] self.status = state['status']
## TODO @classmethod
# class SimpleTransform(): def get_xml_tags(c):
# ''' Type for simple XML to HTML transforms. Examples: return c.xml_tags.keys()
# * Math tags, which go from LON-CAPA-style m-tags to MathJAX
# ''' @classmethod
# xml_tags = {} ## Maps tags to functions def get_uses(c):
return ['capa_input', 'capa_transform']
# @classmethod
# def get_xml_tags(c):
# return c.xml_tags.keys()
# @classmethod
# def get_uses(c):
# return ['capa_transform']
# def get_html(self):
# return self.xml_tags[self.tag](self.xml, self.value, self.status, self.msg)
# def __init__(self, system, xml, item_id = None, track_url=None, state=None, use = 'capa_input'):
# self.xml = xml
# self.tag = xml.tag
# if not state:
# state = {}
# if item_id:
# self.id = item_id
# if xml.get('id'):
# self.id = xml.get('id')
# if 'id' in state:
# self.id = state['id']
# self.system = system
# self.value = ''
# if 'value' in state:
# self.value = state['value']
# self.msg = ''
# if 'feedback' in state and 'message' in state['feedback']:
# self.msg = state['feedback']['message']
# self.status = 'unanswered'
# if 'status' in state:
# self.status = state['status']
def get_html(self):
return self.xml_tags[self.tag](self.xml, self.value, self.status, self.system.render_template, self.msg)
def register_render_function(fn, names=None, cls=SimpleInput): def register_render_function(fn, names=None, cls=SimpleInput):
if names is None: if names is None:
...@@ -136,9 +94,6 @@ def register_render_function(fn, names=None, cls=SimpleInput): ...@@ -136,9 +94,6 @@ def register_render_function(fn, names=None, cls=SimpleInput):
return fn return fn
return wrapped return wrapped
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
@register_render_function @register_render_function
...@@ -201,16 +156,16 @@ def choicegroup(element, value, status, render_template, msg=''): ...@@ -201,16 +156,16 @@ def choicegroup(element, value, status, render_template, msg=''):
return etree.XML(html) return etree.XML(html)
@register_render_function @register_render_function
def textline(element, value, state, render_template, msg=""): def textline(element, value, status, render_template, msg=""):
''' '''
Simple text line input, with optional size specification. Simple text line input, with optional size specification.
''' '''
if element.get('math') or element.get('dojs'): # 'dojs' flag is temporary, for backwards compatibility with 8.02x if element.get('math') or element.get('dojs'): # 'dojs' flag is temporary, for backwards compatibility with 8.02x
return SimpleInput.xml_tags['textline_dynamath'](element,value,state,render_template,msg) return SimpleInput.xml_tags['textline_dynamath'](element,value,status,render_template,msg)
eid=element.get('id') eid=element.get('id')
count = int(eid.split('_')[-2])-1 # HACK count = int(eid.split('_')[-2])-1 # HACK
size = element.get('size') size = element.get('size')
context = {'id':eid, 'value':value, 'state':state, 'count':count, 'size': size, 'msg': msg} context = {'id':eid, 'value':value, 'state':status, 'count':count, 'size': size, 'msg': msg}
html = render_template("textinput.html", context) html = render_template("textinput.html", context)
return etree.XML(html) return etree.XML(html)
......
...@@ -63,6 +63,7 @@ class GenericResponse(object): ...@@ -63,6 +63,7 @@ class GenericResponse(object):
- get_max_score : if defined, this is called to obtain the maximum score possible for this question - get_max_score : if defined, this is called to obtain the maximum score possible for this question
- setup_response : find and note the answer input field IDs for the response; called by __init__ - setup_response : find and note the answer input field IDs for the response; called by __init__
- render_html : render this Response as HTML (must return XHTML compliant string)
- __unicode__ : unicode representation of this Response - __unicode__ : unicode representation of this Response
Each response type may also specify the following attributes: Each response type may also specify the following attributes:
...@@ -114,9 +115,30 @@ class GenericResponse(object): ...@@ -114,9 +115,30 @@ class GenericResponse(object):
if self.max_inputfields==1: if self.max_inputfields==1:
self.answer_id = self.answer_ids[0] # for convenience self.answer_id = self.answer_ids[0] # for convenience
self.default_answer_map = {} # dict for default answer map (provided in input elements)
for entry in self.inputfields:
answer = entry.get('correct_answer')
if answer:
self.default_answer_map[entry.get('id')] = contextualize_text(answer, self.context)
if hasattr(self,'setup_response'): if hasattr(self,'setup_response'):
self.setup_response() self.setup_response()
def render_html(self,renderer):
'''
Return XHTML Element tree representation of this Response.
Arguments:
- renderer : procedure which produces HTML given an ElementTree
'''
tree = etree.Element('span') # render ourself as a <span> + our content
for item in self.xml:
item_xhtml = renderer(item) # call provided procedure to do the rendering
if item_xhtml is not None: tree.append(item_xhtml)
tree.tail = self.xml.tail
return tree
@abc.abstractmethod @abc.abstractmethod
def get_score(self, student_answers): def get_score(self, student_answers):
''' '''
...@@ -132,7 +154,6 @@ class GenericResponse(object): ...@@ -132,7 +154,6 @@ class GenericResponse(object):
''' '''
pass pass
#not an abstract method because plenty of responses will not want to preprocess anything, and we should not require that they override this method.
def setup_response(self): def setup_response(self):
pass pass
...@@ -485,17 +506,17 @@ def sympy_check2(): ...@@ -485,17 +506,17 @@ def sympy_check2():
''' '''
Give correct answer expected for this response. Give correct answer expected for this response.
capa_problem handles correct_answers from entry objects like textline, and that use default_answer_map from entry elements (eg textline),
is what should be used when this response has multiple entry objects. when this response has multiple entry objects.
but for simplicity, if an "expect" attribute was given by the content author but for simplicity, if an "expect" attribute was given by the content author
ie <customresponse expect="foo" ...> then return it now. ie <customresponse expect="foo" ...> then that.
''' '''
if len(self.answer_ids)>1: if len(self.answer_ids)>1:
return {} return self.default_answer_map
if self.expect: if self.expect:
return {self.answer_ids[0] : self.expect} return {self.answer_ids[0] : self.expect}
return {} return self.default_answer_map
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
...@@ -797,9 +818,8 @@ class SchematicResponse(GenericResponse): ...@@ -797,9 +818,8 @@ class SchematicResponse(GenericResponse):
return zip(sorted(self.answer_ids), self.context['correct']) return zip(sorted(self.answer_ids), self.context['correct'])
def get_answers(self): def get_answers(self):
# Since this is explicitly specified in the problem, this will # use answers provided in input elements
# be handled by capa_problem return self.default_answer_map
return {}
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
......
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