diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 1c31725e4b..451891d067 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -38,6 +38,7 @@ import calc from correctmap import CorrectMap import eia import inputtypes +import customrender from util import contextualize_text, convert_files_to_filenames import xqueue_interface @@ -47,23 +48,8 @@ import responsetypes # dict of tagname, Response Class -- this should come from auto-registering response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) -# Different ways students can input code -entry_types = ['textline', - 'schematic', - 'textbox', - 'imageinput', - 'optioninput', - 'choicegroup', - 'radiogroup', - 'checkboxgroup', - 'filesubmission', - 'javascriptinput', - 'crystallography', - 'chemicalequationinput', - 'vsepr_input'] - # extra things displayed after "show answers" is pressed -solution_types = ['solution'] +solution_tags = ['solution'] # these get captured as student responses response_properties = ["codeparam", "responseparam", "answer"] @@ -309,7 +295,7 @@ class LoncapaProblem(object): answer_map.update(results) # include solutions from ... stanzas - for entry in self.tree.xpath("//" + "|//".join(solution_types)): + for entry in self.tree.xpath("//" + "|//".join(solution_tags)): answer = etree.tostring(entry) if answer: answer_map[entry.get('id')] = contextualize_text(answer, self.context) @@ -487,7 +473,7 @@ class LoncapaProblem(object): problemid = problemtree.get('id') # my ID - if problemtree.tag in inputtypes.registered_input_tags(): + if problemtree.tag in inputtypes.registry.registered_tags(): # If this is an inputtype subtree, let it render itself. status = "unsubmitted" msg = '' @@ -513,7 +499,7 @@ class LoncapaProblem(object): 'hint': hint, 'hintmode': hintmode,}} - input_type_cls = inputtypes.get_class_for_tag(problemtree.tag) + input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag) the_input = input_type_cls(self.system, problemtree, state) return the_input.get_html() @@ -521,9 +507,15 @@ class LoncapaProblem(object): if problemtree in self.responders: return self.responders[problemtree].render_html(self._extract_html) + # let each custom renderer render itself: + if problemtree.tag in customrender.registry.registered_tags(): + renderer_class = customrender.registry.get_class_for_tag(problemtree.tag) + renderer = renderer_class(self.system, problemtree) + return renderer.get_html() + + # otherwise, render children recursively, and copy over attributes tree = etree.Element(problemtree.tag) for item in problemtree: - # render child recursively item_xhtml = self._extract_html(item) if item_xhtml is not None: tree.append(item_xhtml) @@ -560,11 +552,12 @@ class LoncapaProblem(object): response_id += 1 answer_id = 1 + input_tags = inputtypes.registry.registered_tags() inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x - for x in (entry_types + solution_types)]), + for x in (input_tags + solution_tags)]), id=response_id_str) - # assign one answer_id for each entry_type or solution_type + # assign one answer_id for each input type or solution type for entry in inputfields: entry.attrib['response_id'] = str(response_id) entry.attrib['answer_id'] = str(answer_id) diff --git a/common/lib/capa/capa/customrender.py b/common/lib/capa/capa/customrender.py new file mode 100644 index 0000000000..ef1044e8b1 --- /dev/null +++ b/common/lib/capa/capa/customrender.py @@ -0,0 +1,100 @@ +""" +This has custom renderers: classes that know how to render certain problem tags (e.g. and +) to html. + +These tags do not have state, so they just get passed the system (for access to render_template), +and the xml element. +""" + +from registry import TagRegistry + +import logging +import re +import shlex # for splitting quoted strings +import json + +from lxml import etree +import xml.sax.saxutils as saxutils +from registry import TagRegistry + +log = logging.getLogger('mitx.' + __name__) + +registry = TagRegistry() + +#----------------------------------------------------------------------------- +class MathRenderer(object): + tags = ['math'] + + def __init__(self, system, xml): + ''' + Render math using latex-like formatting. + + Examples: + + $\displaystyle U(r)=4 U_0 $ + $r_0$ + + We convert these to [mathjax]...[/mathjax] and [mathjaxinline]...[/mathjaxinline] + + TODO: use shorter tags (but this will require converting problem XML files!) + ''' + self.system = system + self.xml = xml + + mathstr = re.sub('\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text) + mtag = 'mathjax' + if not r'\displaystyle' in mathstr: + mtag += 'inline' + else: + mathstr = mathstr.replace(r'\displaystyle', '') + self.mathstr = mathstr.replace('mathjaxinline]', '%s]' % mtag) + + + def get_html(self): + """ + Return the contents of this tag, rendered to html, as an etree element. + """ + # TODO: why are there nested html tags here?? Why are there html tags at all, in fact? + html = '%s%s' % ( + self.mathstr, saxutils.escape(self.xml.tail)) + try: + xhtml = etree.XML(html) + except Exception as err: + if self.system.DEBUG: + msg = '

Error %s

' % ( + str(err).replace('<', '<')) + msg += ('

Failed to construct math expression from

%s

' % + html.replace('<', '<')) + msg += "
" + log.error(msg) + return etree.XML(msg) + else: + raise + return xhtml + + +registry.register(MathRenderer) + +#----------------------------------------------------------------------------- + +class SolutionRenderer(object): + ''' + A solution is just a ... which is given an ID, that is used for displaying an + extended answer (a problem "solution") after "show answers" is pressed. + + Note that the solution content is NOT rendered and returned in the HTML. It is obtained by an + ajax call. + ''' + tags = ['solution'] + + def __init__(self, system, xml): + self.system = system + self.id = xml.get('id') + + def get_html(self): + context = {'id': self.id} + html = self.system.render_template("solutionspan.html", context) + return etree.XML(html) + +registry.register(SolutionRenderer) + diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 145eabd953..9569958c96 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -1,29 +1,3 @@ - - -# template: -''' -class ClassName(InputTypeBase): - """ - """ - - template = "tagname.html" - tags = ['tagname'] - - def __init__(self, system, xml, state): - super(ClassName, self).__init__(system, xml, state) - - - def _get_render_context(self): - - context = {'id': self.id, - - } - return context - -register_input_class(ClassName) -''' - - # # File: courseware/capa/inputtypes.py # @@ -32,11 +6,9 @@ register_input_class(ClassName) Module containing the problem elements which render into input objects - textline -- textbox (change this to textarea?) -- schemmatic -- choicegroup -- radiogroup -- checkboxgroup +- textbox (aka codeinput) +- schematic +- choicegroup (aka radiogroup, checkboxgroup) - javascriptinput - imageinput (for clickable image) - optioninput (for option list) @@ -60,53 +32,13 @@ import json from lxml import etree import xml.sax.saxutils as saxutils +from registry import TagRegistry log = logging.getLogger('mitx.' + __name__) ######################################################################### -_TAGS_TO_CLASSES = {} - -def register_input_class(cls): - """ - Register cls as a supported input type. It is expected to have the same constructor as - InputTypeBase, and to define cls.tags as a list of tags that it implements. - - If an already-registered input type has claimed one of those tags, will raise ValueError. - - If there are no tags in cls.tags, will also raise ValueError. - """ - - # Do all checks and complain before changing any state. - if len(cls.tags) == 0: - raise ValueError("No supported tags for class {0}".format(cls.__name__)) - - for t in cls.tags: - if t in _TAGS_TO_CLASSES: - other_cls = _TAGS_TO_CLASSES[t] - if cls == other_cls: - # registering the same class multiple times seems silly, but ok - continue - raise ValueError("Tag {0} already registered by class {1}. Can't register for class {2}" - .format(t, other_cls.__name__, cls.__name__)) - - # Ok, should be good to change state now. - for t in cls.tags: - _TAGS_TO_CLASSES[t] = cls - -def registered_input_tags(): - """ - Get a list of all the xml tags that map to known input types. - """ - return _TAGS_TO_CLASSES.keys() - - -def get_class_for_tag(tag): - """ - For any tag in registered_input_tags(), return the corresponding class. Otherwise, will raise KeyError. - """ - return _TAGS_TO_CLASSES[tag] - +registry = TagRegistry() class InputTypeBase(object): """ @@ -119,16 +51,18 @@ class InputTypeBase(object): """ Instantiate an InputType class. Arguments: - - system : ModuleSystem instance which provides OS, rendering, and user context. Specifically, must - have a render_template function. + - system : ModuleSystem instance which provides OS, rendering, and user context. + Specifically, must have a render_template function. - xml : Element tree of this Input element - state : a dictionary with optional keys: - * 'value' -- the current value of this input (what the student entered last time) - * 'id' -- the id of this input, typically "{problem-location}_{response-num}_{input-num}" + * 'value' -- the current value of this input + (what the student entered last time) + * 'id' -- the id of this input, typically + "{problem-location}_{response-num}_{input-num}" * 'status' (answered, unanswered, unsubmitted) * 'feedback' (dictionary containing keys for hints, errors, or other - feedback from previous attempt. Specifically 'message', 'hint', 'hintmode'. If 'hintmode' - is 'always', the hint is always displayed.) + feedback from previous attempt. Specifically 'message', 'hint', + 'hintmode'. If 'hintmode' is 'always', the hint is always displayed.) """ self.xml = xml @@ -172,40 +106,13 @@ class InputTypeBase(object): Return the html for this input, as an etree element. """ if self.template is None: - raise NotImplementedError("no rendering template specified for class {0}".format(self.__class__)) + raise NotImplementedError("no rendering template specified for class {0}" + .format(self.__class__)) html = self.system.render_template(self.template, self._get_render_context()) return etree.XML(html) -## TODO: Remove once refactor is complete -def make_class_for_render_function(fn): - """ - Take an old-style render function, return a new-style input class. - """ - - class Impl(InputTypeBase): - """ - Inherit all the constructor logic from InputTypeBase... - """ - tags = [fn.__name__] - def get_html(self): - """...delegate to the render function to do the work""" - return fn(self.xml, self.value, self.status, self.system.render_template, self.msg) - - # don't want all the classes to be called Impl (confuses register_input_class). - Impl.__name__ = fn.__name__.capitalize() - return Impl - - -def _reg(fn): - """ - Register an old-style inputtype render function as a new-style subclass of InputTypeBase. - This will go away once converting all input types to the new format is complete. (TODO) - """ - register_input_class(make_class_for_render_function(fn)) - - #----------------------------------------------------------------------------- @@ -253,7 +160,7 @@ class OptionInput(InputTypeBase): } return context -register_input_class(OptionInput) +registry.register(OptionInput) #----------------------------------------------------------------------------- @@ -346,7 +253,7 @@ def extract_choices(element): return choices -register_input_class(ChoiceGroup) +registry.register(ChoiceGroup) #----------------------------------------------------------------------------- @@ -389,7 +296,7 @@ class JavascriptInput(InputTypeBase): } return context -register_input_class(JavascriptInput) +registry.register(JavascriptInput) #----------------------------------------------------------------------------- @@ -445,7 +352,7 @@ class TextLine(InputTypeBase): } return context -register_input_class(TextLine) +registry.register(TextLine) #----------------------------------------------------------------------------- @@ -485,7 +392,7 @@ class FileSubmission(InputTypeBase): 'required_files': self.required_files,} return context -register_input_class(FileSubmission) +registry.register(FileSubmission) #----------------------------------------------------------------------------- @@ -542,7 +449,7 @@ class CodeInput(InputTypeBase): } return context -register_input_class(CodeInput) +registry.register(CodeInput) #----------------------------------------------------------------------------- @@ -576,74 +483,7 @@ class Schematic(InputTypeBase): 'submit_analyses': self.submit_analyses, } return context -register_input_class(Schematic) - -#----------------------------------------------------------------------------- -### TODO: Move out of inputtypes -def math(element, value, status, render_template, msg=''): - ''' - This is not really an input type. It is a convention from Lon-CAPA, used for - displaying a math equation. - - Examples: - - $\displaystyle U(r)=4 U_0 - $r_0$ - - We convert these to [mathjax]...[/mathjax] and [mathjaxinline]...[/mathjaxinline] - - TODO: use shorter tags (but this will require converting problem XML files!) - ''' - mathstr = re.sub('\$(.*)\$', '[mathjaxinline]\\1[/mathjaxinline]', element.text) - mtag = 'mathjax' - if not '\\displaystyle' in mathstr: - mtag += 'inline' - else: - mathstr = mathstr.replace('\\displaystyle', '') - mathstr = mathstr.replace('mathjaxinline]', '%s]' % mtag) - - - # TODO: why are there nested html tags here?? Why are there html tags at all, in fact? - html = '%s%s' % (mathstr, saxutils.escape(element.tail)) - try: - xhtml = etree.XML(html) - except Exception as err: - if False: # TODO needs to be self.system.DEBUG - but can't access system - msg = '

Error %s

' % str(err).replace('<', '<') - msg += ('

Failed to construct math expression from

%s

' % - html.replace('<', '<')) - msg += "
" - log.error(msg) - return etree.XML(msg) - else: - raise - # xhtml.tail = element.tail # don't forget to include the tail! - return xhtml - -_reg(math) - -#----------------------------------------------------------------------------- - - -def solution(element, value, status, render_template, msg=''): - ''' - This is not really an input type. It is just a ... which is given an ID, - that is used for displaying an extended answer (a problem "solution") after "show answers" - is pressed. Note that the solution content is NOT sent with the HTML. It is obtained - by an ajax call. - ''' - eid = element.get('id') - size = element.get('size') - context = {'id': eid, - 'value': value, - 'state': status, - 'size': size, - 'msg': msg, - } - html = render_template("solutionspan.html", context) - return etree.XML(html) - -_reg(solution) +registry.register(Schematic) #----------------------------------------------------------------------------- @@ -690,7 +530,7 @@ class ImageInput(InputTypeBase): } return context -register_input_class(ImageInput) +registry.register(ImageInput) #----------------------------------------------------------------------------- @@ -730,7 +570,7 @@ class Crystallography(InputTypeBase): } return context -register_input_class(Crystallography) +registry.register(Crystallography) # ------------------------------------------------------------------------- @@ -799,4 +639,4 @@ class ChemicalEquationInput(InputTypeBase): } return context -register_input_class(ChemicalEquationInput) +registry.register(ChemicalEquationInput) diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index c72d2a1538..b06975f6ce 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -4,13 +4,23 @@ import os from mock import Mock +import xml.sax.saxutils as saxutils + TEST_DIR = os.path.dirname(os.path.realpath(__file__)) +def tst_render_template(template, context): + """ + A test version of render to template. Renders to the repr of the context, completely ignoring + the template name. To make the output valid xml, quotes the content, and wraps it in a
+ """ + return '
{0}
'.format(saxutils.escape(repr(context))) + + test_system = Mock( ajax_url='courses/course_id/modx/a_location', track_function=Mock(), get_module=Mock(), - render_template=Mock(), + render_template=tst_render_template, replace_urls=Mock(), user=Mock(), filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), diff --git a/common/lib/capa/capa/tests/test_customrender.py b/common/lib/capa/capa/tests/test_customrender.py new file mode 100644 index 0000000000..7208ab2941 --- /dev/null +++ b/common/lib/capa/capa/tests/test_customrender.py @@ -0,0 +1,76 @@ +from lxml import etree +import unittest +import xml.sax.saxutils as saxutils + +from . import test_system +from capa import customrender + +# just a handy shortcut +lookup_tag = customrender.registry.get_class_for_tag + +def extract_context(xml): + """ + Given an xml element corresponding to the output of test_system.render_template, get back the + original context + """ + return eval(xml.text) + +def quote_attr(s): + return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes + +class HelperTest(unittest.TestCase): + ''' + Make sure that our helper function works! + ''' + def check(self, d): + xml = etree.XML(test_system.render_template('blah', d)) + self.assertEqual(d, extract_context(xml)) + + def test_extract_context(self): + self.check({}) + self.check({1, 2}) + self.check({'id', 'an id'}) + self.check({'with"quote', 'also"quote'}) + + +class SolutionRenderTest(unittest.TestCase): + ''' + Make sure solutions render properly. + ''' + + def test_rendering(self): + solution = 'To compute unicorns, count them.' + xml_str = """{s}""".format(s=solution) + element = etree.fromstring(xml_str) + + renderer = lookup_tag('solution')(test_system, element) + + self.assertEqual(renderer.id, 'solution_12') + + # our test_system "renders" templates to a div with the repr of the context + xml = renderer.get_html() + context = extract_context(xml) + self.assertEqual(context, {'id' : 'solution_12'}) + + +class MathRenderTest(unittest.TestCase): + ''' + Make sure math renders properly. + ''' + + def check_parse(self, latex_in, mathjax_out): + xml_str = """{tex}""".format(tex=latex_in) + element = etree.fromstring(xml_str) + + renderer = lookup_tag('math')(test_system, element) + + self.assertEqual(renderer.mathstr, mathjax_out) + + def test_parsing(self): + self.check_parse('$abc$', '[mathjaxinline]abc[/mathjaxinline]') + self.check_parse('$abc', '$abc') + self.check_parse(r'$\displaystyle 2+2$', '[mathjax] 2+2[/mathjax]') + + + # NOTE: not testing get_html yet because I don't understand why it's doing what it's doing. + diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 773f2b3519..992af14bf9 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -1,5 +1,5 @@ """ -Tests of input types (and actually responsetypes too). +Tests of input types. TODO: - test unicode in values, parameters, etc. @@ -7,27 +7,16 @@ TODO: - test funny xml chars -- should never get xml parse error if things are escaped properly. """ -from datetime import datetime -import json -from mock import Mock -from nose.plugins.skip import SkipTest -import os +from lxml import etree import unittest import xml.sax.saxutils as saxutils from . import test_system from capa import inputtypes -from lxml import etree +# just a handy shortcut +lookup_tag = inputtypes.registry.get_class_for_tag -def tst_render_template(template, context): - """ - A test version of render to template. Renders to the repr of the context, completely ignoring the template name. - """ - return repr(context) - - -system = Mock(render_template=tst_render_template) def quote_attr(s): return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes @@ -44,7 +33,7 @@ class OptionInputTest(unittest.TestCase): state = {'value': 'Down', 'id': 'sky_input', 'status': 'answered'} - option_input = inputtypes.get_class_for_tag('optioninput')(system, element, state) + option_input = lookup_tag('optioninput')(test_system, element, state) context = option_input._get_render_context() @@ -80,7 +69,7 @@ class ChoiceGroupTest(unittest.TestCase): 'id': 'sky_input', 'status': 'answered'} - option_input = inputtypes.get_class_for_tag('choicegroup')(system, element, state) + option_input = lookup_tag('choicegroup')(test_system, element, state) context = option_input._get_render_context() @@ -119,7 +108,7 @@ class ChoiceGroupTest(unittest.TestCase): 'id': 'sky_input', 'status': 'answered'} - the_input = inputtypes.get_class_for_tag(tag)(system, element, state) + the_input = lookup_tag(tag)(test_system, element, state) context = the_input._get_render_context() @@ -164,7 +153,7 @@ class JavascriptInputTest(unittest.TestCase): element = etree.fromstring(xml_str) state = {'value': '3',} - the_input = inputtypes.get_class_for_tag('javascriptinput')(system, element, state) + the_input = lookup_tag('javascriptinput')(test_system, element, state) context = the_input._get_render_context() @@ -191,7 +180,7 @@ class TextLineTest(unittest.TestCase): element = etree.fromstring(xml_str) state = {'value': 'BumbleBee',} - the_input = inputtypes.get_class_for_tag('textline')(system, element, state) + the_input = lookup_tag('textline')(test_system, element, state) context = the_input._get_render_context() @@ -219,7 +208,7 @@ class TextLineTest(unittest.TestCase): element = etree.fromstring(xml_str) state = {'value': 'BumbleBee',} - the_input = inputtypes.get_class_for_tag('textline')(system, element, state) + the_input = lookup_tag('textline')(test_system, element, state) context = the_input._get_render_context() @@ -260,7 +249,7 @@ class FileSubmissionTest(unittest.TestCase): state = {'value': 'BumbleBee.py', 'status': 'incomplete', 'feedback' : {'message': '3'}, } - the_input = inputtypes.get_class_for_tag('filesubmission')(system, element, state) + the_input = lookup_tag('filesubmission')(test_system, element, state) context = the_input._get_render_context() @@ -304,7 +293,7 @@ class CodeInputTest(unittest.TestCase): 'status': 'incomplete', 'feedback' : {'message': '3'}, } - the_input = inputtypes.get_class_for_tag('codeinput')(system, element, state) + the_input = lookup_tag('codeinput')(test_system, element, state) context = the_input._get_render_context() @@ -354,7 +343,7 @@ class SchematicTest(unittest.TestCase): state = {'value': value, 'status': 'unsubmitted'} - the_input = inputtypes.get_class_for_tag('schematic')(system, element, state) + the_input = lookup_tag('schematic')(test_system, element, state) context = the_input._get_render_context() @@ -393,7 +382,7 @@ class ImageInputTest(unittest.TestCase): state = {'value': value, 'status': 'unsubmitted'} - the_input = inputtypes.get_class_for_tag('imageinput')(system, element, state) + the_input = lookup_tag('imageinput')(test_system, element, state) context = the_input._get_render_context() @@ -447,7 +436,7 @@ class CrystallographyTest(unittest.TestCase): state = {'value': value, 'status': 'unsubmitted'} - the_input = inputtypes.get_class_for_tag('crystallography')(system, element, state) + the_input = lookup_tag('crystallography')(test_system, element, state) context = the_input._get_render_context() @@ -476,7 +465,7 @@ class ChemicalEquationTest(unittest.TestCase): element = etree.fromstring(xml_str) state = {'value': 'H2OYeah',} - the_input = inputtypes.get_class_for_tag('chemicalequationinput')(system, element, state) + the_input = lookup_tag('chemicalequationinput')(test_system, element, state) context = the_input._get_render_context()