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 ...@@ -38,6 +38,7 @@ import calc
from correctmap import CorrectMap from correctmap import CorrectMap
import eia import eia
import inputtypes import inputtypes
import customrender
from util import contextualize_text, convert_files_to_filenames from util import contextualize_text, convert_files_to_filenames
import xqueue_interface import xqueue_interface
...@@ -47,23 +48,8 @@ import responsetypes ...@@ -47,23 +48,8 @@ import responsetypes
# dict of tagname, Response Class -- this should come from auto-registering # dict of tagname, Response Class -- this should come from auto-registering
response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) 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 # extra things displayed after "show answers" is pressed
solution_types = ['solution'] solution_tags = ['solution']
# these get captured as student responses # these get captured as student responses
response_properties = ["codeparam", "responseparam", "answer"] response_properties = ["codeparam", "responseparam", "answer"]
...@@ -309,7 +295,7 @@ class LoncapaProblem(object): ...@@ -309,7 +295,7 @@ class LoncapaProblem(object):
answer_map.update(results) answer_map.update(results)
# include solutions from <solution>...</solution> stanzas # 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) answer = etree.tostring(entry)
if answer: if answer:
answer_map[entry.get('id')] = contextualize_text(answer, self.context) answer_map[entry.get('id')] = contextualize_text(answer, self.context)
...@@ -487,7 +473,7 @@ class LoncapaProblem(object): ...@@ -487,7 +473,7 @@ class LoncapaProblem(object):
problemid = problemtree.get('id') # my ID 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. # If this is an inputtype subtree, let it render itself.
status = "unsubmitted" status = "unsubmitted"
msg = '' msg = ''
...@@ -513,7 +499,7 @@ class LoncapaProblem(object): ...@@ -513,7 +499,7 @@ class LoncapaProblem(object):
'hint': hint, 'hint': hint,
'hintmode': hintmode,}} '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) the_input = input_type_cls(self.system, problemtree, state)
return the_input.get_html() return the_input.get_html()
...@@ -521,9 +507,15 @@ class LoncapaProblem(object): ...@@ -521,9 +507,15 @@ class LoncapaProblem(object):
if problemtree in self.responders: if problemtree in self.responders:
return self.responders[problemtree].render_html(self._extract_html) 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) tree = etree.Element(problemtree.tag)
for item in problemtree: for item in problemtree:
# render child recursively
item_xhtml = self._extract_html(item) item_xhtml = self._extract_html(item)
if item_xhtml is not None: if item_xhtml is not None:
tree.append(item_xhtml) tree.append(item_xhtml)
...@@ -560,11 +552,12 @@ class LoncapaProblem(object): ...@@ -560,11 +552,12 @@ class LoncapaProblem(object):
response_id += 1 response_id += 1
answer_id = 1 answer_id = 1
input_tags = inputtypes.registry.registered_tags()
inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x 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) 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: for entry in inputfields:
entry.attrib['response_id'] = str(response_id) entry.attrib['response_id'] = str(response_id)
entry.attrib['answer_id'] = str(answer_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)
...@@ -4,13 +4,23 @@ import os ...@@ -4,13 +4,23 @@ import os
from mock import Mock from mock import Mock
import xml.sax.saxutils as saxutils
TEST_DIR = os.path.dirname(os.path.realpath(__file__)) 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( test_system = Mock(
ajax_url='courses/course_id/modx/a_location', ajax_url='courses/course_id/modx/a_location',
track_function=Mock(), track_function=Mock(),
get_module=Mock(), get_module=Mock(),
render_template=Mock(), render_template=tst_render_template,
replace_urls=Mock(), replace_urls=Mock(),
user=Mock(), user=Mock(),
filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), 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: TODO:
- test unicode in values, parameters, etc. - test unicode in values, parameters, etc.
...@@ -7,27 +7,16 @@ TODO: ...@@ -7,27 +7,16 @@ TODO:
- test funny xml chars -- should never get xml parse error if things are escaped properly. - test funny xml chars -- should never get xml parse error if things are escaped properly.
""" """
from datetime import datetime from lxml import etree
import json
from mock import Mock
from nose.plugins.skip import SkipTest
import os
import unittest import unittest
import xml.sax.saxutils as saxutils import xml.sax.saxutils as saxutils
from . import test_system from . import test_system
from capa import inputtypes 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): def quote_attr(s):
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
...@@ -44,7 +33,7 @@ class OptionInputTest(unittest.TestCase): ...@@ -44,7 +33,7 @@ class OptionInputTest(unittest.TestCase):
state = {'value': 'Down', state = {'value': 'Down',
'id': 'sky_input', 'id': 'sky_input',
'status': 'answered'} '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() context = option_input._get_render_context()
...@@ -80,7 +69,7 @@ class ChoiceGroupTest(unittest.TestCase): ...@@ -80,7 +69,7 @@ class ChoiceGroupTest(unittest.TestCase):
'id': 'sky_input', 'id': 'sky_input',
'status': 'answered'} '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() context = option_input._get_render_context()
...@@ -119,7 +108,7 @@ class ChoiceGroupTest(unittest.TestCase): ...@@ -119,7 +108,7 @@ class ChoiceGroupTest(unittest.TestCase):
'id': 'sky_input', 'id': 'sky_input',
'status': 'answered'} '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() context = the_input._get_render_context()
...@@ -164,7 +153,7 @@ class JavascriptInputTest(unittest.TestCase): ...@@ -164,7 +153,7 @@ class JavascriptInputTest(unittest.TestCase):
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
state = {'value': '3',} 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() context = the_input._get_render_context()
...@@ -191,7 +180,7 @@ class TextLineTest(unittest.TestCase): ...@@ -191,7 +180,7 @@ class TextLineTest(unittest.TestCase):
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
state = {'value': 'BumbleBee',} 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() context = the_input._get_render_context()
...@@ -219,7 +208,7 @@ class TextLineTest(unittest.TestCase): ...@@ -219,7 +208,7 @@ class TextLineTest(unittest.TestCase):
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
state = {'value': 'BumbleBee',} 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() context = the_input._get_render_context()
...@@ -260,7 +249,7 @@ class FileSubmissionTest(unittest.TestCase): ...@@ -260,7 +249,7 @@ class FileSubmissionTest(unittest.TestCase):
state = {'value': 'BumbleBee.py', state = {'value': 'BumbleBee.py',
'status': 'incomplete', 'status': 'incomplete',
'feedback' : {'message': '3'}, } '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() context = the_input._get_render_context()
...@@ -304,7 +293,7 @@ class CodeInputTest(unittest.TestCase): ...@@ -304,7 +293,7 @@ class CodeInputTest(unittest.TestCase):
'status': 'incomplete', 'status': 'incomplete',
'feedback' : {'message': '3'}, } '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() context = the_input._get_render_context()
...@@ -354,7 +343,7 @@ class SchematicTest(unittest.TestCase): ...@@ -354,7 +343,7 @@ class SchematicTest(unittest.TestCase):
state = {'value': value, state = {'value': value,
'status': 'unsubmitted'} '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() context = the_input._get_render_context()
...@@ -393,7 +382,7 @@ class ImageInputTest(unittest.TestCase): ...@@ -393,7 +382,7 @@ class ImageInputTest(unittest.TestCase):
state = {'value': value, state = {'value': value,
'status': 'unsubmitted'} '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() context = the_input._get_render_context()
...@@ -447,7 +436,7 @@ class CrystallographyTest(unittest.TestCase): ...@@ -447,7 +436,7 @@ class CrystallographyTest(unittest.TestCase):
state = {'value': value, state = {'value': value,
'status': 'unsubmitted'} '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() context = the_input._get_render_context()
...@@ -476,7 +465,7 @@ class ChemicalEquationTest(unittest.TestCase): ...@@ -476,7 +465,7 @@ class ChemicalEquationTest(unittest.TestCase):
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
state = {'value': 'H2OYeah',} 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() 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