Commit d36d8cba by Victor Shnayder

Cleanups:

 - switch to using registry
 - pull math and solution "input types" into a separate customrender.py file
 - make capa_problem use new custom renderers and input types registries
 - remove unused imports, methods, etc
 - add tests for math and solution tags.
parent f6637b7f
......@@ -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 <solution>...</solution> 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)
......
"""
This has custom renderers: classes that know how to render certain problem tags (e.g. <math> and
<solution>) 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:
<math>$\displaystyle U(r)=4 U_0 $</math>
<math>$r_0$</math>
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 = '<html><html>%s</html><html>%s</html></html>' % (
self.mathstr, saxutils.escape(self.xml.tail))
try:
xhtml = etree.XML(html)
except Exception as err:
if self.system.DEBUG:
msg = '<html><div class="inline-error"><p>Error %s</p>' % (
str(err).replace('<', '&lt;'))
msg += ('<p>Failed to construct math expression from <pre>%s</pre></p>' %
html.replace('<', '&lt;'))
msg += "</div></html>"
log.error(msg)
return etree.XML(msg)
else:
raise
return xhtml
registry.register(MathRenderer)
#-----------------------------------------------------------------------------
class SolutionRenderer(object):
'''
A solution is just a <span>...</span> 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)
# 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:
<m display="jsmath">$\displaystyle U(r)=4 U_0 </m>
<m>$r_0$</m>
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 = '<html><html>%s</html><html>%s</html></html>' % (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 = '<html><div class="inline-error"><p>Error %s</p>' % str(err).replace('<', '&lt;')
msg += ('<p>Failed to construct math expression from <pre>%s</pre></p>' %
html.replace('<', '&lt;'))
msg += "</div></html>"
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 <span>...</span> 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)
......@@ -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 <div>
"""
return '<div>{0}</div>'.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")),
......
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 = """<solution id="solution_12">{s}</solution>""".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 = """<math>{tex}</math>""".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.
"""
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
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)
# just a handy shortcut
lookup_tag = inputtypes.registry.get_class_for_tag
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()
......
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