Commit 2b6e9359 by Victor Shnayder

Initial refactor of inputtypes into classes.

- for now, wraps existing render functions as separate classes
- a bit of cleanup in how it's called from capa_problem
- initial tests to make sure things are testable.
parent cb291871
...@@ -481,8 +481,8 @@ class LoncapaProblem(object): ...@@ -481,8 +481,8 @@ class LoncapaProblem(object):
problemid = problemtree.get('id') # my ID problemid = problemtree.get('id') # my ID
if problemtree.tag in inputtypes.get_input_xml_tags(): if problemtree.tag in inputtypes.registered_input_tags():
# If this is an inputtype subtree, let it render itself.
status = "unsubmitted" status = "unsubmitted"
msg = '' msg = ''
hint = '' hint = ''
...@@ -499,20 +499,17 @@ class LoncapaProblem(object): ...@@ -499,20 +499,17 @@ class LoncapaProblem(object):
value = self.student_answers[problemid] value = self.student_answers[problemid]
# do the rendering # do the rendering
render_object = inputtypes.SimpleInput(system=self.system,
xml=problemtree, state = {'value': value,
state={'value': value,
'status': status, 'status': status,
'id': problemtree.get('id'), 'id': problemtree.get('id'),
'feedback': {'message': msg, 'feedback': {'message': msg,
'hint': hint, 'hint': hint,
'hintmode': hintmode, 'hintmode': hintmode,}}
}
}, input_type_cls = inputtypes.get_class_for_tag(problemtree.tag)
use='capa_input') the_input = input_type_cls(self.system, problemtree, state)
# function(problemtree, value, status, msg) return the_input.get_html()
# render the special response (textline, schematic,...)
return render_object.get_html()
# let each Response render itself # let each Response render itself
if problemtree in self.responders: if problemtree in self.responders:
......
...@@ -37,102 +37,174 @@ import xml.sax.saxutils as saxutils ...@@ -37,102 +37,174 @@ import xml.sax.saxutils as saxutils
log = logging.getLogger('mitx.' + __name__) log = logging.getLogger('mitx.' + __name__)
#########################################################################
def get_input_xml_tags(): _TAGS_TO_CLASSES = {}
''' Eventually, this will be for all registered input types '''
return SimpleInput.get_xml_tags()
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.
class SimpleInput():# XModule If an already-registered input type has claimed one of those tags, will raise ValueError.
'''
Type for simple inputs -- plain HTML with a form element
'''
# Maps tags to functions If there are no tags in cls.tags, will also raise ValueError.
xml_tags = {} """
def __init__(self, system, xml, item_id=None, track_url=None, state=None, use='capa_input'): # Do all checks and complain before changing any state.
''' if len(cls.tags) == 0:
Instantiate a SimpleInput class. Arguments: 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]
class InputTypeBase(object):
"""
Abstract base class for input types.
"""
- system : ModuleSystem instance which provides OS, rendering, and user context template = None
def __init__(self, system, xml, state):
"""
Instantiate an InputType class. Arguments:
- system : ModuleSystem instance which provides OS, rendering, and user context. Specifically, must
have a render_template function.
- xml : Element tree of this Input element - xml : Element tree of this Input element
- item_id : id for this input element (assigned by capa_problem.LoncapProblem) - string
- track_url : URL used for tracking - string
- state : a dictionary with optional keys: - state : 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. Specifically 'message', 'hint', 'hintmode'. If 'hintmode'
- use : is 'always', the hint is always displayed.)
''' """
self.xml = xml self.xml = xml
self.tag = xml.tag self.tag = xml.tag
self.system = system self.system = system
if not state:
state = {}
## NOTE: ID should only come from one place. ## NOTE: ID should only come from one place. If it comes from multiple,
## If it comes from multiple, we use state first, XML second, and parameter ## we use state first, XML second (in case the xml changed, but we have
## third. Since we don't make this guarantee, we can swap this around in ## existing state with an old id). Since we don't make this guarantee,
## the future if there's a more logical order. ## we can swap this around in the future if there's a more logical
if item_id: ## order.
self.id = item_id
if xml.get('id'): self.id = state.get('id', xml.get('id'))
self.id = xml.get('id') if self.id is None:
raise ValueError("input id state is None. xml is {0}".format(etree.tostring(xml)))
if 'id' in state:
self.id = state['id']
self.value = state.get('value', '') self.value = state.get('value', '')
self.msg = '' feedback = state.get('feedback', {})
feedback = state.get('feedback')
if feedback is not None:
self.msg = feedback.get('message', '') self.msg = feedback.get('message', '')
self.hint = feedback.get('hint', '') self.hint = feedback.get('hint', '')
self.hintmode = feedback.get('hintmode', None) self.hintmode = feedback.get('hintmode', None)
# put hint above msg if to be displayed # put hint above msg if it should be displayed
if self.hintmode == 'always': if self.hintmode == 'always':
# TODO: is the '.' in <br/.> below a bug? self.msg = self.hint + ('<br/>' if self.msg else '') + self.msg
self.msg = self.hint + ('<br/.>' if self.msg else '') + self.msg
self.status = 'unanswered' self.status = state.get('status', 'unanswered')
if 'status' in state:
self.status = state['status']
@classmethod def _get_render_context(self):
def get_xml_tags(c): """
return c.xml_tags.keys() Abstract method. Subclasses should implement to return the dictionary
of keys needed to render their template.
@classmethod (Separate from get_html to faciliate testing of logic separately from the rendering)
def get_uses(c): """
return ['capa_input', 'capa_transform'] raise NotImplementedError
def get_html(self): def get_html(self):
return self.xml_tags[self.tag](self.xml, self.value, """
self.status, self.system.render_template, self.msg) Return a 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__))
html = self.system.render_template(self.template, self._get_render_context())
return etree.XML(html)
def register_render_function(fn, names=None, cls=SimpleInput):
if names is None:
SimpleInput.xml_tags[fn.__name__] = fn
else:
raise NotImplementedError
def wrapped(*args, **kwargs): ## TODO: Remove once refactor is complete
return fn(*args, **kwargs) def make_class_for_render_function(fn):
return wrapped """
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))
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
@register_render_function class OptionInput(InputTypeBase):
"""
Input type for selecting and Select option input type.
Example:
<optioninput options="('Up','Down')" correct="Up"/><text>The location of the sky</text>
"""
template = "optioninput.html"
tags = ['optioninput']
def _get_render_context(self):
return _optioninput(self.xml, self.value, self.status, self.system.render_template, self.msg)
def optioninput(element, value, status, render_template, msg=''): def optioninput(element, value, status, render_template, msg=''):
context = _optioninput(element, value, status, render_template, msg)
html = render_template("optioninput.html", context)
return etree.XML(html)
def _optioninput(element, value, status, render_template, msg=''):
""" """
Select option input type. Select option input type.
...@@ -164,16 +236,16 @@ def optioninput(element, value, status, render_template, msg=''): ...@@ -164,16 +236,16 @@ def optioninput(element, value, status, render_template, msg=''):
'options': osetdict, 'options': osetdict,
'inline': element.get('inline',''), 'inline': element.get('inline',''),
} }
return context
html = render_template("optioninput.html", context) register_input_class(OptionInput)
return etree.XML(html)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of # TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of
# desired semantics. # desired semantics.
@register_render_function # @register_render_function
def choicegroup(element, value, status, render_template, msg=''): def choicegroup(element, value, status, render_template, msg=''):
''' '''
Radio button inputs: multiple choice or true/false Radio button inputs: multiple choice or true/false
...@@ -210,6 +282,7 @@ def choicegroup(element, value, status, render_template, msg=''): ...@@ -210,6 +282,7 @@ def choicegroup(element, value, status, render_template, msg=''):
html = render_template("choicegroup.html", context) html = render_template("choicegroup.html", context)
return etree.XML(html) return etree.XML(html)
_reg(choicegroup)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
def extract_choices(element): def extract_choices(element):
...@@ -237,7 +310,6 @@ def extract_choices(element): ...@@ -237,7 +310,6 @@ def extract_choices(element):
# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of # TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of
# desired semantics. # desired semantics.
@register_render_function
def radiogroup(element, value, status, render_template, msg=''): def radiogroup(element, value, status, render_template, msg=''):
''' '''
Radio button inputs: (multiple choice) Radio button inputs: (multiple choice)
...@@ -258,9 +330,10 @@ def radiogroup(element, value, status, render_template, msg=''): ...@@ -258,9 +330,10 @@ def radiogroup(element, value, status, render_template, msg=''):
return etree.XML(html) return etree.XML(html)
_reg(radiogroup)
# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of # TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of
# desired semantics. # desired semantics.
@register_render_function
def checkboxgroup(element, value, status, render_template, msg=''): def checkboxgroup(element, value, status, render_template, msg=''):
''' '''
Checkbox inputs: (select one or more choices) Checkbox inputs: (select one or more choices)
...@@ -280,7 +353,8 @@ def checkboxgroup(element, value, status, render_template, msg=''): ...@@ -280,7 +353,8 @@ def checkboxgroup(element, value, status, render_template, msg=''):
html = render_template("choicegroup.html", context) html = render_template("choicegroup.html", context)
return etree.XML(html) return etree.XML(html)
@register_render_function _reg(checkboxgroup)
def javascriptinput(element, value, status, render_template, msg='null'): def javascriptinput(element, value, status, render_template, msg='null'):
''' '''
Hidden field for javascript to communicate via; also loads the required Hidden field for javascript to communicate via; also loads the required
...@@ -311,16 +385,16 @@ def javascriptinput(element, value, status, render_template, msg='null'): ...@@ -311,16 +385,16 @@ def javascriptinput(element, value, status, render_template, msg='null'):
html = render_template("javascriptinput.html", context) html = render_template("javascriptinput.html", context)
return etree.XML(html) return etree.XML(html)
_reg(javascriptinput)
@register_render_function
def textline(element, value, status, 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.
''' '''
# TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x
if element.get('math') or element.get('dojs'): if element.get('math') or element.get('dojs'):
return SimpleInput.xml_tags['textline_dynamath'](element, value, status, return textline_dynamath(element, value, status, render_template, msg)
render_template, msg)
eid = element.get('id') eid = element.get('id')
if eid is None: if eid is None:
msg = 'textline has no id: it probably appears outside of a known response type' msg = 'textline has no id: it probably appears outside of a known response type'
...@@ -356,10 +430,11 @@ def textline(element, value, status, render_template, msg=""): ...@@ -356,10 +430,11 @@ def textline(element, value, status, render_template, msg=""):
raise raise
return xhtml return xhtml
_reg(textline)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
@register_render_function
def textline_dynamath(element, value, status, render_template, msg=''): def textline_dynamath(element, value, status, render_template, msg=''):
''' '''
Text line input with dynamic math display (equation rendered on client in real time Text line input with dynamic math display (equation rendered on client in real time
...@@ -399,9 +474,10 @@ def textline_dynamath(element, value, status, render_template, msg=''): ...@@ -399,9 +474,10 @@ def textline_dynamath(element, value, status, render_template, msg=''):
html = render_template("textinput_dynamath.html", context) html = render_template("textinput_dynamath.html", context)
return etree.XML(html) return etree.XML(html)
_reg(textline_dynamath)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
@register_render_function
def filesubmission(element, value, status, render_template, msg=''): def filesubmission(element, value, status, render_template, msg=''):
''' '''
Upload a single file (e.g. for programming assignments) Upload a single file (e.g. for programming assignments)
...@@ -431,10 +507,11 @@ def filesubmission(element, value, status, render_template, msg=''): ...@@ -431,10 +507,11 @@ def filesubmission(element, value, status, render_template, msg=''):
html = render_template("filesubmission.html", context) html = render_template("filesubmission.html", context)
return etree.XML(html) return etree.XML(html)
_reg(filesubmission)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
## TODO: Make a wrapper for <codeinput> ## TODO: Make a wrapper for <codeinput>
@register_render_function
def textbox(element, value, status, render_template, msg=''): def textbox(element, value, status, render_template, msg=''):
''' '''
The textbox is used for code input. The message is the return HTML string from The textbox is used for code input. The message is the return HTML string from
...@@ -493,8 +570,9 @@ def textbox(element, value, status, render_template, msg=''): ...@@ -493,8 +570,9 @@ def textbox(element, value, status, render_template, msg=''):
return xhtml return xhtml
_reg(textbox)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
@register_render_function
def schematic(element, value, status, render_template, msg=''): def schematic(element, value, status, render_template, msg=''):
eid = element.get('id') eid = element.get('id')
height = element.get('height') height = element.get('height')
...@@ -517,10 +595,10 @@ def schematic(element, value, status, render_template, msg=''): ...@@ -517,10 +595,10 @@ def schematic(element, value, status, render_template, msg=''):
html = render_template("schematicinput.html", context) html = render_template("schematicinput.html", context)
return etree.XML(html) return etree.XML(html)
_reg(schematic)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
### TODO: Move out of inputtypes ### TODO: Move out of inputtypes
@register_render_function
def math(element, value, status, render_template, msg=''): def math(element, value, status, render_template, msg=''):
''' '''
This is not really an input type. It is a convention from Lon-CAPA, used for This is not really an input type. It is a convention from Lon-CAPA, used for
...@@ -565,16 +643,17 @@ def math(element, value, status, render_template, msg=''): ...@@ -565,16 +643,17 @@ def math(element, value, status, render_template, msg=''):
# xhtml.tail = element.tail # don't forget to include the tail! # xhtml.tail = element.tail # don't forget to include the tail!
return xhtml return xhtml
_reg(math)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
@register_render_function
def solution(element, value, status, render_template, msg=''): 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, 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" 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 is pressed. Note that the solution content is NOT sent with the HTML. It is obtained
by a JSON call. by an ajax call.
''' '''
eid = element.get('id') eid = element.get('id')
size = element.get('size') size = element.get('size')
...@@ -587,10 +666,11 @@ def solution(element, value, status, render_template, msg=''): ...@@ -587,10 +666,11 @@ def solution(element, value, status, render_template, msg=''):
html = render_template("solutionspan.html", context) html = render_template("solutionspan.html", context)
return etree.XML(html) return etree.XML(html)
_reg(solution)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
@register_render_function
def imageinput(element, value, status, render_template, msg=''): def imageinput(element, value, status, render_template, msg=''):
''' '''
Clickable image as an input field. Element should specify the image source, height, Clickable image as an input field. Element should specify the image source, height,
...@@ -626,3 +706,5 @@ def imageinput(element, value, status, render_template, msg=''): ...@@ -626,3 +706,5 @@ def imageinput(element, value, status, render_template, msg=''):
} }
html = render_template("imageinput.html", context) html = render_template("imageinput.html", context)
return etree.XML(html) return etree.XML(html)
_reg(imageinput)
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
Tests of input types (and actually responsetypes too) Tests of input types (and actually responsetypes too)
""" """
from datetime import datetime from datetime import datetime
import json import json
from mock import Mock
from nose.plugins.skip import SkipTest from nose.plugins.skip import SkipTest
import os import os
import unittest import unittest
...@@ -14,24 +14,55 @@ from capa import inputtypes ...@@ -14,24 +14,55 @@ from capa import inputtypes
from lxml import etree 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)
system = Mock(render_template=tst_render_template)
class OptionInputTest(unittest.TestCase): class OptionInputTest(unittest.TestCase):
''' '''
Make sure option inputs work Make sure option inputs work
''' '''
def test_rendering(self): def test_rendering_new(self):
xml = """<optioninput options="('Up','Down')" id="sky_input" correct="Up"/>""" xml = """<optioninput options="('Up','Down')" id="sky_input" correct="Up"/>"""
element = etree.fromstring(xml) element = etree.fromstring(xml)
value = 'Down' value = 'Down'
status = 'incorrect' status = 'answered'
rendered_element = inputtypes.optioninput(element, value, status, test_system.render_template) context = inputtypes._optioninput(element, value, status, test_system.render_template)
rendered_str = etree.tostring(rendered_element) print 'context: ', context
print rendered_str
self.assertTrue(False) expected = {'value': 'Down',
'options': [('Up', 'Up'), ('Down', 'Down')],
'state': 'answered',
'msg': '',
'inline': '',
'id': 'sky_input'}
self.assertEqual(context, expected)
def test_rendering(self):
xml_str = """<optioninput options="('Up','Down')" id="sky_input" correct="Up"/>"""
element = etree.fromstring(xml_str)
state = {'value': 'Down',
'id': 'sky_input',
'status': 'answered'}
option_input = inputtypes.OptionInput(system, element, state)
context = option_input._get_render_context()
expected = {'value': 'Down',
'options': [('Up', 'Up'), ('Down', 'Down')],
'state': 'answered',
'msg': '',
'inline': '',
'id': 'sky_input'}
self.assertEqual(context, expected)
# TODO: split each inputtype into a get_render_context function and a
# template property, and have the rendering done in one place. (and be
# able to test the logic without dealing with xml at least on the output
# end)
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