Commit 4c269c10 by Peter Baratta

Merge pull request #203 from edx/pbaratta/fix/capa-unicode

Fix unicode for errors on capa problems
parents 08b994b8 3a8f591f
...@@ -51,6 +51,8 @@ setting now run entirely outside the Python sandbox. ...@@ -51,6 +51,8 @@ setting now run entirely outside the Python sandbox.
Blades: Added tests for Video Alpha player. Blades: Added tests for Video Alpha player.
Common: Have the capa module handle unicode better (especially errors)
Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox. Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox.
Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide
......
...@@ -93,7 +93,7 @@ def check_variables(string, variables): ...@@ -93,7 +93,7 @@ def check_variables(string, variables):
Pyparsing uses a left-to-right parser, which makes a more Pyparsing uses a left-to-right parser, which makes a more
elegant approach pretty hopeless. elegant approach pretty hopeless.
""" """
general_whitespace = re.compile('[^\\w]+') general_whitespace = re.compile('[^\\w]+') # TODO consider non-ascii
# List of all alnums in string # List of all alnums in string
possible_variables = re.split(general_whitespace, string) possible_variables = re.split(general_whitespace, string)
bad_variables = [] bad_variables = []
......
...@@ -47,6 +47,9 @@ def randomization_bin(seed, problem_id): ...@@ -47,6 +47,9 @@ def randomization_bin(seed, problem_id):
class Randomization(String): class Randomization(String):
"""
Define a field to store how to randomize a problem.
"""
def from_json(self, value): def from_json(self, value):
if value in ("", "true"): if value in ("", "true"):
return "always" return "always"
...@@ -58,24 +61,39 @@ class Randomization(String): ...@@ -58,24 +61,39 @@ class Randomization(String):
class ComplexEncoder(json.JSONEncoder): class ComplexEncoder(json.JSONEncoder):
"""
Extend the JSON encoder to correctly handle complex numbers
"""
def default(self, obj): def default(self, obj):
"""
Print a nicely formatted complex number, or default to the JSON encoder
"""
if isinstance(obj, complex): if isinstance(obj, complex):
return "{real:.7g}{imag:+.7g}*j".format(real=obj.real, imag=obj.imag) return u"{real:.7g}{imag:+.7g}*j".format(real=obj.real, imag=obj.imag)
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
class CapaFields(object): class CapaFields(object):
attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.user_state) """
Define the possible fields for a Capa problem
"""
attempts = Integer(help="Number of attempts taken by the student on this problem",
default=0, scope=Scope.user_state)
max_attempts = Integer( max_attempts = Integer(
display_name="Maximum Attempts", display_name="Maximum Attempts",
help="Defines the number of times a student can try to answer this problem. If the value is not set, infinite attempts are allowed.", help=("Defines the number of times a student can try to answer this problem. "
"If the value is not set, infinite attempts are allowed."),
values={"min": 0}, scope=Scope.settings values={"min": 0}, scope=Scope.settings
) )
due = Date(help="Date that this problem is due by", scope=Scope.settings) due = Date(help="Date that this problem is due by", scope=Scope.settings)
graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) graceperiod = Timedelta(
help="Amount of time after the due date that submissions will be accepted",
scope=Scope.settings
)
showanswer = String( showanswer = String(
display_name="Show Answer", display_name="Show Answer",
help="Defines when to show the answer to the problem. A default value can be set in Advanced Settings.", help=("Defines when to show the answer to the problem. "
"A default value can be set in Advanced Settings."),
scope=Scope.settings, default="closed", scope=Scope.settings, default="closed",
values=[ values=[
{"display_name": "Always", "value": "always"}, {"display_name": "Always", "value": "always"},
...@@ -86,23 +104,33 @@ class CapaFields(object): ...@@ -86,23 +104,33 @@ class CapaFields(object):
{"display_name": "Past Due", "value": "past_due"}, {"display_name": "Past Due", "value": "past_due"},
{"display_name": "Never", "value": "never"}] {"display_name": "Never", "value": "never"}]
) )
force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False) force_save_button = Boolean(
help="Whether to force the save button to appear on the page",
scope=Scope.settings, default=False
)
rerandomize = Randomization( rerandomize = Randomization(
display_name="Randomization", help="Defines how often inputs are randomized when a student loads the problem. This setting only applies to problems that can have randomly generated numeric values. A default value can be set in Advanced Settings.", display_name="Randomization",
default="always", scope=Scope.settings, values=[{"display_name": "Always", "value": "always"}, help="Defines how often inputs are randomized when a student loads the problem. "
{"display_name": "On Reset", "value": "onreset"}, "This setting only applies to problems that can have randomly generated numeric values. "
{"display_name": "Never", "value": "never"}, "A default value can be set in Advanced Settings.",
{"display_name": "Per Student", "value": "per_student"}] default="always", scope=Scope.settings, values=[
{"display_name": "Always", "value": "always"},
{"display_name": "On Reset", "value": "onreset"},
{"display_name": "Never", "value": "never"},
{"display_name": "Per Student", "value": "per_student"}
]
) )
data = String(help="XML data for the problem", scope=Scope.content) data = String(help="XML data for the problem", scope=Scope.content)
correct_map = Dict(help="Dictionary with the correctness of current student answers", scope=Scope.user_state, default={}) correct_map = Dict(help="Dictionary with the correctness of current student answers",
scope=Scope.user_state, default={})
input_state = Dict(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state) input_state = Dict(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state)
student_answers = Dict(help="Dictionary with the current student responses", scope=Scope.user_state) student_answers = Dict(help="Dictionary with the current student responses", scope=Scope.user_state)
done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.user_state)
seed = Integer(help="Random seed for this student", scope=Scope.user_state) seed = Integer(help="Random seed for this student", scope=Scope.user_state)
weight = Float( weight = Float(
display_name="Problem Weight", display_name="Problem Weight",
help="Defines the number of points each problem is worth. If the value is not set, each response field in the problem is worth one point.", help=("Defines the number of points each problem is worth. "
"If the value is not set, each response field in the problem is worth one point."),
values={"min": 0, "step": .1}, values={"min": 0, "step": .1},
scope=Scope.settings scope=Scope.settings
) )
...@@ -114,12 +142,12 @@ class CapaFields(object): ...@@ -114,12 +142,12 @@ class CapaFields(object):
class CapaModule(CapaFields, XModule): class CapaModule(CapaFields, XModule):
''' """
An XModule implementing LonCapa format problems, implemented by way of An XModule implementing LonCapa format problems, implemented by way of
capa.capa_problem.LoncapaProblem capa.capa_problem.LoncapaProblem
CapaModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__ CapaModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__
''' """
icon_class = 'problem' icon_class = 'problem'
js = {'coffee': [resource_string(__name__, 'js/src/capa/display.coffee'), js = {'coffee': [resource_string(__name__, 'js/src/capa/display.coffee'),
...@@ -134,7 +162,9 @@ class CapaModule(CapaFields, XModule): ...@@ -134,7 +162,9 @@ class CapaModule(CapaFields, XModule):
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]} css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
""" Accepts the same arguments as xmodule.x_module:XModule.__init__ """ """
Accepts the same arguments as xmodule.x_module:XModule.__init__
"""
XModule.__init__(self, *args, **kwargs) XModule.__init__(self, *args, **kwargs)
due_date = self.due due_date = self.due
...@@ -167,7 +197,7 @@ class CapaModule(CapaFields, XModule): ...@@ -167,7 +197,7 @@ class CapaModule(CapaFields, XModule):
self.seed = self.lcp.seed self.seed = self.lcp.seed
except Exception as err: except Exception as err:
msg = 'cannot create LoncapaProblem {loc}: {err}'.format( msg = u'cannot create LoncapaProblem {loc}: {err}'.format(
loc=self.location.url(), err=err) loc=self.location.url(), err=err)
# TODO (vshnayder): do modules need error handlers too? # TODO (vshnayder): do modules need error handlers too?
# We shouldn't be switching on DEBUG. # We shouldn't be switching on DEBUG.
...@@ -176,12 +206,15 @@ class CapaModule(CapaFields, XModule): ...@@ -176,12 +206,15 @@ class CapaModule(CapaFields, XModule):
# TODO (vshnayder): This logic should be general, not here--and may # TODO (vshnayder): This logic should be general, not here--and may
# want to preserve the data instead of replacing it. # want to preserve the data instead of replacing it.
# e.g. in the CMS # e.g. in the CMS
msg = '<p>%s</p>' % msg.replace('<', '&lt;') msg = u'<p>{msg}</p>'.format(msg=cgi.escape(msg))
msg += '<p><pre>%s</pre></p>' % traceback.format_exc().replace('<', '&lt;') msg += u'<p><pre>{tb}</pre></p>'.format(
tb=cgi.escape(traceback.format_exc()))
# create a dummy problem with error message instead of failing # create a dummy problem with error message instead of failing
problem_text = ('<problem><text><span class="inline-error">' problem_text = (u'<problem><text><span class="inline-error">'
'Problem %s has an error:</span>%s</text></problem>' % u'Problem {url} has an error:</span>{msg}</text></problem>'.format(
(self.location.url(), msg)) url=self.location.url(),
msg=msg)
)
self.lcp = self.new_lcp(self.get_state_for_lcp(), text=problem_text) self.lcp = self.new_lcp(self.get_state_for_lcp(), text=problem_text)
else: else:
# add extra info and raise # add extra info and raise
...@@ -192,7 +225,9 @@ class CapaModule(CapaFields, XModule): ...@@ -192,7 +225,9 @@ class CapaModule(CapaFields, XModule):
assert self.seed is not None assert self.seed is not None
def choose_new_seed(self): def choose_new_seed(self):
"""Choose a new seed.""" """
Choose a new seed.
"""
if self.rerandomize == 'never': if self.rerandomize == 'never':
self.seed = 1 self.seed = 1
elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'): elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'):
...@@ -206,6 +241,9 @@ class CapaModule(CapaFields, XModule): ...@@ -206,6 +241,9 @@ class CapaModule(CapaFields, XModule):
self.seed %= MAX_RANDOMIZATION_BINS self.seed %= MAX_RANDOMIZATION_BINS
def new_lcp(self, state, text=None): def new_lcp(self, state, text=None):
"""
Generate a new Loncapa Problem
"""
if text is None: if text is None:
text = self.data text = self.data
...@@ -218,6 +256,9 @@ class CapaModule(CapaFields, XModule): ...@@ -218,6 +256,9 @@ class CapaModule(CapaFields, XModule):
) )
def get_state_for_lcp(self): def get_state_for_lcp(self):
"""
Give a dictionary holding the state of the module
"""
return { return {
'done': self.done, 'done': self.done,
'correct_map': self.correct_map, 'correct_map': self.correct_map,
...@@ -227,6 +268,9 @@ class CapaModule(CapaFields, XModule): ...@@ -227,6 +268,9 @@ class CapaModule(CapaFields, XModule):
} }
def set_state_from_lcp(self): def set_state_from_lcp(self):
"""
Set the module's state from the settings in `self.lcp`
"""
lcp_state = self.lcp.get_state() lcp_state = self.lcp.get_state()
self.done = lcp_state['done'] self.done = lcp_state['done']
self.correct_map = lcp_state['correct_map'] self.correct_map = lcp_state['correct_map']
...@@ -235,26 +279,36 @@ class CapaModule(CapaFields, XModule): ...@@ -235,26 +279,36 @@ class CapaModule(CapaFields, XModule):
self.seed = lcp_state['seed'] self.seed = lcp_state['seed']
def get_score(self): def get_score(self):
"""
Access the problem's score
"""
return self.lcp.get_score() return self.lcp.get_score()
def max_score(self): def max_score(self):
"""
Access the problem's max score
"""
return self.lcp.get_max_score() return self.lcp.get_max_score()
def get_progress(self): def get_progress(self):
''' For now, just return score / max_score """
''' For now, just return score / max_score
"""
d = self.get_score() d = self.get_score()
score = d['score'] score = d['score']
total = d['total'] total = d['total']
if total > 0: if total > 0:
try: try:
return Progress(score, total) return Progress(score, total)
except Exception: except (TypeError, ValueError):
log.exception("Got bad progress") log.exception("Got bad progress")
return None return None
return None return None
def get_html(self): def get_html(self):
"""
Return some html with data about the module
"""
return self.system.render_template('problem_ajax.html', { return self.system.render_template('problem_ajax.html', {
'element_id': self.location.html_id(), 'element_id': self.location.html_id(),
'id': self.id, 'id': self.id,
...@@ -265,6 +319,7 @@ class CapaModule(CapaFields, XModule): ...@@ -265,6 +319,7 @@ class CapaModule(CapaFields, XModule):
def check_button_name(self): def check_button_name(self):
""" """
Determine the name for the "check" button. Determine the name for the "check" button.
Usually it is just "Check", but if this is the student's Usually it is just "Check", but if this is the student's
final attempt, change the name to "Final Check" final attempt, change the name to "Final Check"
""" """
...@@ -350,27 +405,26 @@ class CapaModule(CapaFields, XModule): ...@@ -350,27 +405,26 @@ class CapaModule(CapaFields, XModule):
def handle_problem_html_error(self, err): def handle_problem_html_error(self, err):
""" """
Change our problem to a dummy problem containing Create a dummy problem to represent any errors.
a warning message to display to users.
Returns the HTML to show to users 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. `err` is the Exception encountered while rendering the problem HTML.
""" """
log.exception(err) log.exception(err.message)
# TODO (vshnayder): another switch on DEBUG. # TODO (vshnayder): another switch on DEBUG.
if self.system.DEBUG: if self.system.DEBUG:
msg = ( msg = (
'[courseware.capa.capa_module] <font size="+1" color="red">' u'[courseware.capa.capa_module] <font size="+1" color="red">'
'Failed to generate HTML for problem %s</font>' % u'Failed to generate HTML for problem {url}</font>'.format(
(self.location.url())) url=cgi.escape(self.location.url()))
msg += '<p>Error:</p><p><pre>%s</pre></p>' % str(err).replace('<', '&lt;') )
msg += '<p><pre>%s</pre></p>' % traceback.format_exc().replace('<', '&lt;') msg += u'<p>Error:</p><p><pre>{msg}</pre></p>'.format(msg=cgi.escape(err.message))
msg += u'<p><pre>{tb}</pre></p>'.format(tb=cgi.escape(traceback.format_exc()))
html = msg 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: else:
# We're in non-debug mode, and possibly even in production. We want # We're in non-debug mode, and possibly even in production. We want
# to avoid bricking of problem as much as possible # to avoid bricking of problem as much as possible
...@@ -416,8 +470,12 @@ class CapaModule(CapaFields, XModule): ...@@ -416,8 +470,12 @@ class CapaModule(CapaFields, XModule):
return html return html
def get_problem_html(self, encapsulate=True): 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.''' Return html for the problem.
Adds check, reset, save buttons as necessary based on the problem config
and state.
"""
try: try:
html = self.lcp.get_html() html = self.lcp.get_html()
...@@ -454,22 +512,24 @@ class CapaModule(CapaFields, XModule): ...@@ -454,22 +512,24 @@ class CapaModule(CapaFields, XModule):
html = self.system.render_template('problem.html', context) html = self.system.render_template('problem.html', context)
if encapsulate: if encapsulate:
html = '<div id="problem_{id}" class="problem" data-url="{ajax_url}">'.format( html = u'<div id="problem_{id}" class="problem" data-url="{ajax_url}">'.format(
id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "</div>" id=self.location.html_id(), ajax_url=self.system.ajax_url
) + html + "</div>"
# now do the substitutions which are filesystem based, e.g. '/static/' prefixes # now do the substitutions which are filesystem based, e.g. '/static/' prefixes
return self.system.replace_urls(html) return self.system.replace_urls(html)
def handle_ajax(self, dispatch, get): def handle_ajax(self, dispatch, get):
''' """
This is called by courseware.module_render, to handle an AJAX call. This is called by courseware.module_render, to handle an AJAX call.
"get" is request.POST.
`get` is request.POST.
Returns a json dictionary: Returns a json dictionary:
{ 'progress_changed' : True/False, { 'progress_changed' : True/False,
'progress' : 'none'/'in_progress'/'done', 'progress' : 'none'/'in_progress'/'done',
<other request-specific values here > } <other request-specific values here > }
''' """
handlers = { handlers = {
'problem_get': self.get_problem, 'problem_get': self.get_problem,
'problem_check': self.check_problem, 'problem_check': self.check_problem,
...@@ -508,7 +568,9 @@ class CapaModule(CapaFields, XModule): ...@@ -508,7 +568,9 @@ class CapaModule(CapaFields, XModule):
datetime.datetime.now(UTC()) > self.close_date) datetime.datetime.now(UTC()) > self.close_date)
def closed(self): def closed(self):
''' Is the student still allowed to submit answers? ''' """
Is the student still allowed to submit answers?
"""
if self.max_attempts is not None and self.attempts >= self.max_attempts: if self.max_attempts is not None and self.attempts >= self.max_attempts:
return True return True
if self.is_past_due(): if self.is_past_due():
...@@ -527,18 +589,24 @@ class CapaModule(CapaFields, XModule): ...@@ -527,18 +589,24 @@ class CapaModule(CapaFields, XModule):
return self.lcp.done return self.lcp.done
def is_attempted(self): def is_attempted(self):
"""Used by conditional module""" """
Has the problem been attempted?
used by conditional module
"""
return self.attempts > 0 return self.attempts > 0
def is_correct(self): def is_correct(self):
"""True if full points""" """
True iff full points
"""
d = self.get_score() d = self.get_score()
return d['score'] == d['total'] return d['score'] == d['total']
def answer_available(self): def answer_available(self):
''' """
Is the user allowed to see an answer? Is the user allowed to see an answer?
''' """
if self.showanswer == '': if self.showanswer == '':
return False return False
elif self.showanswer == "never": elif self.showanswer == "never":
...@@ -570,7 +638,7 @@ class CapaModule(CapaFields, XModule): ...@@ -570,7 +638,7 @@ class CapaModule(CapaFields, XModule):
Delivers grading response (e.g. from asynchronous code checking) to Delivers grading response (e.g. from asynchronous code checking) to
the capa problem, so its score can be updated the capa problem, so its score can be updated
'get' must have a field 'response' which is a string that contains the `get` must have a field `response` which is a string that contains the
grader's response grader's response
No ajax return is needed. Return empty dict. No ajax return is needed. Return empty dict.
...@@ -584,7 +652,7 @@ class CapaModule(CapaFields, XModule): ...@@ -584,7 +652,7 @@ class CapaModule(CapaFields, XModule):
return dict() # No AJAX return is needed return dict() # No AJAX return is needed
def handle_ungraded_response(self, get): def handle_ungraded_response(self, get):
''' """
Delivers a response from the XQueue to the capa problem Delivers a response from the XQueue to the capa problem
The score of the problem will not be updated The score of the problem will not be updated
...@@ -597,7 +665,7 @@ class CapaModule(CapaFields, XModule): ...@@ -597,7 +665,7 @@ class CapaModule(CapaFields, XModule):
empty dictionary empty dictionary
No ajax return is needed, so an empty dict is returned No ajax return is needed, so an empty dict is returned
''' """
queuekey = get['queuekey'] queuekey = get['queuekey']
score_msg = get['xqueue_body'] score_msg = get['xqueue_body']
# pass along the xqueue message to the problem # pass along the xqueue message to the problem
...@@ -606,25 +674,25 @@ class CapaModule(CapaFields, XModule): ...@@ -606,25 +674,25 @@ class CapaModule(CapaFields, XModule):
return dict() return dict()
def handle_input_ajax(self, get): def handle_input_ajax(self, get):
''' """
Handle ajax calls meant for a particular input in the problem Handle ajax calls meant for a particular input in the problem
Args: Args:
- get (dict) - data that should be passed to the input - get (dict) - data that should be passed to the input
Returns: Returns:
- dict containing the response from the input - dict containing the response from the input
''' """
response = self.lcp.handle_input_ajax(get) response = self.lcp.handle_input_ajax(get)
# save any state changes that may occur # save any state changes that may occur
self.set_state_from_lcp() self.set_state_from_lcp()
return response return response
def get_answer(self, get): def get_answer(self, get):
''' """
For the "show answer" button. For the "show answer" button.
Returns the answers: {'answers' : answers} Returns the answers: {'answers' : answers}
''' """
event_info = dict() event_info = dict()
event_info['problem_id'] = self.location.url() event_info['problem_id'] = self.location.url()
self.system.track_function('showanswer', event_info) self.system.track_function('showanswer', event_info)
...@@ -641,7 +709,8 @@ class CapaModule(CapaFields, XModule): ...@@ -641,7 +709,8 @@ class CapaModule(CapaFields, XModule):
try: try:
new_answer = {answer_id: self.system.replace_urls(answers[answer_id])} new_answer = {answer_id: self.system.replace_urls(answers[answer_id])}
except TypeError: except TypeError:
log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id])) log.debug(u'Unable to perform URL substitution on answers[%s]: %s',
answer_id, answers[answer_id])
new_answer = {answer_id: answers[answer_id]} new_answer = {answer_id: answers[answer_id]}
new_answers.update(new_answer) new_answers.update(new_answer)
...@@ -649,40 +718,44 @@ class CapaModule(CapaFields, XModule): ...@@ -649,40 +718,44 @@ class CapaModule(CapaFields, XModule):
# Figure out if we should move these to capa_problem? # Figure out if we should move these to capa_problem?
def get_problem(self, get): def get_problem(self, get):
''' Return results of get_problem_html, as a simple dict for json-ing. """
Return results of get_problem_html, as a simple dict for json-ing.
{ 'html': <the-html> } { 'html': <the-html> }
Used if we want to reconfirm we have the right thing e.g. after Used if we want to reconfirm we have the right thing e.g. after
several AJAX calls. several AJAX calls.
''' """
return {'html': self.get_problem_html(encapsulate=False)} return {'html': self.get_problem_html(encapsulate=False)}
@staticmethod @staticmethod
def make_dict_of_responses(get): def make_dict_of_responses(get):
'''Make dictionary of student responses (aka "answers") """
get is POST dictionary (Django QueryDict). Make dictionary of student responses (aka "answers")
The *get* dict has keys of the form 'x_y', which are mapped `get` is POST dictionary (Django QueryDict).
The `get` dict has keys of the form 'x_y', which are mapped
to key 'y' in the returned dict. For example, to key 'y' in the returned dict. For example,
'input_1_2_3' would be mapped to '1_2_3' in the returned dict. '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 Some inputs always expect a list in the returned dict
(e.g. checkbox inputs). The convention is that (e.g. checkbox inputs). The convention is that
keys in the *get* dict that end with '[]' will always keys in the `get` dict that end with '[]' will always
have list values in the returned dict. have list values in the returned dict.
For example, if the *get* dict contains {'input_1[]': 'test' } For example, if the `get` dict contains {'input_1[]': 'test' }
then the output dict would contain {'1': ['test'] } then the output dict would contain {'1': ['test'] }
(the value is a list). (the value is a list).
Raises an exception if: Raises an exception if:
A key in the *get* dictionary does not contain >= 1 underscores -A key in the `get` dictionary does not contain at least one underscore
(e.g. "input" is invalid; "input_1" is valid) (e.g. "input" is invalid, but "input_1" is valid)
Two keys end up with the same name in the returned dict. -Two keys end up with the same name in the returned dict.
(e.g. 'input_1' and 'input_1[]', which both get mapped (e.g. 'input_1' and 'input_1[]', which both get mapped to 'input_1'
to 'input_1' in the returned dict) in the returned dict)
''' """
answers = dict() answers = dict()
for key in get: for key in get:
...@@ -693,7 +766,7 @@ class CapaModule(CapaFields, XModule): ...@@ -693,7 +766,7 @@ class CapaModule(CapaFields, XModule):
# will return (key, '', '') # will return (key, '', '')
# We detect this and raise an error # We detect this and raise an error
if not name: if not name:
raise ValueError("%s must contain at least one underscore" % str(key)) raise ValueError(u"{key} must contain at least one underscore".format(key=key))
else: else:
# This allows for answers which require more than one value for # This allows for answers which require more than one value for
...@@ -711,7 +784,7 @@ class CapaModule(CapaFields, XModule): ...@@ -711,7 +784,7 @@ class CapaModule(CapaFields, XModule):
# If the name already exists, then we don't want # If the name already exists, then we don't want
# to override it. Raise an error instead # to override it. Raise an error instead
if name in answers: if name in answers:
raise ValueError("Key %s already exists in answers dict" % str(name)) raise ValueError(u"Key {name} already exists in answers dict".format(name=name))
else: else:
answers[name] = val answers[name] = val
...@@ -729,12 +802,13 @@ class CapaModule(CapaFields, XModule): ...@@ -729,12 +802,13 @@ class CapaModule(CapaFields, XModule):
}) })
def check_problem(self, get): def check_problem(self, get):
''' Checks whether answers to a problem are correct, and """
returns a map of correct/incorrect answers: Checks whether answers to a problem are correct
{'success' : 'correct' | 'incorrect' | AJAX alert msg string, Returns a map of correct/incorrect answers:
'contents' : html} {'success' : 'correct' | 'incorrect' | AJAX alert msg string,
''' 'contents' : html}
"""
event_info = dict() event_info = dict()
event_info['state'] = self.lcp.get_state() event_info['state'] = self.lcp.get_state()
event_info['problem_id'] = self.location.url() event_info['problem_id'] = self.location.url()
...@@ -759,7 +833,8 @@ class CapaModule(CapaFields, XModule): ...@@ -759,7 +833,8 @@ class CapaModule(CapaFields, XModule):
prev_submit_time = self.lcp.get_recentmost_queuetime() prev_submit_time = self.lcp.get_recentmost_queuetime()
waittime_between_requests = self.system.xqueue['waittime'] waittime_between_requests = self.system.xqueue['waittime']
if (current_time - prev_submit_time).total_seconds() < waittime_between_requests: if (current_time - prev_submit_time).total_seconds() < waittime_between_requests:
msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests msg = u'You must wait at least {wait} seconds between submissions'.format(
wait=waittime_between_requests)
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
try: try:
...@@ -776,19 +851,19 @@ class CapaModule(CapaFields, XModule): ...@@ -776,19 +851,19 @@ class CapaModule(CapaFields, XModule):
# the full exception, including traceback, # the full exception, including traceback,
# in the response # in the response
if self.system.user_is_staff: if self.system.user_is_staff:
msg = "Staff debug info: %s" % traceback.format_exc() msg = u"Staff debug info: {tb}".format(tb=cgi.escape(traceback.format_exc()))
# Otherwise, display just an error message, # Otherwise, display just an error message,
# without a stack trace # without a stack trace
else: else:
msg = "Error: %s" % str(inst.message) msg = u"Error: {msg}".format(msg=inst.message)
return {'success': msg} return {'success': msg}
except Exception as err: except Exception as err:
if self.system.DEBUG: if self.system.DEBUG:
msg = "Error checking problem: " + str(err) msg = u"Error checking problem: {}".format(err.message)
msg += '\nTraceback:\n' + traceback.format_exc() msg += u'\nTraceback:\n{}'.format(traceback.format_exc())
return {'success': msg} return {'success': msg}
raise raise
...@@ -937,16 +1012,17 @@ class CapaModule(CapaFields, XModule): ...@@ -937,16 +1012,17 @@ class CapaModule(CapaFields, XModule):
'msg': msg} 'msg': msg}
def reset_problem(self, get): def reset_problem(self, get):
''' Changes problem state to unfinished -- removes student answers, """
and causes problem to rerender itself. Changes problem state to unfinished -- removes student answers,
and causes problem to rerender itself.
Returns a dictionary of the form: Returns a dictionary of the form:
{'success': True/False, {'success': True/False,
'html': Problem HTML string } 'html': Problem HTML string }
If an error occurs, the dictionary will also have an If an error occurs, the dictionary will also have an
'error' key containing an error message. `error` key containing an error message.
''' """
event_info = dict() event_info = dict()
event_info['old_state'] = self.lcp.get_state() event_info['old_state'] = self.lcp.get_state()
event_info['problem_id'] = self.location.url() event_info['problem_id'] = self.location.url()
...@@ -993,7 +1069,8 @@ class CapaDescriptor(CapaFields, RawDescriptor): ...@@ -993,7 +1069,8 @@ class CapaDescriptor(CapaFields, RawDescriptor):
mako_template = "widgets/problem-edit.html" mako_template = "widgets/problem-edit.html"
js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]} js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]}
js_module_name = "MarkdownEditingDescriptor" js_module_name = "MarkdownEditingDescriptor"
css = {'scss': [resource_string(__name__, 'css/editor/edit.scss'), resource_string(__name__, 'css/problem/edit.scss')]} css = {'scss': [resource_string(__name__, 'css/editor/edit.scss'),
resource_string(__name__, 'css/problem/edit.scss')]}
# Capa modules have some additional metadata: # Capa modules have some additional metadata:
# TODO (vshnayder): do problems have any other metadata? Do they # TODO (vshnayder): do problems have any other metadata? Do they
......
"""Tests of the Capa XModule""" # -*- coding: utf-8 -*-
"""
Tests of the Capa XModule
"""
#pylint: disable=C0111 #pylint: disable=C0111
#pylint: disable=R0904 #pylint: disable=R0904
#pylint: disable=C0103 #pylint: disable=C0103
...@@ -8,11 +11,12 @@ import datetime ...@@ -8,11 +11,12 @@ import datetime
from mock import Mock, patch from mock import Mock, patch
import unittest import unittest
import random import random
import json
import xmodule import xmodule
from capa.responsetypes import StudentInputError, \ from capa.responsetypes import (StudentInputError, LoncapaProblemError,
LoncapaProblemError, ResponseError ResponseError)
from xmodule.capa_module import CapaModule from xmodule.capa_module import CapaModule, ComplexEncoder
from xmodule.modulestore import Location from xmodule.modulestore import Location
from django.http import QueryDict from django.http import QueryDict
...@@ -47,12 +51,16 @@ class CapaFactory(object): ...@@ -47,12 +51,16 @@ class CapaFactory(object):
@staticmethod @staticmethod
def input_key(): def input_key():
""" Return the input key to use when passing GET parameters """ """
Return the input key to use when passing GET parameters
"""
return ("input_" + CapaFactory.answer_key()) return ("input_" + CapaFactory.answer_key())
@staticmethod @staticmethod
def answer_key(): def answer_key():
""" Return the key stored in the capa problem answer dict """ """
Return the key stored in the capa problem answer dict
"""
return ("-".join(['i4x', 'edX', 'capa_test', 'problem', return ("-".join(['i4x', 'edX', 'capa_test', 'problem',
'SampleProblem%d' % CapaFactory.num]) + 'SampleProblem%d' % CapaFactory.num]) +
"_2_1") "_2_1")
...@@ -361,7 +369,9 @@ class CapaModuleTest(unittest.TestCase): ...@@ -361,7 +369,9 @@ class CapaModuleTest(unittest.TestCase):
result = CapaModule.make_dict_of_responses(invalid_get_dict) result = CapaModule.make_dict_of_responses(invalid_get_dict)
def _querydict_from_dict(self, param_dict): def _querydict_from_dict(self, param_dict):
""" Create a Django QueryDict from a Python dictionary """ """
Create a Django QueryDict from a Python dictionary
"""
# QueryDict objects are immutable by default, so we make # QueryDict objects are immutable by default, so we make
# a copy that we can update. # a copy that we can update.
...@@ -496,9 +506,10 @@ class CapaModuleTest(unittest.TestCase): ...@@ -496,9 +506,10 @@ class CapaModuleTest(unittest.TestCase):
def test_check_problem_error(self): def test_check_problem_error(self):
# Try each exception that capa_module should handle # Try each exception that capa_module should handle
for exception_class in [StudentInputError, exception_classes = [StudentInputError,
LoncapaProblemError, LoncapaProblemError,
ResponseError]: ResponseError]
for exception_class in exception_classes:
# Create the module # Create the module
module = CapaFactory.create(attempts=1) module = CapaFactory.create(attempts=1)
...@@ -520,6 +531,60 @@ class CapaModuleTest(unittest.TestCase): ...@@ -520,6 +531,60 @@ class CapaModuleTest(unittest.TestCase):
# Expect that the number of attempts is NOT incremented # Expect that the number of attempts is NOT incremented
self.assertEqual(module.attempts, 1) self.assertEqual(module.attempts, 1)
def test_check_problem_other_errors(self):
"""
Test that errors other than the expected kinds give an appropriate message.
See also `test_check_problem_error` for the "expected kinds" or errors.
"""
# Create the module
module = CapaFactory.create(attempts=1)
# Ensure that the user is NOT staff
module.system.user_is_staff = False
# Ensure that DEBUG is on
module.system.DEBUG = True
# Simulate answering a problem that raises the exception
with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade:
error_msg = u"Superterrible error happened: ☠"
mock_grade.side_effect = Exception(error_msg)
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Expect an AJAX alert message in 'success'
self.assertTrue(error_msg in result['success'])
def test_check_problem_error_nonascii(self):
# Try each exception that capa_module should handle
exception_classes = [StudentInputError,
LoncapaProblemError,
ResponseError]
for exception_class in exception_classes:
# Create the module
module = CapaFactory.create(attempts=1)
# Ensure that the user is NOT staff
module.system.user_is_staff = False
# Simulate answering a problem that raises the exception
with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade:
mock_grade.side_effect = exception_class(u"ȧƈƈḗƞŧḗḓ ŧḗẋŧ ƒǿř ŧḗşŧīƞɠ")
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Expect an AJAX alert message in 'success'
expected_msg = u'Error: ȧƈƈḗƞŧḗḓ ŧḗẋŧ ƒǿř ŧḗşŧīƞɠ'
self.assertEqual(expected_msg, result['success'])
# Expect that the number of attempts is NOT incremented
self.assertEqual(module.attempts, 1)
def test_check_problem_error_with_staff_user(self): def test_check_problem_error_with_staff_user(self):
# Try each exception that capa module should handle # Try each exception that capa module should handle
...@@ -1021,6 +1086,33 @@ class CapaModuleTest(unittest.TestCase): ...@@ -1021,6 +1086,33 @@ class CapaModuleTest(unittest.TestCase):
# Expect that the module has created a new dummy problem with the error # Expect that the module has created a new dummy problem with the error
self.assertNotEqual(original_problem, module.lcp) self.assertNotEqual(original_problem, module.lcp)
def test_get_problem_html_error_w_debug(self):
"""
Test the html response when an error occurs with DEBUG on
"""
module = CapaFactory.create()
# Simulate throwing an exception when the capa problem
# is asked to render itself as HTML
error_msg = u"Superterrible error happened: ☠"
module.lcp.get_html = Mock(side_effect=Exception(error_msg))
# Stub out the get_test_system rendering function
module.system.render_template = Mock(return_value="<div>Test Template HTML</div>")
# Make sure DEBUG is on
module.system.DEBUG = True
# Try to render the module with DEBUG turned on
html = module.get_problem_html()
self.assertTrue(html is not None)
# Check the rendering context
render_args, _ = module.system.render_template.call_args
context = render_args[1]
self.assertTrue(error_msg in context['problem']['html'])
def test_random_seed_no_change(self): def test_random_seed_no_change(self):
# Run the test for each possible rerandomize value # Run the test for each possible rerandomize value
...@@ -1126,3 +1218,28 @@ class CapaModuleTest(unittest.TestCase): ...@@ -1126,3 +1218,28 @@ class CapaModuleTest(unittest.TestCase):
for i in range(200): for i in range(200):
module = CapaFactory.create(rerandomize=rerandomize) module = CapaFactory.create(rerandomize=rerandomize)
assert 0 <= module.seed < 1000 assert 0 <= module.seed < 1000
@patch('xmodule.capa_module.log')
@patch('xmodule.capa_module.Progress')
def test_get_progress_error(self, mock_progress, mock_log):
"""
Check that an exception given in `Progress` produces a `log.exception` call.
"""
error_types = [TypeError, ValueError]
for error_type in error_types:
mock_progress.side_effect = error_type
module = CapaFactory.create()
self.assertIsNone(module.get_progress())
mock_log.exception.assert_called_once_with('Got bad progress')
mock_log.reset_mock()
class ComplexEncoderTest(unittest.TestCase):
def test_default(self):
"""
Check that complex numbers can be encoded into JSON.
"""
complex_num = 1 - 1j
expected_str = '1-1*j'
json_str = json.dumps(complex_num, cls=ComplexEncoder)
self.assertEqual(expected_str, json_str[1:-1]) # ignore quotes
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