diff --git a/common/lib/capa/capa_problem.py b/common/lib/capa/capa_problem.py index f790190..c63c13d 100644 --- a/common/lib/capa/capa_problem.py +++ b/common/lib/capa/capa_problem.py @@ -23,7 +23,6 @@ import scipy import struct from lxml import etree -from lxml.etree import Element from xml.sax.saxutils import unescape from util import contextualize_text @@ -36,6 +35,7 @@ import eia log = logging.getLogger(__name__) +# dict of tagname, Response Class -- this should come from auto-registering response_types = {'numericalresponse': NumericalResponse, 'formularesponse': FormulaResponse, 'customresponse': CustomResponse, @@ -47,20 +47,13 @@ response_types = {'numericalresponse': NumericalResponse, 'optionresponse': OptionResponse, 'symbolicresponse': SymbolicResponse, } + entry_types = ['textline', 'schematic', 'choicegroup', 'textbox', 'imageinput', 'optioninput'] -solution_types = ['solution'] # extra things displayed after "show answers" is pressed -response_properties = ["responseparam", "answer"] # these get captured as student responses +solution_types = ['solution'] # extra things displayed after "show answers" is pressed +response_properties = ["responseparam", "answer"] # these get captured as student responses -# How to convert from original XML to HTML -# We should do this with xlst later +# special problem tags which should be turned into innocuous HTML 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'}, "math": {'tag': 'span'}, } @@ -74,18 +67,6 @@ global_context = {'random': random, # These should be removed from HTML output, including all subelements 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): ''' @@ -142,7 +123,8 @@ class LoncapaProblem(object): 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 - # 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) def __unicode__(self): @@ -166,7 +148,7 @@ class LoncapaProblem(object): used to give complex problems (eg programming questions) multiple points. ''' maxscore = 0 - for responder in self.responders: + for responder in self.responders.values(): if hasattr(responder,'get_max_score'): try: maxscore += responder.get_max_score() @@ -182,6 +164,10 @@ class LoncapaProblem(object): return maxscore 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 for key in self.correct_map: if self.correct_map[key] == u'correct': @@ -206,7 +192,7 @@ class LoncapaProblem(object): self.student_answers = answers self.correct_map = dict() 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 self.correct_map.update(results) return self.correct_map @@ -218,24 +204,14 @@ class LoncapaProblem(object): (see capa_module) """ answer_map = dict() - for responder in self.responders: + for responder in self.responders.values(): results = responder.get_answers() 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 - # Tentative merge; we should figure out how we want to handle hints and solutions for entry in self.tree.xpath("//" + "|//".join(solution_types)): answer = etree.tostring(entry) - if answer: - answer_map[entry.get('id')] = answer + if answer: answer_map[entry.get('id')] = answer return answer_map @@ -244,7 +220,7 @@ class LoncapaProblem(object): the dicts returned by grade_answers and get_question_answers. (Though get_question_answers may only return a subset of these.""" answer_ids = [] - for responder in self.responders: + for responder in self.responders.values(): answer_ids.append(responder.get_answers().keys()) return answer_ids @@ -252,7 +228,7 @@ class LoncapaProblem(object): ''' 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 ======== def extract_context(self, tree, seed=struct.unpack('i', os.urandom(4))[0]): # private @@ -264,12 +240,11 @@ class LoncapaProblem(object): Problem XML goes to Python execution context. Runs everything in script tags ''' random.seed(self.seed) - context = {'global_context': global_context} # save global context in here also - context.update(global_context) # initialize context to have stuff in global_context - context['__builtins__'] = globals()['__builtins__'] # put globals there also - context['the_lcp'] = self # pass instance of LoncapaProblem in + context = {'global_context': global_context} # save global context in here also + context.update(global_context) # initialize context to have stuff in global_context + context['__builtins__'] = globals()['__builtins__'] # put globals there also + context['the_lcp'] = self # pass instance of LoncapaProblem in - #for script in tree.xpath('/problem/script'): for script in tree.findall('.//script'): stype = script.get('type') if stype: @@ -288,16 +263,20 @@ class LoncapaProblem(object): return context 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: return 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(): # 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 @@ -334,31 +313,25 @@ class LoncapaProblem(object): use='capa_input') 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: - subitems = self.extract_html(item) - if subitems is not None: - for subitem in subitems: - tree.append(subitem) - for (key, value) in problemtree.items(): - tree.set(key, value) + item_xhtml = self.extract_html(item) # nothing special: recurse + if item_xhtml is not None: + tree.append(item_xhtml) + + 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.text = problemtree.text tree.tail = problemtree.tail - if problemtree.tag in html_transforms: - 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] + return tree def preprocess_problem(self, tree, correct_map=dict(), answer_map=dict()): # private ''' @@ -370,7 +343,7 @@ class LoncapaProblem(object): Also create capa Response instances for each responsetype and save as self.responders ''' response_id = 1 - self.responders = [] + self.responders = {} for response in tree.xpath('//' + "|//".join(response_types)): response_id_str = self.problem_id + "_" + str(response_id) response.attrib['id'] = response_id_str # create and save ID for this response @@ -389,7 +362,7 @@ class LoncapaProblem(object): answer_id = answer_id + 1 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 # TODO: We should make the namespaces consistent and unique (e.g. %s_problem_%i). diff --git a/common/lib/capa/inputtypes.py b/common/lib/capa/inputtypes.py index 3b25be3..10fbdb7 100644 --- a/common/lib/capa/inputtypes.py +++ b/common/lib/capa/inputtypes.py @@ -33,26 +33,17 @@ def get_input_xml_tags(): class SimpleInput():# XModule ''' Type for simple inputs -- plain HTML with a form element + State is a dictionary with optional keys: * Value * ID * Status (answered, unanswered, unsubmitted) * Feedback (dictionary containing keys for hints, errors, or other feedback from previous attempt) + ''' 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'): self.xml = xml @@ -83,49 +74,16 @@ class SimpleInput():# XModule if 'status' in state: self.status = state['status'] -## TODO -# class SimpleTransform(): -# ''' Type for simple XML to HTML transforms. Examples: -# * Math tags, which go from LON-CAPA-style m-tags to MathJAX -# ''' -# xml_tags = {} ## Maps tags to functions - -# @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'] + @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 register_render_function(fn, names=None, cls=SimpleInput): if names is None: @@ -136,9 +94,6 @@ def register_render_function(fn, names=None, cls=SimpleInput): return fn return wrapped - - - #----------------------------------------------------------------------------- @register_render_function @@ -201,16 +156,16 @@ def choicegroup(element, value, status, render_template, msg=''): return etree.XML(html) @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. ''' 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') count = int(eid.split('_')[-2])-1 # HACK 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) return etree.XML(html) diff --git a/common/lib/capa/responsetypes.py b/common/lib/capa/responsetypes.py index 1c09493..bfd4281 100644 --- a/common/lib/capa/responsetypes.py +++ b/common/lib/capa/responsetypes.py @@ -63,7 +63,8 @@ class GenericResponse(object): - 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__ - - __unicode__ : unicode representation of this Response + - render_html : render this Response as HTML (must return XHTML compliant string) + - __unicode__ : unicode representation of this Response Each response type may also specify the following attributes: @@ -114,9 +115,30 @@ class GenericResponse(object): if self.max_inputfields==1: 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'): 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 def get_score(self, student_answers): ''' @@ -132,7 +154,6 @@ class GenericResponse(object): ''' 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): pass @@ -485,17 +506,17 @@ def sympy_check2(): ''' Give correct answer expected for this response. - capa_problem handles correct_answers from entry objects like textline, and that - is what should be used when this response has multiple entry objects. + use default_answer_map from entry elements (eg textline), + when this response has multiple entry objects. 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: - return {} + return self.default_answer_map if self.expect: return {self.answer_ids[0] : self.expect} - return {} + return self.default_answer_map #----------------------------------------------------------------------------- @@ -797,9 +818,8 @@ class SchematicResponse(GenericResponse): return zip(sorted(self.answer_ids), self.context['correct']) def get_answers(self): - # Since this is explicitly specified in the problem, this will - # be handled by capa_problem - return {} + # use answers provided in input elements + return self.default_answer_map #-----------------------------------------------------------------------------