Commit 4f29d084 by ichuang

Merge pull request #841 from MITx/victor/capa_cleanup

Victor/capa cleanup

Looks good.  Yes, the code exec part will need more thought, particularly with respect to security.
parents 242dd4f7 61e574ee
......@@ -48,7 +48,7 @@ general_whitespace = re.compile('[^\w]+')
def check_variables(string, variables):
''' Confirm the only variables in string are defined.
'''Confirm the only variables in string are defined.
Pyparsing uses a left-to-right parser, which makes the more
elegant approach pretty hopeless.
......@@ -56,7 +56,8 @@ def check_variables(string, variables):
achar = reduce(lambda a,b:a|b ,map(Literal,alphas)) # Any alphabetic character
undefined_variable = achar + Word(alphanums)
undefined_variable.setParseAction(lambda x:UndefinedVariable("".join(x)).raiseself())
varnames = varnames | undefined_variable'''
varnames = varnames | undefined_variable
'''
possible_variables = re.split(general_whitespace, string) # List of all alnums in string
bad_variables = list()
for v in possible_variables:
......@@ -71,7 +72,8 @@ def check_variables(string, variables):
def evaluator(variables, functions, string, cs=False):
''' Evaluate an expression. Variables are passed as a dictionary
'''
Evaluate an expression. Variables are passed as a dictionary
from string to value. Unary functions are passed as a dictionary
from string to function. Variables must be floats.
cs: Case sensitive
......@@ -108,6 +110,7 @@ def evaluator(variables, functions, string, cs=False):
if string.strip() == "":
return float('nan')
ops = {"^": operator.pow,
"*": operator.mul,
"/": operator.truediv,
......@@ -169,14 +172,19 @@ def evaluator(variables, functions, string, cs=False):
def func_parse_action(x):
return [all_functions[x[0]](x[1])]
number_suffix = reduce(lambda a, b: a | b, map(Literal, suffixes.keys()), NoMatch()) # SI suffixes and percent
# SI suffixes and percent
number_suffix = reduce(lambda a, b: a | b, map(Literal, suffixes.keys()), NoMatch())
(dot, minus, plus, times, div, lpar, rpar, exp) = map(Literal, ".-+*/()^")
number_part = Word(nums)
inner_number = (number_part + Optional("." + number_part)) | ("." + number_part) # 0.33 or 7 or .34
number = Optional(minus | plus) + inner_number + \
Optional(CaselessLiteral("E") + Optional("-") + number_part) + \
Optional(number_suffix) # 0.33k or -17
# 0.33 or 7 or .34
inner_number = (number_part + Optional("." + number_part)) | ("." + number_part)
# 0.33k or -17
number = (Optional(minus | plus) + inner_number
+ Optional(CaselessLiteral("E") + Optional("-") + number_part)
+ Optional(number_suffix))
number = number.setParseAction(number_parse_action) # Convert to number
# Predefine recursive variables
......@@ -201,9 +209,11 @@ def evaluator(variables, functions, string, cs=False):
varnames.setParseAction(lambda x: map(lambda y: all_variables[y], x))
else:
varnames = NoMatch()
# Same thing for functions.
if len(all_functions) > 0:
funcnames = sreduce(lambda x, y: x | y, map(lambda x: CasedLiteral(x), all_functions.keys()))
funcnames = sreduce(lambda x, y: x | y,
map(lambda x: CasedLiteral(x), all_functions.keys()))
function = funcnames + lpar.suppress() + expr + rpar.suppress()
function.setParseAction(func_parse_action)
else:
......
......@@ -3,8 +3,9 @@
#
# Nomenclature:
#
# A capa Problem is a collection of text and capa Response questions. Each Response may have one or more
# Input entry fields. The capa Problem may include a solution.
# A capa Problem is a collection of text and capa Response questions.
# Each Response may have one or more Input entry fields.
# The capa problem may include a solution.
#
'''
Main module which shows problems (of "capa" type).
......@@ -42,9 +43,23 @@ import responsetypes
# dict of tagname, Response Class -- this should come from auto-registering
response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
entry_types = ['textline', 'schematic', 'textbox', 'imageinput', 'optioninput', 'choicegroup', 'radiogroup', 'checkboxgroup', 'filesubmission', 'javascriptinput']
solution_types = ['solution'] # extra things displayed after "show answers" is pressed
response_properties = ["codeparam", "responseparam", "answer"] # these get captured as student responses
# Different ways students can input code
entry_types = ['textline',
'schematic',
'textbox',
'imageinput',
'optioninput',
'choicegroup',
'radiogroup',
'checkboxgroup',
'filesubmission',
'javascriptinput',]
# extra things displayed after "show answers" is pressed
solution_types = ['solution']
# these get captured as student responses
response_properties = ["codeparam", "responseparam", "answer"]
# special problem tags which should be turned into innocuous HTML
html_transforms = {'problem': {'tag': 'div'},
......@@ -83,7 +98,8 @@ class LoncapaProblem(object):
- id (string): identifier for this problem; often a filename (no spaces)
- state (dict): student state
- seed (int): random number generator seed (int)
- system (ModuleSystem): ModuleSystem instance which provides OS, rendering, and user context
- system (ModuleSystem): ModuleSystem instance which provides OS,
rendering, and user context
'''
......@@ -107,19 +123,24 @@ class LoncapaProblem(object):
if not self.seed:
self.seed = struct.unpack('i', os.urandom(4))[0]
problem_text = re.sub("startouttext\s*/", "text", problem_text) # Convert startouttext and endouttext to proper <text></text>
# Convert startouttext and endouttext to proper <text></text>
problem_text = re.sub("startouttext\s*/", "text", problem_text)
problem_text = re.sub("endouttext\s*/", "/text", problem_text)
self.problem_text = problem_text
self.tree = etree.XML(problem_text) # parse problem XML file into an element tree
self._process_includes() # handle any <include file="foo"> tags
# parse problem XML file into an element tree
self.tree = etree.XML(problem_text)
# handle any <include file="foo"> tags
self._process_includes()
# construct script processor context (eg for customresponse problems)
self.context = self._extract_context(self.tree, seed=self.seed)
# pre-parse the XML tree: modifies it to add ID's and perform some in-place transformations
# this also creates the dict (self.responders) of Response instances for each question in the problem.
# the dict has keys = xml subtree of Response, values = Response instance
# Pre-parse the XML tree: modifies it to add ID's and perform some in-place
# transformations. This also creates the dict (self.responders) of Response
# instances for each question in the problem. The dict has keys = xml subtree of
# Response, values = Response instance
self._preprocess_problem(self.tree)
if not self.student_answers: # True when student_answers is an empty dict
......@@ -134,6 +155,9 @@ class LoncapaProblem(object):
self.done = False
def set_initial_display(self):
"""
Set the student's answers to the responders' initial displays, if specified.
"""
initial_answers = dict()
for responder in self.responders.values():
if hasattr(responder, 'get_initial_display'):
......@@ -145,9 +169,11 @@ class LoncapaProblem(object):
return u"LoncapaProblem ({0})".format(self.problem_id)
def get_state(self):
''' Stored per-user session data neeeded to:
'''
Stored per-user session data neeeded to:
1) Recreate the problem
2) Populate any student answers. '''
2) Populate any student answers.
'''
return {'seed': self.seed,
'student_answers': self.student_answers,
......@@ -156,7 +182,7 @@ class LoncapaProblem(object):
def get_max_score(self):
'''
Return maximum score for this problem.
Return the maximum score for this problem.
'''
maxscore = 0
for response, responder in self.responders.iteritems():
......@@ -164,11 +190,11 @@ class LoncapaProblem(object):
return maxscore
def get_score(self):
'''
"""
Compute score for this problem. The score is the number of points awarded.
Returns a dictionary {'score': integer, from 0 to get_max_score(),
'total': get_max_score()}.
'''
"""
correct = 0
for key in self.correct_map:
try:
......@@ -204,13 +230,15 @@ class LoncapaProblem(object):
def is_queued(self):
'''
Returns True if any part of the problem has been submitted to an external queue
(e.g. for grading.)
'''
return any(self.correct_map.is_queued(answer_id) for answer_id in self.correct_map)
def get_recentmost_queuetime(self):
'''
Returns a DateTime object that represents the timestamp of the most recent queueing request, or None if not queued
Returns a DateTime object that represents the timestamp of the most recent
queueing request, or None if not queued
'''
if not self.is_queued():
return None
......@@ -219,7 +247,8 @@ class LoncapaProblem(object):
queuetime_strs = [self.correct_map.get_queuetime_str(answer_id)
for answer_id in self.correct_map
if self.correct_map.is_queued(answer_id)]
queuetimes = [datetime.strptime(qt_str, xqueue_interface.dateformat) for qt_str in queuetime_strs]
queuetimes = [datetime.strptime(qt_str, xqueue_interface.dateformat)
for qt_str in queuetime_strs]
return max(queuetimes)
......@@ -235,14 +264,20 @@ class LoncapaProblem(object):
Calls the Response for each question in this problem, to do the actual grading.
'''
# if answers include File objects, convert them to filenames.
self.student_answers = convert_files_to_filenames(answers)
oldcmap = self.correct_map # old CorrectMap
newcmap = CorrectMap() # start new with empty CorrectMap
# old CorrectMap
oldcmap = self.correct_map
# start new with empty CorrectMap
newcmap = CorrectMap()
# log.debug('Responders: %s' % self.responders)
for responder in self.responders.values(): # Call each responsetype instance to do actual grading
if 'filesubmission' in responder.allowed_inputfields: # File objects are passed only if responsetype
# explicitly allows for file submissions
# Call each responsetype instance to do actual grading
for responder in self.responders.values():
# File objects are passed only if responsetype explicitly allows for file
# submissions
if 'filesubmission' in responder.allowed_inputfields:
results = responder.evaluate_answers(answers, oldcmap)
else:
results = responder.evaluate_answers(convert_files_to_filenames(answers), oldcmap)
......@@ -252,28 +287,33 @@ class LoncapaProblem(object):
return newcmap
def get_question_answers(self):
"""Returns a dict of answer_ids to answer values. If we cannot generate
"""
Returns a dict of answer_ids to answer values. If we cannot generate
an answer (this sometimes happens in customresponses), that answer_id is
not included. Called by "show answers" button JSON request
(see capa_module)
"""
# dict of (id, correct_answer)
answer_map = dict()
for response in self.responders.keys():
results = self.responder_answers[response]
answer_map.update(results) # dict of (id,correct_answer)
answer_map.update(results)
# include solutions from <solution>...</solution> stanzas
for entry in self.tree.xpath("//" + "|//".join(solution_types)):
answer = etree.tostring(entry)
if answer: answer_map[entry.get('id')] = contextualize_text(answer, self.context)
if answer:
answer_map[entry.get('id')] = contextualize_text(answer, self.context)
log.debug('answer_map = %s' % answer_map)
return answer_map
def get_answer_ids(self):
"""Return the IDs of all the responses -- these are the keys used for
"""
Return the IDs of all the responses -- these are the keys used for
the dicts returned by grade_answers and get_question_answers. (Though
get_question_answers may only return a subset of these."""
get_question_answers may only return a subset of these.
"""
answer_ids = []
for response in self.responders.keys():
results = self.responder_answers[response]
......@@ -298,7 +338,8 @@ class LoncapaProblem(object):
file = inc.get('file')
if file is not None:
try:
ifp = self.system.filestore.open(file) # open using ModuleSystem OSFS filestore
# open using ModuleSystem OSFS filestore
ifp = self.system.filestore.open(file)
except Exception as err:
log.warning('Error %s in problem xml include: %s' % (
err, etree.tostring(inc, pretty_print=True)))
......@@ -311,7 +352,8 @@ class LoncapaProblem(object):
else:
continue
try:
incxml = etree.XML(ifp.read()) # read in and convert to XML
# read in and convert to XML
incxml = etree.XML(ifp.read())
except Exception as err:
log.warning('Error %s in problem xml include: %s' % (
err, etree.tostring(inc, pretty_print=True)))
......@@ -322,6 +364,7 @@ class LoncapaProblem(object):
raise
else:
continue
# insert new XML into tree in place of inlcude
parent = inc.getparent()
parent.insert(parent.index(inc), incxml)
......@@ -329,11 +372,13 @@ class LoncapaProblem(object):
log.debug('Included %s into %s' % (file, self.problem_id))
def _extract_system_path(self, script):
'''
"""
Extracts and normalizes additional paths for code execution.
For now, there's a default path of data/course/code; this may be removed
at some point.
'''
script : ?? (TODO)
"""
DEFAULT_PATH = ['code']
......@@ -351,7 +396,6 @@ class LoncapaProblem(object):
# path is an absolute path or a path relative to the data dir
dir = os.path.join(self.system.filestore.root_path, dir)
abs_dir = os.path.normpath(dir)
#log.debug("appending to path: %s" % abs_dir)
path.append(abs_dir)
return path
......@@ -362,13 +406,20 @@ class LoncapaProblem(object):
context of this problem. Provides ability to randomize problems, and also set
variables for problem answer checking.
Problem XML goes to Python execution context. Runs everything in script tags
Problem XML goes to Python execution context. Runs everything in script tags.
'''
random.seed(self.seed)
context = {'global_context': global_context} # save global context in here also
context.update(global_context) # initialize context to have stuff in global_context
context['__builtins__'] = globals()['__builtins__'] # put globals there also
context['the_lcp'] = self # pass instance of LoncapaProblem in
# save global context in here also
context = {'global_context': global_context}
# initialize context to have stuff in global_context
context.update(global_context)
# put globals there also
context['__builtins__'] = globals()['__builtins__']
# pass instance of LoncapaProblem in
context['the_lcp'] = self
context['script_code'] = ''
self._execute_scripts(tree.findall('.//script'), context)
......@@ -395,9 +446,11 @@ class LoncapaProblem(object):
code = script.text
XMLESC = {"&apos;": "'", "&quot;": '"'}
code = unescape(code, XMLESC)
context['script_code'] += code # store code source in context
# store code source in context
context['script_code'] += code
try:
exec code in context, context # use "context" for global context; thus defs in code are global within code
# use "context" for global context; thus defs in code are global within code
exec code in context, context
except Exception as err:
log.exception("Error while execing script code: " + code)
msg = "Error while executing script code: %s" % str(err).replace('<','&lt;')
......@@ -415,7 +468,8 @@ class LoncapaProblem(object):
Used by get_html.
'''
if problemtree.tag == 'script' and problemtree.get('type') and 'javascript' in problemtree.get('type'):
if (problemtree.tag == 'script' and problemtree.get('type')
and 'javascript' in problemtree.get('type')):
# leave javascript intact.
return problemtree
......@@ -453,21 +507,26 @@ class LoncapaProblem(object):
}
},
use='capa_input')
return render_object.get_html() # function(problemtree, value, status, msg) # render the special response (textline, schematic,...)
# function(problemtree, value, status, msg)
# render the special response (textline, schematic,...)
return render_object.get_html()
if problemtree in self.responders: # let each Response render itself
# let each Response render itself
if problemtree in self.responders:
return self.responders[problemtree].render_html(self._extract_html)
tree = etree.Element(problemtree.tag)
for item in problemtree:
item_xhtml = self._extract_html(item) # nothing special: recurse
# render child recursively
item_xhtml = self._extract_html(item)
if item_xhtml is not None:
tree.append(item_xhtml)
if tree.tag in html_transforms:
tree.tag = html_transforms[problemtree.tag]['tag']
else:
for (key, value) in problemtree.items(): # copy attributes over if not innocufying
# copy attributes over if not innocufying
for (key, value) in problemtree.items():
tree.set(key, value)
tree.text = problemtree.text
......@@ -490,31 +549,41 @@ class LoncapaProblem(object):
self.responders = {}
for response in tree.xpath('//' + "|//".join(response_tag_dict)):
response_id_str = self.problem_id + "_" + str(response_id)
response.set('id', response_id_str) # create and save ID for this response
# create and save ID for this response
response.set('id', response_id_str)
response_id += 1
answer_id = 1
inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x for x in (entry_types + solution_types)]),
inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x
for x in (entry_types + solution_types)]),
id=response_id_str)
for entry in inputfields: # assign one answer_id for each entry_type or solution_type
# assign one answer_id for each entry_type or solution_type
for entry in inputfields:
entry.attrib['response_id'] = str(response_id)
entry.attrib['answer_id'] = str(answer_id)
entry.attrib['id'] = "%s_%i_%i" % (self.problem_id, response_id, answer_id)
answer_id = answer_id + 1
responder = response_tag_dict[response.tag](response, inputfields, self.context, self.system) # instantiate capa Response
self.responders[response] = responder # save in list in self
# instantiate capa Response
responder = response_tag_dict[response.tag](response, inputfields,
self.context, self.system)
# save in list in self
self.responders[response] = responder
# get responder answers (do this only once, since there may be a performance cost, eg with externalresponse)
# get responder answers (do this only once, since there may be a performance cost,
# eg with externalresponse)
self.responder_answers = {}
for response in self.responders.keys():
try:
self.responder_answers[response] = self.responders[response].get_answers()
except:
log.debug('responder %s failed to properly return get_answers()' % self.responders[response]) # FIXME
log.debug('responder %s failed to properly return get_answers()',
self.responders[response]) # FIXME
raise
# <solution>...</solution> may not be associated with any specific response; give IDs for those separately
# <solution>...</solution> may not be associated with any specific response; give
# IDs for those separately
# TODO: We should make the namespaces consistent and unique (e.g. %s_problem_%i).
solution_id = 1
for solution in tree.findall('.//solution'):
......
......@@ -5,23 +5,26 @@
class CorrectMap(object):
'''
"""
Stores map between answer_id and response evaluation result for each question
in a capa problem. The response evaluation result for each answer_id includes
(correctness, npoints, msg, hint, hintmode).
- correctness : either 'correct' or 'incorrect'
- npoints : None, or integer specifying number of points awarded for this answer_id
- msg : string (may have HTML) giving extra message response (displayed below textline or textbox)
- hint : string (may have HTML) giving optional hint (displayed below textline or textbox, above msg)
- msg : string (may have HTML) giving extra message response
(displayed below textline or textbox)
- hint : string (may have HTML) giving optional hint
(displayed below textline or textbox, above msg)
- hintmode : one of (None,'on_request','always') criteria for displaying hint
- queuestate : Dict {key:'', time:''} where key is a secret string, and time is a string dump
of a DateTime object in the format '%Y%m%d%H%M%S'. Is None when not queued
Behaves as a dict.
'''
"""
def __init__(self, *args, **kwargs):
self.cmap = dict() # start with empty dict
# start with empty dict
self.cmap = dict()
self.items = self.cmap.items
self.keys = self.cmap.keys
self.set(*args, **kwargs)
......@@ -33,7 +36,15 @@ class CorrectMap(object):
return self.cmap.__iter__()
# See the documentation for 'set_dict' for the use of kwargs
def set(self, answer_id=None, correctness=None, npoints=None, msg='', hint='', hintmode=None, queuestate=None, **kwargs):
def set(self,
answer_id=None,
correctness=None,
npoints=None,
msg='',
hint='',
hintmode=None,
queuestate=None, **kwargs):
if answer_id is not None:
self.cmap[answer_id] = {'correctness': correctness,
'npoints': npoints,
......@@ -56,9 +67,10 @@ class CorrectMap(object):
'''
Set internal dict of CorrectMap to provided correct_map dict
correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This means that
when the definition of CorrectMap (e.g. its properties) are altered, existing correct_map dict
not coincide with the newest CorrectMap format as defined by self.set.
correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This
means that when the definition of CorrectMap (e.g. its properties) are altered,
an existing correct_map dict not coincide with the newest CorrectMap format as
defined by self.set.
For graceful migration, feed the contents of each correct map to self.set, rather than
making a direct copy of the given correct_map dict. This way, the common keys between
......@@ -69,14 +81,20 @@ class CorrectMap(object):
If correct_map is a one-level dict, then convert it to the new dict of dicts format.
'''
if correct_map and not (type(correct_map[correct_map.keys()[0]]) == dict):
self.__init__() # empty current dict
for k in correct_map: self.set(k, correct_map[k]) # create new dict entries
# empty current dict
self.__init__()
# create new dict entries
for k in correct_map:
self.set(k, correct_map[k])
else:
self.__init__()
for k in correct_map: self.set(k, **correct_map[k])
for k in correct_map:
self.set(k, **correct_map[k])
def is_correct(self, answer_id):
if answer_id in self.cmap: return self.cmap[answer_id]['correctness'] == 'correct'
if answer_id in self.cmap:
return self.cmap[answer_id]['correctness'] == 'correct'
return None
def is_queued(self, answer_id):
......@@ -94,14 +112,18 @@ class CorrectMap(object):
return npoints
elif self.is_correct(answer_id):
return 1
return 0 # if not correct and no points have been assigned, return 0
# if not correct and no points have been assigned, return 0
return 0
def set_property(self, answer_id, property, value):
if answer_id in self.cmap: self.cmap[answer_id][property] = value
else: self.cmap[answer_id] = {property: value}
if answer_id in self.cmap:
self.cmap[answer_id][property] = value
else:
self.cmap[answer_id] = {property: value}
def get_property(self, answer_id, property, default=None):
if answer_id in self.cmap: return self.cmap[answer_id].get(property, default)
if answer_id in self.cmap:
return self.cmap[answer_id].get(property, default)
return default
def get_correctness(self, answer_id):
......
""" Standard resistor codes.
"""
Standard resistor codes.
http://en.wikipedia.org/wiki/Electronic_color_code
"""
E6 = [10, 15, 22, 33, 47, 68]
E12 = [10, 12, 15, 18, 22, 27, 33, 39, 47, 56, 68, 82]
E24 = [10, 12, 15, 18, 22, 27, 33, 39, 47, 56, 68, 82, 11, 13, 16, 20, 24, 30, 36, 43, 51, 62, 75, 91]
E48 = [100, 121, 147, 178, 215, 261, 316, 383, 464, 562, 681, 825, 105, 127, 154, 187, 226, 274, 332, 402, 487, 590, 715, 866, 110, 133, 162, 196, 237, 287, 348, 422, 511, 619, 750, 909, 115, 140, 169, 205, 249, 301, 365, 442, 536, 649, 787, 953]
E96 = [100, 121, 147, 178, 215, 261, 316, 383, 464, 562, 681, 825, 102, 124, 150, 182, 221, 267, 324, 392, 475, 576, 698, 845, 105, 127, 154, 187, 226, 274, 332, 402, 487, 590, 715, 866, 107, 130, 158, 191, 232, 280, 340, 412, 499, 604, 732, 887, 110, 133, 162, 196, 237, 287, 348, 422, 511, 619, 750, 909, 113, 137, 165, 200, 243, 294, 357, 432, 523, 634, 768, 931, 115, 140, 169, 205, 249, 301, 365, 442, 536, 649, 787, 953, 118, 143, 174, 210, 255, 309, 374, 453, 549, 665, 806, 976]
E192 = [100, 121, 147, 178, 215, 261, 316, 383, 464, 562, 681, 825, 101, 123, 149, 180, 218, 264, 320, 388, 470, 569, 690, 835, 102, 124, 150, 182, 221, 267, 324, 392, 475, 576, 698, 845, 104, 126, 152, 184, 223, 271, 328, 397, 481, 583, 706, 856, 105, 127, 154, 187, 226, 274, 332, 402, 487, 590, 715, 866, 106, 129, 156, 189, 229, 277, 336, 407, 493, 597, 723, 876, 107, 130, 158, 191, 232, 280, 340, 412, 499, 604, 732, 887, 109, 132, 160, 193, 234, 284, 344, 417, 505, 612, 741, 898, 110, 133, 162, 196, 237, 287, 348, 422, 511, 619, 750, 909, 111, 135, 164, 198, 240, 291, 352, 427, 517, 626, 759, 920, 113, 137, 165, 200, 243, 294, 357, 432, 523, 634, 768, 931, 114, 138, 167, 203, 246, 298, 361, 437, 530, 642, 777, 942, 115, 140, 169, 205, 249, 301, 365, 442, 536, 649, 787, 953, 117, 142, 172, 208, 252, 305, 370, 448, 542, 657, 796, 965, 118, 143, 174, 210, 255, 309, 374, 453, 549, 665, 806, 976, 120, 145, 176, 213, 258, 312, 379, 459, 556, 673, 816, 988]
......@@ -2,7 +2,7 @@
# File: courseware/capa/inputtypes.py
#
'''
"""
Module containing the problem elements which render into input objects
- textline
......@@ -16,15 +16,16 @@ Module containing the problem elements which render into input objects
- optioninput (for option list)
- filesubmission (upload a file)
These are matched by *.html files templates/*.html which are mako templates with the actual html.
These are matched by *.html files templates/*.html which are mako templates with the
actual html.
Each input type takes the xml tree as 'element', the previous answer as 'value', and the graded status as 'status'
Each input type takes the xml tree as 'element', the previous answer as 'value', and the
graded status as'status'
"""
'''
# TODO: rename "state" to "status" for all below
# status is currently the answer for the problem ID for the input element,
# but it will turn into a dict containing both the answer and any associated message for the problem ID for the input element.
# TODO: rename "state" to "status" for all below. status is currently the answer for the
# problem ID for the input element, but it will turn into a dict containing both the
# answer and any associated message for the problem ID for the input element.
import logging
import re
......@@ -47,7 +48,8 @@ class SimpleInput():# XModule
Type for simple inputs -- plain HTML with a form element
'''
xml_tags = {} # # Maps tags to functions
# Maps tags to functions
xml_tags = {}
def __init__(self, system, xml, item_id=None, track_url=None, state=None, use='capa_input'):
'''
......@@ -69,19 +71,23 @@ class SimpleInput():# XModule
self.xml = xml
self.tag = xml.tag
self.system = system
if not state: state = {}
if not state:
state = {}
## ID should only come from one place.
## NOTE: ID should only come from one place.
## If it comes from multiple, we use state first, XML second, and parameter
## third. Since we don't make this guarantee, we can swap this around in
## the future if there's a more logical order.
if item_id: self.id = item_id
if xml.get('id'): self.id = xml.get('id')
if 'id' in state: self.id = state['id']
if item_id:
self.id = item_id
if xml.get('id'):
self.id = xml.get('id')
if 'id' in state:
self.id = state['id']
self.value = ''
if 'value' in state:
self.value = state['value']
self.value = state.get('value', '')
self.msg = ''
feedback = state.get('feedback')
......@@ -92,6 +98,7 @@ class SimpleInput():# XModule
# put hint above msg if to be displayed
if self.hintmode == 'always':
# TODO: is the '.' in <br/.> below a bug?
self.msg = self.hint + ('<br/.>' if self.msg else '') + self.msg
self.status = 'unanswered'
......@@ -107,7 +114,8 @@ class SimpleInput():# XModule
return ['capa_input', 'capa_transform']
def get_html(self):
return self.xml_tags[self.tag](self.xml, self.value, self.status, self.system.render_template, self.msg)
return self.xml_tags[self.tag](self.xml, self.value,
self.status, self.system.render_template, self.msg)
def register_render_function(fn, names=None, cls=SimpleInput):
......@@ -125,24 +133,26 @@ def register_render_function(fn, names=None, cls=SimpleInput):
@register_render_function
def optioninput(element, value, status, render_template, msg=''):
'''
"""
Select option input type.
Example:
<optioninput options="('Up','Down')" correct="Up"/><text>The location of the sky</text>
'''
"""
eid = element.get('id')
options = element.get('options')
if not options:
raise Exception("[courseware.capa.inputtypes.optioninput] Missing options specification in " + etree.tostring(element))
raise Exception(
"[courseware.capa.inputtypes.optioninput] Missing options specification in "
+ etree.tostring(element))
oset = shlex.shlex(options[1:-1])
oset.quotes = "'"
oset.whitespace = ","
oset = [x[1:-1] for x in list(oset)]
# osetdict = dict([('option_%s_%s' % (eid,x),oset[x]) for x in range(len(oset)) ]) # make dict with IDs
osetdict = [(oset[x], oset[x]) for x in range(len(oset))] # make ordered list with (key,value) same
# make ordered list with (key,value) same
osetdict = [(oset[x], oset[x]) for x in range(len(oset))]
# TODO: allow ordering to be randomized
context = {'id': eid,
......@@ -166,26 +176,35 @@ def choicegroup(element, value, status, render_template, msg=''):
'''
Radio button inputs: multiple choice or true/false
TODO: allow order of choices to be randomized, following lon-capa spec. Use "location" attribute,
ie random, top, bottom.
TODO: allow order of choices to be randomized, following lon-capa spec. Use
"location" attribute, ie random, top, bottom.
'''
eid = element.get('id')
if element.get('type') == "MultipleChoice":
type = "radio"
element_type = "radio"
elif element.get('type') == "TrueFalse":
type = "checkbox"
element_type = "checkbox"
else:
type = "radio"
element_type = "radio"
choices = []
for choice in element:
if not choice.tag == 'choice':
raise Exception("[courseware.capa.inputtypes.choicegroup] Error only <choice> tags should be immediate children of a <choicegroup>, found %s instead" % choice.tag)
raise Exception("[courseware.capa.inputtypes.choicegroup] "
"Error: only <choice> tags should be immediate children "
"of a <choicegroup>, found %s instead" % choice.tag)
ctext = ""
ctext += ''.join([etree.tostring(x) for x in choice]) # TODO: what if choice[0] has math tags in it?
# TODO: what if choice[0] has math tags in it?
ctext += ''.join([etree.tostring(x) for x in choice])
if choice.text is not None:
ctext += choice.text # TODO: fix order?
# TODO: fix order?
ctext += choice.text
choices.append((choice.get("name"), ctext))
context = {'id': eid, 'value': value, 'state': status, 'input_type': type, 'choices': choices, 'name_array_suffix': ''}
context = {'id': eid,
'value': value,
'state': status,
'input_type': element_type,
'choices': choices,
'name_array_suffix': ''}
html = render_template("choicegroup.html", context)
return etree.XML(html)
......@@ -196,8 +215,8 @@ def extract_choices(element):
Extracts choices for a few input types, such as radiogroup and
checkboxgroup.
TODO: allow order of choices to be randomized, following lon-capa spec. Use "location" attribute,
ie random, top, bottom.
TODO: allow order of choices to be randomized, following lon-capa spec. Use
"location" attribute, ie random, top, bottom.
'''
choices = []
......@@ -226,7 +245,12 @@ def radiogroup(element, value, status, render_template, msg=''):
choices = extract_choices(element)
context = {'id': eid, 'value': value, 'state': status, 'input_type': 'radio', 'choices': choices, 'name_array_suffix': '[]'}
context = {'id': eid,
'value': value,
'state': status,
'input_type': 'radio',
'choices': choices,
'name_array_suffix': '[]'}
html = render_template("choicegroup.html", context)
return etree.XML(html)
......@@ -244,7 +268,12 @@ def checkboxgroup(element, value, status, render_template, msg=''):
choices = extract_choices(element)
context = {'id': eid, 'value': value, 'state': status, 'input_type': 'checkbox', 'choices': choices, 'name_array_suffix': '[]'}
context = {'id': eid,
'value': value,
'state': status,
'input_type': 'checkbox',
'choices': choices,
'name_array_suffix': '[]'}
html = render_template("choicegroup.html", context)
return etree.XML(html)
......@@ -269,40 +298,58 @@ def javascriptinput(element, value, status, render_template, msg='null'):
escapedict = {'"': '&quot;'}
value = saxutils.escape(value, escapedict)
msg = saxutils.escape(msg, escapedict)
context = {'id': eid, 'params': params, 'display_file': display_file,
'display_class': display_class, 'problem_state': problem_state,
'value': value, 'evaluation': msg,
context = {'id': eid,
'params': params,
'display_file': display_file,
'display_class': display_class,
'problem_state': problem_state,
'value': value,
'evaluation': msg,
}
html = render_template("javascriptinput.html", context)
return etree.XML(html)
@register_render_function
def textline(element, value, status, render_template, msg=""):
'''
Simple text line input, with optional size specification.
'''
if element.get('math') or element.get('dojs'): # 'dojs' flag is temporary, for backwards compatibility with 8.02x
return SimpleInput.xml_tags['textline_dynamath'](element, value, status, render_template, msg)
# TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x
if element.get('math') or element.get('dojs'):
return SimpleInput.xml_tags['textline_dynamath'](element, value, status,
render_template, msg)
eid = element.get('id')
if eid is None:
msg = 'textline has no id: it probably appears outside of a known response type'
msg += "\nSee problem XML source line %s" % getattr(element, 'sourceline', '<unavailable>')
raise Exception(msg)
count = int(eid.split('_')[-2]) - 1 # HACK
size = element.get('size')
hidden = element.get('hidden', '') # if specified, then textline is hidden and id is stored in div of name given by hidden
# if specified, then textline is hidden and id is stored in div of name given by hidden
hidden = element.get('hidden', '')
# Escape answers with quotes, so they don't crash the system!
escapedict = {'"': '&quot;'}
value = saxutils.escape(value, escapedict) # otherwise, answers with quotes in them crashes the system!
context = {'id': eid, 'value': value, 'state': status, 'count': count, 'size': size, 'msg': msg, 'hidden': hidden,
value = saxutils.escape(value, escapedict)
context = {'id': eid,
'value': value,
'state': status,
'count': count,
'size': size,
'msg': msg,
'hidden': hidden,
'inline': element.get('inline',''),
}
html = render_template("textinput.html", context)
try:
xhtml = etree.XML(html)
except Exception as err:
if True: # TODO needs to be self.system.DEBUG - but can't access system
# TODO: needs to be self.system.DEBUG - but can't access system
if True:
log.debug('[inputtypes.textline] failed to parse XML for:\n%s' % html)
raise
return xhtml
......@@ -313,7 +360,8 @@ def textline(element, value, status, render_template, msg=""):
@register_render_function
def textline_dynamath(element, value, status, render_template, msg=''):
'''
Text line input with dynamic math display (equation rendered on client in real time during input).
Text line input with dynamic math display (equation rendered on client in real time
during input).
'''
# TODO: Make a wrapper for <formulainput>
# TODO: Make an AJAX loop to confirm equation is okay in real-time as user types
......@@ -325,7 +373,8 @@ def textline_dynamath(element, value, status, render_template, msg=''):
eid = element.get('id')
count = int(eid.split('_')[-2]) - 1 # HACK
size = element.get('size')
hidden = element.get('hidden', '') # if specified, then textline is hidden and id is stored in div of name given by hidden
# if specified, then textline is hidden and id is stored in div of name given by hidden
hidden = element.get('hidden', '')
# Preprocessor to insert between raw input and Mathjax
preprocessor = {'class_name': element.get('preprocessorClassName',''),
......@@ -337,10 +386,14 @@ def textline_dynamath(element, value, status, render_template, msg=''):
escapedict = {'"': '&quot;'}
value = saxutils.escape(value, escapedict)
context = {'id': eid, 'value': value, 'state': status, 'count': count, 'size': size,
'msg': msg, 'hidden': hidden,
'preprocessor': preprocessor,
}
context = {'id': eid,
'value': value,
'state': status,
'count': count,
'size': size,
'msg': msg,
'hidden': hidden,
'preprocessor': preprocessor,}
html = render_template("textinput_dynamath.html", context)
return etree.XML(html)
......@@ -360,15 +413,19 @@ def filesubmission(element, value, status, render_template, msg=''):
# Check if problem has been queued
queue_len = 0
if status == 'incomplete': # Flag indicating that the problem has been queued, 'msg' is length of queue
# Flag indicating that the problem has been queued, 'msg' is length of queue
if status == 'incomplete':
status = 'queued'
queue_len = msg
msg = 'Submitted to grader.'
context = { 'id': eid, 'state': status, 'msg': msg, 'value': value,
'queue_len': queue_len, 'allowed_files': allowed_files,
'required_files': required_files
}
context = { 'id': eid,
'state': status,
'msg': msg,
'value': value,
'queue_len': queue_len,
'allowed_files': allowed_files,
'required_files': required_files,}
html = render_template("filesubmission.html", context)
return etree.XML(html)
......@@ -387,13 +444,17 @@ def textbox(element, value, status, render_template, msg=''):
size = element.get('size')
rows = element.get('rows') or '30'
cols = element.get('cols') or '80'
hidden = element.get('hidden', '') # if specified, then textline is hidden and id is stored in div of name given by hidden
# if specified, then textline is hidden and id is stored in div of name given by hidden
hidden = element.get('hidden', '')
if not value: value = element.text # if no student input yet, then use the default input given by the problem
# if no student input yet, then use the default input given by the problem
if not value:
value = element.text
# Check if problem has been queued
queue_len = 0
if status == 'incomplete': # Flag indicating that the problem has been queued, 'msg' is length of queue
# Flag indicating that the problem has been queued, 'msg' is length of queue
if status == 'incomplete':
status = 'queued'
queue_len = msg
msg = 'Submitted to grader.'
......@@ -404,10 +465,18 @@ def textbox(element, value, status, render_template, msg=''):
tabsize = element.get('tabsize','4')
tabsize = int(tabsize)
context = {'id': eid, 'value': value, 'state': status, 'count': count, 'size': size, 'msg': msg,
'mode': mode, 'linenumbers': linenumbers,
'rows': rows, 'cols': cols,
'hidden': hidden, 'tabsize': tabsize,
context = {'id': eid,
'value': value,
'state': status,
'count': count,
'size': size,
'msg': msg,
'mode': mode,
'linenumbers': linenumbers,
'rows': rows,
'cols': cols,
'hidden': hidden,
'tabsize': tabsize,
'queue_len': queue_len,
}
html = render_template("textbox.html", context)
......@@ -475,7 +544,8 @@ def math(element, value, status, render_template, msg=''):
# mathstr = mathstr.replace('\\displaystyle','')
#else:
# isinline = True
# html = render_template("mathstring.html",{'mathstr':mathstr,'isinline':isinline,'tail':element.tail})
# html = render_template("mathstring.html", {'mathstr':mathstr,
# 'isinline':isinline,'tail':element.tail})
html = '<html><html>%s</html><html>%s</html></html>' % (mathstr, saxutils.escape(element.tail))
try:
......@@ -483,7 +553,8 @@ def math(element, value, status, render_template, msg=''):
except Exception as err:
if False: # TODO needs to be self.system.DEBUG - but can't access system
msg = '<html><div class="inline-error"><p>Error %s</p>' % str(err).replace('<', '&lt;')
msg += '<p>Failed to construct math expression from <pre>%s</pre></p>' % html.replace('<', '&lt;')
msg += ('<p>Failed to construct math expression from <pre>%s</pre></p>' %
html.replace('<', '&lt;'))
msg += "</div></html>"
log.error(msg)
return etree.XML(msg)
......@@ -520,11 +591,13 @@ def solution(element, value, status, render_template, msg=''):
@register_render_function
def imageinput(element, value, status, render_template, msg=''):
'''
Clickable image as an input field. Element should specify the image source, height, and width, eg
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="388" height="560" />
Clickable image as an input field. Element should specify the image source, height,
and width, e.g.
TODO: showanswer for imageimput does not work yet - need javascript to put rectangle over acceptable area of image.
<imageinput src="/static/Figures/Skier-conservation-of-energy.jpg" width="388" height="560" />
TODO: showanswer for imageimput does not work yet - need javascript to put rectangle
over acceptable area of image.
'''
eid = element.get('id')
src = element.get('src')
......
......@@ -63,7 +63,7 @@ class StudentInputError(Exception):
class LoncapaResponse(object):
'''
"""
Base class for CAPA responsetypes. Each response type (ie a capa question,
which is part of a capa problem) is represented as a subclass,
which should provide the following methods:
......@@ -77,19 +77,29 @@ class LoncapaResponse(object):
In addition, these methods are optional:
- setup_response : find and note the answer input field IDs for the response; called by __init__
- check_hint_condition : check to see if the student's answers satisfy a particular condition for a hint to be displayed
- render_html : render this Response as HTML (must return XHTML compliant string)
- setup_response : find and note the answer input field IDs for the response; called
by __init__
- check_hint_condition : check to see if the student's answers satisfy a particular
condition for a hint to be displayed
- render_html : render this Response as HTML (must return XHTML-compliant string)
- __unicode__ : unicode representation of this Response
Each response type may also specify the following attributes:
- max_inputfields : (int) maximum number of answer input fields (checked in __init__ if not None)
- max_inputfields : (int) maximum number of answer input fields (checked in __init__
if not None)
- allowed_inputfields : list of allowed input fields (each a string) for this Response
- required_attributes : list of required attributes (each a string) on the main response XML stanza
- hint_tag : xhtml tag identifying hint associated with this response inside hintgroup
'''
- required_attributes : list of required attributes (each a string) on the main
response XML stanza
- hint_tag : xhtml tag identifying hint associated with this response inside
hintgroup
"""
__metaclass__ = abc.ABCMeta # abc = Abstract Base Class
response_tag = None
......@@ -121,26 +131,32 @@ class LoncapaResponse(object):
raise LoncapaProblemError(msg)
if self.max_inputfields and len(inputfields) > self.max_inputfields:
msg = "%s: cannot have more than %s input fields" % (unicode(self), self.max_inputfields)
msg = "%s: cannot have more than %s input fields" % (
unicode(self), self.max_inputfields)
msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '<unavailable>')
raise LoncapaProblemError(msg)
for prop in self.required_attributes:
if not xml.get(prop):
msg = "Error in problem specification: %s missing required attribute %s" % (unicode(self), prop)
msg = "Error in problem specification: %s missing required attribute %s" % (
unicode(self), prop)
msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '<unavailable>')
raise LoncapaProblemError(msg)
self.answer_ids = [x.get('id') for x in self.inputfields] # ordered list of answer_id values for this response
# ordered list of answer_id values for this response
self.answer_ids = [x.get('id') for x in self.inputfields]
if self.max_inputfields == 1:
self.answer_id = self.answer_ids[0] # for convenience
# for convenience
self.answer_id = self.answer_ids[0]
self.maxpoints = dict()
for inputfield in self.inputfields:
maxpoints = inputfield.get('points','1') # By default, each answerfield is worth 1 point
# By default, each answerfield is worth 1 point
maxpoints = inputfield.get('points', '1')
self.maxpoints.update({inputfield.get('id'): int(maxpoints)})
self.default_answer_map = {} # dict for default answer map (provided in input elements)
# dict for default answer map (provided in input elements)
self.default_answer_map = {}
for entry in self.inputfields:
answer = entry.get('correct_answer')
if answer:
......@@ -163,10 +179,13 @@ class LoncapaResponse(object):
- renderer : procedure which produces HTML given an ElementTree
'''
tree = etree.Element('span') # render ourself as a <span> + our content
# render ourself as a <span> + our content
tree = etree.Element('span')
for item in self.xml:
item_xhtml = renderer(item) # call provided procedure to do the rendering
if item_xhtml is not None: tree.append(item_xhtml)
# call provided procedure to do the rendering
item_xhtml = renderer(item)
if item_xhtml is not None:
tree.append(item_xhtml)
tree.tail = self.xml.tail
return tree
......@@ -192,21 +211,21 @@ class LoncapaResponse(object):
Modifies new_cmap, by adding hints to answer_id entries as appropriate.
'''
hintgroup = self.xml.find('hintgroup')
if hintgroup is None: return
if hintgroup is None:
return
# hint specified by function?
hintfn = hintgroup.get('hintfn')
if hintfn:
'''
Hint is determined by a function defined in the <script> context; evaluate that function to obtain
list of hint, hintmode for each answer_id.
Hint is determined by a function defined in the <script> context; evaluate
that function to obtain list of hint, hintmode for each answer_id.
The function should take arguments (answer_ids, student_answers, new_cmap, old_cmap)
and it should modify new_cmap as appropriate.
We may extend this in the future to add another argument which provides a callback procedure
to a social hint generation system.
We may extend this in the future to add another argument which provides a
callback procedure to a social hint generation system.
'''
if not hintfn in self.context:
msg = 'missing specified hint function %s in script context' % hintfn
......@@ -237,14 +256,20 @@ class LoncapaResponse(object):
# </hintgroup>
# </formularesponse>
if self.hint_tag is not None and hintgroup.find(self.hint_tag) is not None and hasattr(self, 'check_hint_condition'):
if (self.hint_tag is not None
and hintgroup.find(self.hint_tag) is not None
and hasattr(self, 'check_hint_condition')):
rephints = hintgroup.findall(self.hint_tag)
hints_to_show = self.check_hint_condition(rephints, student_answers)
hintmode = hintgroup.get('mode', 'always') # can be 'on_request' or 'always' (default)
# can be 'on_request' or 'always' (default)
hintmode = hintgroup.get('mode', 'always')
for hintpart in hintgroup.findall('hintpart'):
if hintpart.get('on') in hints_to_show:
hint_text = hintpart.find('text').text
aid = self.answer_ids[-1] # make the hint appear after the last answer box in this response
# make the hint appear after the last answer box in this response
aid = self.answer_ids[-1]
new_cmap.set_hint_and_mode(aid, hint_text, hintmode)
log.debug('after hint: new_cmap = %s' % new_cmap)
......@@ -255,10 +280,10 @@ class LoncapaResponse(object):
(correctness, npoints, msg) for each answer_id.
Arguments:
- student_answers : dict of (answer_id,answer) where answer = student input (string)
- old_cmap : previous CorrectMap (may be empty); useful for analyzing or recording history of responses
- old_cmap : previous CorrectMap (may be empty); useful for analyzing or
recording history of responses
'''
pass
......@@ -273,10 +298,13 @@ class LoncapaResponse(object):
'''
Return a list of hints to show.
- hxml_set : list of Element trees, each specifying a condition to be satisfied for a named hint condition
- hxml_set : list of Element trees, each specifying a condition to be
satisfied for a named hint condition
- student_answers : dict of student answers
Returns a list of names of hint conditions which were satisfied. Those are used to determine which hints are displayed.
Returns a list of names of hint conditions which were satisfied. Those are used
to determine which hints are displayed.
'''
pass
......@@ -290,10 +318,10 @@ class LoncapaResponse(object):
#-----------------------------------------------------------------------------
class JavascriptResponse(LoncapaResponse):
'''
"""
This response type is used when the student's answer is graded via
Javascript using Node.js.
'''
"""
response_tag = 'javascriptresponse'
max_inputfields = 1
......@@ -483,7 +511,7 @@ class JavascriptResponse(LoncapaResponse):
#-----------------------------------------------------------------------------
class ChoiceResponse(LoncapaResponse):
'''
"""
This response type is used when the student chooses from a discrete set of
choices. Currently, to be marked correct, all "correct" choices must be
supplied by the student, and no extraneous choices may be included.
......@@ -528,7 +556,7 @@ class ChoiceResponse(LoncapaResponse):
choices must be given names. This behavior seems like a leaky abstraction,
and it'd be nice to change this at some point.
'''
"""
response_tag = 'choiceresponse'
max_inputfields = 1
......@@ -594,7 +622,8 @@ class MultipleChoiceResponse(LoncapaResponse):
allowed_inputfields = ['choicegroup']
def setup_response(self):
self.mc_setup_response() # call secondary setup for MultipleChoice questions, to set name attributes
# call secondary setup for MultipleChoice questions, to set name attributes
self.mc_setup_response()
# define correct choices (after calling secondary setup)
xml = self.xml
......@@ -609,7 +638,8 @@ class MultipleChoiceResponse(LoncapaResponse):
for response in self.xml.xpath("choicegroup"):
rtype = response.get('type')
if rtype not in ["MultipleChoice"]:
response.set("type", "MultipleChoice") # force choicegroup to be MultipleChoice if not valid
# force choicegroup to be MultipleChoice if not valid
response.set("type", "MultipleChoice")
for choice in list(response):
if choice.get("name") is None:
choice.set("name", "choice_" + str(i))
......@@ -621,8 +651,10 @@ class MultipleChoiceResponse(LoncapaResponse):
'''
grade student response.
'''
# log.debug('%s: student_answers=%s, correct_choices=%s' % (unicode(self),student_answers,self.correct_choices))
if self.answer_id in student_answers and student_answers[self.answer_id] in self.correct_choices:
# log.debug('%s: student_answers=%s, correct_choices=%s' % (
# unicode(self), student_answers, self.correct_choices))
if (self.answer_id in student_answers
and student_answers[self.answer_id] in self.correct_choices):
return CorrectMap(self.answer_id, 'correct')
else:
return CorrectMap(self.answer_id, 'incorrect')
......@@ -662,10 +694,14 @@ class OptionResponse(LoncapaResponse):
'''
TODO: handle direction and randomize
'''
snippets = [{'snippet': '''<optionresponse direction="vertical" randomize="yes">
<optioninput options="('Up','Down')" correct="Up"><text>The location of the sky</text></optioninput>
<optioninput options="('Up','Down')" correct="Down"><text>The location of the earth</text></optioninput>
</optionresponse>'''}]
snippets = [{'snippet': """<optionresponse direction="vertical" randomize="yes">
<optioninput options="('Up','Down')" correct="Up">
<text>The location of the sky</text>
</optioninput>
<optioninput options="('Up','Down')" correct="Down">
<text>The location of the earth</text>
</optioninput>
</optionresponse>"""}]
response_tag = 'optionresponse'
hint_tag = 'optionhint'
......@@ -721,12 +757,13 @@ class NumericalResponse(LoncapaResponse):
'''Grade a numeric response '''
student_answer = student_answers[self.answer_id]
try:
correct = compare_with_tolerance(evaluator(dict(), dict(), student_answer), complex(self.correct_answer), self.tolerance)
correct = compare_with_tolerance(evaluator(dict(), dict(), student_answer),
complex(self.correct_answer), self.tolerance)
# We should catch this explicitly.
# I think this is just pyparsing.ParseException, calc.UndefinedVariable:
# But we'd need to confirm
except:
raise StudentInputError("Invalid input: could not interpret '%s' as a number" %\
raise StudentInputError("Invalid input: could not interpret '%s' as a number" %
cgi.escape(student_answer))
if correct:
......@@ -734,7 +771,7 @@ class NumericalResponse(LoncapaResponse):
else:
return CorrectMap(self.answer_id, 'incorrect')
# TODO: add check_hint_condition(self,hxml_set,student_answers)
# TODO: add check_hint_condition(self, hxml_set, student_answers)
def get_answers(self):
return {self.answer_id: self.correct_answer}
......@@ -784,7 +821,7 @@ class CustomResponse(LoncapaResponse):
Custom response. The python code to be run should be in <answer>...</answer>
or in a <script>...</script>
'''
snippets = [{'snippet': '''<customresponse>
snippets = [{'snippet': """<customresponse>
<text>
<br/>
Suppose that \(I(t)\) rises from \(0\) to \(I_S\) at a time \(t_0 \neq 0\)
......@@ -802,8 +839,8 @@ class CustomResponse(LoncapaResponse):
if not(r=="IS*u(t-t0)"):
correct[0] ='incorrect'
</answer>
</customresponse>'''},
{'snippet': '''<script type="loncapa/python"><![CDATA[
</customresponse>"""},
{'snippet': """<script type="loncapa/python"><![CDATA[
def sympy_check2():
messages[0] = '%s:%s' % (submission[0],fromjs[0].replace('<','&lt;'))
......@@ -816,7 +853,7 @@ def sympy_check2():
<customresponse cfn="sympy_check2" type="cs" expect="2.27E-39" dojs="math" size="30" answer="2.27E-39">
<textline size="40" dojs="math" />
<responseparam description="Numerical Tolerance" type="tolerance" default="0.00001" name="tol"/>
</customresponse>'''}]
</customresponse>"""}]
response_tag = 'customresponse'
allowed_inputfields = ['textline', 'textbox']
......@@ -830,7 +867,8 @@ def sympy_check2():
log.debug('answer_ids=%s' % self.answer_ids)
# the <answer>...</answer> stanza should be local to the current <customresponse>. So try looking there first.
# the <answer>...</answer> stanza should be local to the current <customresponse>.
# So try looking there first.
self.code = None
answer = None
try:
......@@ -838,8 +876,9 @@ def sympy_check2():
except IndexError:
# print "xml = ",etree.tostring(xml,pretty_print=True)
# if we have a "cfn" attribute then look for the function specified by cfn, in the problem context
# ie the comparison function is defined in the <script>...</script> stanza instead
# if we have a "cfn" attribute then look for the function specified by cfn, in
# the problem context ie the comparison function is defined in the
# <script>...</script> stanza instead
cfn = xml.get('cfn')
if cfn:
log.debug("cfn = %s" % cfn)
......@@ -847,13 +886,14 @@ def sympy_check2():
self.code = self.context[cfn]
else:
msg = "%s: can't find cfn %s in context" % (unicode(self), cfn)
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>')
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline',
'<unavailable>')
raise LoncapaProblemError(msg)
if not self.code:
if answer is None:
# raise Exception,"[courseware.capa.responsetypes.customresponse] missing code checking script! id=%s" % self.myid
log.error("[courseware.capa.responsetypes.customresponse] missing code checking script! id=%s" % self.myid)
log.error("[courseware.capa.responsetypes.customresponse] missing"
" code checking script! id=%s" % self.myid)
self.code = ''
else:
answer_src = answer.get('src')
......@@ -870,41 +910,68 @@ def sympy_check2():
log.debug('%s: student_answers=%s' % (unicode(self), student_answers))
idset = sorted(self.answer_ids) # ordered list of answer id's
# ordered list of answer id's
idset = sorted(self.answer_ids)
try:
submission = [student_answers[k] for k in idset] # ordered list of answers
# ordered list of answers
submission = [student_answers[k] for k in idset]
except Exception as err:
msg = '[courseware.capa.responsetypes.customresponse] error getting student answer from %s' % student_answers
msg = ('[courseware.capa.responsetypes.customresponse] error getting'
' student answer from %s' % student_answers)
msg += '\n idset = %s, error = %s' % (idset, err)
log.error(msg)
raise Exception(msg)
# global variable in context which holds the Presentation MathML from dynamic math input
dynamath = [student_answers.get(k + '_dynamath', None) for k in idset] # ordered list of dynamath responses
# ordered list of dynamath responses
dynamath = [student_answers.get(k + '_dynamath', None) for k in idset]
# if there is only one box, and it's empty, then don't evaluate
if len(idset) == 1 and not submission[0]:
# default to no error message on empty answer (to be consistent with other responsetypes)
# but allow author to still have the old behavior by setting empty_answer_err attribute
msg = '<span class="inline-error">No answer entered!</span>' if self.xml.get('empty_answer_err') else ''
# default to no error message on empty answer (to be consistent with other
# responsetypes) but allow author to still have the old behavior by setting
# empty_answer_err attribute
msg = ('<span class="inline-error">No answer entered!</span>'
if self.xml.get('empty_answer_err') else '')
return CorrectMap(idset[0], 'incorrect', msg=msg)
# NOTE: correct = 'unknown' could be dangerous. Inputtypes such as textline are not expecting 'unknown's
# NOTE: correct = 'unknown' could be dangerous. Inputtypes such as textline are
# not expecting 'unknown's
correct = ['unknown'] * len(idset)
messages = [''] * len(idset)
# put these in the context of the check function evaluator
# note that this doesn't help the "cfn" version - only the exec version
self.context.update({'xml': self.xml, # our subtree
'response_id': self.myid, # my ID
'expect': self.expect, # expected answer (if given as attribute)
'submission': submission, # ordered list of student answers from entry boxes in our subtree
'idset': idset, # ordered list of ID's of all entry boxes in our subtree
'dynamath': dynamath, # ordered list of all javascript inputs in our subtree
'answers': student_answers, # dict of student's responses, with keys being entry box IDs
'correct': correct, # the list to be filled in by the check function
'messages': messages, # the list of messages to be filled in by the check function
'options': self.xml.get('options'), # any options to be passed to the cfn
self.context.update({
# our subtree
'xml': self.xml,
# my ID
'response_id': self.myid,
# expected answer (if given as attribute)
'expect': self.expect,
# ordered list of student answers from entry boxes in our subtree
'submission': submission,
# ordered list of ID's of all entry boxes in our subtree
'idset': idset,
# ordered list of all javascript inputs in our subtree
'dynamath': dynamath,
# dict of student's responses, with keys being entry box IDs
'answers': student_answers,
# the list to be filled in by the check function
'correct': correct,
# the list of messages to be filled in by the check function
'messages': messages,
# any options to be passed to the cfn
'options': self.xml.get('options'),
'testdat': 'hello world',
})
......@@ -921,8 +988,10 @@ def sympy_check2():
print "oops in customresponse (code) error %s" % err
print "context = ", self.context
print traceback.format_exc()
raise StudentInputError("Error: Problem could not be evaluated with your input") # Notify student
else: # self.code is not a string; assume its a function
# Notify student
raise StudentInputError("Error: Problem could not be evaluated with your input")
else:
# self.code is not a string; assume its a function
# this is an interface to the Tutor2 check functions
fn = self.code
......@@ -958,7 +1027,8 @@ def sympy_check2():
msg = '<html>' + msg + '</html>'
msg = msg.replace('&#60;', '&lt;')
#msg = msg.replace('&lt;','<')
msg = etree.tostring(fromstring_bs(msg, convertEntities=None), pretty_print=True)
msg = etree.tostring(fromstring_bs(msg, convertEntities=None),
pretty_print=True)
#msg = etree.tostring(fromstring_bs(msg),pretty_print=True)
msg = msg.replace('&#13;', '')
#msg = re.sub('<html>(.*)</html>','\\1',msg,flags=re.M|re.DOTALL) # python 2.7
......@@ -1022,18 +1092,19 @@ class SymbolicResponse(CustomResponse):
class CodeResponse(LoncapaResponse):
'''
"""
Grade student code using an external queueing server, called 'xqueue'
Expects 'xqueue' dict in ModuleSystem with the following keys that are needed by CodeResponse:
system.xqueue = { 'interface': XqueueInterface object,
'callback_url': Per-StudentModule callback URL where results are posted (string),
'callback_url': Per-StudentModule callback URL
where results are posted (string),
'default_queuename': Default queuename to submit request (string)
}
External requests are only submitted for student submission grading
(i.e. and not for getting reference answers)
'''
"""
response_tag = 'coderesponse'
allowed_inputfields = ['textbox', 'filesubmission']
......@@ -1046,7 +1117,8 @@ class CodeResponse(LoncapaResponse):
TODO: Determines whether in synchronous or asynchronous (queued) mode
'''
xml = self.xml
self.url = xml.get('url', None) # TODO: XML can override external resource (grader/queue) URL
# TODO: XML can override external resource (grader/queue) URL
self.url = xml.get('url', None)
self.queue_name = xml.get('queuename', self.system.xqueue['default_queuename'])
# VS[compat]:
......@@ -1107,7 +1179,8 @@ class CodeResponse(LoncapaResponse):
# Extract 'answer' and 'initial_display' from XML. Note that the code to be exec'ed here is:
# (1) Internal edX code, i.e. NOT student submissions, and
# (2) The code should only define the strings 'initial_display', 'answer', 'preamble', 'test_program'
# (2) The code should only define the strings 'initial_display', 'answer',
# 'preamble', 'test_program'
# following the ExternalResponse XML format
penv = {}
penv['__builtins__'] = globals()['__builtins__']
......@@ -1120,10 +1193,12 @@ class CodeResponse(LoncapaResponse):
self.answer = penv['answer']
self.initial_display = penv['initial_display']
except Exception as err:
log.error("Error in CodeResponse %s: Problem reference code does not define 'answer' and/or 'initial_display' in <answer>...</answer>" % err)
log.error("Error in CodeResponse %s: Problem reference code does not define"
" 'answer' and/or 'initial_display' in <answer>...</answer>" % err)
raise Exception(err)
# Finally, make the ExternalResponse input XML format conform to the generic exteral grader interface
# Finally, make the ExternalResponse input XML format conform to the generic
# exteral grader interface
# The XML tagging of grader_payload is pyxserver-specific
grader_payload = '<pyxserver>'
grader_payload += '<tests>' + tests + '</tests>\n'
......@@ -1133,9 +1208,11 @@ class CodeResponse(LoncapaResponse):
def get_score(self, student_answers):
try:
submission = student_answers[self.answer_id] # Note that submission can be a file
# Note that submission can be a file
submission = student_answers[self.answer_id]
except Exception as err:
log.error('Error in CodeResponse %s: cannot get student answer for %s; student_answers=%s' %
log.error('Error in CodeResponse %s: cannot get student answer for %s;'
' student_answers=%s' %
(err, self.answer_id, convert_files_to_filenames(student_answers)))
raise Exception(err)
......@@ -1157,7 +1234,8 @@ class CodeResponse(LoncapaResponse):
# Generate body
if is_list_of_files(submission):
self.context.update({'submission': ''}) # TODO: Get S3 pointer from the Queue
# TODO: Get S3 pointer from the Queue
self.context.update({'submission': ''})
else:
self.context.update({'submission': submission})
......@@ -1171,7 +1249,8 @@ class CodeResponse(LoncapaResponse):
# Submit request. When successful, 'msg' is the prior length of the queue
if is_list_of_files(submission):
contents.update({'student_response': ''}) # TODO: Is there any information we want to send here?
# TODO: Is there any information we want to send here?
contents.update({'student_response': ''})
(error, msg) = qinterface.send_to_queue(header=xheader,
body=json.dumps(contents),
files_to_upload=submission)
......@@ -1182,18 +1261,20 @@ class CodeResponse(LoncapaResponse):
# State associated with the queueing request
queuestate = {'key': queuekey,
'time': qtime,
}
'time': qtime,}
cmap = CorrectMap()
if error:
cmap.set(self.answer_id, queuestate=None,
msg='Unable to deliver your submission to grader. (Reason: %s.) Please try again later.' % msg)
msg='Unable to deliver your submission to grader. (Reason: %s.)'
' Please try again later.' % msg)
else:
# Queueing mechanism flags:
# 1) Backend: Non-null CorrectMap['queuestate'] indicates that the problem has been queued
# 2) Frontend: correctness='incomplete' eventually trickles down through inputtypes.textbox
# and .filesubmission to inform the browser to poll the LMS
# 1) Backend: Non-null CorrectMap['queuestate'] indicates that
# the problem has been queued
# 2) Frontend: correctness='incomplete' eventually trickles down
# through inputtypes.textbox and .filesubmission to inform the
# browser to poll the LMS
cmap.set(self.answer_id, queuestate=queuestate, correctness='incomplete', msg=msg)
return cmap
......@@ -1202,24 +1283,29 @@ class CodeResponse(LoncapaResponse):
(valid_score_msg, correct, points, msg) = self._parse_score_msg(score_msg)
if not valid_score_msg:
oldcmap.set(self.answer_id, msg='Invalid grader reply. Please contact the course staff.')
oldcmap.set(self.answer_id,
msg='Invalid grader reply. Please contact the course staff.')
return oldcmap
correctness = 'correct' if correct else 'incorrect'
self.context['correct'] = correctness # TODO: Find out how this is used elsewhere, if any
# TODO: Find out how this is used elsewhere, if any
self.context['correct'] = correctness
# Replace 'oldcmap' with new grading results if queuekey matches.
# If queuekey does not match, we keep waiting for the score_msg whose key actually matches
# Replace 'oldcmap' with new grading results if queuekey matches. If queuekey
# does not match, we keep waiting for the score_msg whose key actually matches
if oldcmap.is_right_queuekey(self.answer_id, queuekey):
# Sanity check on returned points
if points < 0:
points = 0
elif points > self.maxpoints[self.answer_id]:
points = self.maxpoints[self.answer_id]
oldcmap.set(self.answer_id, npoints=points, correctness=correctness, msg=msg.replace('&nbsp;', '&#160;'), queuestate=None) # Queuestate is consumed
# Queuestate is consumed
oldcmap.set(self.answer_id, npoints=points, correctness=correctness,
msg=msg.replace('&nbsp;', '&#160;'), queuestate=None)
else:
log.debug('CodeResponse: queuekey %s does not match for answer_id=%s.' % (queuekey, self.answer_id))
log.debug('CodeResponse: queuekey %s does not match for answer_id=%s.' %
(queuekey, self.answer_id))
return oldcmap
......@@ -1231,7 +1317,7 @@ class CodeResponse(LoncapaResponse):
return {self.answer_id: self.initial_display}
def _parse_score_msg(self, score_msg):
'''
"""
Grader reply is a JSON-dump of the following dict
{ 'correct': True/False,
'score': Numeric value (floating point is okay) to assign to answer
......@@ -1242,19 +1328,22 @@ class CodeResponse(LoncapaResponse):
correct: Correctness of submission (Boolean)
score: Points to be assigned (numeric, can be float)
msg: Message from grader to display to student (string)
'''
"""
fail = (False, False, 0, '')
try:
score_result = json.loads(score_msg)
except (TypeError, ValueError):
log.error("External grader message should be a JSON-serialized dict. Received score_msg = %s" % score_msg)
log.error("External grader message should be a JSON-serialized dict."
" Received score_msg = %s" % score_msg)
return fail
if not isinstance(score_result, dict):
log.error("External grader message should be a JSON-serialized dict. Received score_result = %s" % score_result)
log.error("External grader message should be a JSON-serialized dict."
" Received score_result = %s" % score_result)
return fail
for tag in ['correct', 'score', 'msg']:
if tag not in score_result:
log.error("External grader message is missing one or more required tags: 'correct', 'score', 'msg'")
log.error("External grader message is missing one or more required"
" tags: 'correct', 'score', 'msg'")
return fail
# Next, we need to check that the contents of the external grader message
......@@ -1265,7 +1354,8 @@ class CodeResponse(LoncapaResponse):
try:
etree.fromstring(msg)
except etree.XMLSyntaxError as err:
log.error("Unable to parse external grader message as valid XML: score_msg['msg']=%s" % msg)
log.error("Unable to parse external grader message as valid"
" XML: score_msg['msg']=%s" % msg)
return fail
return (True, score_result['correct'], score_result['score'], msg)
......@@ -1325,9 +1415,9 @@ main()
def setup_response(self):
xml = self.xml
self.url = xml.get('url') or "http://qisx.mit.edu:8889/pyloncapa" # FIXME - hardcoded URL
# FIXME - hardcoded URL
self.url = xml.get('url') or "http://qisx.mit.edu:8889/pyloncapa"
# answer = xml.xpath('//*[@id=$id]//answer',id=xml.get('id'))[0] # FIXME - catch errors
answer = xml.find('answer')
if answer is not None:
answer_src = answer.get('src')
......@@ -1335,7 +1425,8 @@ main()
self.code = self.system.filesystem.open('src/' + answer_src).read()
else:
self.code = answer.text
else: # no <answer> stanza; get code from <script>
else:
# no <answer> stanza; get code from <script>
self.code = self.context['script_code']
if not self.code:
msg = '%s: Missing answer script code for externalresponse' % unicode(self)
......@@ -1362,19 +1453,22 @@ main()
payload.update(extra_payload)
try:
r = requests.post(self.url, data=payload) # call external server
# call external server. TODO: synchronous call, can block for a long time
r = requests.post(self.url, data=payload)
except Exception as err:
msg = 'Error %s - cannot connect to external server url=%s' % (err, self.url)
log.error(msg)
raise Exception(msg)
if self.system.DEBUG: log.info('response = %s' % r.text)
if self.system.DEBUG:
log.info('response = %s' % r.text)
if (not r.text) or (not r.text.strip()):
raise Exception('Error: no response from external server url=%s' % self.url)
try:
rxml = etree.fromstring(r.text) # response is XML; prase it
# response is XML; parse it
rxml = etree.fromstring(r.text)
except Exception as err:
msg = 'Error %s - cannot parse response from external server r.text=%s' % (err, r.text)
log.error(msg)
......@@ -1388,7 +1482,8 @@ main()
try:
submission = [student_answers[k] for k in idset]
except Exception as err:
log.error('Error %s: cannot get student answer for %s; student_answers=%s' % (err, self.answer_ids, student_answers))
log.error('Error %s: cannot get student answer for %s; student_answers=%s' %
(err, self.answer_ids, student_answers))
raise Exception(err)
self.context.update({'submission': submission})
......@@ -1401,7 +1496,9 @@ main()
log.error('Error %s' % err)
if self.system.DEBUG:
cmap.set_dict(dict(zip(sorted(self.answer_ids), ['incorrect'] * len(idset))))
cmap.set_property(self.answer_ids[0], 'msg', '<span class="inline-error">%s</span>' % str(err).replace('<', '&lt;'))
cmap.set_property(
self.answer_ids[0], 'msg',
'<span class="inline-error">%s</span>' % str(err).replace('<', '&lt;'))
return cmap
ad = rxml.find('awarddetail').text
......@@ -1435,7 +1532,8 @@ main()
exans[0] = msg
if not (len(exans) == len(self.answer_ids)):
log.error('Expected %d answers from external server, only got %d!' % (len(self.answer_ids), len(exans)))
log.error('Expected %d answers from external server, only got %d!' %
(len(self.answer_ids), len(exans)))
raise Exception('Short response from external server')
return dict(zip(self.answer_ids, exans))
......@@ -1487,11 +1585,14 @@ class FormulaResponse(LoncapaResponse):
typeslist = []
else:
typeslist = ts.split(',')
if 'ci' in typeslist: # Case insensitive
if 'ci' in typeslist:
# Case insensitive
self.case_sensitive = False
elif 'cs' in typeslist: # Case sensitive
elif 'cs' in typeslist:
# Case sensitive
self.case_sensitive = True
else: # Default
else:
# Default
self.case_sensitive = False
def get_score(self, student_answers):
......@@ -1509,12 +1610,14 @@ class FormulaResponse(LoncapaResponse):
for i in range(numsamples):
instructor_variables = self.strip_dict(dict(self.context))
student_variables = dict()
for var in ranges: # ranges give numerical ranges for testing
# ranges give numerical ranges for testing
for var in ranges:
value = random.uniform(*ranges[var])
instructor_variables[str(var)] = value
student_variables[str(var)] = value
#log.debug('formula: instructor_vars=%s, expected=%s' % (instructor_variables,expected))
instructor_result = evaluator(instructor_variables, dict(), expected, cs=self.case_sensitive)
instructor_result = evaluator(instructor_variables, dict(),
expected, cs=self.case_sensitive)
try:
#log.debug('formula: student_vars=%s, given=%s' % (student_variables,given))
student_result = evaluator(student_variables,
......@@ -1540,8 +1643,8 @@ class FormulaResponse(LoncapaResponse):
keys and all non-numeric values stripped out. All values also
converted to float. Used so we can safely use Python contexts.
'''
d = dict([(k, numpy.complex(d[k])) for k in d if type(k) == str and \
k.isalnum() and \
d = dict([(k, numpy.complex(d[k])) for k in d if type(k) == str and
k.isalnum() and
isinstance(d[k], numbers.Number)])
return d
......@@ -1577,7 +1680,8 @@ class SchematicResponse(LoncapaResponse):
answer = xml.xpath('//*[@id=$id]//answer', id=xml.get('id'))[0]
answer_src = answer.get('src')
if answer_src is not None:
self.code = self.system.filestore.open('src/' + answer_src).read() # Untested; never used
# Untested; never used
self.code = self.system.filestore.open('src/' + answer_src).read()
else:
self.code = answer.text
......@@ -1633,17 +1737,19 @@ class ImageResponse(LoncapaResponse):
# parse expected answer
# TODO: Compile regexp on file load
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]', expectedset[aid].strip().replace(' ', ''))
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
expectedset[aid].strip().replace(' ', ''))
if not m:
msg = 'Error in problem specification! cannot parse rectangle in %s' % (etree.tostring(self.ielements[aid],
pretty_print=True))
msg = 'Error in problem specification! cannot parse rectangle in %s' % (
etree.tostring(self.ielements[aid], pretty_print=True))
raise Exception('[capamodule.capa.responsetypes.imageinput] ' + msg)
(llx, lly, urx, ury) = [int(x) for x in m.groups()]
# parse given answer
m = re.match('\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
if not m:
raise Exception('[capamodule.capa.responsetypes.imageinput] error grading %s (input=%s)' % (aid, given))
raise Exception('[capamodule.capa.responsetypes.imageinput] '
'error grading %s (input=%s)' % (aid, given))
(gx, gy) = [int(x) for x in m.groups()]
# answer is correct if (x,y) is within the specified rectangle
......@@ -1660,4 +1766,17 @@ class ImageResponse(LoncapaResponse):
# TEMPORARY: List of all response subclasses
# FIXME: To be replaced by auto-registration
__all__ = [CodeResponse, NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, ExternalResponse, ImageResponse, OptionResponse, SymbolicResponse, StringResponse, ChoiceResponse, MultipleChoiceResponse, TrueFalseResponse, JavascriptResponse]
__all__ = [CodeResponse,
NumericalResponse,
FormulaResponse,
CustomResponse,
SchematicResponse,
ExternalResponse,
ImageResponse,
OptionResponse,
SymbolicResponse,
StringResponse,
ChoiceResponse,
MultipleChoiceResponse,
TrueFalseResponse,
JavascriptResponse]
......@@ -53,8 +53,4 @@ def is_file(file_to_test):
'''
Duck typing to check if 'file_to_test' is a File object
'''
is_file = True
for method in ['read', 'name']:
if not hasattr(file_to_test, method):
is_file = False
return is_file
return all(hasattr(file_to_test, method) for method in ['read', 'name'])
......@@ -20,7 +20,7 @@ def make_hashkey(seed):
def make_xheader(lms_callback_url, lms_key, queue_name):
'''
"""
Generate header for delivery and reply of queue request.
Xqueue header is a JSON-serialized dict:
......@@ -28,19 +28,19 @@ def make_xheader(lms_callback_url, lms_key, queue_name):
'lms_key': secret key used by LMS to protect its state (string),
'queue_name': designate a specific queue within xqueue server, e.g. 'MITx-6.00x' (string)
}
'''
"""
return json.dumps({ 'lms_callback_url': lms_callback_url,
'lms_key': lms_key,
'queue_name': queue_name })
def parse_xreply(xreply):
'''
"""
Parse the reply from xqueue. Messages are JSON-serialized dict:
{ 'return_code': 0 (success), 1 (fail)
'content': Message from xqueue (string)
}
'''
"""
try:
xreply = json.loads(xreply)
except ValueError, err:
......@@ -63,7 +63,7 @@ class XQueueInterface(object):
self.session = requests.session(auth=requests_auth)
def send_to_queue(self, header, body, files_to_upload=None):
'''
"""
Submit a request to xqueue.
header: JSON-serialized dict in the format described in 'xqueue_interface.make_xheader'
......@@ -74,14 +74,16 @@ class XQueueInterface(object):
files_to_upload: List of file objects to be uploaded to xqueue along with queue request
Returns (error_code, msg) where error_code != 0 indicates an error
'''
"""
# Attempt to send to queue
(error, msg) = self._send_to_queue(header, body, files_to_upload)
if error and (msg == 'login_required'): # Log in, then try again
# Log in, then try again
if error and (msg == 'login_required'):
self._login()
if files_to_upload is not None:
for f in files_to_upload: # Need to rewind file pointers
# Need to rewind file pointers
for f in files_to_upload:
f.seek(0)
(error, msg) = self._send_to_queue(header, body, files_to_upload)
......@@ -91,7 +93,7 @@ class XQueueInterface(object):
def _login(self):
payload = { 'username': self.auth['username'],
'password': self.auth['password'] }
return self._http_post(self.url+'/xqueue/login/', payload)
return self._http_post(self.url + '/xqueue/login/', payload)
def _send_to_queue(self, header, body, files_to_upload):
......@@ -102,7 +104,7 @@ class XQueueInterface(object):
for f in files_to_upload:
files.update({ f.name: f })
return self._http_post(self.url+'/xqueue/submit/', payload, files=files)
return self._http_post(self.url + '/xqueue/submit/', payload, files=files)
def _http_post(self, url, data, files=None):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment