Merge branch 'master' into fix/will/mc_checkbox_bug
This commit is contained in:
@@ -146,6 +146,13 @@ class LoncapaProblem(object):
|
||||
if not self.student_answers: # True when student_answers is an empty dict
|
||||
self.set_initial_display()
|
||||
|
||||
# dictionary of InputType objects associated with this problem
|
||||
# input_id string -> InputType object
|
||||
self.inputs = {}
|
||||
|
||||
self.extracted_tree = self._extract_html(self.tree)
|
||||
|
||||
|
||||
def do_reset(self):
|
||||
'''
|
||||
Reset internal state to unfinished, with no answers
|
||||
@@ -324,7 +331,27 @@ 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)), self.context)
|
||||
html = contextualize_text(etree.tostring(self._extract_html(self.tree)), self.context)
|
||||
return html
|
||||
|
||||
|
||||
def handle_input_ajax(self, get):
|
||||
'''
|
||||
InputTypes can support specialized AJAX calls. Find the correct input and pass along the correct data
|
||||
|
||||
Also, parse out the dispatch from the get so that it can be passed onto the input type nicely
|
||||
'''
|
||||
|
||||
# pull out the id
|
||||
input_id = get['input_id']
|
||||
if self.inputs[input_id]:
|
||||
dispatch = get['dispatch']
|
||||
return self.inputs[input_id].handle_ajax(dispatch, get)
|
||||
else:
|
||||
log.warning("Could not find matching input for id: %s" % problem_id)
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
# ======= Private Methods Below ========
|
||||
|
||||
@@ -458,6 +485,8 @@ class LoncapaProblem(object):
|
||||
finally:
|
||||
sys.path = original_path
|
||||
|
||||
|
||||
|
||||
def _extract_html(self, problemtree): # private
|
||||
'''
|
||||
Main (private) function which converts Problem XML tree to HTML.
|
||||
@@ -468,6 +497,7 @@ class LoncapaProblem(object):
|
||||
|
||||
Used by get_html.
|
||||
'''
|
||||
|
||||
if (problemtree.tag == 'script' and problemtree.get('type')
|
||||
and 'javascript' in problemtree.get('type')):
|
||||
# leave javascript intact.
|
||||
@@ -484,8 +514,9 @@ class LoncapaProblem(object):
|
||||
msg = ''
|
||||
hint = ''
|
||||
hintmode = None
|
||||
input_id = problemtree.get('id')
|
||||
if problemid in self.correct_map:
|
||||
pid = problemtree.get('id')
|
||||
pid = input_id
|
||||
status = self.correct_map.get_correctness(pid)
|
||||
msg = self.correct_map.get_msg(pid)
|
||||
hint = self.correct_map.get_hint(pid)
|
||||
@@ -496,21 +527,23 @@ class LoncapaProblem(object):
|
||||
value = self.student_answers[problemid]
|
||||
|
||||
# do the rendering
|
||||
|
||||
state = {'value': value,
|
||||
'status': status,
|
||||
'id': problemtree.get('id'),
|
||||
'id': input_id,
|
||||
'feedback': {'message': msg,
|
||||
'hint': hint,
|
||||
'hintmode': hintmode, }}
|
||||
|
||||
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()
|
||||
# save the input type so that we can make ajax calls on it if we need to
|
||||
self.inputs[input_id] = input_type_cls(self.system, problemtree, state)
|
||||
return self.inputs[input_id].get_html()
|
||||
|
||||
# let each Response render itself
|
||||
if problemtree in self.responders:
|
||||
return self.responders[problemtree].render_html(self._extract_html)
|
||||
overall_msg = self.correct_map.get_overall_message()
|
||||
return self.responders[problemtree].render_html(self._extract_html,
|
||||
response_msg=overall_msg)
|
||||
|
||||
# let each custom renderer render itself:
|
||||
if problemtree.tag in customrender.registry.registered_tags():
|
||||
|
||||
@@ -27,6 +27,7 @@ class CorrectMap(object):
|
||||
self.cmap = dict()
|
||||
self.items = self.cmap.items
|
||||
self.keys = self.cmap.keys
|
||||
self.overall_message = ""
|
||||
self.set(*args, **kwargs)
|
||||
|
||||
def __getitem__(self, *args, **kwargs):
|
||||
@@ -94,7 +95,7 @@ class CorrectMap(object):
|
||||
|
||||
def is_correct(self, answer_id):
|
||||
if answer_id in self.cmap:
|
||||
return self.cmap[answer_id]['correctness'] == 'correct'
|
||||
return self.cmap[answer_id]['correctness'] in ['correct', 'partially-correct']
|
||||
return None
|
||||
|
||||
def is_queued(self, answer_id):
|
||||
@@ -104,16 +105,21 @@ class CorrectMap(object):
|
||||
return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key
|
||||
|
||||
def get_queuetime_str(self, answer_id):
|
||||
return self.cmap[answer_id]['queuestate']['time']
|
||||
if self.cmap[answer_id]['queuestate']:
|
||||
return self.cmap[answer_id]['queuestate']['time']
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_npoints(self, answer_id):
|
||||
npoints = self.get_property(answer_id, 'npoints')
|
||||
if npoints is not None:
|
||||
return npoints
|
||||
elif self.is_correct(answer_id):
|
||||
return 1
|
||||
# if not correct and no points have been assigned, return 0
|
||||
return 0
|
||||
""" Return the number of points for an answer:
|
||||
If the answer is correct, return the assigned
|
||||
number of points (default: 1 point)
|
||||
Otherwise, return 0 points """
|
||||
if self.is_correct(answer_id):
|
||||
npoints = self.get_property(answer_id, 'npoints')
|
||||
return npoints if npoints is not None else 1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def set_property(self, answer_id, property, value):
|
||||
if answer_id in self.cmap:
|
||||
@@ -153,3 +159,15 @@ class CorrectMap(object):
|
||||
if not isinstance(other_cmap, CorrectMap):
|
||||
raise Exception('CorrectMap.update called with invalid argument %s' % other_cmap)
|
||||
self.cmap.update(other_cmap.get_dict())
|
||||
self.set_overall_message(other_cmap.get_overall_message())
|
||||
|
||||
|
||||
def set_overall_message(self, message_str):
|
||||
""" Set a message that applies to the question as a whole,
|
||||
rather than to individual inputs. """
|
||||
self.overall_message = str(message_str) if message_str else ""
|
||||
|
||||
def get_overall_message(self):
|
||||
""" Retrieve a message that applies to the question as a whole.
|
||||
If no message is available, returns the empty string """
|
||||
return self.overall_message
|
||||
|
||||
@@ -45,8 +45,10 @@ import re
|
||||
import shlex # for splitting quoted strings
|
||||
import sys
|
||||
import os
|
||||
import pyparsing
|
||||
|
||||
from registry import TagRegistry
|
||||
from capa.chem import chemcalc
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
@@ -215,6 +217,18 @@ class InputTypeBase(object):
|
||||
"""
|
||||
pass
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
"""
|
||||
InputTypes that need to handle specialized AJAX should override this.
|
||||
|
||||
Input:
|
||||
dispatch: a string that can be used to determine how to handle the data passed in
|
||||
get: a dictionary containing the data that was sent with the ajax call
|
||||
|
||||
Output:
|
||||
a dictionary object that can be serialized into JSON. This will be sent back to the Javascript.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _get_render_context(self):
|
||||
"""
|
||||
@@ -740,6 +754,45 @@ class ChemicalEquationInput(InputTypeBase):
|
||||
"""
|
||||
return {'previewer': '/static/js/capa/chemical_equation_preview.js', }
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
'''
|
||||
Since we only have chemcalc preview this input, check to see if it
|
||||
matches the corresponding dispatch and send it through if it does
|
||||
'''
|
||||
if dispatch == 'preview_chemcalc':
|
||||
return self.preview_chemcalc(get)
|
||||
return {}
|
||||
|
||||
def preview_chemcalc(self, get):
|
||||
"""
|
||||
Render an html preview of a chemical formula or equation. get should
|
||||
contain a key 'formula' and value 'some formula string'.
|
||||
|
||||
Returns a json dictionary:
|
||||
{
|
||||
'preview' : 'the-preview-html' or ''
|
||||
'error' : 'the-error' or ''
|
||||
}
|
||||
"""
|
||||
|
||||
result = {'preview': '',
|
||||
'error': ''}
|
||||
formula = get['formula']
|
||||
if formula is None:
|
||||
result['error'] = "No formula specified."
|
||||
return result
|
||||
|
||||
try:
|
||||
result['preview'] = chemcalc.render_to_html(formula)
|
||||
except pyparsing.ParseException as p:
|
||||
result['error'] = "Couldn't parse formula: {0}".format(p)
|
||||
except Exception:
|
||||
# this is unexpected, so log
|
||||
log.warning("Error while previewing chemical formula", exc_info=True)
|
||||
result['error'] = "Error while rendering preview"
|
||||
|
||||
return result
|
||||
|
||||
registry.register(ChemicalEquationInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
@@ -909,33 +962,142 @@ registry.register(DesignProtein2dInput)
|
||||
class EditAGeneInput(InputTypeBase):
|
||||
"""
|
||||
An input type for editing a gene. Integrates with the genex java applet.
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
<editagene width="800" hight="500" dna_sequence="ETAAGGCTATAACCGA" />
|
||||
"""
|
||||
|
||||
|
||||
template = "editageneinput.html"
|
||||
tags = ['editageneinput']
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Note: width, hight, and dna_sequencee are required.
|
||||
"""
|
||||
Note: width, height, and dna_sequencee are required.
|
||||
"""
|
||||
return [Attribute('width'),
|
||||
Attribute('height'),
|
||||
Attribute('dna_sequence')
|
||||
Attribute('dna_sequence'),
|
||||
Attribute('genex_problem_number')
|
||||
]
|
||||
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
"""
|
||||
context = {
|
||||
'applet_loader': '/static/js/capa/edit-a-gene.js',
|
||||
}
|
||||
|
||||
|
||||
return context
|
||||
|
||||
registry.register(EditAGeneInput)
|
||||
|
||||
#---------------------------------------------------------------------
|
||||
|
||||
class AnnotationInput(InputTypeBase):
|
||||
"""
|
||||
Input type for annotations: students can enter some notes or other text
|
||||
(currently ungraded), and then choose from a set of tags/optoins, which are graded.
|
||||
|
||||
Example:
|
||||
|
||||
<annotationinput>
|
||||
<title>Annotation Exercise</title>
|
||||
<text>
|
||||
They are the ones who, at the public assembly, had put savage derangement [ate] into my thinking
|
||||
[phrenes] |89 on that day when I myself deprived Achilles of his honorific portion [geras]
|
||||
</text>
|
||||
<comment>Agamemnon says that ate or 'derangement' was the cause of his actions: why could Zeus say the same thing?</comment>
|
||||
<comment_prompt>Type a commentary below:</comment_prompt>
|
||||
<tag_prompt>Select one tag:</tag_prompt>
|
||||
<options>
|
||||
<option choice="correct">ate - both a cause and an effect</option>
|
||||
<option choice="incorrect">ate - a cause</option>
|
||||
<option choice="partially-correct">ate - an effect</option>
|
||||
</options>
|
||||
</annotationinput>
|
||||
|
||||
# TODO: allow ordering to be randomized
|
||||
"""
|
||||
|
||||
template = "annotationinput.html"
|
||||
tags = ['annotationinput']
|
||||
|
||||
def setup(self):
|
||||
xml = self.xml
|
||||
|
||||
self.debug = False # set to True to display extra debug info with input
|
||||
self.return_to_annotation = True # return only works in conjunction with annotatable xmodule
|
||||
|
||||
self.title = xml.findtext('./title', 'Annotation Exercise')
|
||||
self.text = xml.findtext('./text')
|
||||
self.comment = xml.findtext('./comment')
|
||||
self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:')
|
||||
self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:')
|
||||
self.options = self._find_options()
|
||||
|
||||
# Need to provide a value that JSON can parse if there is no
|
||||
# student-supplied value yet.
|
||||
if self.value == '':
|
||||
self.value = 'null'
|
||||
|
||||
self._validate_options()
|
||||
|
||||
def _find_options(self):
|
||||
''' Returns an array of dicts where each dict represents an option. '''
|
||||
elements = self.xml.findall('./options/option')
|
||||
return [{
|
||||
'id': index,
|
||||
'description': option.text,
|
||||
'choice': option.get('choice')
|
||||
} for (index, option) in enumerate(elements) ]
|
||||
|
||||
def _validate_options(self):
|
||||
''' Raises a ValueError if the choice attribute is missing or invalid. '''
|
||||
valid_choices = ('correct', 'partially-correct', 'incorrect')
|
||||
for option in self.options:
|
||||
choice = option['choice']
|
||||
if choice is None:
|
||||
raise ValueError('Missing required choice attribute.')
|
||||
elif choice not in valid_choices:
|
||||
raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format(choice, ', '.join(valid_choices)))
|
||||
|
||||
def _unpack(self, json_value):
|
||||
''' Unpacks the json input state into a dict. '''
|
||||
d = json.loads(json_value)
|
||||
if type(d) != dict:
|
||||
d = {}
|
||||
|
||||
comment_value = d.get('comment', '')
|
||||
if not isinstance(comment_value, basestring):
|
||||
comment_value = ''
|
||||
|
||||
options_value = d.get('options', [])
|
||||
if not isinstance(options_value, list):
|
||||
options_value = []
|
||||
|
||||
return {
|
||||
'options_value': options_value,
|
||||
'has_options_value': len(options_value) > 0, # for convenience
|
||||
'comment_value': comment_value,
|
||||
}
|
||||
|
||||
def _extra_context(self):
|
||||
extra_context = {
|
||||
'title': self.title,
|
||||
'text': self.text,
|
||||
'comment': self.comment,
|
||||
'comment_prompt': self.comment_prompt,
|
||||
'tag_prompt': self.tag_prompt,
|
||||
'options': self.options,
|
||||
'return_to_annotation': self.return_to_annotation,
|
||||
'debug': self.debug
|
||||
}
|
||||
|
||||
extra_context.update(self._unpack(self.value))
|
||||
|
||||
return extra_context
|
||||
|
||||
registry.register(AnnotationInput)
|
||||
|
||||
|
||||
@@ -174,13 +174,14 @@ class LoncapaResponse(object):
|
||||
'''
|
||||
return sum(self.maxpoints.values())
|
||||
|
||||
def render_html(self, renderer):
|
||||
def render_html(self, renderer, response_msg=''):
|
||||
'''
|
||||
Return XHTML Element tree representation of this Response.
|
||||
|
||||
Arguments:
|
||||
|
||||
- renderer : procedure which produces HTML given an ElementTree
|
||||
- response_msg: a message displayed at the end of the Response
|
||||
'''
|
||||
# render ourself as a <span> + our content
|
||||
tree = etree.Element('span')
|
||||
@@ -195,6 +196,11 @@ class LoncapaResponse(object):
|
||||
if item_xhtml is not None:
|
||||
tree.append(item_xhtml)
|
||||
tree.tail = self.xml.tail
|
||||
|
||||
# Add a <div> for the message at the end of the response
|
||||
if response_msg:
|
||||
tree.append(self._render_response_msg_html(response_msg))
|
||||
|
||||
return tree
|
||||
|
||||
def evaluate_answers(self, student_answers, old_cmap):
|
||||
@@ -319,6 +325,29 @@ class LoncapaResponse(object):
|
||||
def __unicode__(self):
|
||||
return u'LoncapaProblem Response %s' % self.xml.tag
|
||||
|
||||
def _render_response_msg_html(self, response_msg):
|
||||
""" Render a <div> for a message that applies to the entire response.
|
||||
|
||||
*response_msg* is a string, which may contain XHTML markup
|
||||
|
||||
Returns an etree element representing the response message <div> """
|
||||
# First try wrapping the text in a <div> and parsing
|
||||
# it as an XHTML tree
|
||||
try:
|
||||
response_msg_div = etree.XML('<div>%s</div>' % str(response_msg))
|
||||
|
||||
# If we can't do that, create the <div> and set the message
|
||||
# as the text of the <div>
|
||||
except:
|
||||
response_msg_div = etree.Element('div')
|
||||
response_msg_div.text = str(response_msg)
|
||||
|
||||
|
||||
# Set the css class of the message <div>
|
||||
response_msg_div.set("class", "response_message")
|
||||
|
||||
return response_msg_div
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
@@ -882,7 +911,8 @@ def sympy_check2():
|
||||
allowed_inputfields = ['textline', 'textbox', 'crystallography',
|
||||
'chemicalequationinput', 'vsepr_input',
|
||||
'drag_and_drop_input', 'editamoleculeinput',
|
||||
'designprotein2dinput', 'editageneinput']
|
||||
'designprotein2dinput', 'editageneinput',
|
||||
'annotationinput']
|
||||
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
@@ -965,6 +995,7 @@ def sympy_check2():
|
||||
# not expecting 'unknown's
|
||||
correct = ['unknown'] * len(idset)
|
||||
messages = [''] * len(idset)
|
||||
overall_message = ""
|
||||
|
||||
# put these in the context of the check function evaluator
|
||||
# note that this doesn't help the "cfn" version - only the exec version
|
||||
@@ -996,6 +1027,10 @@ def sympy_check2():
|
||||
# the list of messages to be filled in by the check function
|
||||
'messages': messages,
|
||||
|
||||
# a message that applies to the entire response
|
||||
# instead of a particular input
|
||||
'overall_message': overall_message,
|
||||
|
||||
# any options to be passed to the cfn
|
||||
'options': self.xml.get('options'),
|
||||
'testdat': 'hello world',
|
||||
@@ -1010,6 +1045,7 @@ def sympy_check2():
|
||||
exec self.code in self.context['global_context'], self.context
|
||||
correct = self.context['correct']
|
||||
messages = self.context['messages']
|
||||
overall_message = self.context['overall_message']
|
||||
except Exception as err:
|
||||
print "oops in customresponse (code) error %s" % err
|
||||
print "context = ", self.context
|
||||
@@ -1044,34 +1080,100 @@ def sympy_check2():
|
||||
log.error(traceback.format_exc())
|
||||
raise Exception("oops in customresponse (cfn) error %s" % err)
|
||||
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret)
|
||||
|
||||
if type(ret) == dict:
|
||||
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
|
||||
msg = ret['msg']
|
||||
|
||||
if 1:
|
||||
# try to clean up message html
|
||||
msg = '<html>' + msg + '</html>'
|
||||
msg = msg.replace('<', '<')
|
||||
#msg = msg.replace('<','<')
|
||||
msg = etree.tostring(fromstring_bs(msg, convertEntities=None),
|
||||
pretty_print=True)
|
||||
#msg = etree.tostring(fromstring_bs(msg),pretty_print=True)
|
||||
msg = msg.replace(' ', '')
|
||||
#msg = re.sub('<html>(.*)</html>','\\1',msg,flags=re.M|re.DOTALL) # python 2.7
|
||||
msg = re.sub('(?ms)<html>(.*)</html>', '\\1', msg)
|
||||
# One kind of dictionary the check function can return has the
|
||||
# form {'ok': BOOLEAN, 'msg': STRING}
|
||||
# If there are multiple inputs, they all get marked
|
||||
# to the same correct/incorrect value
|
||||
if 'ok' in ret:
|
||||
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset)
|
||||
msg = ret.get('msg', None)
|
||||
msg = self.clean_message_html(msg)
|
||||
|
||||
messages[0] = msg
|
||||
# If there is only one input, apply the message to that input
|
||||
# Otherwise, apply the message to the whole problem
|
||||
if len(idset) > 1:
|
||||
overall_message = msg
|
||||
else:
|
||||
messages[0] = msg
|
||||
|
||||
|
||||
# Another kind of dictionary the check function can return has
|
||||
# the form:
|
||||
# {'overall_message': STRING,
|
||||
# 'input_list': [{ 'ok': BOOLEAN, 'msg': STRING }, ...] }
|
||||
#
|
||||
# This allows the function to return an 'overall message'
|
||||
# that applies to the entire problem, as well as correct/incorrect
|
||||
# status and messages for individual inputs
|
||||
elif 'input_list' in ret:
|
||||
overall_message = ret.get('overall_message', '')
|
||||
input_list = ret['input_list']
|
||||
|
||||
correct = []
|
||||
messages = []
|
||||
for input_dict in input_list:
|
||||
correct.append('correct' if input_dict['ok'] else 'incorrect')
|
||||
msg = self.clean_message_html(input_dict['msg']) if 'msg' in input_dict else None
|
||||
messages.append(msg)
|
||||
|
||||
# Otherwise, we do not recognize the dictionary
|
||||
# Raise an exception
|
||||
else:
|
||||
log.error(traceback.format_exc())
|
||||
raise Exception("CustomResponse: check function returned an invalid dict")
|
||||
|
||||
# The check function can return a boolean value,
|
||||
# indicating whether all inputs should be marked
|
||||
# correct or incorrect
|
||||
else:
|
||||
correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset)
|
||||
|
||||
# build map giving "correct"ness of the answer(s)
|
||||
correct_map = CorrectMap()
|
||||
|
||||
overall_message = self.clean_message_html(overall_message)
|
||||
correct_map.set_overall_message(overall_message)
|
||||
|
||||
for k in range(len(idset)):
|
||||
npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0
|
||||
correct_map.set(idset[k], correct[k], msg=messages[k],
|
||||
npoints=npoints)
|
||||
return correct_map
|
||||
|
||||
def clean_message_html(self, msg):
|
||||
|
||||
# If *msg* is an empty string, then the code below
|
||||
# will return "</html>". To avoid this, we first check
|
||||
# that *msg* is a non-empty string.
|
||||
if msg:
|
||||
|
||||
# When we parse *msg* using etree, there needs to be a root
|
||||
# element, so we wrap the *msg* text in <html> tags
|
||||
msg = '<html>' + msg + '</html>'
|
||||
|
||||
# Replace < characters
|
||||
msg = msg.replace('<', '<')
|
||||
|
||||
# Use etree to prettify the HTML
|
||||
msg = etree.tostring(fromstring_bs(msg, convertEntities=None),
|
||||
pretty_print=True)
|
||||
|
||||
msg = msg.replace(' ', '')
|
||||
|
||||
# Remove the <html> tags we introduced earlier, so we're
|
||||
# left with just the prettified message markup
|
||||
msg = re.sub('(?ms)<html>(.*)</html>', '\\1', msg)
|
||||
|
||||
# Strip leading and trailing whitespace
|
||||
return msg.strip()
|
||||
|
||||
# If we start with an empty string, then return an empty string
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_answers(self):
|
||||
'''
|
||||
Give correct answer expected for this response.
|
||||
@@ -1842,6 +1944,117 @@ class ImageResponse(LoncapaResponse):
|
||||
dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements]))
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class AnnotationResponse(LoncapaResponse):
|
||||
'''
|
||||
Checking of annotation responses.
|
||||
|
||||
The response contains both a comment (student commentary) and an option (student tag).
|
||||
Only the tag is currently graded. Answers may be incorrect, partially correct, or correct.
|
||||
'''
|
||||
response_tag = 'annotationresponse'
|
||||
allowed_inputfields = ['annotationinput']
|
||||
max_inputfields = 1
|
||||
default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2 }
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
self.scoring_map = self._get_scoring_map()
|
||||
self.answer_map = self._get_answer_map()
|
||||
self.maxpoints = self._get_max_points()
|
||||
|
||||
def get_score(self, student_answers):
|
||||
''' Returns a CorrectMap for the student answer, which may include
|
||||
partially correct answers.'''
|
||||
student_answer = student_answers[self.answer_id]
|
||||
student_option = self._get_submitted_option_id(student_answer)
|
||||
|
||||
scoring = self.scoring_map[self.answer_id]
|
||||
is_valid = student_option is not None and student_option in scoring.keys()
|
||||
|
||||
(correctness, points) = ('incorrect', None)
|
||||
if is_valid:
|
||||
correctness = scoring[student_option]['correctness']
|
||||
points = scoring[student_option]['points']
|
||||
|
||||
return CorrectMap(self.answer_id, correctness=correctness, npoints=points)
|
||||
|
||||
def get_answers(self):
|
||||
return self.answer_map
|
||||
|
||||
def _get_scoring_map(self):
|
||||
''' Returns a dict of option->scoring for each input. '''
|
||||
scoring = self.default_scoring
|
||||
choices = dict([(choice,choice) for choice in scoring])
|
||||
scoring_map = {}
|
||||
|
||||
for inputfield in self.inputfields:
|
||||
option_scoring = dict([(option['id'], {
|
||||
'correctness': choices.get(option['choice']),
|
||||
'points': scoring.get(option['choice'])
|
||||
}) for option in self._find_options(inputfield) ])
|
||||
|
||||
scoring_map[inputfield.get('id')] = option_scoring
|
||||
|
||||
return scoring_map
|
||||
|
||||
def _get_answer_map(self):
|
||||
''' Returns a dict of answers for each input.'''
|
||||
answer_map = {}
|
||||
for inputfield in self.inputfields:
|
||||
correct_option = self._find_option_with_choice(inputfield, 'correct')
|
||||
if correct_option is not None:
|
||||
answer_map[inputfield.get('id')] = correct_option.get('description')
|
||||
return answer_map
|
||||
|
||||
def _get_max_points(self):
|
||||
''' Returns a dict of the max points for each input: input id -> maxpoints. '''
|
||||
scoring = self.default_scoring
|
||||
correct_points = scoring.get('correct')
|
||||
return dict([(inputfield.get('id'), correct_points) for inputfield in self.inputfields])
|
||||
|
||||
def _find_options(self, inputfield):
|
||||
''' Returns an array of dicts where each dict represents an option. '''
|
||||
elements = inputfield.findall('./options/option')
|
||||
return [{
|
||||
'id': index,
|
||||
'description': option.text,
|
||||
'choice': option.get('choice')
|
||||
} for (index, option) in enumerate(elements) ]
|
||||
|
||||
def _find_option_with_choice(self, inputfield, choice):
|
||||
''' Returns the option with the given choice value, otherwise None. '''
|
||||
for option in self._find_options(inputfield):
|
||||
if option['choice'] == choice:
|
||||
return option
|
||||
|
||||
def _unpack(self, json_value):
|
||||
''' Unpacks a student response value submitted as JSON.'''
|
||||
d = json.loads(json_value)
|
||||
if type(d) != dict:
|
||||
d = {}
|
||||
|
||||
comment_value = d.get('comment', '')
|
||||
if not isinstance(d, basestring):
|
||||
comment_value = ''
|
||||
|
||||
options_value = d.get('options', [])
|
||||
if not isinstance(options_value, list):
|
||||
options_value = []
|
||||
|
||||
return {
|
||||
'options_value': options_value,
|
||||
'comment_value': comment_value
|
||||
}
|
||||
|
||||
def _get_submitted_option_id(self, student_answer):
|
||||
''' Return the single option that was selected, otherwise None.'''
|
||||
submitted = self._unpack(student_answer)
|
||||
option_ids = submitted['options_value']
|
||||
if len(option_ids) == 1:
|
||||
return option_ids[0]
|
||||
return None
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
# TEMPORARY: List of all response subclasses
|
||||
# FIXME: To be replaced by auto-registration
|
||||
|
||||
@@ -1858,4 +2071,5 @@ __all__ = [CodeResponse,
|
||||
ChoiceResponse,
|
||||
MultipleChoiceResponse,
|
||||
TrueFalseResponse,
|
||||
JavascriptResponse]
|
||||
JavascriptResponse,
|
||||
AnnotationResponse]
|
||||
|
||||
70
common/lib/capa/capa/templates/annotationinput.html
Normal file
70
common/lib/capa/capa/templates/annotationinput.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<form class="annotation-input">
|
||||
<div class="script_placeholder" data-src="/static/js/capa/annotationinput.js"/>
|
||||
|
||||
<div class="annotation-header">
|
||||
${title}
|
||||
|
||||
% if return_to_annotation:
|
||||
<a class="annotation-return" href="javascript:void(0)">Return to Annotation</a><br/>
|
||||
% endif
|
||||
</div>
|
||||
<div class="annotation-body">
|
||||
|
||||
<div class="block block-highlight">${text}</div>
|
||||
<div class="block block-comment">${comment}</div>
|
||||
|
||||
<div class="block">${comment_prompt}</div>
|
||||
<textarea class="comment" id="input_${id}_comment" name="input_${id}_comment">${comment_value|h}</textarea>
|
||||
|
||||
<div class="block">${tag_prompt}</div>
|
||||
<ul class="tags">
|
||||
% for option in options:
|
||||
<li>
|
||||
% if has_options_value:
|
||||
% if all([c == 'correct' for c in option['choice'], status]):
|
||||
<span class="tag-status correct" id="status_${id}"></span>
|
||||
% elif all([c == 'partially-correct' for c in option['choice'], status]):
|
||||
<span class="tag-status partially-correct" id="status_${id}"></span>
|
||||
% elif all([c == 'incorrect' for c in option['choice'], status]):
|
||||
<span class="tag-status incorrect" id="status_${id}"></span>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
<span class="tag
|
||||
% if option['id'] in options_value:
|
||||
selected
|
||||
% endif
|
||||
" data-id="${option['id']}">
|
||||
${option['description']}
|
||||
</span>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
|
||||
% if debug:
|
||||
<div class="debug-value">
|
||||
Rendered with value:<br/>
|
||||
<pre>${value|h}</pre>
|
||||
Current input value:<br/>
|
||||
<input type="text" class="value" name="input_${id}" id="input_${id}" value="${value|h}" />
|
||||
</div>
|
||||
% else:
|
||||
<input type="hidden" class="value" name="input_${id}" id="input_${id}" value="${value|h}" />
|
||||
% endif
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif status == 'incorrect' and not has_options_value:
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% endif
|
||||
|
||||
<p id="answer_${id}" class="answer answer-annotation"></p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
% if msg:
|
||||
<span class="message">${msg|n}</span>
|
||||
% endif
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" data-input-id="${id}" value="${value|h}"
|
||||
% if size:
|
||||
size="${size}"
|
||||
% endif
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<section id="editageneinput_${id}" class="editageneinput">
|
||||
<section id="editageneinput_${id}" class="editageneinput">
|
||||
<div class="script_placeholder" data-src="/static/js/capa/genex/genex.nocache.js?raw"/>
|
||||
<div class="script_placeholder" data-src="${applet_loader}"/>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
@@ -8,16 +9,12 @@
|
||||
% elif status == 'incorrect':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% elif status == 'incomplete':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
<div class="incomplete" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<object type="application/x-java-applet" id="applet_${id}" class="applet" width="${width}" height="${height}">
|
||||
<param name="archive" value="/static/applets/capa/genex.jar" />
|
||||
<param name="code" value="GX.GenexApplet.class" />
|
||||
<param name="DNA_SEQUENCE" value="${dna_sequence}" />
|
||||
Applet failed to run. No Java plug-in was found.
|
||||
</object>
|
||||
|
||||
|
||||
<div id="genex_container"></div>
|
||||
<input type="hidden" name="dna_sequence" id="dna_sequence" value ="${dna_sequence}"></input>
|
||||
<input type="hidden" name="genex_problem_number" id="genex_problem_number" value ="${genex_problem_number}"></input>
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" value="${value|h}"/>
|
||||
|
||||
<p class="status">
|
||||
@@ -37,3 +34,4 @@
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
|
||||
|
||||
@@ -666,3 +666,36 @@ class StringResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
return ResponseXMLFactory.textline_input_xml(**kwargs)
|
||||
|
||||
class AnnotationResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for creating <annotationresponse> XML trees """
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <annotationresponse> element """
|
||||
return etree.Element("annotationresponse")
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create a <annotationinput> element."""
|
||||
|
||||
input_element = etree.Element("annotationinput")
|
||||
|
||||
text_children = [
|
||||
{'tag': 'title', 'text': kwargs.get('title', 'super cool annotation') },
|
||||
{'tag': 'text', 'text': kwargs.get('text', 'texty text') },
|
||||
{'tag': 'comment', 'text':kwargs.get('comment', 'blah blah erudite comment blah blah') },
|
||||
{'tag': 'comment_prompt', 'text': kwargs.get('comment_prompt', 'type a commentary below') },
|
||||
{'tag': 'tag_prompt', 'text': kwargs.get('tag_prompt', 'select one tag') }
|
||||
]
|
||||
|
||||
for child in text_children:
|
||||
etree.SubElement(input_element, child['tag']).text = child['text']
|
||||
|
||||
default_options = [('green', 'correct'),('eggs', 'incorrect'),('ham', 'partially-correct')]
|
||||
options = kwargs.get('options', default_options)
|
||||
options_element = etree.SubElement(input_element, 'options')
|
||||
|
||||
for (description, correctness) in options:
|
||||
option_element = etree.SubElement(options_element, 'option', {'choice': correctness})
|
||||
option_element.text = description
|
||||
|
||||
return input_element
|
||||
|
||||
|
||||
152
common/lib/capa/capa/tests/test_correctmap.py
Normal file
152
common/lib/capa/capa/tests/test_correctmap.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import unittest
|
||||
from capa.correctmap import CorrectMap
|
||||
import datetime
|
||||
|
||||
class CorrectMapTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.cmap = CorrectMap()
|
||||
|
||||
def test_set_input_properties(self):
|
||||
|
||||
# Set the correctmap properties for two inputs
|
||||
self.cmap.set(answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5,
|
||||
msg='Test message',
|
||||
hint='Test hint',
|
||||
hintmode='always',
|
||||
queuestate={'key':'secretstring',
|
||||
'time':'20130228100026'})
|
||||
|
||||
self.cmap.set(answer_id='2_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=None,
|
||||
msg=None,
|
||||
hint=None,
|
||||
hintmode=None,
|
||||
queuestate=None)
|
||||
|
||||
# Assert that each input has the expected properties
|
||||
self.assertTrue(self.cmap.is_correct('1_2_1'))
|
||||
self.assertFalse(self.cmap.is_correct('2_2_1'))
|
||||
|
||||
self.assertEqual(self.cmap.get_correctness('1_2_1'), 'correct')
|
||||
self.assertEqual(self.cmap.get_correctness('2_2_1'), 'incorrect')
|
||||
|
||||
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5)
|
||||
self.assertEqual(self.cmap.get_npoints('2_2_1'), 0)
|
||||
|
||||
self.assertEqual(self.cmap.get_msg('1_2_1'), 'Test message')
|
||||
self.assertEqual(self.cmap.get_msg('2_2_1'), None)
|
||||
|
||||
self.assertEqual(self.cmap.get_hint('1_2_1'), 'Test hint')
|
||||
self.assertEqual(self.cmap.get_hint('2_2_1'), None)
|
||||
|
||||
self.assertEqual(self.cmap.get_hintmode('1_2_1'), 'always')
|
||||
self.assertEqual(self.cmap.get_hintmode('2_2_1'), None)
|
||||
|
||||
self.assertTrue(self.cmap.is_queued('1_2_1'))
|
||||
self.assertFalse(self.cmap.is_queued('2_2_1'))
|
||||
|
||||
self.assertEqual(self.cmap.get_queuetime_str('1_2_1'), '20130228100026')
|
||||
self.assertEqual(self.cmap.get_queuetime_str('2_2_1'), None)
|
||||
|
||||
self.assertTrue(self.cmap.is_right_queuekey('1_2_1', 'secretstring'))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('1_2_1', 'invalidstr'))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('1_2_1', ''))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('1_2_1', None))
|
||||
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', 'secretstring'))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', 'invalidstr'))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', ''))
|
||||
self.assertFalse(self.cmap.is_right_queuekey('2_2_1', None))
|
||||
|
||||
|
||||
def test_get_npoints(self):
|
||||
# Set the correctmap properties for 4 inputs
|
||||
# 1) correct, 5 points
|
||||
# 2) correct, None points
|
||||
# 3) incorrect, 5 points
|
||||
# 4) incorrect, None points
|
||||
# 5) correct, 0 points
|
||||
self.cmap.set(answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5)
|
||||
|
||||
self.cmap.set(answer_id='2_2_1',
|
||||
correctness='correct',
|
||||
npoints=None)
|
||||
|
||||
self.cmap.set(answer_id='3_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=5)
|
||||
|
||||
self.cmap.set(answer_id='4_2_1',
|
||||
correctness='incorrect',
|
||||
npoints=None)
|
||||
|
||||
self.cmap.set(answer_id='5_2_1',
|
||||
correctness='correct',
|
||||
npoints=0)
|
||||
|
||||
# Assert that we get the expected points
|
||||
# If points assigned and correct --> npoints
|
||||
# If no points assigned and correct --> 1 point
|
||||
# Otherwise --> 0 points
|
||||
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5)
|
||||
self.assertEqual(self.cmap.get_npoints('2_2_1'), 1)
|
||||
self.assertEqual(self.cmap.get_npoints('3_2_1'), 0)
|
||||
self.assertEqual(self.cmap.get_npoints('4_2_1'), 0)
|
||||
self.assertEqual(self.cmap.get_npoints('5_2_1'), 0)
|
||||
|
||||
|
||||
def test_set_overall_message(self):
|
||||
|
||||
# Default is an empty string string
|
||||
self.assertEqual(self.cmap.get_overall_message(), "")
|
||||
|
||||
# Set a message that applies to the whole question
|
||||
self.cmap.set_overall_message("Test message")
|
||||
|
||||
# Retrieve the message
|
||||
self.assertEqual(self.cmap.get_overall_message(), "Test message")
|
||||
|
||||
# Setting the message to None --> empty string
|
||||
self.cmap.set_overall_message(None)
|
||||
self.assertEqual(self.cmap.get_overall_message(), "")
|
||||
|
||||
def test_update_from_correctmap(self):
|
||||
# Initialize a CorrectMap with some properties
|
||||
self.cmap.set(answer_id='1_2_1',
|
||||
correctness='correct',
|
||||
npoints=5,
|
||||
msg='Test message',
|
||||
hint='Test hint',
|
||||
hintmode='always',
|
||||
queuestate={'key':'secretstring',
|
||||
'time':'20130228100026'})
|
||||
|
||||
self.cmap.set_overall_message("Test message")
|
||||
|
||||
# Create a second cmap, then update it to have the same properties
|
||||
# as the first cmap
|
||||
other_cmap = CorrectMap()
|
||||
other_cmap.update(self.cmap)
|
||||
|
||||
# Assert that it has all the same properties
|
||||
self.assertEqual(other_cmap.get_overall_message(),
|
||||
self.cmap.get_overall_message())
|
||||
|
||||
self.assertEqual(other_cmap.get_dict(),
|
||||
self.cmap.get_dict())
|
||||
|
||||
|
||||
def test_update_from_invalid(self):
|
||||
# Should get an exception if we try to update() a CorrectMap
|
||||
# with a non-CorrectMap value
|
||||
invalid_list = [None, "string", 5, datetime.datetime.today()]
|
||||
|
||||
for invalid in invalid_list:
|
||||
with self.assertRaises(Exception):
|
||||
self.cmap.update(invalid)
|
||||
211
common/lib/capa/capa/tests/test_html_render.py
Normal file
211
common/lib/capa/capa/tests/test_html_render.py
Normal file
@@ -0,0 +1,211 @@
|
||||
import unittest
|
||||
from lxml import etree
|
||||
import os
|
||||
import textwrap
|
||||
import json
|
||||
import mock
|
||||
|
||||
from capa.capa_problem import LoncapaProblem
|
||||
from response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory
|
||||
from . import test_system
|
||||
|
||||
class CapaHtmlRenderTest(unittest.TestCase):
|
||||
|
||||
def test_blank_problem(self):
|
||||
"""
|
||||
It's important that blank problems don't break, since that's
|
||||
what you start with in studio.
|
||||
"""
|
||||
xml_str = "<problem> </problem>"
|
||||
|
||||
# Create the problem
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
|
||||
# Render the HTML
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
# expect that we made it here without blowing up
|
||||
|
||||
def test_include_html(self):
|
||||
# Create a test file to include
|
||||
self._create_test_file('test_include.xml',
|
||||
'<test>Test include</test>')
|
||||
|
||||
# Generate some XML with an <include>
|
||||
xml_str = textwrap.dedent("""
|
||||
<problem>
|
||||
<include file="test_include.xml"/>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# Create the problem
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
|
||||
# Render the HTML
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect that the include file was embedded in the problem
|
||||
test_element = rendered_html.find("test")
|
||||
self.assertEqual(test_element.tag, "test")
|
||||
self.assertEqual(test_element.text, "Test include")
|
||||
|
||||
|
||||
def test_process_outtext(self):
|
||||
# Generate some XML with <startouttext /> and <endouttext />
|
||||
xml_str = textwrap.dedent("""
|
||||
<problem>
|
||||
<startouttext/>Test text<endouttext/>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# Create the problem
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
|
||||
# Render the HTML
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect that the <startouttext /> and <endouttext />
|
||||
# were converted to <span></span> tags
|
||||
span_element = rendered_html.find('span')
|
||||
self.assertEqual(span_element.text, 'Test text')
|
||||
|
||||
def test_render_script(self):
|
||||
# Generate some XML with a <script> tag
|
||||
xml_str = textwrap.dedent("""
|
||||
<problem>
|
||||
<script>test=True</script>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# Create the problem
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
|
||||
# Render the HTML
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect that the script element has been removed from the rendered HTML
|
||||
script_element = rendered_html.find('script')
|
||||
self.assertEqual(None, script_element)
|
||||
|
||||
def test_render_response_xml(self):
|
||||
# Generate some XML for a string response
|
||||
kwargs = {'question_text': "Test question",
|
||||
'explanation_text': "Test explanation",
|
||||
'answer': 'Test answer',
|
||||
'hints': [('test prompt', 'test_hint', 'test hint text')]}
|
||||
xml_str = StringResponseXMLFactory().build_xml(**kwargs)
|
||||
|
||||
# Mock out the template renderer
|
||||
test_system.render_template = mock.Mock()
|
||||
test_system.render_template.return_value = "<div>Input Template Render</div>"
|
||||
|
||||
# Create the problem and render the HTML
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect problem has been turned into a <div>
|
||||
self.assertEqual(rendered_html.tag, "div")
|
||||
|
||||
# Expect question text is in a <p> child
|
||||
question_element = rendered_html.find("p")
|
||||
self.assertEqual(question_element.text, "Test question")
|
||||
|
||||
# Expect that the response has been turned into a <span>
|
||||
response_element = rendered_html.find("span")
|
||||
self.assertEqual(response_element.tag, "span")
|
||||
|
||||
# Expect that the response <span>
|
||||
# that contains a <div> for the textline
|
||||
textline_element = response_element.find("div")
|
||||
self.assertEqual(textline_element.text, 'Input Template Render')
|
||||
|
||||
# Expect a child <div> for the solution
|
||||
# with the rendered template
|
||||
solution_element = rendered_html.find("div")
|
||||
self.assertEqual(solution_element.text, 'Input Template Render')
|
||||
|
||||
# Expect that the template renderer was called with the correct
|
||||
# arguments, once for the textline input and once for
|
||||
# the solution
|
||||
expected_textline_context = {'status': 'unsubmitted',
|
||||
'value': '',
|
||||
'preprocessor': None,
|
||||
'msg': '',
|
||||
'inline': False,
|
||||
'hidden': False,
|
||||
'do_math': False,
|
||||
'id': '1_2_1',
|
||||
'size': None}
|
||||
|
||||
expected_solution_context = {'id': '1_solution_1'}
|
||||
|
||||
expected_calls = [mock.call('textline.html', expected_textline_context),
|
||||
mock.call('solutionspan.html', expected_solution_context),
|
||||
mock.call('textline.html', expected_textline_context),
|
||||
mock.call('solutionspan.html', expected_solution_context)]
|
||||
|
||||
self.assertEqual(test_system.render_template.call_args_list,
|
||||
expected_calls)
|
||||
|
||||
|
||||
def test_render_response_with_overall_msg(self):
|
||||
# CustomResponse script that sets an overall_message
|
||||
script=textwrap.dedent("""
|
||||
def check_func(*args):
|
||||
msg = '<p>Test message 1<br /></p><p>Test message 2</p>'
|
||||
return {'overall_message': msg,
|
||||
'input_list': [ {'ok': True, 'msg': '' } ] }
|
||||
""")
|
||||
|
||||
# Generate some XML for a CustomResponse
|
||||
kwargs = {'script':script, 'cfn': 'check_func'}
|
||||
xml_str = CustomResponseXMLFactory().build_xml(**kwargs)
|
||||
|
||||
# Create the problem and render the html
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
|
||||
# Grade the problem
|
||||
correctmap = problem.grade_answers({'1_2_1': 'test'})
|
||||
|
||||
# Render the html
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
|
||||
# Expect that there is a <div> within the response <div>
|
||||
# with css class response_message
|
||||
msg_div_element = rendered_html.find(".//div[@class='response_message']")
|
||||
self.assertEqual(msg_div_element.tag, "div")
|
||||
self.assertEqual(msg_div_element.get('class'), "response_message")
|
||||
|
||||
# Expect that the <div> contains our message (as part of the XML tree)
|
||||
msg_p_elements = msg_div_element.findall('p')
|
||||
self.assertEqual(msg_p_elements[0].tag, "p")
|
||||
self.assertEqual(msg_p_elements[0].text, "Test message 1")
|
||||
|
||||
self.assertEqual(msg_p_elements[1].tag, "p")
|
||||
self.assertEqual(msg_p_elements[1].text, "Test message 2")
|
||||
|
||||
|
||||
def test_substitute_python_vars(self):
|
||||
# Generate some XML with Python variables defined in a script
|
||||
# and used later as attributes
|
||||
xml_str = textwrap.dedent("""
|
||||
<problem>
|
||||
<script>test="TEST"</script>
|
||||
<span attr="$test"></span>
|
||||
</problem>
|
||||
""")
|
||||
|
||||
# Create the problem and render the HTML
|
||||
problem = LoncapaProblem(xml_str, '1', system=test_system)
|
||||
rendered_html = etree.XML(problem.get_html())
|
||||
|
||||
# Expect that the variable $test has been replaced with its value
|
||||
span_element = rendered_html.find('span')
|
||||
self.assertEqual(span_element.get('attr'), "TEST")
|
||||
|
||||
def _create_test_file(self, path, content_str):
|
||||
test_fp = test_system.filestore.open(path, "w")
|
||||
test_fp.write(content_str)
|
||||
test_fp.close()
|
||||
|
||||
self.addCleanup(lambda: os.remove(test_fp.name))
|
||||
@@ -482,27 +482,43 @@ class ChemicalEquationTest(unittest.TestCase):
|
||||
'''
|
||||
Check that chemical equation inputs work.
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
size = "42"
|
||||
xml_str = """<chemicalequationinput id="prob_1_2" size="{size}"/>""".format(size=size)
|
||||
def setUp(self):
|
||||
self.size = "42"
|
||||
xml_str = """<chemicalequationinput id="prob_1_2" size="{size}"/>""".format(size=self.size)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'H2OYeah', }
|
||||
the_input = lookup_tag('chemicalequationinput')(test_system, element, state)
|
||||
self.the_input = lookup_tag('chemicalequationinput')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
def test_rendering(self):
|
||||
''' Verify that the render context matches the expected render context'''
|
||||
context = self.the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': 'H2OYeah',
|
||||
'status': 'unanswered',
|
||||
'msg': '',
|
||||
'size': size,
|
||||
'size': self.size,
|
||||
'previewer': '/static/js/capa/chemical_equation_preview.js',
|
||||
}
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
def test_chemcalc_ajax_sucess(self):
|
||||
''' Verify that using the correct dispatch and valid data produces a valid response'''
|
||||
|
||||
data = {'formula': "H"}
|
||||
response = self.the_input.handle_ajax("preview_chemcalc", data)
|
||||
|
||||
self.assertTrue('preview' in response)
|
||||
self.assertNotEqual(response['preview'], '')
|
||||
self.assertEqual(response['error'], "")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class DragAndDropTest(unittest.TestCase):
|
||||
'''
|
||||
@@ -570,3 +586,65 @@ class DragAndDropTest(unittest.TestCase):
|
||||
context.pop('drag_and_drop_json')
|
||||
expected.pop('drag_and_drop_json')
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
class AnnotationInputTest(unittest.TestCase):
|
||||
'''
|
||||
Make sure option inputs work
|
||||
'''
|
||||
def test_rendering(self):
|
||||
xml_str = '''
|
||||
<annotationinput>
|
||||
<title>foo</title>
|
||||
<text>bar</text>
|
||||
<comment>my comment</comment>
|
||||
<comment_prompt>type a commentary</comment_prompt>
|
||||
<tag_prompt>select a tag</tag_prompt>
|
||||
<options>
|
||||
<option choice="correct">x</option>
|
||||
<option choice="incorrect">y</option>
|
||||
<option choice="partially-correct">z</option>
|
||||
</options>
|
||||
</annotationinput>
|
||||
'''
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
value = {"comment": "blah blah", "options": [1]}
|
||||
json_value = json.dumps(value)
|
||||
state = {
|
||||
'value': json_value,
|
||||
'id': 'annotation_input',
|
||||
'status': 'answered'
|
||||
}
|
||||
|
||||
tag = 'annotationinput'
|
||||
|
||||
the_input = lookup_tag(tag)(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {
|
||||
'id': 'annotation_input',
|
||||
'value': value,
|
||||
'status': 'answered',
|
||||
'msg': '',
|
||||
'title': 'foo',
|
||||
'text': 'bar',
|
||||
'comment': 'my comment',
|
||||
'comment_prompt': 'type a commentary',
|
||||
'tag_prompt': 'select a tag',
|
||||
'options': [
|
||||
{'id': 0, 'description': 'x', 'choice': 'correct'},
|
||||
{'id': 1, 'description': 'y', 'choice': 'incorrect'},
|
||||
{'id': 2, 'description': 'z', 'choice': 'partially-correct'}
|
||||
],
|
||||
'value': json_value,
|
||||
'options_value': value['options'],
|
||||
'has_options_value': len(value['options']) > 0,
|
||||
'comment_value': value['comment'],
|
||||
'debug': False,
|
||||
'return_to_annotation': True
|
||||
}
|
||||
|
||||
self.maxDiff = None
|
||||
self.assertDictEqual(context, expected)
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
from nose.plugins.skip import SkipTest
|
||||
import os
|
||||
import unittest
|
||||
import textwrap
|
||||
|
||||
from . import test_system
|
||||
|
||||
@@ -663,30 +664,43 @@ class CustomResponseTest(ResponseTest):
|
||||
|
||||
# Inline code can update the global messages list
|
||||
# to pass messages to the CorrectMap for a particular input
|
||||
inline_script = """messages[0] = "Test Message" """
|
||||
# The code can also set the global overall_message (str)
|
||||
# to pass a message that applies to the whole response
|
||||
inline_script = textwrap.dedent("""
|
||||
messages[0] = "Test Message"
|
||||
overall_message = "Overall message"
|
||||
""")
|
||||
problem = self.build_problem(answer=inline_script)
|
||||
|
||||
input_dict = {'1_2_1': '0'}
|
||||
msg = problem.grade_answers(input_dict).get_msg('1_2_1')
|
||||
self.assertEqual(msg, "Test Message")
|
||||
correctmap = problem.grade_answers(input_dict)
|
||||
|
||||
def test_function_code(self):
|
||||
# Check that the message for the particular input was received
|
||||
input_msg = correctmap.get_msg('1_2_1')
|
||||
self.assertEqual(input_msg, "Test Message")
|
||||
|
||||
# For function code, we pass in three arguments:
|
||||
# Check that the overall message (for the whole response) was received
|
||||
overall_msg = correctmap.get_overall_message()
|
||||
self.assertEqual(overall_msg, "Overall message")
|
||||
|
||||
|
||||
def test_function_code_single_input(self):
|
||||
|
||||
# For function code, we pass in these arguments:
|
||||
#
|
||||
# 'expect' is the expect attribute of the <customresponse>
|
||||
#
|
||||
# 'answer_given' is the answer the student gave (if there is just one input)
|
||||
# or an ordered list of answers (if there are multiple inputs)
|
||||
#
|
||||
# 'student_answers' is a dictionary of answers by input ID
|
||||
#
|
||||
#
|
||||
# The function should return a dict of the form
|
||||
# { 'ok': BOOL, 'msg': STRING }
|
||||
#
|
||||
script = """def check_func(expect, answer_given, student_answers):
|
||||
return {'ok': answer_given == expect, 'msg': 'Message text'}"""
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
return {'ok': answer_given == expect, 'msg': 'Message text'}
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func", expect="42")
|
||||
|
||||
@@ -698,7 +712,7 @@ class CustomResponseTest(ResponseTest):
|
||||
msg = correct_map.get_msg('1_2_1')
|
||||
|
||||
self.assertEqual(correctness, 'correct')
|
||||
self.assertEqual(msg, "Message text\n")
|
||||
self.assertEqual(msg, "Message text")
|
||||
|
||||
# Incorrect answer
|
||||
input_dict = {'1_2_1': '0'}
|
||||
@@ -708,19 +722,108 @@ class CustomResponseTest(ResponseTest):
|
||||
msg = correct_map.get_msg('1_2_1')
|
||||
|
||||
self.assertEqual(correctness, 'incorrect')
|
||||
self.assertEqual(msg, "Message text\n")
|
||||
self.assertEqual(msg, "Message text")
|
||||
|
||||
def test_multiple_inputs(self):
|
||||
def test_function_code_multiple_input_no_msg(self):
|
||||
|
||||
# Check functions also have the option of returning
|
||||
# a single boolean value
|
||||
# If true, mark all the inputs correct
|
||||
# If false, mark all the inputs incorrect
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
return (answer_given[0] == expect and
|
||||
answer_given[1] == expect)
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func",
|
||||
expect="42", num_inputs=2)
|
||||
|
||||
# Correct answer -- expect both inputs marked correct
|
||||
input_dict = {'1_2_1': '42', '1_2_2': '42'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_1')
|
||||
self.assertEqual(correctness, 'correct')
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_2')
|
||||
self.assertEqual(correctness, 'correct')
|
||||
|
||||
# One answer incorrect -- expect both inputs marked incorrect
|
||||
input_dict = {'1_2_1': '0', '1_2_2': '42'}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_1')
|
||||
self.assertEqual(correctness, 'incorrect')
|
||||
|
||||
correctness = correct_map.get_correctness('1_2_2')
|
||||
self.assertEqual(correctness, 'incorrect')
|
||||
|
||||
|
||||
def test_function_code_multiple_inputs(self):
|
||||
|
||||
# If the <customresponse> has multiple inputs associated with it,
|
||||
# the check function can return a dict of the form:
|
||||
#
|
||||
# {'overall_message': STRING,
|
||||
# 'input_list': [{'ok': BOOL, 'msg': STRING}, ...] }
|
||||
#
|
||||
# 'overall_message' is displayed at the end of the response
|
||||
#
|
||||
# 'input_list' contains dictionaries representing the correctness
|
||||
# and message for each input.
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
check1 = (int(answer_given[0]) == 1)
|
||||
check2 = (int(answer_given[1]) == 2)
|
||||
check3 = (int(answer_given[2]) == 3)
|
||||
return {'overall_message': 'Overall message',
|
||||
'input_list': [
|
||||
{'ok': check1, 'msg': 'Feedback 1'},
|
||||
{'ok': check2, 'msg': 'Feedback 2'},
|
||||
{'ok': check3, 'msg': 'Feedback 3'} ] }
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script,
|
||||
cfn="check_func", num_inputs=3)
|
||||
|
||||
# Grade the inputs (one input incorrect)
|
||||
input_dict = {'1_2_1': '-999', '1_2_2': '2', '1_2_3': '3' }
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
# Expect that we receive the overall message (for the whole response)
|
||||
self.assertEqual(correct_map.get_overall_message(), "Overall message")
|
||||
|
||||
# Expect that the inputs were graded individually
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'incorrect')
|
||||
self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct')
|
||||
self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct')
|
||||
|
||||
# Expect that we received messages for each individual input
|
||||
self.assertEqual(correct_map.get_msg('1_2_1'), 'Feedback 1')
|
||||
self.assertEqual(correct_map.get_msg('1_2_2'), 'Feedback 2')
|
||||
self.assertEqual(correct_map.get_msg('1_2_3'), 'Feedback 3')
|
||||
|
||||
|
||||
def test_multiple_inputs_return_one_status(self):
|
||||
# When given multiple inputs, the 'answer_given' argument
|
||||
# to the check_func() is a list of inputs
|
||||
#
|
||||
# The sample script below marks the problem as correct
|
||||
# if and only if it receives answer_given=[1,2,3]
|
||||
# (or string values ['1','2','3'])
|
||||
script = """def check_func(expect, answer_given, student_answers):
|
||||
check1 = (int(answer_given[0]) == 1)
|
||||
check2 = (int(answer_given[1]) == 2)
|
||||
check3 = (int(answer_given[2]) == 3)
|
||||
return {'ok': (check1 and check2 and check3), 'msg': 'Message text'}"""
|
||||
#
|
||||
# Since we return a dict describing the status of one input,
|
||||
# we expect that the same 'ok' value is applied to each
|
||||
# of the inputs.
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
check1 = (int(answer_given[0]) == 1)
|
||||
check2 = (int(answer_given[1]) == 2)
|
||||
check3 = (int(answer_given[2]) == 3)
|
||||
return {'ok': (check1 and check2 and check3),
|
||||
'msg': 'Message text'}
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script,
|
||||
cfn="check_func", num_inputs=3)
|
||||
@@ -743,6 +846,37 @@ class CustomResponseTest(ResponseTest):
|
||||
self.assertEqual(correct_map.get_correctness('1_2_2'), 'correct')
|
||||
self.assertEqual(correct_map.get_correctness('1_2_3'), 'correct')
|
||||
|
||||
# Message is interpreted as an "overall message"
|
||||
self.assertEqual(correct_map.get_overall_message(), 'Message text')
|
||||
|
||||
def test_script_exception(self):
|
||||
|
||||
# Construct a script that will raise an exception
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
raise Exception("Test")
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func")
|
||||
|
||||
# Expect that an exception gets raised when we check the answer
|
||||
with self.assertRaises(Exception):
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
def test_invalid_dict_exception(self):
|
||||
|
||||
# Construct a script that passes back an invalid dict format
|
||||
script = textwrap.dedent("""
|
||||
def check_func(expect, answer_given):
|
||||
return {'invalid': 'test'}
|
||||
""")
|
||||
|
||||
problem = self.build_problem(script=script, cfn="check_func")
|
||||
|
||||
# Expect that an exception gets raised when we check the answer
|
||||
with self.assertRaises(Exception):
|
||||
problem.grade_answers({'1_2_1': '42'})
|
||||
|
||||
|
||||
class SchematicResponseTest(ResponseTest):
|
||||
from response_xml_factory import SchematicResponseXMLFactory
|
||||
@@ -772,3 +906,40 @@ class SchematicResponseTest(ResponseTest):
|
||||
# (That is, our script verifies that the context
|
||||
# is what we expect)
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'), 'correct')
|
||||
|
||||
class AnnotationResponseTest(ResponseTest):
|
||||
from response_xml_factory import AnnotationResponseXMLFactory
|
||||
xml_factory_class = AnnotationResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
(correct, partially, incorrect) = ('correct', 'partially-correct', 'incorrect')
|
||||
|
||||
answer_id = '1_2_1'
|
||||
options = (('x', correct),('y', partially),('z', incorrect))
|
||||
make_answer = lambda option_ids: {answer_id: json.dumps({'options': option_ids })}
|
||||
|
||||
tests = [
|
||||
{'correctness': correct, 'points': 2,'answers': make_answer([0]) },
|
||||
{'correctness': partially, 'points': 1, 'answers': make_answer([1]) },
|
||||
{'correctness': incorrect, 'points': 0, 'answers': make_answer([2]) },
|
||||
{'correctness': incorrect, 'points': 0, 'answers': make_answer([0,1,2]) },
|
||||
{'correctness': incorrect, 'points': 0, 'answers': make_answer([]) },
|
||||
{'correctness': incorrect, 'points': 0, 'answers': make_answer('') },
|
||||
{'correctness': incorrect, 'points': 0, 'answers': make_answer(None) },
|
||||
{'correctness': incorrect, 'points': 0, 'answers': {answer_id: 'null' } },
|
||||
]
|
||||
|
||||
for (index, test) in enumerate(tests):
|
||||
expected_correctness = test['correctness']
|
||||
expected_points = test['points']
|
||||
answers = test['answers']
|
||||
|
||||
problem = self.build_problem(options=options)
|
||||
correct_map = problem.grade_answers(answers)
|
||||
actual_correctness = correct_map.get_correctness(answer_id)
|
||||
actual_points = correct_map.get_npoints(answer_id)
|
||||
|
||||
self.assertEqual(expected_correctness, actual_correctness,
|
||||
msg="%s should be marked %s" % (answer_id, expected_correctness))
|
||||
self.assertEqual(expected_points, actual_points,
|
||||
msg="%s should have %d points" % (answer_id, expected_points))
|
||||
|
||||
@@ -46,6 +46,7 @@ setup(
|
||||
"custom_tag_template = xmodule.raw_module:RawDescriptor",
|
||||
"about = xmodule.html_module:AboutDescriptor",
|
||||
"graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor",
|
||||
"annotatable = xmodule.annotatable_module:AnnotatableDescriptor",
|
||||
"foldit = xmodule.foldit_module:FolditDescriptor",
|
||||
]
|
||||
}
|
||||
|
||||
131
common/lib/xmodule/xmodule/annotatable_module.py
Normal file
131
common/lib/xmodule/xmodule/annotatable_module.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import logging
|
||||
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string, resource_listdir
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.modulestore.mongo import MongoModuleStore
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class AnnotatableModule(XModule):
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/html/display.coffee'),
|
||||
resource_string(__name__, 'js/src/annotatable/display.coffee')],
|
||||
'js': []
|
||||
}
|
||||
js_module_name = "Annotatable"
|
||||
css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]}
|
||||
icon_class = 'annotatable'
|
||||
|
||||
def _get_annotation_class_attr(self, index, el):
|
||||
""" Returns a dict with the CSS class attribute to set on the annotation
|
||||
and an XML key to delete from the element.
|
||||
"""
|
||||
|
||||
attr = {}
|
||||
cls = ['annotatable-span', 'highlight']
|
||||
highlight_key = 'highlight'
|
||||
color = el.get(highlight_key)
|
||||
|
||||
if color is not None:
|
||||
if color in self.highlight_colors:
|
||||
cls.append('highlight-'+color)
|
||||
attr['_delete'] = highlight_key
|
||||
attr['value'] = ' '.join(cls)
|
||||
|
||||
return { 'class' : attr }
|
||||
|
||||
def _get_annotation_data_attr(self, index, el):
|
||||
""" Returns a dict in which the keys are the HTML data attributes
|
||||
to set on the annotation element. Each data attribute has a
|
||||
corresponding 'value' and (optional) '_delete' key to specify
|
||||
an XML attribute to delete.
|
||||
"""
|
||||
|
||||
data_attrs = {}
|
||||
attrs_map = {
|
||||
'body': 'data-comment-body',
|
||||
'title': 'data-comment-title',
|
||||
'problem': 'data-problem-id'
|
||||
}
|
||||
|
||||
for xml_key in attrs_map.keys():
|
||||
if xml_key in el.attrib:
|
||||
value = el.get(xml_key, '')
|
||||
html_key = attrs_map[xml_key]
|
||||
data_attrs[html_key] = { 'value': value, '_delete': xml_key }
|
||||
|
||||
return data_attrs
|
||||
|
||||
def _render_annotation(self, index, el):
|
||||
""" Renders an annotation element for HTML output. """
|
||||
attr = {}
|
||||
attr.update(self._get_annotation_class_attr(index, el))
|
||||
attr.update(self._get_annotation_data_attr(index, el))
|
||||
|
||||
el.tag = 'span'
|
||||
|
||||
for key in attr.keys():
|
||||
el.set(key, attr[key]['value'])
|
||||
if '_delete' in attr[key] and attr[key]['_delete'] is not None:
|
||||
delete_key = attr[key]['_delete']
|
||||
del el.attrib[delete_key]
|
||||
|
||||
|
||||
def _render_content(self):
|
||||
""" Renders annotatable content with annotation spans and returns HTML. """
|
||||
xmltree = etree.fromstring(self.content)
|
||||
xmltree.tag = 'div'
|
||||
if 'display_name' in xmltree.attrib:
|
||||
del xmltree.attrib['display_name']
|
||||
|
||||
index = 0
|
||||
for el in xmltree.findall('.//annotation'):
|
||||
self._render_annotation(index, el)
|
||||
index += 1
|
||||
|
||||
return etree.tostring(xmltree, encoding='unicode')
|
||||
|
||||
def _extract_instructions(self, xmltree):
|
||||
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
|
||||
instructions = xmltree.find('instructions')
|
||||
if instructions is not None:
|
||||
instructions.tag = 'div'
|
||||
xmltree.remove(instructions)
|
||||
return etree.tostring(instructions, encoding='unicode')
|
||||
return None
|
||||
|
||||
def get_html(self):
|
||||
""" Renders parameters to template. """
|
||||
context = {
|
||||
'display_name': self.display_name,
|
||||
'element_id': self.element_id,
|
||||
'instructions_html': self.instructions,
|
||||
'content_html': self._render_content()
|
||||
}
|
||||
|
||||
return self.system.render_template('annotatable.html', context)
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
|
||||
xmltree = etree.fromstring(self.definition['data'])
|
||||
|
||||
self.instructions = self._extract_instructions(xmltree)
|
||||
self.content = etree.tostring(xmltree, encoding='unicode')
|
||||
self.element_id = self.location.html_id()
|
||||
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
|
||||
|
||||
class AnnotatableDescriptor(RawDescriptor):
|
||||
module_class = AnnotatableModule
|
||||
stores_state = True
|
||||
template_dir_name = "annotatable"
|
||||
mako_template = "widgets/raw-edit.html"
|
||||
|
||||
@@ -135,8 +135,8 @@ class CapaModule(XModule):
|
||||
self.grace_period = None
|
||||
self.close_date = self.display_due_date
|
||||
|
||||
max_attempts = self.metadata.get('attempts', None)
|
||||
if max_attempts:
|
||||
max_attempts = self.metadata.get('attempts')
|
||||
if max_attempts is not None and max_attempts != '':
|
||||
self.max_attempts = int(max_attempts)
|
||||
else:
|
||||
self.max_attempts = None
|
||||
@@ -247,123 +247,177 @@ class CapaModule(XModule):
|
||||
'progress': Progress.to_js_status_str(self.get_progress())
|
||||
})
|
||||
|
||||
def check_button_name(self):
|
||||
"""
|
||||
Determine the name for the "check" button.
|
||||
Usually it is just "Check", but if this is the student's
|
||||
final attempt, change the name to "Final Check"
|
||||
"""
|
||||
if self.max_attempts is not None:
|
||||
final_check = (self.attempts >= self.max_attempts - 1)
|
||||
else:
|
||||
final_check = False
|
||||
|
||||
return "Final Check" if final_check else "Check"
|
||||
|
||||
def should_show_check_button(self):
|
||||
"""
|
||||
Return True/False to indicate whether to show the "Check" button.
|
||||
"""
|
||||
submitted_without_reset = (self.is_completed() and self.rerandomize == "always")
|
||||
|
||||
# If the problem is closed (past due / too many attempts)
|
||||
# then we do NOT show the "check" button
|
||||
# Also, do not show the "check" button if we're waiting
|
||||
# for the user to reset a randomized problem
|
||||
if self.closed() or submitted_without_reset:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def should_show_reset_button(self):
|
||||
"""
|
||||
Return True/False to indicate whether to show the "Reset" button.
|
||||
"""
|
||||
is_survey_question = (self.max_attempts == 0)
|
||||
|
||||
if self.rerandomize in ["always", "onreset"]:
|
||||
|
||||
# If the problem is closed (and not a survey question with max_attempts==0),
|
||||
# then do NOT show the reset button.
|
||||
# If the problem hasn't been submitted yet, then do NOT show
|
||||
# the reset button.
|
||||
if (self.closed() and not is_survey_question) or not self.is_completed():
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
# Only randomized problems need a "reset" button
|
||||
else:
|
||||
return False
|
||||
|
||||
def should_show_save_button(self):
|
||||
"""
|
||||
Return True/False to indicate whether to show the "Save" button.
|
||||
"""
|
||||
|
||||
# If the user has forced the save button to display,
|
||||
# then show it as long as the problem is not closed
|
||||
# (past due / too many attempts)
|
||||
if self.force_save_button == "true":
|
||||
return not self.closed()
|
||||
else:
|
||||
is_survey_question = (self.max_attempts == 0)
|
||||
needs_reset = self.is_completed() and self.rerandomize == "always"
|
||||
|
||||
# If the problem is closed (and not a survey question with max_attempts==0),
|
||||
# then do NOT show the reset button
|
||||
# If we're waiting for the user to reset a randomized problem
|
||||
# then do NOT show the reset button
|
||||
if (self.closed() and not is_survey_question) or needs_reset:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def handle_problem_html_error(self, err):
|
||||
"""
|
||||
Change our problem to a dummy problem containing
|
||||
a warning message to display to users.
|
||||
|
||||
Returns the HTML to show to users
|
||||
|
||||
*err* is the Exception encountered while rendering the problem HTML.
|
||||
"""
|
||||
log.exception(err)
|
||||
|
||||
# TODO (vshnayder): another switch on DEBUG.
|
||||
if self.system.DEBUG:
|
||||
msg = (
|
||||
'[courseware.capa.capa_module] <font size="+1" color="red">'
|
||||
'Failed to generate HTML for problem %s</font>' %
|
||||
(self.location.url()))
|
||||
msg += '<p>Error:</p><p><pre>%s</pre></p>' % str(err).replace('<', '<')
|
||||
msg += '<p><pre>%s</pre></p>' % traceback.format_exc().replace('<', '<')
|
||||
html = msg
|
||||
|
||||
# We're in non-debug mode, and possibly even in production. We want
|
||||
# to avoid bricking of problem as much as possible
|
||||
else:
|
||||
|
||||
# Presumably, student submission has corrupted LoncapaProblem HTML.
|
||||
# First, pull down all student answers
|
||||
student_answers = self.lcp.student_answers
|
||||
answer_ids = student_answers.keys()
|
||||
|
||||
# Some inputtypes, such as dynamath, have additional "hidden" state that
|
||||
# is not exposed to the student. Keep those hidden
|
||||
# TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id
|
||||
hidden_state_keywords = ['dynamath']
|
||||
for answer_id in answer_ids:
|
||||
for hidden_state_keyword in hidden_state_keywords:
|
||||
if answer_id.find(hidden_state_keyword) >= 0:
|
||||
student_answers.pop(answer_id)
|
||||
|
||||
# Next, generate a fresh LoncapaProblem
|
||||
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
|
||||
state=None, # Tabula rasa
|
||||
seed=self.seed, system=self.system)
|
||||
|
||||
# Prepend a scary warning to the student
|
||||
warning = '<div class="capa_reset">'\
|
||||
'<h2>Warning: The problem has been reset to its initial state!</h2>'\
|
||||
'The problem\'s state was corrupted by an invalid submission. ' \
|
||||
'The submission consisted of:'\
|
||||
'<ul>'
|
||||
for student_answer in student_answers.values():
|
||||
if student_answer != '':
|
||||
warning += '<li>' + cgi.escape(student_answer) + '</li>'
|
||||
warning += '</ul>'\
|
||||
'If this error persists, please contact the course staff.'\
|
||||
'</div>'
|
||||
|
||||
html = warning
|
||||
try:
|
||||
html += self.lcp.get_html()
|
||||
except Exception, err: # Couldn't do it. Give up
|
||||
log.exception(err)
|
||||
raise
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def get_problem_html(self, encapsulate=True):
|
||||
'''Return html for the problem. Adds check, reset, save buttons
|
||||
as necessary based on the problem config and state.'''
|
||||
|
||||
try:
|
||||
html = self.lcp.get_html()
|
||||
|
||||
# If we cannot construct the problem HTML,
|
||||
# then generate an error message instead.
|
||||
except Exception, err:
|
||||
log.exception(err)
|
||||
html = self.handle_problem_html_error(err)
|
||||
|
||||
# TODO (vshnayder): another switch on DEBUG.
|
||||
if self.system.DEBUG:
|
||||
msg = (
|
||||
'[courseware.capa.capa_module] <font size="+1" color="red">'
|
||||
'Failed to generate HTML for problem %s</font>' %
|
||||
(self.location.url()))
|
||||
msg += '<p>Error:</p><p><pre>%s</pre></p>' % str(err).replace('<', '<')
|
||||
msg += '<p><pre>%s</pre></p>' % traceback.format_exc().replace('<', '<')
|
||||
html = msg
|
||||
else:
|
||||
# We're in non-debug mode, and possibly even in production. We want
|
||||
# to avoid bricking of problem as much as possible
|
||||
|
||||
# Presumably, student submission has corrupted LoncapaProblem HTML.
|
||||
# First, pull down all student answers
|
||||
student_answers = self.lcp.student_answers
|
||||
answer_ids = student_answers.keys()
|
||||
|
||||
# Some inputtypes, such as dynamath, have additional "hidden" state that
|
||||
# is not exposed to the student. Keep those hidden
|
||||
# TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id
|
||||
hidden_state_keywords = ['dynamath']
|
||||
for answer_id in answer_ids:
|
||||
for hidden_state_keyword in hidden_state_keywords:
|
||||
if answer_id.find(hidden_state_keyword) >= 0:
|
||||
student_answers.pop(answer_id)
|
||||
|
||||
# Next, generate a fresh LoncapaProblem
|
||||
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
|
||||
state=None, # Tabula rasa
|
||||
seed=self.seed, system=self.system)
|
||||
|
||||
# Prepend a scary warning to the student
|
||||
warning = '<div class="capa_reset">'\
|
||||
'<h2>Warning: The problem has been reset to its initial state!</h2>'\
|
||||
'The problem\'s state was corrupted by an invalid submission. ' \
|
||||
'The submission consisted of:'\
|
||||
'<ul>'
|
||||
for student_answer in student_answers.values():
|
||||
if student_answer != '':
|
||||
warning += '<li>' + cgi.escape(student_answer) + '</li>'
|
||||
warning += '</ul>'\
|
||||
'If this error persists, please contact the course staff.'\
|
||||
'</div>'
|
||||
|
||||
html = warning
|
||||
try:
|
||||
html += self.lcp.get_html()
|
||||
except Exception, err: # Couldn't do it. Give up
|
||||
log.exception(err)
|
||||
raise
|
||||
# The convention is to pass the name of the check button
|
||||
# if we want to show a check button, and False otherwise
|
||||
# This works because non-empty strings evaluate to True
|
||||
if self.should_show_check_button():
|
||||
check_button = self.check_button_name()
|
||||
else:
|
||||
check_button = False
|
||||
|
||||
content = {'name': self.display_name,
|
||||
'html': html,
|
||||
'weight': self.descriptor.weight,
|
||||
}
|
||||
|
||||
# We using strings as truthy values, because the terminology of the
|
||||
# check button is context-specific.
|
||||
|
||||
# Put a "Check" button if unlimited attempts or still some left
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts - 1:
|
||||
check_button = "Check"
|
||||
else:
|
||||
# Will be final check so let user know that
|
||||
check_button = "Final Check"
|
||||
|
||||
reset_button = True
|
||||
save_button = True
|
||||
|
||||
# If we're after deadline, or user has exhausted attempts,
|
||||
# question is read-only.
|
||||
if self.closed():
|
||||
check_button = False
|
||||
reset_button = False
|
||||
save_button = False
|
||||
|
||||
# If attempts=0 then show just check and reset buttons; this is for survey questions using capa
|
||||
if self.max_attempts==0:
|
||||
check_button = False
|
||||
reset_button = True
|
||||
save_button = True
|
||||
|
||||
# User submitted a problem, and hasn't reset. We don't want
|
||||
# more submissions.
|
||||
if self.lcp.done and self.rerandomize == "always":
|
||||
check_button = False
|
||||
save_button = False
|
||||
|
||||
# Only show the reset button if pressing it will show different values
|
||||
if self.rerandomize not in ["always", "onreset"]:
|
||||
reset_button = False
|
||||
|
||||
# User hasn't submitted an answer yet -- we don't want resets
|
||||
if not self.lcp.done:
|
||||
reset_button = False
|
||||
|
||||
# We may not need a "save" button if infinite number of attempts and
|
||||
# non-randomized. The problem author can force it. It's a bit weird for
|
||||
# randomization to control this; should perhaps be cleaned up.
|
||||
if (self.force_save_button == "false") and (self.max_attempts is None and self.rerandomize != "always"):
|
||||
save_button = False
|
||||
|
||||
context = {'problem': content,
|
||||
'id': self.id,
|
||||
'check_button': check_button,
|
||||
'reset_button': reset_button,
|
||||
'save_button': save_button,
|
||||
'reset_button': self.should_show_reset_button(),
|
||||
'save_button': self.should_show_save_button(),
|
||||
'answer_available': self.answer_available(),
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'attempts_used': self.attempts,
|
||||
@@ -396,6 +450,7 @@ class CapaModule(XModule):
|
||||
'problem_save': self.save_problem,
|
||||
'problem_show': self.get_answer,
|
||||
'score_update': self.update_score,
|
||||
'input_ajax': self.lcp.handle_input_ajax
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
@@ -419,7 +474,7 @@ class CapaModule(XModule):
|
||||
|
||||
def closed(self):
|
||||
''' Is the student still allowed to submit answers? '''
|
||||
if self.attempts == self.max_attempts:
|
||||
if self.max_attempts is not None and self.attempts >= self.max_attempts:
|
||||
return True
|
||||
if self.is_past_due():
|
||||
return True
|
||||
@@ -528,21 +583,61 @@ class CapaModule(XModule):
|
||||
def make_dict_of_responses(get):
|
||||
'''Make dictionary of student responses (aka "answers")
|
||||
get is POST dictionary.
|
||||
|
||||
The *get* dict has keys of the form 'x_y', which are mapped
|
||||
to key 'y' in the returned dict. For example,
|
||||
'input_1_2_3' would be mapped to '1_2_3' in the returned dict.
|
||||
|
||||
Some inputs always expect a list in the returned dict
|
||||
(e.g. checkbox inputs). The convention is that
|
||||
keys in the *get* dict that end with '[]' will always
|
||||
have list values in the returned dict.
|
||||
For example, if the *get* dict contains {'input_1[]': 'test' }
|
||||
then the output dict would contain {'1': ['test'] }
|
||||
(the value is a list).
|
||||
|
||||
Raises an exception if:
|
||||
|
||||
A key in the *get* dictionary does not contain >= 1 underscores
|
||||
(e.g. "input" is invalid; "input_1" is valid)
|
||||
|
||||
Two keys end up with the same name in the returned dict.
|
||||
(e.g. 'input_1' and 'input_1[]', which both get mapped
|
||||
to 'input_1' in the returned dict)
|
||||
'''
|
||||
answers = dict()
|
||||
for key in get:
|
||||
# e.g. input_resistor_1 ==> resistor_1
|
||||
_, _, name = key.partition('_')
|
||||
|
||||
# This allows for answers which require more than one value for
|
||||
# the same form input (e.g. checkbox inputs). The convention is that
|
||||
# if the name ends with '[]' (which looks like an array), then the
|
||||
# answer will be an array.
|
||||
if not name.endswith('[]'):
|
||||
answers[name] = get[key]
|
||||
# If key has no underscores, then partition
|
||||
# will return (key, '', '')
|
||||
# We detect this and raise an error
|
||||
if name is '':
|
||||
raise ValueError("%s must contain at least one underscore" % str(key))
|
||||
|
||||
else:
|
||||
name = name[:-2]
|
||||
answers[name] = get.getlist(key)
|
||||
# This allows for answers which require more than one value for
|
||||
# the same form input (e.g. checkbox inputs). The convention is that
|
||||
# if the name ends with '[]' (which looks like an array), then the
|
||||
# answer will be an array.
|
||||
is_list_key = name.endswith('[]')
|
||||
name = name[:-2] if is_list_key else name
|
||||
|
||||
if is_list_key:
|
||||
if type(get[key]) is list:
|
||||
val = get[key]
|
||||
else:
|
||||
val = [get[key]]
|
||||
else:
|
||||
val = get[key]
|
||||
|
||||
# If the name already exists, then we don't want
|
||||
# to override it. Raise an error instead
|
||||
if name in answers:
|
||||
raise ValueError("Key %s already exists in answers dict" % str(name))
|
||||
else:
|
||||
answers[name] = val
|
||||
|
||||
return answers
|
||||
|
||||
@@ -550,7 +645,7 @@ class CapaModule(XModule):
|
||||
''' Checks whether answers to a problem are correct, and
|
||||
returns a map of correct/incorrect answers:
|
||||
|
||||
{'success' : bool,
|
||||
{'success' : 'correct' | 'incorrect' | AJAX alert msg string,
|
||||
'contents' : html}
|
||||
'''
|
||||
event_info = dict()
|
||||
@@ -609,11 +704,11 @@ class CapaModule(XModule):
|
||||
# 'success' will always be incorrect
|
||||
event_info['correct_map'] = correct_map.get_dict()
|
||||
event_info['success'] = success
|
||||
event_info['attempts'] = self.attempts
|
||||
event_info['attempts'] = self.attempts
|
||||
self.system.track_function('save_problem_check', event_info)
|
||||
|
||||
if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
|
||||
self.system.psychometrics_handler(self.get_instance_state())
|
||||
if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
|
||||
self.system.psychometrics_handler(self.get_instance_state())
|
||||
|
||||
# render problem into HTML
|
||||
html = self.get_problem_html(encapsulate=False)
|
||||
@@ -663,7 +758,12 @@ class CapaModule(XModule):
|
||||
''' Changes problem state to unfinished -- removes student answers,
|
||||
and causes problem to rerender itself.
|
||||
|
||||
Returns problem html as { 'html' : html-string }.
|
||||
Returns a dictionary of the form:
|
||||
{'success': True/False,
|
||||
'html': Problem HTML string }
|
||||
|
||||
If an error occurs, the dictionary will also have an
|
||||
'error' key containing an error message.
|
||||
'''
|
||||
event_info = dict()
|
||||
event_info['old_state'] = self.lcp.get_state()
|
||||
@@ -686,6 +786,7 @@ class CapaModule(XModule):
|
||||
# reset random number generator seed (note the self.lcp.get_state()
|
||||
# in next line)
|
||||
self.lcp.seed = None
|
||||
|
||||
|
||||
self.lcp = LoncapaProblem(self.definition['data'],
|
||||
self.location.html_id(), self.lcp.get_state(),
|
||||
@@ -694,7 +795,8 @@ class CapaModule(XModule):
|
||||
event_info['new_state'] = self.lcp.get_state()
|
||||
self.system.track_function('reset_problem', event_info)
|
||||
|
||||
return {'html': self.get_problem_html(encapsulate=False)}
|
||||
return { 'success': True,
|
||||
'html': self.get_problem_html(encapsulate=False)}
|
||||
|
||||
|
||||
class CapaDescriptor(RawDescriptor):
|
||||
|
||||
@@ -35,7 +35,8 @@ class StaticContent(object):
|
||||
@staticmethod
|
||||
def compute_location(org, course, name, revision=None, is_thumbnail=False):
|
||||
name = name.replace('/', '_')
|
||||
return Location([XASSET_LOCATION_TAG, org, course, 'asset' if not is_thumbnail else 'thumbnail', Location.clean(name), revision])
|
||||
return Location([XASSET_LOCATION_TAG, org, course, 'asset' if not is_thumbnail else 'thumbnail',
|
||||
Location.clean_keeping_underscores(name), revision])
|
||||
|
||||
def get_id(self):
|
||||
return StaticContent.get_id_from_location(self.location)
|
||||
|
||||
@@ -127,6 +127,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
|
||||
# disable the syllabus content for courses that do not provide a syllabus
|
||||
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
|
||||
self._grading_policy = {}
|
||||
self.set_grading_policy(self.definition['data'].get('grading_policy', None))
|
||||
|
||||
self.test_center_exams = []
|
||||
@@ -196,11 +197,9 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
grading_policy.update(course_policy)
|
||||
|
||||
# Here is where we should parse any configurations, so that we can fail early
|
||||
grading_policy['RAW_GRADER'] = grading_policy['GRADER'] # used for cms access
|
||||
grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER'])
|
||||
self._grading_policy = grading_policy
|
||||
|
||||
|
||||
# Use setters so that side effecting to .definitions works
|
||||
self.raw_grader = grading_policy['GRADER'] # used for cms access
|
||||
self.grade_cutoffs = grading_policy['GRADE_CUTOFFS']
|
||||
|
||||
@classmethod
|
||||
def read_grading_policy(cls, paths, system):
|
||||
@@ -319,7 +318,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
@property
|
||||
def grader(self):
|
||||
return self._grading_policy['GRADER']
|
||||
return grader_from_conf(self.raw_grader)
|
||||
|
||||
@property
|
||||
def raw_grader(self):
|
||||
|
||||
169
common/lib/xmodule/xmodule/css/annotatable/display.scss
Normal file
169
common/lib/xmodule/xmodule/css/annotatable/display.scss
Normal file
@@ -0,0 +1,169 @@
|
||||
$border-color: #C8C8C8;
|
||||
$body-font-size: em(14);
|
||||
|
||||
.annotatable-header {
|
||||
margin-bottom: .5em;
|
||||
.annotatable-title {
|
||||
font-size: em(22);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.annotatable-section {
|
||||
position: relative;
|
||||
padding: .5em 1em;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: .5em;
|
||||
margin-bottom: .5em;
|
||||
|
||||
&.shaded { background-color: #EDEDED; }
|
||||
|
||||
.annotatable-section-title {
|
||||
font-weight: bold;
|
||||
a { font-weight: normal; }
|
||||
}
|
||||
.annotatable-section-body {
|
||||
border-top: 1px solid $border-color;
|
||||
margin-top: .5em;
|
||||
padding-top: .5em;
|
||||
@include clearfix;
|
||||
}
|
||||
|
||||
ul.instructions-template {
|
||||
list-style: disc;
|
||||
margin-left: 4em;
|
||||
b { font-weight: bold; }
|
||||
i { font-style: italic; }
|
||||
code {
|
||||
display: inline;
|
||||
white-space: pre;
|
||||
font-family: Courier New, monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.annotatable-toggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin: 2px 1em 2px 0;
|
||||
&.expanded:after { content: " \2191" }
|
||||
&.collapsed:after { content: " \2193" }
|
||||
}
|
||||
|
||||
.annotatable-span {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
|
||||
@each $highlight in (
|
||||
(yellow rgba(255,255,10,0.3) rgba(255,255,10,0.9)),
|
||||
(red rgba(178,19,16,0.3) rgba(178,19,16,0.9)),
|
||||
(orange rgba(255,165,0,0.3) rgba(255,165,0,0.9)),
|
||||
(green rgba(25,255,132,0.3) rgba(25,255,132,0.9)),
|
||||
(blue rgba(35,163,255,0.3) rgba(35,163,255,0.9)),
|
||||
(purple rgba(115,9,178,0.3) rgba(115,9,178,0.9))) {
|
||||
|
||||
$marker: nth($highlight,1);
|
||||
$color: nth($highlight,2);
|
||||
$selected_color: nth($highlight,3);
|
||||
|
||||
@if $marker == yellow {
|
||||
&.highlight {
|
||||
background-color: $color;
|
||||
&.selected { background-color: $selected_color; }
|
||||
}
|
||||
}
|
||||
&.highlight-#{$marker} {
|
||||
background-color: $color;
|
||||
&.selected { background-color: $selected_color; }
|
||||
}
|
||||
}
|
||||
|
||||
&.hide {
|
||||
cursor: none;
|
||||
background-color: inherit;
|
||||
.annotatable-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.annotatable-comment {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-tooltip.qtip.ui-tooltip {
|
||||
font-size: $body-font-size;
|
||||
border: 1px solid #333;
|
||||
border-radius: 1em;
|
||||
background-color: rgba(0,0,0,.85);
|
||||
color: #fff;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
.ui-tooltip-titlebar {
|
||||
font-size: em(16);
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
.ui-tooltip-title {
|
||||
padding: 5px 0px;
|
||||
border-bottom: 2px solid #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
.ui-tooltip-icon {
|
||||
right: 10px;
|
||||
background: #333;
|
||||
}
|
||||
.ui-state-hover {
|
||||
color: inherit;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
}
|
||||
.ui-tooltip-content {
|
||||
color: inherit;
|
||||
font-size: em(14);
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
padding: 0 10px 10px 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
p {
|
||||
color: inherit;
|
||||
line-height: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-tooltip.qtip.ui-tooltip-annotatable {
|
||||
max-width: 375px;
|
||||
.ui-tooltip-content {
|
||||
padding: 0 10px;
|
||||
.annotatable-comment {
|
||||
display: block;
|
||||
margin: 0px 0px 10px 0;
|
||||
max-height: 225px;
|
||||
overflow: auto;
|
||||
}
|
||||
.annotatable-reply {
|
||||
display: block;
|
||||
border-top: 2px solid #333;
|
||||
padding: 5px 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
&:after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
left: 50%;
|
||||
height: 0;
|
||||
width: 0;
|
||||
margin-left: -5px;
|
||||
border: 10px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, .85);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +240,15 @@ section.problem {
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
&.partially-correct {
|
||||
@include inline-block();
|
||||
background: url('../images/partially-correct-icon.png') center center no-repeat;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
&.incorrect, &.ui-icon-close {
|
||||
@include inline-block();
|
||||
background: url('../images/incorrect-icon.png') center center no-repeat;
|
||||
@@ -811,4 +820,91 @@ section.problem {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.annotation-input {
|
||||
$yellow: rgba(255,255,10,0.3);
|
||||
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 1em;
|
||||
margin: 0 0 1em 0;
|
||||
|
||||
.annotation-header {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding: .5em 1em;
|
||||
}
|
||||
.annotation-body { padding: .5em 1em; }
|
||||
a.annotation-return {
|
||||
float: right;
|
||||
font: inherit;
|
||||
font-weight: normal;
|
||||
}
|
||||
a.annotation-return:after { content: " \2191" }
|
||||
|
||||
.block, ul.tags {
|
||||
margin: .5em 0;
|
||||
padding: 0;
|
||||
}
|
||||
.block-highlight {
|
||||
padding: .5em;
|
||||
color: #333;
|
||||
font-style: normal;
|
||||
background-color: $yellow;
|
||||
border: 1px solid darken($yellow, 10%);
|
||||
}
|
||||
.block-comment { font-style: italic; }
|
||||
|
||||
ul.tags {
|
||||
display: block;
|
||||
list-style-type: none;
|
||||
margin-left: 1em;
|
||||
li {
|
||||
display: block;
|
||||
margin: 1em 0 0 0;
|
||||
position: relative;
|
||||
.tag {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgb(102,102,102);
|
||||
margin-left: 40px;
|
||||
&.selected {
|
||||
background-color: $yellow;
|
||||
}
|
||||
}
|
||||
.tag-status {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
.tag-status, .tag { padding: .25em .5em; }
|
||||
}
|
||||
}
|
||||
textarea.comment {
|
||||
$num-lines-to-show: 5;
|
||||
$line-height: 1.4em;
|
||||
$padding: .2em;
|
||||
width: 100%;
|
||||
padding: $padding (2 * $padding);
|
||||
line-height: $line-height;
|
||||
height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2);
|
||||
}
|
||||
.answer-annotation { display: block; margin: 0; }
|
||||
|
||||
/* for debugging the input value field. enable the debug flag on the inputtype */
|
||||
.debug-value {
|
||||
color: #fff;
|
||||
padding: 1em;
|
||||
margin: 1em 0;
|
||||
background-color: #999;
|
||||
border: 1px solid #000;
|
||||
input[type="text"] { width: 100%; }
|
||||
pre { background-color: #CCC; color: #000; }
|
||||
&:before {
|
||||
display: block;
|
||||
content: "debug input value";
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
common/lib/xmodule/xmodule/css/foldit/leaderboard.scss
Normal file
20
common/lib/xmodule/xmodule/css/foldit/leaderboard.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
$leaderboard: #F4F4F4;
|
||||
|
||||
section.foldit {
|
||||
div.folditchallenge {
|
||||
table {
|
||||
border: 1px solid lighten($leaderboard, 10%);
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th {
|
||||
background: $leaderboard;
|
||||
color: darken($leaderboard, 25%);
|
||||
}
|
||||
td {
|
||||
background: lighten($leaderboard, 3%);
|
||||
border-bottom: 1px solid #fff;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,27 @@ from xmodule.xml_module import XmlDescriptor
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class FolditModule(XModule):
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
# ooh look--I'm lazy, so hardcoding the 7.00x required level.
|
||||
# If we need it generalized, can pull from the xml later
|
||||
self.required_level = 4
|
||||
self.required_sublevel = 5
|
||||
"""
|
||||
|
||||
Example:
|
||||
<foldit show_basic_score="true"
|
||||
required_level="4"
|
||||
required_sublevel="3"
|
||||
show_leaderboard="false"/>
|
||||
"""
|
||||
req_level = self.metadata.get("required_level")
|
||||
req_sublevel = self.metadata.get("required_sublevel")
|
||||
|
||||
# default to what Spring_7012x uses
|
||||
self.required_level = req_level if req_level else 4
|
||||
self.required_sublevel = req_sublevel if req_sublevel else 5
|
||||
|
||||
def parse_due_date():
|
||||
"""
|
||||
@@ -66,6 +79,14 @@ class FolditModule(XModule):
|
||||
PuzzleComplete.completed_puzzles(self.system.anonymous_student_id),
|
||||
key=lambda d: (d['set'], d['subset']))
|
||||
|
||||
def puzzle_leaders(self, n=10):
|
||||
"""
|
||||
Returns a list of n pairs (user, score) corresponding to the top
|
||||
scores; the pairs are in descending order of score.
|
||||
"""
|
||||
from foldit.models import Score
|
||||
|
||||
return [(e['username'], e['score']) for e in Score.get_tops_n(10)]
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
@@ -75,15 +96,48 @@ class FolditModule(XModule):
|
||||
self.required_level,
|
||||
self.required_sublevel)
|
||||
|
||||
showbasic = (self.metadata.get("show_basic_score", "").lower() == "true")
|
||||
showleader = (self.metadata.get("show_leaderboard", "").lower() == "true")
|
||||
|
||||
context = {
|
||||
'due': self.due_str,
|
||||
'success': self.is_complete(),
|
||||
'goal_level': goal_level,
|
||||
'completed': self.completed_puzzles(),
|
||||
'top_scores': self.puzzle_leaders(),
|
||||
'show_basic': showbasic,
|
||||
'show_leader': showleader,
|
||||
'folditbasic': self.get_basicpuzzles_html(),
|
||||
'folditchallenge': self.get_challenge_html()
|
||||
}
|
||||
|
||||
return self.system.render_template('foldit.html', context)
|
||||
|
||||
def get_basicpuzzles_html(self):
|
||||
"""
|
||||
Render html for the basic puzzle section.
|
||||
"""
|
||||
goal_level = '{0}-{1}'.format(
|
||||
self.required_level,
|
||||
self.required_sublevel)
|
||||
|
||||
context = {
|
||||
'due': self.due_str,
|
||||
'success': self.is_complete(),
|
||||
'goal_level': goal_level,
|
||||
'completed': self.completed_puzzles(),
|
||||
}
|
||||
return self.system.render_template('folditbasic.html', context)
|
||||
|
||||
return self.system.render_template('foldit.html', context)
|
||||
def get_challenge_html(self):
|
||||
"""
|
||||
Render html for challenge (i.e., the leaderboard)
|
||||
"""
|
||||
|
||||
context = {
|
||||
'top_scores': self.puzzle_leaders()}
|
||||
|
||||
return self.system.render_template('folditchallenge.html', context)
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
@@ -97,9 +151,10 @@ class FolditModule(XModule):
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
class FolditDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding open ended response questions to courses
|
||||
Module for adding Foldit problems to courses
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = FolditModule
|
||||
@@ -119,6 +174,6 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
For now, don't need anything from the xml
|
||||
Get the xml_object's attributes.
|
||||
"""
|
||||
return {}
|
||||
return {'metadata': xml_object.attrib}
|
||||
|
||||
35
common/lib/xmodule/xmodule/js/fixtures/annotatable.html
Normal file
35
common/lib/xmodule/xmodule/js/fixtures/annotatable.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<section class='xmodule_display xmodule_AnnotatableModule' data-type='Annotatable'>
|
||||
<div class="annotatable-wrapper">
|
||||
<div class="annotatable-header">
|
||||
<div class="annotatable-title">First Annotation Exercise</div>
|
||||
</div>
|
||||
<div class="annotatable-section">
|
||||
<div class="annotatable-section-title">
|
||||
Instructions
|
||||
<a class="annotatable-toggle annotatable-toggle-instructions expanded" href="javascript:void(0)">Collapse Instructions</a>
|
||||
</div>
|
||||
<div class="annotatable-section-body annotatable-instructions">
|
||||
<div><p>The main goal of this exercise is to start practicing the art of slow reading.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotatable-section">
|
||||
<div class="annotatable-section-title">
|
||||
Guided Discussion
|
||||
<a class="annotatable-toggle annotatable-toggle-annotations" href="javascript:void(0)">Hide Annotations</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotatable-content">
|
||||
|87 No, those who are really responsible are Zeus and Fate [Moira] and the Fury [Erinys] who roams in the mist. <br/>
|
||||
|88 <span data-problem-id="0" data-comment-body="Agamemnon says..." class="annotatable-span highlight" data-comment-title="Your Title Here">They are the ones who</span><br/>
|
||||
|100 He [= Zeus], making a formal declaration [eukhesthai], spoke up at a meeting of all the gods and said: <br/>
|
||||
|101 <span data-problem-id="1" data-comment-body="When Zeus speaks..." class="annotatable-span highlight">“hear me, all gods and all goddesses,</span><br/>
|
||||
|113 but he swore a great oath.
|
||||
<span data-problem-id="2" data-comment-body="How is the ‘veering off-course’ ..." class="annotatable-span highlight">And right then and there</span><br/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
|
||||
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
|
||||
<section class="problem"><a class="annotation-return" href="javascript:void(0)">Return to Annotation</a></section>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
describe 'Annotatable', ->
|
||||
beforeEach ->
|
||||
loadFixtures 'annotatable.html'
|
||||
describe 'constructor', ->
|
||||
el = $('.xmodule_display.xmodule_AnnotatableModule')
|
||||
beforeEach ->
|
||||
@annotatable = new Annotatable(el)
|
||||
it 'works', ->
|
||||
expect(1).toBe(1)
|
||||
197
common/lib/xmodule/xmodule/js/src/annotatable/display.coffee
Normal file
197
common/lib/xmodule/xmodule/js/src/annotatable/display.coffee
Normal file
@@ -0,0 +1,197 @@
|
||||
class @Annotatable
|
||||
_debug: false
|
||||
|
||||
# selectors for the annotatable xmodule
|
||||
toggleAnnotationsSelector: '.annotatable-toggle-annotations'
|
||||
toggleInstructionsSelector: '.annotatable-toggle-instructions'
|
||||
instructionsSelector: '.annotatable-instructions'
|
||||
sectionSelector: '.annotatable-section'
|
||||
spanSelector: '.annotatable-span'
|
||||
replySelector: '.annotatable-reply'
|
||||
|
||||
# these selectors are for responding to events from the annotation capa problem type
|
||||
problemXModuleSelector: '.xmodule_CapaModule'
|
||||
problemSelector: 'section.problem'
|
||||
problemInputSelector: 'section.problem .annotation-input'
|
||||
problemReturnSelector: 'section.problem .annotation-return'
|
||||
|
||||
constructor: (el) ->
|
||||
console.log 'loaded Annotatable' if @_debug
|
||||
@el = el
|
||||
@$el = $(el)
|
||||
@init()
|
||||
|
||||
$: (selector) ->
|
||||
$(selector, @el)
|
||||
|
||||
init: () ->
|
||||
@initEvents()
|
||||
@initTips()
|
||||
|
||||
initEvents: () ->
|
||||
# Initialize toggle handlers for the instructions and annotations sections
|
||||
[@annotationsHidden, @instructionsHidden] = [false, false]
|
||||
@$(@toggleAnnotationsSelector).bind 'click', @onClickToggleAnnotations
|
||||
@$(@toggleInstructionsSelector).bind 'click', @onClickToggleInstructions
|
||||
|
||||
# Initialize handler for 'reply to annotation' events that scroll to
|
||||
# the associated problem. The reply buttons are part of the tooltip
|
||||
# content. It's important that the tooltips be configured to render
|
||||
# as descendants of the annotation module and *not* the document.body.
|
||||
@$el.delegate @replySelector, 'click', @onClickReply
|
||||
|
||||
# Initialize handler for 'return to annotation' events triggered from problems.
|
||||
# 1) There are annotationinput capa problems rendered on the page
|
||||
# 2) Each one has an embedded return link (see annotation capa problem template).
|
||||
# Since the capa problem injects HTML content via AJAX, the best we can do is
|
||||
# is let the click events bubble up to the body and handle them there.
|
||||
$('body').delegate @problemReturnSelector, 'click', @onClickReturn
|
||||
|
||||
initTips: () ->
|
||||
# tooltips are used to display annotations for highlighted text spans
|
||||
@$(@spanSelector).each (index, el) =>
|
||||
$(el).qtip(@getSpanTipOptions el)
|
||||
|
||||
getSpanTipOptions: (el) ->
|
||||
content:
|
||||
title:
|
||||
text: @makeTipTitle(el)
|
||||
text: @makeTipContent(el)
|
||||
position:
|
||||
my: 'bottom center' # of tooltip
|
||||
at: 'top center' # of target
|
||||
target: $(el) # where the tooltip was triggered (i.e. the annotation span)
|
||||
container: @$el
|
||||
adjust:
|
||||
y: -5
|
||||
show:
|
||||
event: 'click mouseenter'
|
||||
solo: true
|
||||
hide:
|
||||
event: 'click mouseleave'
|
||||
delay: 500,
|
||||
fixed: true # don't hide the tooltip if it is moused over
|
||||
style:
|
||||
classes: 'ui-tooltip-annotatable'
|
||||
events:
|
||||
show: @onShowTip
|
||||
|
||||
onClickToggleAnnotations: (e) => @toggleAnnotations()
|
||||
|
||||
onClickToggleInstructions: (e) => @toggleInstructions()
|
||||
|
||||
onClickReply: (e) => @replyTo(e.currentTarget)
|
||||
|
||||
onClickReturn: (e) => @returnFrom(e.currentTarget)
|
||||
|
||||
onShowTip: (event, api) =>
|
||||
event.preventDefault() if @annotationsHidden
|
||||
|
||||
getSpanForProblemReturn: (el) ->
|
||||
problem_id = $(@problemReturnSelector).index(el)
|
||||
@$(@spanSelector).filter("[data-problem-id='#{problem_id}']")
|
||||
|
||||
getProblem: (el) ->
|
||||
problem_id = @getProblemId(el)
|
||||
$(@problemSelector).has(@problemInputSelector).eq(problem_id)
|
||||
|
||||
getProblemId: (el) ->
|
||||
$(el).data('problem-id')
|
||||
|
||||
toggleAnnotations: () ->
|
||||
hide = (@annotationsHidden = not @annotationsHidden)
|
||||
@toggleAnnotationButtonText hide
|
||||
@toggleSpans hide
|
||||
@toggleTips hide
|
||||
|
||||
toggleTips: (hide) ->
|
||||
visible = @findVisibleTips()
|
||||
@hideTips visible
|
||||
|
||||
toggleAnnotationButtonText: (hide) ->
|
||||
buttonText = (if hide then 'Show' else 'Hide')+' Annotations'
|
||||
@$(@toggleAnnotationsSelector).text(buttonText)
|
||||
|
||||
toggleInstructions: () ->
|
||||
hide = (@instructionsHidden = not @instructionsHidden)
|
||||
@toggleInstructionsButton hide
|
||||
@toggleInstructionsText hide
|
||||
|
||||
toggleInstructionsButton: (hide) ->
|
||||
txt = (if hide then 'Expand' else 'Collapse')+' Instructions'
|
||||
cls = (if hide then ['expanded', 'collapsed'] else ['collapsed','expanded'])
|
||||
@$(@toggleInstructionsSelector).text(txt).removeClass(cls[0]).addClass(cls[1])
|
||||
|
||||
toggleInstructionsText: (hide) ->
|
||||
slideMethod = (if hide then 'slideUp' else 'slideDown')
|
||||
@$(@instructionsSelector)[slideMethod]()
|
||||
|
||||
toggleSpans: (hide) ->
|
||||
@$(@spanSelector).toggleClass 'hide', hide, 250
|
||||
|
||||
replyTo: (buttonEl) ->
|
||||
offset = -20
|
||||
el = @getProblem buttonEl
|
||||
if el.length > 0
|
||||
@scrollTo(el, @afterScrollToProblem, offset)
|
||||
else
|
||||
console.log('problem not found. event: ', e) if @_debug
|
||||
|
||||
returnFrom: (buttonEl) ->
|
||||
offset = -200
|
||||
el = @getSpanForProblemReturn buttonEl
|
||||
if el.length > 0
|
||||
@scrollTo(el, @afterScrollToSpan, offset)
|
||||
else
|
||||
console.log('span not found. event:', e) if @_debug
|
||||
|
||||
scrollTo: (el, after, offset = -20) ->
|
||||
$('html,body').scrollTo(el, {
|
||||
duration: 500
|
||||
onAfter: @_once => after?.call this, el
|
||||
offset: offset
|
||||
}) if $(el).length > 0
|
||||
|
||||
afterScrollToProblem: (problem_el) ->
|
||||
problem_el.effect 'highlight', {}, 500
|
||||
|
||||
afterScrollToSpan: (span_el) ->
|
||||
span_el.addClass 'selected', 400, 'swing', ->
|
||||
span_el.removeClass 'selected', 400, 'swing'
|
||||
|
||||
makeTipContent: (el) ->
|
||||
(api) =>
|
||||
text = $(el).data('comment-body')
|
||||
comment = @createComment(text)
|
||||
problem_id = @getProblemId(el)
|
||||
reply = @createReplyLink(problem_id)
|
||||
$(comment).add(reply)
|
||||
|
||||
makeTipTitle: (el) ->
|
||||
(api) =>
|
||||
title = $(el).data('comment-title')
|
||||
(if title then title else 'Commentary')
|
||||
|
||||
createComment: (text) ->
|
||||
$("<div class=\"annotatable-comment\">#{text}</div>")
|
||||
|
||||
createReplyLink: (problem_id) ->
|
||||
$("<a class=\"annotatable-reply\" href=\"javascript:void(0);\" data-problem-id=\"#{problem_id}\">Reply to Annotation</a>")
|
||||
|
||||
findVisibleTips: () ->
|
||||
visible = []
|
||||
@$(@spanSelector).each (index, el) ->
|
||||
api = $(el).qtip('api')
|
||||
tip = $(api?.elements.tooltip)
|
||||
if tip.is(':visible')
|
||||
visible.push el
|
||||
visible
|
||||
|
||||
hideTips: (elements) ->
|
||||
$(elements).qtip('hide')
|
||||
|
||||
_once: (fn) ->
|
||||
done = false
|
||||
return =>
|
||||
fn.call this unless done
|
||||
done = true
|
||||
@@ -76,6 +76,24 @@ class @Problem
|
||||
# TODO: Some logic to dynamically adjust polling rate based on queuelen
|
||||
window.queuePollerID = window.setTimeout(@poll, 1000)
|
||||
|
||||
|
||||
# Use this if you want to make an ajax call on the input type object
|
||||
# static method so you don't have to instantiate a Problem in order to use it
|
||||
# Input:
|
||||
# url: the AJAX url of the problem
|
||||
# input_id: the input_id of the input you would like to make the call on
|
||||
# NOTE: the id is the ${id} part of "input_${id}" during rendering
|
||||
# If this function is passed the entire prefixed id, the backend may have trouble
|
||||
# finding the correct input
|
||||
# dispatch: string that indicates how this data should be handled by the inputtype
|
||||
# callback: the function that will be called once the AJAX call has been completed.
|
||||
# It will be passed a response object
|
||||
@inputAjax: (url, input_id, dispatch, data, callback) ->
|
||||
data['dispatch'] = dispatch
|
||||
data['input_id'] = input_id
|
||||
$.postWithPrefix "#{url}/input_ajax", data, callback
|
||||
|
||||
|
||||
render: (content) ->
|
||||
if content
|
||||
@el.html(content)
|
||||
|
||||
@@ -71,6 +71,17 @@ class Location(_LocationBase):
|
||||
"""
|
||||
return Location._clean(value, INVALID_CHARS)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def clean_keeping_underscores(value):
|
||||
"""
|
||||
Return value, replacing INVALID_CHARS, but not collapsing multiple '_' chars.
|
||||
This for cleaning asset names, as the YouTube ID's may have underscores in them, and we need the
|
||||
transcript asset name to match. In the future we may want to change the behavior of _clean.
|
||||
"""
|
||||
return INVALID_CHARS.sub('_', value)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def clean_for_url_name(value):
|
||||
"""
|
||||
|
||||
@@ -64,7 +64,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
|
||||
location = Location(location)
|
||||
json_data = self.module_data.get(location)
|
||||
if json_data is None:
|
||||
return self.modulestore.get_item(location)
|
||||
module = self.modulestore.get_item(location)
|
||||
if module is not None:
|
||||
# update our own cache after going to the DB to get cache miss
|
||||
self.module_data.update(module.system.module_data)
|
||||
return module
|
||||
else:
|
||||
# load the module and apply the inherited metadata
|
||||
try:
|
||||
|
||||
@@ -5,132 +5,132 @@ from xmodule.modulestore.mongo import MongoModuleStore
|
||||
|
||||
|
||||
def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False):
|
||||
# first check to see if the modulestore is Mongo backed
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||
# first check to see if the modulestore is Mongo backed
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||
|
||||
# check to see if the dest_location exists as an empty course
|
||||
# we need an empty course because the app layers manage the permissions and users
|
||||
if not modulestore.has_item(dest_location):
|
||||
raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location))
|
||||
# check to see if the dest_location exists as an empty course
|
||||
# we need an empty course because the app layers manage the permissions and users
|
||||
if not modulestore.has_item(dest_location):
|
||||
raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location))
|
||||
|
||||
# verify that the dest_location really is an empty course, which means only one
|
||||
dest_modules = modulestore.get_items([dest_location.tag, dest_location.org, dest_location.course, None, None, None])
|
||||
# verify that the dest_location really is an empty course, which means only one
|
||||
dest_modules = modulestore.get_items([dest_location.tag, dest_location.org, dest_location.course, None, None, None])
|
||||
|
||||
if len(dest_modules) != 1:
|
||||
raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location))
|
||||
if len(dest_modules) != 1:
|
||||
raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location))
|
||||
|
||||
# check to see if the source course is actually there
|
||||
if not modulestore.has_item(source_location):
|
||||
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||
# check to see if the source course is actually there
|
||||
if not modulestore.has_item(source_location):
|
||||
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||
|
||||
# Get all modules under this namespace which is (tag, org, course) tuple
|
||||
# Get all modules under this namespace which is (tag, org, course) tuple
|
||||
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
|
||||
for module in modules:
|
||||
original_loc = Location(module.location)
|
||||
for module in modules:
|
||||
original_loc = Location(module.location)
|
||||
|
||||
if original_loc.category != 'course':
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
else:
|
||||
# on the course module we also have to update the module name
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course, name=dest_location.name)
|
||||
if original_loc.category != 'course':
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
else:
|
||||
# on the course module we also have to update the module name
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course, name=dest_location.name)
|
||||
|
||||
print "Cloning module {0} to {1}....".format(original_loc, module.location)
|
||||
print "Cloning module {0} to {1}....".format(original_loc, module.location)
|
||||
|
||||
if 'data' in module.definition:
|
||||
modulestore.update_item(module.location, module.definition['data'])
|
||||
if 'data' in module.definition:
|
||||
modulestore.update_item(module.location, module.definition['data'])
|
||||
|
||||
# repoint children
|
||||
if 'children' in module.definition:
|
||||
new_children = []
|
||||
for child_loc_url in module.definition['children']:
|
||||
child_loc = Location(child_loc_url)
|
||||
child_loc = child_loc._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
new_children = new_children + [child_loc.url()]
|
||||
# repoint children
|
||||
if 'children' in module.definition:
|
||||
new_children = []
|
||||
for child_loc_url in module.definition['children']:
|
||||
child_loc = Location(child_loc_url)
|
||||
child_loc = child_loc._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
new_children = new_children + [child_loc.url()]
|
||||
|
||||
modulestore.update_children(module.location, new_children)
|
||||
modulestore.update_children(module.location, new_children)
|
||||
|
||||
# save metadata
|
||||
modulestore.update_metadata(module.location, module.metadata)
|
||||
# save metadata
|
||||
modulestore.update_metadata(module.location, module.metadata)
|
||||
|
||||
# now iterate through all of the assets and clone them
|
||||
# first the thumbnails
|
||||
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
content = contentstore.find(thumb_loc)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
# now iterate through all of the assets and clone them
|
||||
# first the thumbnails
|
||||
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
content = contentstore.find(thumb_loc)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
print "Cloning thumbnail {0} to {1}".format(thumb_loc, content.location)
|
||||
print "Cloning thumbnail {0} to {1}".format(thumb_loc, content.location)
|
||||
|
||||
contentstore.save(content)
|
||||
contentstore.save(content)
|
||||
|
||||
# now iterate through all of the assets, also updating the thumbnail pointer
|
||||
# now iterate through all of the assets, also updating the thumbnail pointer
|
||||
|
||||
assets = contentstore.get_all_content_for_course(source_location)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
content = contentstore.find(asset_loc)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
assets = contentstore.get_all_content_for_course(source_location)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
content = contentstore.find(asset_loc)
|
||||
content.location = content.location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
# be sure to update the pointer to the thumbnail
|
||||
if content.thumbnail_location is not None:
|
||||
content.thumbnail_location = content.thumbnail_location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
# be sure to update the pointer to the thumbnail
|
||||
if content.thumbnail_location is not None:
|
||||
content.thumbnail_location = content.thumbnail_location._replace(org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
|
||||
print "Cloning asset {0} to {1}".format(asset_loc, content.location)
|
||||
print "Cloning asset {0} to {1}".format(asset_loc, content.location)
|
||||
|
||||
contentstore.save(content)
|
||||
contentstore.save(content)
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def delete_course(modulestore, contentstore, source_location, commit = False):
|
||||
# first check to see if the modulestore is Mongo backed
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
|
||||
|
||||
# check to see if the source course is actually there
|
||||
if not modulestore.has_item(source_location):
|
||||
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||
# check to see if the source course is actually there
|
||||
if not modulestore.has_item(source_location):
|
||||
raise Exception("Cannot find a course at {0}. Aborting".format(source_location))
|
||||
|
||||
# first delete all of the thumbnails
|
||||
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
id = StaticContent.get_id_from_location(thumb_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
# first delete all of the thumbnails
|
||||
thumbs = contentstore.get_all_content_thumbnails_for_course(source_location)
|
||||
for thumb in thumbs:
|
||||
thumb_loc = Location(thumb["_id"])
|
||||
id = StaticContent.get_id_from_location(thumb_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
if commit:
|
||||
contentstore.delete(id)
|
||||
|
||||
# then delete all of the assets
|
||||
assets = contentstore.get_all_content_for_course(source_location)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
id = StaticContent.get_id_from_location(asset_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
if commit:
|
||||
contentstore.delete(id)
|
||||
|
||||
# then delete all course modules
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
|
||||
for module in modules:
|
||||
if module.category != 'course': # save deleting the course module for last
|
||||
print "Deleting {0}...".format(module.location)
|
||||
if commit:
|
||||
modulestore.delete_item(module.location)
|
||||
|
||||
# finally delete the top-level course module itself
|
||||
print "Deleting {0}...".format(source_location)
|
||||
if commit:
|
||||
contentstore.delete(id)
|
||||
modulestore.delete_item(source_location)
|
||||
|
||||
# then delete all of the assets
|
||||
assets = contentstore.get_all_content_for_course(source_location)
|
||||
for asset in assets:
|
||||
asset_loc = Location(asset["_id"])
|
||||
id = StaticContent.get_id_from_location(asset_loc)
|
||||
print "Deleting {0}...".format(id)
|
||||
if commit:
|
||||
contentstore.delete(id)
|
||||
|
||||
# then delete all course modules
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
|
||||
for module in modules:
|
||||
if module.category != 'course': # save deleting the course module for last
|
||||
print "Deleting {0}...".format(module.location)
|
||||
if commit:
|
||||
modulestore.delete_item(module.location)
|
||||
|
||||
# finally delete the top-level course module itself
|
||||
print "Deleting {0}...".format(source_location)
|
||||
if commit:
|
||||
modulestore.delete_item(source_location)
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
@@ -7,47 +7,47 @@ from json import dumps
|
||||
|
||||
def export_to_xml(modulestore, contentstore, course_location, root_dir, course_dir):
|
||||
|
||||
course = modulestore.get_item(course_location)
|
||||
course = modulestore.get_item(course_location)
|
||||
|
||||
fs = OSFS(root_dir)
|
||||
export_fs = fs.makeopendir(course_dir)
|
||||
fs = OSFS(root_dir)
|
||||
export_fs = fs.makeopendir(course_dir)
|
||||
|
||||
xml = course.export_to_xml(export_fs)
|
||||
with export_fs.open('course.xml', 'w') as course_xml:
|
||||
course_xml.write(xml)
|
||||
xml = course.export_to_xml(export_fs)
|
||||
with export_fs.open('course.xml', 'w') as course_xml:
|
||||
course_xml.write(xml)
|
||||
|
||||
# export the static assets
|
||||
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
|
||||
# export the static assets
|
||||
contentstore.export_all_for_course(course_location, root_dir + '/' + course_dir + '/static/')
|
||||
|
||||
# export the static tabs
|
||||
export_extra_content(export_fs, modulestore, course_location, 'static_tab', 'tabs', '.html')
|
||||
# export the static tabs
|
||||
export_extra_content(export_fs, modulestore, course_location, 'static_tab', 'tabs', '.html')
|
||||
|
||||
# export the custom tags
|
||||
export_extra_content(export_fs, modulestore, course_location, 'custom_tag_template', 'custom_tags')
|
||||
# export the custom tags
|
||||
export_extra_content(export_fs, modulestore, course_location, 'custom_tag_template', 'custom_tags')
|
||||
|
||||
# export the course updates
|
||||
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
|
||||
# export the course updates
|
||||
export_extra_content(export_fs, modulestore, course_location, 'course_info', 'info', '.html')
|
||||
|
||||
# export the grading policy
|
||||
policies_dir = export_fs.makeopendir('policies')
|
||||
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
|
||||
if 'grading_policy' in course.definition['data']:
|
||||
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
|
||||
grading_policy.write(dumps(course.definition['data']['grading_policy']))
|
||||
# export the grading policy
|
||||
policies_dir = export_fs.makeopendir('policies')
|
||||
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
|
||||
if 'grading_policy' in course.definition['data']:
|
||||
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
|
||||
grading_policy.write(dumps(course.definition['data']['grading_policy']))
|
||||
|
||||
# export all of the course metadata in policy.json
|
||||
with course_run_policy_dir.open('policy.json', 'w') as course_policy:
|
||||
policy = {}
|
||||
policy = {'course/' + course.location.name: course.metadata}
|
||||
course_policy.write(dumps(policy))
|
||||
# export all of the course metadata in policy.json
|
||||
with course_run_policy_dir.open('policy.json', 'w') as course_policy:
|
||||
policy = {}
|
||||
policy = {'course/' + course.location.name: course.metadata}
|
||||
course_policy.write(dumps(policy))
|
||||
|
||||
|
||||
def export_extra_content(export_fs, modulestore, course_location, category_type, dirname, file_suffix=''):
|
||||
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
|
||||
items = modulestore.get_items(query_loc)
|
||||
query_loc = Location('i4x', course_location.org, course_location.course, category_type, None)
|
||||
items = modulestore.get_items(query_loc)
|
||||
|
||||
if len(items) > 0:
|
||||
item_dir = export_fs.makeopendir(dirname)
|
||||
for item in items:
|
||||
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
|
||||
item_file.write(item.definition['data'].encode('utf8'))
|
||||
if len(items) > 0:
|
||||
item_dir = export_fs.makeopendir(dirname)
|
||||
for item in items:
|
||||
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
|
||||
item_file.write(item.definition['data'].encode('utf8'))
|
||||
|
||||
@@ -79,6 +79,9 @@ class CombinedOpenEndedV1Module():
|
||||
INTERMEDIATE_DONE = 'intermediate_done'
|
||||
DONE = 'done'
|
||||
|
||||
#Where the templates live for this problem
|
||||
TEMPLATE_DIR = "combinedopenended"
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs):
|
||||
|
||||
@@ -343,7 +346,7 @@ class CombinedOpenEndedV1Module():
|
||||
Output: rendered html
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
def get_html_nonsystem(self):
|
||||
@@ -354,7 +357,7 @@ class CombinedOpenEndedV1Module():
|
||||
Output: HTML rendered directly via Mako
|
||||
"""
|
||||
context = self.get_context()
|
||||
html = self.system.render_template('combined_open_ended.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
def get_html_base(self):
|
||||
@@ -531,7 +534,7 @@ class CombinedOpenEndedV1Module():
|
||||
'task_name' : 'Scored Rubric',
|
||||
'class_name' : 'combined-rubric-container'
|
||||
}
|
||||
html = self.system.render_template('combined_open_ended_results.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context)
|
||||
return {'html': html, 'success': True}
|
||||
|
||||
def get_legend(self, get):
|
||||
@@ -543,7 +546,7 @@ class CombinedOpenEndedV1Module():
|
||||
context = {
|
||||
'legend_list' : LEGEND_LIST,
|
||||
}
|
||||
html = self.system.render_template('combined_open_ended_legend.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended_legend.html'.format(self.TEMPLATE_DIR), context)
|
||||
return {'html': html, 'success': True}
|
||||
|
||||
def get_results(self, get):
|
||||
@@ -574,7 +577,7 @@ class CombinedOpenEndedV1Module():
|
||||
'submission_id' : ri['submission_ids'][i],
|
||||
}
|
||||
context_list.append(context)
|
||||
feedback_table = self.system.render_template('open_ended_result_table.html', {
|
||||
feedback_table = self.system.render_template('{0}/open_ended_result_table.html'.format(self.TEMPLATE_DIR), {
|
||||
'context_list' : context_list,
|
||||
'grader_type_image_dict' : GRADER_TYPE_IMAGE_DICT,
|
||||
'human_grader_types' : HUMAN_GRADER_TYPE,
|
||||
@@ -586,7 +589,7 @@ class CombinedOpenEndedV1Module():
|
||||
'task_name' : "Feedback",
|
||||
'class_name' : "result-container",
|
||||
}
|
||||
html = self.system.render_template('combined_open_ended_results.html', context)
|
||||
html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context)
|
||||
return {'html': html, 'success': True}
|
||||
|
||||
def get_status_ajax(self, get):
|
||||
@@ -700,7 +703,7 @@ class CombinedOpenEndedV1Module():
|
||||
'legend_list' : LEGEND_LIST,
|
||||
'render_via_ajax' : render_via_ajax,
|
||||
}
|
||||
status_html = self.system.render_template("combined_open_ended_status.html", context)
|
||||
status_html = self.system.render_template("{0}/combined_open_ended_status.html".format(self.TEMPLATE_DIR), context)
|
||||
|
||||
return status_html
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ class RubricParsingError(Exception):
|
||||
|
||||
class CombinedOpenEndedRubric(object):
|
||||
|
||||
TEMPLATE_DIR = "combinedopenended/openended"
|
||||
|
||||
def __init__ (self, system, view_only = False):
|
||||
self.has_score = False
|
||||
self.view_only = view_only
|
||||
@@ -57,9 +59,9 @@ class CombinedOpenEndedRubric(object):
|
||||
rubric_scores = [cat['score'] for cat in rubric_categories]
|
||||
max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories)
|
||||
max_score = max(max_scores)
|
||||
rubric_template = 'open_ended_rubric.html'
|
||||
rubric_template = '{0}/open_ended_rubric.html'.format(self.TEMPLATE_DIR)
|
||||
if self.view_only:
|
||||
rubric_template = 'open_ended_view_only_rubric.html'
|
||||
rubric_template = '{0}/open_ended_view_only_rubric.html'.format(self.TEMPLATE_DIR)
|
||||
html = self.system.render_template(rubric_template,
|
||||
{'categories': rubric_categories,
|
||||
'has_score': self.has_score,
|
||||
@@ -207,7 +209,7 @@ class CombinedOpenEndedRubric(object):
|
||||
for grader_type in tuple[3]:
|
||||
rubric_categories[i]['options'][j]['grader_types'].append(grader_type)
|
||||
|
||||
html = self.system.render_template('open_ended_combined_rubric.html',
|
||||
html = self.system.render_template('{0}/open_ended_combined_rubric.html'.format(self.TEMPLATE_DIR),
|
||||
{'categories': rubric_categories,
|
||||
'has_score': True,
|
||||
'view_only': True,
|
||||
|
||||
@@ -40,6 +40,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
</openended>
|
||||
"""
|
||||
|
||||
TEMPLATE_DIR = "combinedopenended/openended"
|
||||
|
||||
def setup_response(self, system, location, definition, descriptor):
|
||||
"""
|
||||
Sets up the response type.
|
||||
@@ -397,10 +399,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
rubric_scores = rubric_dict['rubric_scores']
|
||||
|
||||
if not response_items['success']:
|
||||
return system.render_template("open_ended_error.html",
|
||||
return system.render_template("{0}/open_ended_error.html".format(self.TEMPLATE_DIR),
|
||||
{'errors': feedback})
|
||||
|
||||
feedback_template = system.render_template("open_ended_feedback.html", {
|
||||
feedback_template = system.render_template("{0}/open_ended_feedback.html".format(self.TEMPLATE_DIR), {
|
||||
'grader_type': response_items['grader_type'],
|
||||
'score': "{0} / {1}".format(response_items['score'], self.max_score()),
|
||||
'feedback': feedback,
|
||||
@@ -558,7 +560,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
@return: Rendered html
|
||||
"""
|
||||
context = {'msg': feedback, 'id': "1", 'rows': 50, 'cols': 50}
|
||||
html = system.render_template('open_ended_evaluation.html', context)
|
||||
html = system.render_template('{0}/open_ended_evaluation.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
def handle_ajax(self, dispatch, get, system):
|
||||
@@ -692,7 +694,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
'eta_message' : eta_string,
|
||||
}
|
||||
html = system.render_template('open_ended.html', context)
|
||||
html = system.render_template('{0}/open_ended.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
</selfassessment>
|
||||
"""
|
||||
|
||||
TEMPLATE_DIR = "combinedopenended/selfassessment"
|
||||
|
||||
def setup_response(self, system, location, definition, descriptor):
|
||||
"""
|
||||
Sets up the module
|
||||
@@ -68,7 +70,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
'accept_file_upload': self.accept_file_upload,
|
||||
}
|
||||
|
||||
html = system.render_template('self_assessment_prompt.html', context)
|
||||
html = system.render_template('{0}/self_assessment_prompt.html'.format(self.TEMPLATE_DIR), context)
|
||||
return html
|
||||
|
||||
|
||||
@@ -129,7 +131,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
#This is a dev_facing_error
|
||||
raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.state))
|
||||
|
||||
return system.render_template('self_assessment_rubric.html', context)
|
||||
return system.render_template('{0}/self_assessment_rubric.html'.format(self.TEMPLATE_DIR), context)
|
||||
|
||||
def get_hint_html(self, system):
|
||||
"""
|
||||
@@ -155,7 +157,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
|
||||
#This is a dev_facing_error
|
||||
raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.state))
|
||||
|
||||
return system.render_template('self_assessment_hint.html', context)
|
||||
return system.render_template('{0}/self_assessment_hint.html'.format(self.TEMPLATE_DIR), context)
|
||||
|
||||
|
||||
def save_answer(self, get, system):
|
||||
|
||||
@@ -471,6 +471,9 @@ class PeerGradingModule(XModule):
|
||||
#This is a student_facing_error
|
||||
error_text = "Could not get list of problems to peer grade. Please notify course staff."
|
||||
success = False
|
||||
except:
|
||||
log.exception("Could not contact peer grading service.")
|
||||
success = False
|
||||
|
||||
|
||||
def _find_corresponding_module_for_location(location):
|
||||
@@ -524,7 +527,7 @@ class PeerGradingModule(XModule):
|
||||
'''
|
||||
Show individual problem interface
|
||||
'''
|
||||
if get == None or get.get('location') == None:
|
||||
if get is None or get.get('location') is None:
|
||||
if not self.use_for_single_location:
|
||||
#This is an error case, because it must be set to use a single location to be called without get parameters
|
||||
#This is a dev_facing_error
|
||||
@@ -589,7 +592,6 @@ class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
'task_xml': dictionary of xml strings,
|
||||
}
|
||||
"""
|
||||
log.debug("In definition")
|
||||
expected_children = []
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) == 0:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: 'Annotation'
|
||||
data: |
|
||||
<annotatable>
|
||||
<instructions>
|
||||
<p>Enter your (optional) instructions for the exercise in HTML format.</p>
|
||||
<p>Annotations are specified by an <code><annotation></code> tag which may may have the following attributes:</p>
|
||||
<ul class="instructions-template">
|
||||
<li><code>title</code> (optional). Title of the annotation. Defaults to <i>Commentary</i> if omitted.</li>
|
||||
<li><code>body</code> (<b>required</b>). Text of the annotation.</li>
|
||||
<li><code>problem</code> (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have <code>problem="0"</code>.</li>
|
||||
<li><code>highlight</code> (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.</li>
|
||||
</ul>
|
||||
</instructions>
|
||||
<p>Add your HTML with annotation spans here.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <annotation title="My title" body="My comment" highlight="yellow" problem="0">Ut sodales laoreet est, egestas gravida felis egestas nec.</annotation> Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.</p>
|
||||
<p>Nulla facilisi. <annotation body="Basic annotation example." problem="1">Pellentesque id vestibulum libero.</annotation> Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.</p>
|
||||
</annotatable>
|
||||
children: []
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: Open Ended Response
|
||||
max_attempts: 1
|
||||
max_score: 1
|
||||
is_graded: False
|
||||
version: 1
|
||||
display_name: Open Ended Response
|
||||
skip_spelling_checks: False
|
||||
accept_file_upload: False
|
||||
data: |
|
||||
<combinedopenended>
|
||||
<rubric>
|
||||
<rubric>
|
||||
<category>
|
||||
<description>Category 1</description>
|
||||
<option>
|
||||
The response does not incorporate what is needed for a one response.
|
||||
</option>
|
||||
<option>
|
||||
The response is correct for category 1.
|
||||
</option>
|
||||
</category>
|
||||
</rubric>
|
||||
</rubric>
|
||||
<prompt>
|
||||
<p>Why is the sky blue?</p>
|
||||
</prompt>
|
||||
<task>
|
||||
<selfassessment/>
|
||||
</task>
|
||||
<task>
|
||||
<openended min_score_to_attempt="1" max_score_to_attempt="2">
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "peer_grading.conf", "problem_id" : "700x/Demo"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</task>
|
||||
</combinedopenended>
|
||||
|
||||
|
||||
children: []
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: Peer Grading Interface
|
||||
attempts: 1
|
||||
use_for_single_location: False
|
||||
link_to_location: None
|
||||
is_graded: False
|
||||
max_grade: 1
|
||||
data: |
|
||||
<peergrading>
|
||||
</peergrading>
|
||||
|
||||
children: []
|
||||
@@ -28,21 +28,35 @@ open_ended_grading_interface = {
|
||||
'grading_controller' : 'grading_controller'
|
||||
}
|
||||
|
||||
test_system = ModuleSystem(
|
||||
ajax_url='courses/course_id/modx/a_location',
|
||||
track_function=Mock(),
|
||||
get_module=Mock(),
|
||||
# "render" to just the context...
|
||||
render_template=lambda template, context: str(context),
|
||||
replace_urls=Mock(),
|
||||
user=Mock(is_staff=False),
|
||||
filestore=Mock(),
|
||||
debug=True,
|
||||
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
|
||||
anonymous_student_id='student',
|
||||
open_ended_grading_interface= open_ended_grading_interface
|
||||
)
|
||||
|
||||
def test_system():
|
||||
"""
|
||||
Construct a test ModuleSystem instance.
|
||||
|
||||
By default, the render_template() method simply returns
|
||||
the context it is passed as a string.
|
||||
You can override this behavior by monkey patching:
|
||||
|
||||
system = test_system()
|
||||
system.render_template = my_render_func
|
||||
|
||||
where my_render_func is a function of the form
|
||||
my_render_func(template, context)
|
||||
"""
|
||||
return ModuleSystem(
|
||||
ajax_url='courses/course_id/modx/a_location',
|
||||
track_function=Mock(),
|
||||
get_module=Mock(),
|
||||
render_template=lambda template, context: str(context),
|
||||
replace_urls=lambda html: str(html),
|
||||
user=Mock(is_staff=False),
|
||||
filestore=Mock(),
|
||||
debug=True,
|
||||
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
|
||||
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
|
||||
anonymous_student_id='student',
|
||||
open_ended_grading_interface= open_ended_grading_interface
|
||||
)
|
||||
|
||||
|
||||
class ModelsTest(unittest.TestCase):
|
||||
|
||||
129
common/lib/xmodule/xmodule/tests/test_annotatable_module.py
Normal file
129
common/lib/xmodule/xmodule/tests/test_annotatable_module.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Module annotatable tests"""
|
||||
|
||||
import unittest
|
||||
|
||||
from lxml import etree
|
||||
from mock import Mock
|
||||
|
||||
from xmodule.annotatable_module import AnnotatableModule
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from . import test_system
|
||||
|
||||
class AnnotatableModuleTestCase(unittest.TestCase):
|
||||
location = Location(["i4x", "edX", "toy", "annotatable", "guided_discussion"])
|
||||
sample_xml = '''
|
||||
<annotatable display_name="Iliad">
|
||||
<instructions>Read the text.</instructions>
|
||||
<p>
|
||||
<annotation body="first">Sing</annotation>,
|
||||
<annotation title="goddess" body="second">O goddess</annotation>,
|
||||
<annotation title="anger" body="third" highlight="blue">the anger of Achilles son of Peleus</annotation>,
|
||||
that brought <i>countless</i> ills upon the Achaeans. Many a brave soul did it send
|
||||
hurrying down to Hades, and many a hero did it yield a prey to dogs and
|
||||
<div style="font-weight:bold"><annotation body="fourth" problem="4">vultures</annotation>, for so were the counsels
|
||||
of Jove fulfilled from the day on which the son of Atreus, king of men, and great
|
||||
Achilles, first fell out with one another.</div>
|
||||
</p>
|
||||
<annotation title="footnote" body="the end">The Iliad of Homer by Samuel Butler</annotation>
|
||||
</annotatable>
|
||||
'''
|
||||
definition = { 'data': sample_xml }
|
||||
descriptor = Mock()
|
||||
instance_state = None
|
||||
shared_state = None
|
||||
|
||||
def setUp(self):
|
||||
self.annotatable = AnnotatableModule(test_system(), self.location, self.definition, self.descriptor, self.instance_state, self.shared_state)
|
||||
|
||||
def test_annotation_data_attr(self):
|
||||
el = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')
|
||||
|
||||
expected_attr = {
|
||||
'data-comment-body': {'value': 'foo', '_delete': 'body' },
|
||||
'data-comment-title': {'value': 'bar', '_delete': 'title'},
|
||||
'data-problem-id': {'value': '0', '_delete': 'problem'}
|
||||
}
|
||||
|
||||
actual_attr = self.annotatable._get_annotation_data_attr(0, el)
|
||||
|
||||
self.assertTrue(type(actual_attr) is dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_class_attr_default(self):
|
||||
xml = '<annotation title="x" body="y" problem="0">test</annotation>'
|
||||
el = etree.fromstring(xml)
|
||||
|
||||
expected_attr = { 'class': { 'value': 'annotatable-span highlight' } }
|
||||
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
|
||||
|
||||
self.assertTrue(type(actual_attr) is dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_class_attr_with_valid_highlight(self):
|
||||
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
|
||||
|
||||
for color in self.annotatable.highlight_colors:
|
||||
el = etree.fromstring(xml.format(highlight=color))
|
||||
value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color)
|
||||
|
||||
expected_attr = { 'class': {
|
||||
'value': value,
|
||||
'_delete': 'highlight' }
|
||||
}
|
||||
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
|
||||
|
||||
self.assertTrue(type(actual_attr) is dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_class_attr_with_invalid_highlight(self):
|
||||
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
|
||||
|
||||
for invalid_color in ['rainbow', 'blink', 'invisible', '', None]:
|
||||
el = etree.fromstring(xml.format(highlight=invalid_color))
|
||||
expected_attr = { 'class': {
|
||||
'value': 'annotatable-span highlight',
|
||||
'_delete': 'highlight' }
|
||||
}
|
||||
actual_attr = self.annotatable._get_annotation_class_attr(0, el)
|
||||
|
||||
self.assertTrue(type(actual_attr) is dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_render_annotation(self):
|
||||
expected_html = '<span class="annotatable-span highlight highlight-yellow" data-comment-title="x" data-comment-body="y" data-problem-id="0">z</span>'
|
||||
expected_el = etree.fromstring(expected_html)
|
||||
|
||||
actual_el = etree.fromstring('<annotation title="x" body="y" problem="0" highlight="yellow">z</annotation>')
|
||||
self.annotatable._render_annotation(0, actual_el)
|
||||
|
||||
self.assertEqual(expected_el.tag, actual_el.tag)
|
||||
self.assertEqual(expected_el.text, actual_el.text)
|
||||
self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib))
|
||||
|
||||
def test_render_content(self):
|
||||
content = self.annotatable._render_content()
|
||||
el = etree.fromstring(content)
|
||||
|
||||
self.assertEqual('div', el.tag, 'root tag is a div')
|
||||
|
||||
expected_num_annotations = 5
|
||||
actual_num_annotations = el.xpath('count(//span[contains(@class,"annotatable-span")])')
|
||||
self.assertEqual(expected_num_annotations, actual_num_annotations, 'check number of annotations')
|
||||
|
||||
def test_get_html(self):
|
||||
context = self.annotatable.get_html()
|
||||
for key in ['display_name', 'element_id', 'content_html', 'instructions_html']:
|
||||
self.assertIn(key, context)
|
||||
|
||||
def test_extract_instructions(self):
|
||||
xmltree = etree.fromstring(self.sample_xml)
|
||||
|
||||
expected_xml = u"<div>Read the text.</div>"
|
||||
actual_xml = self.annotatable._extract_instructions(xmltree)
|
||||
self.assertIsNotNone(actual_xml)
|
||||
self.assertEqual(expected_xml.strip(), actual_xml.strip())
|
||||
|
||||
xmltree = etree.fromstring('<annotatable>foo</annotatable>')
|
||||
actual = self.annotatable._extract_instructions(xmltree)
|
||||
self.assertIsNone(actual)
|
||||
@@ -1,9 +1,12 @@
|
||||
import datetime
|
||||
import json
|
||||
from mock import Mock
|
||||
from mock import Mock, MagicMock, patch
|
||||
from pprint import pprint
|
||||
import unittest
|
||||
import random
|
||||
|
||||
import xmodule
|
||||
import capa
|
||||
from xmodule.capa_module import CapaModule
|
||||
from xmodule.modulestore import Location
|
||||
from lxml import etree
|
||||
@@ -33,6 +36,18 @@ class CapaFactory(object):
|
||||
CapaFactory.num += 1
|
||||
return CapaFactory.num
|
||||
|
||||
@staticmethod
|
||||
def input_key():
|
||||
""" Return the input key to use when passing GET parameters """
|
||||
return ("input_" + CapaFactory.answer_key())
|
||||
|
||||
@staticmethod
|
||||
def answer_key():
|
||||
""" Return the key stored in the capa problem answer dict """
|
||||
return ("-".join(['i4x', 'edX', 'capa_test', 'problem',
|
||||
'SampleProblem%d' % CapaFactory.num]) +
|
||||
"_2_1")
|
||||
|
||||
@staticmethod
|
||||
def create(graceperiod=None,
|
||||
due=None,
|
||||
@@ -59,11 +74,10 @@ class CapaFactory(object):
|
||||
module.
|
||||
|
||||
attempts: also added to instance state. Will be converted to an int.
|
||||
correct: if True, the problem will be initialized to be answered correctly.
|
||||
"""
|
||||
definition = {'data': CapaFactory.sample_problem_xml, }
|
||||
location = Location(["i4x", "edX", "capa_test", "problem",
|
||||
"SampleProblem{0}".format(CapaFactory.next_num())])
|
||||
"SampleProblem%d" % CapaFactory.next_num()])
|
||||
metadata = {}
|
||||
if graceperiod is not None:
|
||||
metadata['graceperiod'] = graceperiod
|
||||
@@ -89,19 +103,14 @@ class CapaFactory(object):
|
||||
# since everything else is a string.
|
||||
instance_state_dict['attempts'] = int(attempts)
|
||||
|
||||
if correct:
|
||||
# TODO: make this actually set an answer of 3.14, and mark it correct
|
||||
#instance_state_dict['student_answers'] = {}
|
||||
#instance_state_dict['correct_map'] = {}
|
||||
pass
|
||||
|
||||
|
||||
if len(instance_state_dict) > 0:
|
||||
instance_state = json.dumps(instance_state_dict)
|
||||
else:
|
||||
instance_state = None
|
||||
|
||||
module = CapaModule(test_system, location,
|
||||
system = test_system()
|
||||
system.render_template = Mock(return_value="<div>Test Template HTML</div>")
|
||||
module = CapaModule(system, location,
|
||||
definition, descriptor,
|
||||
instance_state, None, metadata=metadata)
|
||||
|
||||
@@ -135,6 +144,8 @@ class CapaModuleTest(unittest.TestCase):
|
||||
"Factory should be creating unique names for each problem")
|
||||
|
||||
|
||||
|
||||
|
||||
def test_correct(self):
|
||||
"""
|
||||
Check that the factory creates correct and incorrect problems properly.
|
||||
@@ -178,6 +189,7 @@ class CapaModuleTest(unittest.TestCase):
|
||||
max_attempts="1",
|
||||
attempts="0",
|
||||
due=self.yesterday_str)
|
||||
|
||||
self.assertTrue(after_due_date.answer_available())
|
||||
|
||||
|
||||
@@ -282,3 +294,578 @@ class CapaModuleTest(unittest.TestCase):
|
||||
due=self.yesterday_str,
|
||||
graceperiod=self.two_day_delta_str)
|
||||
self.assertTrue(still_in_grace.answer_available())
|
||||
|
||||
|
||||
def test_closed(self):
|
||||
|
||||
# Attempts < Max attempts --> NOT closed
|
||||
module = CapaFactory.create(max_attempts="1", attempts="0")
|
||||
self.assertFalse(module.closed())
|
||||
|
||||
# Attempts < Max attempts --> NOT closed
|
||||
module = CapaFactory.create(max_attempts="2", attempts="1")
|
||||
self.assertFalse(module.closed())
|
||||
|
||||
# Attempts = Max attempts --> closed
|
||||
module = CapaFactory.create(max_attempts="1", attempts="1")
|
||||
self.assertTrue(module.closed())
|
||||
|
||||
# Attempts > Max attempts --> closed
|
||||
module = CapaFactory.create(max_attempts="1", attempts="2")
|
||||
self.assertTrue(module.closed())
|
||||
|
||||
# Max attempts = 0 --> closed
|
||||
module = CapaFactory.create(max_attempts="0", attempts="2")
|
||||
self.assertTrue(module.closed())
|
||||
|
||||
# Past due --> closed
|
||||
module = CapaFactory.create(max_attempts="1", attempts="0",
|
||||
due=self.yesterday_str)
|
||||
self.assertTrue(module.closed())
|
||||
|
||||
|
||||
def test_parse_get_params(self):
|
||||
|
||||
# Valid GET param dict
|
||||
valid_get_dict = {'input_1': 'test',
|
||||
'input_1_2': 'test',
|
||||
'input_1_2_3': 'test',
|
||||
'input_[]_3': 'test',
|
||||
'input_4': None,
|
||||
'input_5': [],
|
||||
'input_6': 5}
|
||||
|
||||
result = CapaModule.make_dict_of_responses(valid_get_dict)
|
||||
|
||||
# Expect that we get a dict with "input" stripped from key names
|
||||
# and that we get the same values back
|
||||
for key in result.keys():
|
||||
original_key = "input_" + key
|
||||
self.assertTrue(original_key in valid_get_dict,
|
||||
"Output dict should have key %s" % original_key)
|
||||
self.assertEqual(valid_get_dict[original_key], result[key])
|
||||
|
||||
|
||||
# Valid GET param dict with list keys
|
||||
valid_get_dict = {'input_2[]': ['test1', 'test2']}
|
||||
result = CapaModule.make_dict_of_responses(valid_get_dict)
|
||||
self.assertTrue('2' in result)
|
||||
self.assertEqual(valid_get_dict['input_2[]'], result['2'])
|
||||
|
||||
# If we use [] at the end of a key name, we should always
|
||||
# get a list, even if there's just one value
|
||||
valid_get_dict = {'input_1[]': 'test'}
|
||||
result = CapaModule.make_dict_of_responses(valid_get_dict)
|
||||
self.assertEqual(result['1'], ['test'])
|
||||
|
||||
|
||||
# If we have no underscores in the name, then the key is invalid
|
||||
invalid_get_dict = {'input': 'test'}
|
||||
with self.assertRaises(ValueError):
|
||||
result = CapaModule.make_dict_of_responses(invalid_get_dict)
|
||||
|
||||
|
||||
# Two equivalent names (one list, one non-list)
|
||||
# One of the values would overwrite the other, so detect this
|
||||
# and raise an exception
|
||||
invalid_get_dict = {'input_1[]': 'test 1',
|
||||
'input_1': 'test 2' }
|
||||
with self.assertRaises(ValueError):
|
||||
result = CapaModule.make_dict_of_responses(invalid_get_dict)
|
||||
|
||||
def test_check_problem_correct(self):
|
||||
|
||||
module = CapaFactory.create(attempts=1)
|
||||
|
||||
# Simulate that all answers are marked correct, no matter
|
||||
# what the input is, by patching CorrectMap.is_correct()
|
||||
# Also simulate rendering the HTML
|
||||
with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct,\
|
||||
patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html:
|
||||
mock_is_correct.return_value = True
|
||||
mock_html.return_value = "Test HTML"
|
||||
|
||||
# Check the problem
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
result = module.check_problem(get_request_dict)
|
||||
|
||||
# Expect that the problem is marked correct
|
||||
self.assertEqual(result['success'], 'correct')
|
||||
|
||||
# Expect that we get the (mocked) HTML
|
||||
self.assertEqual(result['contents'], 'Test HTML')
|
||||
|
||||
# Expect that the number of attempts is incremented by 1
|
||||
self.assertEqual(module.attempts, 2)
|
||||
|
||||
|
||||
def test_check_problem_incorrect(self):
|
||||
|
||||
module = CapaFactory.create(attempts=0)
|
||||
|
||||
# Simulate marking the input incorrect
|
||||
with patch('capa.correctmap.CorrectMap.is_correct') as mock_is_correct:
|
||||
mock_is_correct.return_value = False
|
||||
|
||||
# Check the problem
|
||||
get_request_dict = { CapaFactory.input_key(): '0' }
|
||||
result = module.check_problem(get_request_dict)
|
||||
|
||||
# Expect that the problem is marked correct
|
||||
self.assertEqual(result['success'], 'incorrect')
|
||||
|
||||
# Expect that the number of attempts is incremented by 1
|
||||
self.assertEqual(module.attempts, 1)
|
||||
|
||||
|
||||
def test_check_problem_closed(self):
|
||||
module = CapaFactory.create(attempts=3)
|
||||
|
||||
# Problem closed -- cannot submit
|
||||
# Simulate that CapaModule.closed() always returns True
|
||||
with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
|
||||
mock_closed.return_value = True
|
||||
with self.assertRaises(xmodule.exceptions.NotFoundError):
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
module.check_problem(get_request_dict)
|
||||
|
||||
# Expect that number of attempts NOT incremented
|
||||
self.assertEqual(module.attempts, 3)
|
||||
|
||||
|
||||
def test_check_problem_resubmitted_with_randomize(self):
|
||||
# Randomize turned on
|
||||
module = CapaFactory.create(rerandomize='always', attempts=0)
|
||||
|
||||
# Simulate that the problem is completed
|
||||
module.lcp.done = True
|
||||
|
||||
# Expect that we cannot submit
|
||||
with self.assertRaises(xmodule.exceptions.NotFoundError):
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
module.check_problem(get_request_dict)
|
||||
|
||||
# Expect that number of attempts NOT incremented
|
||||
self.assertEqual(module.attempts, 0)
|
||||
|
||||
|
||||
def test_check_problem_resubmitted_no_randomize(self):
|
||||
# Randomize turned off
|
||||
module = CapaFactory.create(rerandomize='never', attempts=0)
|
||||
|
||||
# Simulate that the problem is completed
|
||||
module.lcp.done = True
|
||||
|
||||
# Expect that we can submit successfully
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
result = module.check_problem(get_request_dict)
|
||||
|
||||
self.assertEqual(result['success'], 'correct')
|
||||
|
||||
# Expect that number of attempts IS incremented
|
||||
self.assertEqual(module.attempts, 1)
|
||||
|
||||
|
||||
def test_check_problem_queued(self):
|
||||
module = CapaFactory.create(attempts=1)
|
||||
|
||||
# Simulate that the problem is queued
|
||||
with patch('capa.capa_problem.LoncapaProblem.is_queued') \
|
||||
as mock_is_queued,\
|
||||
patch('capa.capa_problem.LoncapaProblem.get_recentmost_queuetime') \
|
||||
as mock_get_queuetime:
|
||||
|
||||
mock_is_queued.return_value = True
|
||||
mock_get_queuetime.return_value = datetime.datetime.now()
|
||||
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
result = module.check_problem(get_request_dict)
|
||||
|
||||
# Expect an AJAX alert message in 'success'
|
||||
self.assertTrue('You must wait' in result['success'])
|
||||
|
||||
# Expect that the number of attempts is NOT incremented
|
||||
self.assertEqual(module.attempts, 1)
|
||||
|
||||
|
||||
def test_check_problem_student_input_error(self):
|
||||
module = CapaFactory.create(attempts=1)
|
||||
|
||||
# Simulate a student input exception
|
||||
with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade:
|
||||
mock_grade.side_effect = capa.responsetypes.StudentInputError('test error')
|
||||
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
result = module.check_problem(get_request_dict)
|
||||
|
||||
# Expect an AJAX alert message in 'success'
|
||||
self.assertTrue('test error' in result['success'])
|
||||
|
||||
# Expect that the number of attempts is NOT incremented
|
||||
self.assertEqual(module.attempts, 1)
|
||||
|
||||
|
||||
def test_reset_problem(self):
|
||||
module = CapaFactory.create()
|
||||
|
||||
# Mock the module's capa problem
|
||||
# to simulate that the problem is done
|
||||
mock_problem = MagicMock(capa.capa_problem.LoncapaProblem)
|
||||
mock_problem.done = True
|
||||
module.lcp = mock_problem
|
||||
|
||||
# Stub out HTML rendering
|
||||
with patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html:
|
||||
mock_html.return_value = "<div>Test HTML</div>"
|
||||
|
||||
# Reset the problem
|
||||
get_request_dict = {}
|
||||
result = module.reset_problem(get_request_dict)
|
||||
|
||||
# Expect that the request was successful
|
||||
self.assertTrue('success' in result and result['success'])
|
||||
|
||||
# Expect that the problem HTML is retrieved
|
||||
self.assertTrue('html' in result)
|
||||
self.assertEqual(result['html'], "<div>Test HTML</div>")
|
||||
|
||||
# Expect that the problem was reset
|
||||
mock_problem.do_reset.assert_called_once_with()
|
||||
|
||||
|
||||
def test_reset_problem_closed(self):
|
||||
module = CapaFactory.create()
|
||||
|
||||
# Simulate that the problem is closed
|
||||
with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
|
||||
mock_closed.return_value = True
|
||||
|
||||
# Try to reset the problem
|
||||
get_request_dict = {}
|
||||
result = module.reset_problem(get_request_dict)
|
||||
|
||||
# Expect that the problem was NOT reset
|
||||
self.assertTrue('success' in result and not result['success'])
|
||||
|
||||
|
||||
def test_reset_problem_not_done(self):
|
||||
module = CapaFactory.create()
|
||||
|
||||
# Simulate that the problem is NOT done
|
||||
module.lcp.done = False
|
||||
|
||||
# Try to reset the problem
|
||||
get_request_dict = {}
|
||||
result = module.reset_problem(get_request_dict)
|
||||
|
||||
# Expect that the problem was NOT reset
|
||||
self.assertTrue('success' in result and not result['success'])
|
||||
|
||||
|
||||
def test_save_problem(self):
|
||||
module = CapaFactory.create()
|
||||
|
||||
# Simulate that the problem is not done (not attempted or reset)
|
||||
module.lcp.done = False
|
||||
|
||||
# Save the problem
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
result = module.save_problem(get_request_dict)
|
||||
|
||||
# Expect that answers are saved to the problem
|
||||
expected_answers = { CapaFactory.answer_key(): '3.14' }
|
||||
self.assertEqual(module.lcp.student_answers, expected_answers)
|
||||
|
||||
# Expect that the result is success
|
||||
self.assertTrue('success' in result and result['success'])
|
||||
|
||||
|
||||
def test_save_problem_closed(self):
|
||||
module = CapaFactory.create()
|
||||
|
||||
# Simulate that the problem is NOT done (not attempted or reset)
|
||||
module.lcp.done = False
|
||||
|
||||
# Simulate that the problem is closed
|
||||
with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
|
||||
mock_closed.return_value = True
|
||||
|
||||
# Try to save the problem
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
result = module.save_problem(get_request_dict)
|
||||
|
||||
# Expect that the result is failure
|
||||
self.assertTrue('success' in result and not result['success'])
|
||||
|
||||
|
||||
def test_save_problem_submitted_with_randomize(self):
|
||||
module = CapaFactory.create(rerandomize='always')
|
||||
|
||||
# Simulate that the problem is completed
|
||||
module.lcp.done = True
|
||||
|
||||
# Try to save
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
result = module.save_problem(get_request_dict)
|
||||
|
||||
# Expect that we cannot save
|
||||
self.assertTrue('success' in result and not result['success'])
|
||||
|
||||
|
||||
def test_save_problem_submitted_no_randomize(self):
|
||||
module = CapaFactory.create(rerandomize='never')
|
||||
|
||||
# Simulate that the problem is completed
|
||||
module.lcp.done = True
|
||||
|
||||
# Try to save
|
||||
get_request_dict = { CapaFactory.input_key(): '3.14' }
|
||||
result = module.save_problem(get_request_dict)
|
||||
|
||||
# Expect that we succeed
|
||||
self.assertTrue('success' in result and result['success'])
|
||||
|
||||
def test_check_button_name(self):
|
||||
|
||||
# If last attempt, button name changes to "Final Check"
|
||||
# Just in case, we also check what happens if we have
|
||||
# more attempts than allowed.
|
||||
attempts = random.randint(1, 10)
|
||||
module = CapaFactory.create(attempts=attempts-1, max_attempts=attempts)
|
||||
self.assertEqual(module.check_button_name(), "Final Check")
|
||||
|
||||
module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
|
||||
self.assertEqual(module.check_button_name(), "Final Check")
|
||||
|
||||
module = CapaFactory.create(attempts=attempts + 1, max_attempts=attempts)
|
||||
self.assertEqual(module.check_button_name(), "Final Check")
|
||||
|
||||
# Otherwise, button name is "Check"
|
||||
module = CapaFactory.create(attempts=attempts-2, max_attempts=attempts)
|
||||
self.assertEqual(module.check_button_name(), "Check")
|
||||
|
||||
module = CapaFactory.create(attempts=attempts-3, max_attempts=attempts)
|
||||
self.assertEqual(module.check_button_name(), "Check")
|
||||
|
||||
# If no limit on attempts, then always show "Check"
|
||||
module = CapaFactory.create(attempts=attempts-3)
|
||||
self.assertEqual(module.check_button_name(), "Check")
|
||||
|
||||
module = CapaFactory.create(attempts=0)
|
||||
self.assertEqual(module.check_button_name(), "Check")
|
||||
|
||||
def test_should_show_check_button(self):
|
||||
|
||||
attempts = random.randint(1,10)
|
||||
|
||||
# If we're after the deadline, do NOT show check button
|
||||
module = CapaFactory.create(due=self.yesterday_str)
|
||||
self.assertFalse(module.should_show_check_button())
|
||||
|
||||
# If user is out of attempts, do NOT show the check button
|
||||
module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
|
||||
self.assertFalse(module.should_show_check_button())
|
||||
|
||||
# If survey question (max_attempts = 0), do NOT show the check button
|
||||
module = CapaFactory.create(max_attempts=0)
|
||||
self.assertFalse(module.should_show_check_button())
|
||||
|
||||
# If user submitted a problem but hasn't reset,
|
||||
# do NOT show the check button
|
||||
# Note: we can only reset when rerandomize="always"
|
||||
module = CapaFactory.create(rerandomize="always")
|
||||
module.lcp.done = True
|
||||
self.assertFalse(module.should_show_check_button())
|
||||
|
||||
# Otherwise, DO show the check button
|
||||
module = CapaFactory.create()
|
||||
self.assertTrue(module.should_show_check_button())
|
||||
|
||||
# If the user has submitted the problem
|
||||
# and we do NOT have a reset button, then we can show the check button
|
||||
# Setting rerandomize to "never" ensures that the reset button
|
||||
# is not shown
|
||||
module = CapaFactory.create(rerandomize="never")
|
||||
module.lcp.done = True
|
||||
self.assertTrue(module.should_show_check_button())
|
||||
|
||||
|
||||
def test_should_show_reset_button(self):
|
||||
|
||||
attempts = random.randint(1,10)
|
||||
|
||||
# If we're after the deadline, do NOT show the reset button
|
||||
module = CapaFactory.create(due=self.yesterday_str)
|
||||
module.lcp.done = True
|
||||
self.assertFalse(module.should_show_reset_button())
|
||||
|
||||
# If the user is out of attempts, do NOT show the reset button
|
||||
module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
|
||||
module.lcp.done = True
|
||||
self.assertFalse(module.should_show_reset_button())
|
||||
|
||||
# If we're NOT randomizing, then do NOT show the reset button
|
||||
module = CapaFactory.create(rerandomize="never")
|
||||
module.lcp.done = True
|
||||
self.assertFalse(module.should_show_reset_button())
|
||||
|
||||
# If the user hasn't submitted an answer yet,
|
||||
# then do NOT show the reset button
|
||||
module = CapaFactory.create()
|
||||
module.lcp.done = False
|
||||
self.assertFalse(module.should_show_reset_button())
|
||||
|
||||
# Otherwise, DO show the reset button
|
||||
module = CapaFactory.create()
|
||||
module.lcp.done = True
|
||||
self.assertTrue(module.should_show_reset_button())
|
||||
|
||||
# If survey question for capa (max_attempts = 0),
|
||||
# DO show the reset button
|
||||
module = CapaFactory.create(max_attempts=0)
|
||||
module.lcp.done = True
|
||||
self.assertTrue(module.should_show_reset_button())
|
||||
|
||||
|
||||
def test_should_show_save_button(self):
|
||||
|
||||
attempts = random.randint(1,10)
|
||||
|
||||
# If we're after the deadline, do NOT show the save button
|
||||
module = CapaFactory.create(due=self.yesterday_str)
|
||||
module.lcp.done = True
|
||||
self.assertFalse(module.should_show_save_button())
|
||||
|
||||
# If the user is out of attempts, do NOT show the save button
|
||||
module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
|
||||
module.lcp.done = True
|
||||
self.assertFalse(module.should_show_save_button())
|
||||
|
||||
# If user submitted a problem but hasn't reset, do NOT show the save button
|
||||
module = CapaFactory.create(rerandomize="always")
|
||||
module.lcp.done = True
|
||||
self.assertFalse(module.should_show_save_button())
|
||||
|
||||
# Otherwise, DO show the save button
|
||||
module = CapaFactory.create()
|
||||
module.lcp.done = False
|
||||
self.assertTrue(module.should_show_save_button())
|
||||
|
||||
# If we're not randomizing, then we can re-save
|
||||
module = CapaFactory.create(rerandomize="never")
|
||||
module.lcp.done = True
|
||||
self.assertTrue(module.should_show_save_button())
|
||||
|
||||
# If survey question for capa (max_attempts = 0),
|
||||
# DO show the save button
|
||||
module = CapaFactory.create(max_attempts=0)
|
||||
module.lcp.done = False
|
||||
self.assertTrue(module.should_show_save_button())
|
||||
|
||||
def test_should_show_save_button_force_save_button(self):
|
||||
# If we're after the deadline, do NOT show the save button
|
||||
# even though we're forcing a save
|
||||
module = CapaFactory.create(due=self.yesterday_str,
|
||||
force_save_button="true")
|
||||
module.lcp.done = True
|
||||
self.assertFalse(module.should_show_save_button())
|
||||
|
||||
# If the user is out of attempts, do NOT show the save button
|
||||
attempts = random.randint(1,10)
|
||||
module = CapaFactory.create(attempts=attempts,
|
||||
max_attempts=attempts,
|
||||
force_save_button="true")
|
||||
module.lcp.done = True
|
||||
self.assertFalse(module.should_show_save_button())
|
||||
|
||||
# Otherwise, if we force the save button,
|
||||
# then show it even if we would ordinarily
|
||||
# require a reset first
|
||||
module = CapaFactory.create(force_save_button="true",
|
||||
rerandomize="always")
|
||||
module.lcp.done = True
|
||||
self.assertTrue(module.should_show_save_button())
|
||||
|
||||
def test_no_max_attempts(self):
|
||||
module = CapaFactory.create(max_attempts='')
|
||||
html = module.get_problem_html()
|
||||
# assert that we got here without exploding
|
||||
|
||||
|
||||
def test_get_problem_html(self):
|
||||
module = CapaFactory.create()
|
||||
|
||||
# We've tested the show/hide button logic in other tests,
|
||||
# so here we hard-wire the values
|
||||
show_check_button = bool(random.randint(0,1) % 2)
|
||||
show_reset_button = bool(random.randint(0,1) % 2)
|
||||
show_save_button = bool(random.randint(0,1) % 2)
|
||||
|
||||
module.should_show_check_button = Mock(return_value=show_check_button)
|
||||
module.should_show_reset_button = Mock(return_value=show_reset_button)
|
||||
module.should_show_save_button = Mock(return_value=show_save_button)
|
||||
|
||||
# Mock the system rendering function
|
||||
module.system.render_template = Mock(return_value="<div>Test Template HTML</div>")
|
||||
|
||||
# Patch the capa problem's HTML rendering
|
||||
with patch('capa.capa_problem.LoncapaProblem.get_html') as mock_html:
|
||||
mock_html.return_value = "<div>Test Problem HTML</div>"
|
||||
|
||||
# Render the problem HTML
|
||||
html = module.get_problem_html(encapsulate=False)
|
||||
|
||||
# Also render the problem encapsulated in a <div>
|
||||
html_encapsulated = module.get_problem_html(encapsulate=True)
|
||||
|
||||
# Expect that we get the rendered template back
|
||||
self.assertEqual(html, "<div>Test Template HTML</div>")
|
||||
|
||||
# Check the rendering context
|
||||
render_args,_ = module.system.render_template.call_args
|
||||
self.assertEqual(len(render_args), 2)
|
||||
|
||||
template_name = render_args[0]
|
||||
self.assertEqual(template_name, "problem.html")
|
||||
|
||||
context = render_args[1]
|
||||
self.assertEqual(context['problem']['html'], "<div>Test Problem HTML</div>")
|
||||
self.assertEqual(bool(context['check_button']), show_check_button)
|
||||
self.assertEqual(bool(context['reset_button']), show_reset_button)
|
||||
self.assertEqual(bool(context['save_button']), show_save_button)
|
||||
|
||||
# Assert that the encapsulated html contains the original html
|
||||
self.assertTrue(html in html_encapsulated)
|
||||
|
||||
|
||||
def test_get_problem_html_error(self):
|
||||
"""
|
||||
In production, when an error occurs with the problem HTML
|
||||
rendering, a "dummy" problem is created with an error
|
||||
message to display to the user.
|
||||
"""
|
||||
module = CapaFactory.create()
|
||||
|
||||
# Save the original problem so we can compare it later
|
||||
original_problem = module.lcp
|
||||
|
||||
# Simulate throwing an exception when the capa problem
|
||||
# is asked to render itself as HTML
|
||||
module.lcp.get_html = Mock(side_effect=Exception("Test"))
|
||||
|
||||
# Stub out the test_system rendering function
|
||||
module.system.render_template = Mock(return_value="<div>Test Template HTML</div>")
|
||||
|
||||
# Turn off DEBUG
|
||||
module.system.DEBUG = False
|
||||
|
||||
# Try to render the module with DEBUG turned off
|
||||
html = module.get_problem_html()
|
||||
|
||||
# Check the rendering context
|
||||
render_args,_ = module.system.render_template.call_args
|
||||
context = render_args[1]
|
||||
self.assertTrue("error" in context['problem']['html'])
|
||||
|
||||
# Expect that the module has created a new dummy problem with the error
|
||||
self.assertNotEqual(original_problem, module.lcp)
|
||||
|
||||
@@ -54,7 +54,8 @@ class OpenEndedChildTest(unittest.TestCase):
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
self.openendedchild = OpenEndedChild(test_system, self.location,
|
||||
self.test_system = test_system()
|
||||
self.openendedchild = OpenEndedChild(self.test_system, self.location,
|
||||
self.definition, self.descriptor, self.static_data, self.metadata)
|
||||
|
||||
|
||||
@@ -69,7 +70,7 @@ class OpenEndedChildTest(unittest.TestCase):
|
||||
|
||||
|
||||
def test_latest_post_assessment_empty(self):
|
||||
answer = self.openendedchild.latest_post_assessment(test_system)
|
||||
answer = self.openendedchild.latest_post_assessment(self.test_system)
|
||||
self.assertEqual(answer, "")
|
||||
|
||||
|
||||
@@ -106,7 +107,7 @@ class OpenEndedChildTest(unittest.TestCase):
|
||||
post_assessment = "Post assessment"
|
||||
self.openendedchild.record_latest_post_assessment(post_assessment)
|
||||
self.assertEqual(post_assessment,
|
||||
self.openendedchild.latest_post_assessment(test_system))
|
||||
self.openendedchild.latest_post_assessment(self.test_system))
|
||||
|
||||
def test_get_score(self):
|
||||
new_answer = "New Answer"
|
||||
@@ -125,7 +126,7 @@ class OpenEndedChildTest(unittest.TestCase):
|
||||
|
||||
|
||||
def test_reset(self):
|
||||
self.openendedchild.reset(test_system)
|
||||
self.openendedchild.reset(self.test_system)
|
||||
state = json.loads(self.openendedchild.get_instance_state())
|
||||
self.assertEqual(state['state'], OpenEndedChild.INITIAL)
|
||||
|
||||
@@ -182,11 +183,13 @@ class OpenEndedModuleTest(unittest.TestCase):
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
test_system.location = self.location
|
||||
self.test_system = test_system()
|
||||
|
||||
self.test_system.location = self.location
|
||||
self.mock_xqueue = MagicMock()
|
||||
self.mock_xqueue.send_to_queue.return_value = (None, "Message")
|
||||
test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 1}
|
||||
self.openendedmodule = OpenEndedModule(test_system, self.location,
|
||||
self.test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 1}
|
||||
self.openendedmodule = OpenEndedModule(self.test_system, self.location,
|
||||
self.definition, self.descriptor, self.static_data, self.metadata)
|
||||
|
||||
def test_message_post(self):
|
||||
@@ -195,7 +198,7 @@ class OpenEndedModuleTest(unittest.TestCase):
|
||||
'grader_id': '1',
|
||||
'score': 3}
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
|
||||
student_info = {'anonymous_student_id': self.test_system.anonymous_student_id,
|
||||
'submission_time': qtime}
|
||||
contents = {
|
||||
'feedback': get['feedback'],
|
||||
@@ -205,7 +208,7 @@ class OpenEndedModuleTest(unittest.TestCase):
|
||||
'student_info': json.dumps(student_info)
|
||||
}
|
||||
|
||||
result = self.openendedmodule.message_post(get, test_system)
|
||||
result = self.openendedmodule.message_post(get, self.test_system)
|
||||
self.assertTrue(result['success'])
|
||||
# make sure it's actually sending something we want to the queue
|
||||
self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY)
|
||||
@@ -216,7 +219,7 @@ class OpenEndedModuleTest(unittest.TestCase):
|
||||
def test_send_to_grader(self):
|
||||
submission = "This is a student submission"
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
student_info = {'anonymous_student_id': test_system.anonymous_student_id,
|
||||
student_info = {'anonymous_student_id': self.test_system.anonymous_student_id,
|
||||
'submission_time': qtime}
|
||||
contents = self.openendedmodule.payload.copy()
|
||||
contents.update({
|
||||
@@ -224,7 +227,7 @@ class OpenEndedModuleTest(unittest.TestCase):
|
||||
'student_response': submission,
|
||||
'max_score': self.max_score
|
||||
})
|
||||
result = self.openendedmodule.send_to_grader(submission, test_system)
|
||||
result = self.openendedmodule.send_to_grader(submission, self.test_system)
|
||||
self.assertTrue(result)
|
||||
self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY)
|
||||
|
||||
@@ -238,7 +241,7 @@ class OpenEndedModuleTest(unittest.TestCase):
|
||||
}
|
||||
get = {'queuekey': "abcd",
|
||||
'xqueue_body': score_msg}
|
||||
self.openendedmodule.update_score(get, test_system)
|
||||
self.openendedmodule.update_score(get, self.test_system)
|
||||
|
||||
def update_score_single(self):
|
||||
self.openendedmodule.new_history_entry("New Entry")
|
||||
@@ -261,11 +264,11 @@ class OpenEndedModuleTest(unittest.TestCase):
|
||||
}
|
||||
get = {'queuekey': "abcd",
|
||||
'xqueue_body': json.dumps(score_msg)}
|
||||
self.openendedmodule.update_score(get, test_system)
|
||||
self.openendedmodule.update_score(get, self.test_system)
|
||||
|
||||
def test_latest_post_assessment(self):
|
||||
self.update_score_single()
|
||||
assessment = self.openendedmodule.latest_post_assessment(test_system)
|
||||
assessment = self.openendedmodule.latest_post_assessment(self.test_system)
|
||||
self.assertFalse(assessment == '')
|
||||
# check for errors
|
||||
self.assertFalse('errors' in assessment)
|
||||
@@ -336,7 +339,13 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
|
||||
descriptor = Mock()
|
||||
|
||||
def setUp(self):
|
||||
self.combinedoe = CombinedOpenEndedV1Module(test_system, self.location, self.definition, self.descriptor, static_data = self.static_data, metadata=self.metadata)
|
||||
self.test_system = test_system()
|
||||
self.combinedoe = CombinedOpenEndedV1Module(self.test_system,
|
||||
self.location,
|
||||
self.definition,
|
||||
self.descriptor,
|
||||
static_data = self.static_data,
|
||||
metadata=self.metadata)
|
||||
|
||||
def test_get_tag_name(self):
|
||||
name = self.combinedoe.get_tag_name("<t>Tag</t>")
|
||||
|
||||
@@ -56,6 +56,9 @@ class ConditionalModuleTest(unittest.TestCase):
|
||||
'''Get a dummy system'''
|
||||
return DummySystem(load_error_modules)
|
||||
|
||||
def setUp(self):
|
||||
self.test_system = test_system()
|
||||
|
||||
def get_course(self, name):
|
||||
"""Get a test course by directory name. If there's more than one, error."""
|
||||
print "Importing {0}".format(name)
|
||||
@@ -85,14 +88,14 @@ class ConditionalModuleTest(unittest.TestCase):
|
||||
location = descriptor.location
|
||||
instance_state = instance_states.get(location.category, None)
|
||||
print "inner_get_module, location=%s, inst_state=%s" % (location, instance_state)
|
||||
return descriptor.xmodule_constructor(test_system)(instance_state, shared_state)
|
||||
return descriptor.xmodule_constructor(self.test_system)(instance_state, shared_state)
|
||||
|
||||
location = Location(["i4x", "edX", "cond_test", "conditional", "condone"])
|
||||
|
||||
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
|
||||
return text
|
||||
test_system.replace_urls = replace_urls
|
||||
test_system.get_module = inner_get_module
|
||||
self.test_system.replace_urls = replace_urls
|
||||
self.test_system.get_module = inner_get_module
|
||||
|
||||
module = inner_get_module(location)
|
||||
print "module: ", module
|
||||
|
||||
@@ -19,9 +19,14 @@ class ContentTest(unittest.TestCase):
|
||||
|
||||
content = StaticContent('loc', 'name', 'content_type', 'data')
|
||||
self.assertIsNone(content.thumbnail_location)
|
||||
def test_generate_thumbnail_nonimage(self):
|
||||
def test_generate_thumbnail_image(self):
|
||||
contentStore = ContentStore()
|
||||
content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters.jpg'), None)
|
||||
content = Content(Location(u'c4x', u'mitX', u'800', u'asset', u'monsters__.jpg'), None)
|
||||
(thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content)
|
||||
self.assertIsNone(thumbnail_content)
|
||||
self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters.jpg'), thumbnail_file_location)
|
||||
self.assertEqual(Location(u'c4x', u'mitX', u'800', u'thumbnail', u'monsters__.jpg'), thumbnail_file_location)
|
||||
def test_compute_location(self):
|
||||
# We had a bug that __ got converted into a single _. Make sure that substitution of INVALID_CHARS (like space)
|
||||
# still happen.
|
||||
asset_location = StaticContent.compute_location('mitX', '400', 'subs__1eo_jXvZnE .srt.sjson')
|
||||
self.assertEqual(Location(u'c4x', u'mitX', u'400', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None), asset_location)
|
||||
|
||||
@@ -53,13 +53,13 @@ class SelfAssessmentTest(unittest.TestCase):
|
||||
'skip_basic_checks' : False,
|
||||
}
|
||||
|
||||
self.module = SelfAssessmentModule(test_system, self.location,
|
||||
self.module = SelfAssessmentModule(test_system(), self.location,
|
||||
self.definition, self.descriptor,
|
||||
static_data,
|
||||
state, metadata=self.metadata)
|
||||
|
||||
def test_get_html(self):
|
||||
html = self.module.get_html(test_system)
|
||||
html = self.module.get_html(self.module.system)
|
||||
self.assertTrue("This is sample prompt text" in html)
|
||||
|
||||
def test_self_assessment_flow(self):
|
||||
@@ -82,10 +82,11 @@ class SelfAssessmentTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(self.module.get_score()['score'], 0)
|
||||
|
||||
self.module.save_answer({'student_answer': "I am an answer"}, test_system)
|
||||
self.module.save_answer({'student_answer': "I am an answer"},
|
||||
self.module.system)
|
||||
self.assertEqual(self.module.state, self.module.ASSESSING)
|
||||
|
||||
self.module.save_assessment(mock_query_dict, test_system)
|
||||
self.module.save_assessment(mock_query_dict, self.module.system)
|
||||
self.assertEqual(self.module.state, self.module.DONE)
|
||||
|
||||
|
||||
@@ -94,7 +95,8 @@ class SelfAssessmentTest(unittest.TestCase):
|
||||
self.assertEqual(self.module.state, self.module.INITIAL)
|
||||
|
||||
# if we now assess as right, skip the REQUEST_HINT state
|
||||
self.module.save_answer({'student_answer': 'answer 4'}, test_system)
|
||||
self.module.save_answer({'student_answer': 'answer 4'},
|
||||
self.module.system)
|
||||
responses['assessment'] = '1'
|
||||
self.module.save_assessment(mock_query_dict, test_system)
|
||||
self.module.save_assessment(mock_query_dict, self.module.system)
|
||||
self.assertEqual(self.module.state, self.module.DONE)
|
||||
|
||||
@@ -379,7 +379,11 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy:
|
||||
val = val_for_xml(attr)
|
||||
#logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr))
|
||||
xml_object.set(attr, val)
|
||||
try:
|
||||
xml_object.set(attr, val)
|
||||
except Exception, e:
|
||||
logging.exception('Failed to serialize metadata attribute {0} with value {1}. This could mean data loss!!! Exception: {2}'.format(attr, val, e))
|
||||
pass
|
||||
|
||||
if self.export_to_file():
|
||||
# Write the definition to a file
|
||||
|
||||
Reference in New Issue
Block a user