Commit f0e1b477 by Victor Shnayder

Merge pull request #1703 from MITx/feature/diana/matlab-input

Matlab Input Type
parents a91f1278 4bda05d9
...@@ -16,7 +16,6 @@ This is used by capa_module. ...@@ -16,7 +16,6 @@ This is used by capa_module.
from __future__ import division from __future__ import division
from datetime import datetime from datetime import datetime
import json
import logging import logging
import math import math
import numpy import numpy
...@@ -32,8 +31,6 @@ from xml.sax.saxutils import unescape ...@@ -32,8 +31,6 @@ from xml.sax.saxutils import unescape
from copy import deepcopy from copy import deepcopy
import chem import chem
import chem.chemcalc
import chem.chemtools
import chem.miller import chem.miller
import verifiers import verifiers
import verifiers.draganddrop import verifiers.draganddrop
...@@ -70,9 +67,6 @@ global_context = {'random': random, ...@@ -70,9 +67,6 @@ global_context = {'random': random,
'scipy': scipy, 'scipy': scipy,
'calc': calc, 'calc': calc,
'eia': eia, 'eia': eia,
'chemcalc': chem.chemcalc,
'chemtools': chem.chemtools,
'miller': chem.miller,
'draganddrop': verifiers.draganddrop} 'draganddrop': verifiers.draganddrop}
# These should be removed from HTML output, including all subelements # These should be removed from HTML output, including all subelements
...@@ -97,8 +91,13 @@ class LoncapaProblem(object): ...@@ -97,8 +91,13 @@ class LoncapaProblem(object):
- problem_text (string): xml defining the problem - problem_text (string): xml defining the problem
- id (string): identifier for this problem; often a filename (no spaces) - id (string): identifier for this problem; often a filename (no spaces)
- state (dict): student state - seed (int): random number generator seed (int)
- seed (int): random number generator seed (int) - state (dict): containing the following keys:
- 'seed' - (int) random number generator seed
- 'student_answers' - (dict) maps input id to the stored answer for that input
- 'correct_map' (CorrectMap) a map of each input to their 'correctness'
- 'done' - (bool) indicates whether or not this problem is considered done
- 'input_state' - (dict) maps input_id to a dictionary that holds the state for that input
- system (ModuleSystem): ModuleSystem instance which provides OS, - system (ModuleSystem): ModuleSystem instance which provides OS,
rendering, and user context rendering, and user context
...@@ -110,21 +109,23 @@ class LoncapaProblem(object): ...@@ -110,21 +109,23 @@ class LoncapaProblem(object):
self.system = system self.system = system
if self.system is None: if self.system is None:
raise Exception() raise Exception()
self.seed = seed
state = state if state else {}
if state:
if 'seed' in state: # Set seed according to the following priority:
self.seed = state['seed'] # 1. Contained in problem's state
if 'student_answers' in state: # 2. Passed into capa_problem via constructor
self.student_answers = state['student_answers'] # 3. Assign from the OS's random number generator
if 'correct_map' in state: self.seed = state.get('seed', seed)
self.correct_map.set_dict(state['correct_map']) if self.seed is None:
if 'done' in state: self.seed = struct.unpack('i', os.urandom(4))
self.done = state['done'] self.student_answers = state.get('student_answers', {})
if 'correct_map' in state:
# TODO: Does this deplete the Linux entropy pool? Is this fast enough? self.correct_map.set_dict(state['correct_map'])
if not self.seed: self.done = state.get('done', False)
self.seed = struct.unpack('i', os.urandom(4))[0] self.input_state = state.get('input_state', {})
# 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("startouttext\s*/", "text", problem_text)
...@@ -188,6 +189,7 @@ class LoncapaProblem(object): ...@@ -188,6 +189,7 @@ class LoncapaProblem(object):
return {'seed': self.seed, return {'seed': self.seed,
'student_answers': self.student_answers, 'student_answers': self.student_answers,
'correct_map': self.correct_map.get_dict(), 'correct_map': self.correct_map.get_dict(),
'input_state': self.input_state,
'done': self.done} 'done': self.done}
def get_max_score(self): def get_max_score(self):
...@@ -237,6 +239,20 @@ class LoncapaProblem(object): ...@@ -237,6 +239,20 @@ class LoncapaProblem(object):
self.correct_map.set_dict(cmap.get_dict()) self.correct_map.set_dict(cmap.get_dict())
return cmap return cmap
def ungraded_response(self, xqueue_msg, queuekey):
'''
Handle any responses from the xqueue that do not contain grades
Will try to pass the queue message to all inputtypes that can handle ungraded responses
Does not return any value
'''
# check against each inputtype
for the_input in self.inputs.values():
# if the input type has an ungraded function, pass in the values
if hasattr(the_input, 'ungraded_response'):
the_input.ungraded_response(xqueue_msg, queuekey)
def is_queued(self): def is_queued(self):
''' '''
Returns True if any part of the problem has been submitted to an external queue Returns True if any part of the problem has been submitted to an external queue
...@@ -351,7 +367,7 @@ class LoncapaProblem(object): ...@@ -351,7 +367,7 @@ class LoncapaProblem(object):
dispatch = get['dispatch'] dispatch = get['dispatch']
return self.inputs[input_id].handle_ajax(dispatch, get) return self.inputs[input_id].handle_ajax(dispatch, get)
else: else:
log.warning("Could not find matching input for id: %s" % problem_id) log.warning("Could not find matching input for id: %s" % input_id)
return {} return {}
...@@ -527,11 +543,15 @@ class LoncapaProblem(object): ...@@ -527,11 +543,15 @@ class LoncapaProblem(object):
value = "" value = ""
if self.student_answers and problemid in self.student_answers: if self.student_answers and problemid in self.student_answers:
value = self.student_answers[problemid] value = self.student_answers[problemid]
if input_id not in self.input_state:
self.input_state[input_id] = {}
# do the rendering # do the rendering
state = {'value': value, state = {'value': value,
'status': status, 'status': status,
'id': input_id, 'id': input_id,
'input_state': self.input_state[input_id],
'feedback': {'message': msg, 'feedback': {'message': msg,
'hint': hint, 'hint': hint,
'hintmode': hintmode, }} 'hintmode': hintmode, }}
......
...@@ -37,18 +37,18 @@ graded status as'status' ...@@ -37,18 +37,18 @@ graded status as'status'
# makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a # makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a
# general css and layout strategy for capa, document it, then implement it. # general css and layout strategy for capa, document it, then implement it.
from collections import namedtuple
import json import json
import logging import logging
from lxml import etree from lxml import etree
import re import re
import shlex # for splitting quoted strings import shlex # for splitting quoted strings
import sys import sys
import os
import pyparsing import pyparsing
from .registry import TagRegistry from .registry import TagRegistry
from capa.chem import chemcalc from capa.chem import chemcalc
import xqueue_interface
from datetime import datetime
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -97,7 +97,8 @@ class Attribute(object): ...@@ -97,7 +97,8 @@ class Attribute(object):
""" """
val = element.get(self.name) val = element.get(self.name)
if self.default == self._sentinel and val is None: if self.default == self._sentinel and val is None:
raise ValueError('Missing required attribute {0}.'.format(self.name)) raise ValueError(
'Missing required attribute {0}.'.format(self.name))
if val is None: if val is None:
# not required, so return default # not required, so return default
...@@ -132,6 +133,8 @@ class InputTypeBase(object): ...@@ -132,6 +133,8 @@ class InputTypeBase(object):
* 'id' -- the id of this input, typically * 'id' -- the id of this input, typically
"{problem-location}_{response-num}_{input-num}" "{problem-location}_{response-num}_{input-num}"
* 'status' (answered, unanswered, unsubmitted) * 'status' (answered, unanswered, unsubmitted)
* 'input_state' -- dictionary containing any inputtype-specific state
that has been preserved
* 'feedback' (dictionary containing keys for hints, errors, or other * 'feedback' (dictionary containing keys for hints, errors, or other
feedback from previous attempt. Specifically 'message', 'hint', feedback from previous attempt. Specifically 'message', 'hint',
'hintmode'. If 'hintmode' is 'always', the hint is always displayed.) 'hintmode'. If 'hintmode' is 'always', the hint is always displayed.)
...@@ -149,7 +152,8 @@ class InputTypeBase(object): ...@@ -149,7 +152,8 @@ class InputTypeBase(object):
self.id = state.get('id', xml.get('id')) self.id = state.get('id', xml.get('id'))
if self.id is None: if self.id is None:
raise ValueError("input id state is None. xml is {0}".format(etree.tostring(xml))) raise ValueError("input id state is None. xml is {0}".format(
etree.tostring(xml)))
self.value = state.get('value', '') self.value = state.get('value', '')
...@@ -157,6 +161,7 @@ class InputTypeBase(object): ...@@ -157,6 +161,7 @@ class InputTypeBase(object):
self.msg = feedback.get('message', '') self.msg = feedback.get('message', '')
self.hint = feedback.get('hint', '') self.hint = feedback.get('hint', '')
self.hintmode = feedback.get('hintmode', None) self.hintmode = feedback.get('hintmode', None)
self.input_state = state.get('input_state', {})
# put hint above msg if it should be displayed # put hint above msg if it should be displayed
if self.hintmode == 'always': if self.hintmode == 'always':
...@@ -169,14 +174,15 @@ class InputTypeBase(object): ...@@ -169,14 +174,15 @@ class InputTypeBase(object):
self.process_requirements() self.process_requirements()
# Call subclass "constructor" -- means they don't have to worry about calling # Call subclass "constructor" -- means they don't have to worry about calling
# super().__init__, and are isolated from changes to the input constructor interface. # super().__init__, and are isolated from changes to the input
# constructor interface.
self.setup() self.setup()
except Exception as err: except Exception as err:
# Something went wrong: add xml to message, but keep the traceback # Something went wrong: add xml to message, but keep the traceback
msg = "Error in xml '{x}': {err} ".format(x=etree.tostring(xml), err=str(err)) msg = "Error in xml '{x}': {err} ".format(
x=etree.tostring(xml), err=str(err))
raise Exception, msg, sys.exc_info()[2] raise Exception, msg, sys.exc_info()[2]
@classmethod @classmethod
def get_attributes(cls): def get_attributes(cls):
""" """
...@@ -186,7 +192,6 @@ class InputTypeBase(object): ...@@ -186,7 +192,6 @@ class InputTypeBase(object):
""" """
return [] return []
def process_requirements(self): def process_requirements(self):
""" """
Subclasses can declare lists of required and optional attributes. This Subclasses can declare lists of required and optional attributes. This
...@@ -196,7 +201,8 @@ class InputTypeBase(object): ...@@ -196,7 +201,8 @@ class InputTypeBase(object):
Processes attributes, putting the results in the self.loaded_attributes dictionary. Also creates a set Processes attributes, putting the results in the self.loaded_attributes dictionary. Also creates a set
self.to_render, containing the names of attributes that should be included in the context by default. self.to_render, containing the names of attributes that should be included in the context by default.
""" """
# Use local dicts and sets so that if there are exceptions, we don't end up in a partially-initialized state. # Use local dicts and sets so that if there are exceptions, we don't
# end up in a partially-initialized state.
loaded = {} loaded = {}
to_render = set() to_render = set()
for a in self.get_attributes(): for a in self.get_attributes():
...@@ -226,7 +232,7 @@ class InputTypeBase(object): ...@@ -226,7 +232,7 @@ class InputTypeBase(object):
get: a dictionary containing the data that was sent with the ajax call get: a dictionary containing the data that was sent with the ajax call
Output: Output:
a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. a dictionary object that can be serialized into JSON. This will be sent back to the Javascript.
""" """
pass pass
...@@ -247,8 +253,9 @@ class InputTypeBase(object): ...@@ -247,8 +253,9 @@ class InputTypeBase(object):
'value': self.value, 'value': self.value,
'status': self.status, 'status': self.status,
'msg': self.msg, 'msg': self.msg,
} }
context.update((a, v) for (a, v) in self.loaded_attributes.iteritems() if a in self.to_render) context.update((a, v) for (
a, v) in self.loaded_attributes.iteritems() if a in self.to_render)
context.update(self._extra_context()) context.update(self._extra_context())
return context return context
...@@ -371,7 +378,6 @@ class ChoiceGroup(InputTypeBase): ...@@ -371,7 +378,6 @@ class ChoiceGroup(InputTypeBase):
return [Attribute("show_correctness", "always"), return [Attribute("show_correctness", "always"),
Attribute("submitted_message", "Answer received.")] Attribute("submitted_message", "Answer received.")]
def _extra_context(self): def _extra_context(self):
return {'input_type': self.html_input_type, return {'input_type': self.html_input_type,
'choices': self.choices, 'choices': self.choices,
...@@ -436,7 +442,6 @@ class JavascriptInput(InputTypeBase): ...@@ -436,7 +442,6 @@ class JavascriptInput(InputTypeBase):
Attribute('display_class', None), Attribute('display_class', None),
Attribute('display_file', None), ] Attribute('display_file', None), ]
def setup(self): def setup(self):
# Need to provide a value that JSON can parse if there is no # Need to provide a value that JSON can parse if there is no
# student-supplied value yet. # student-supplied value yet.
...@@ -459,7 +464,6 @@ class TextLine(InputTypeBase): ...@@ -459,7 +464,6 @@ class TextLine(InputTypeBase):
template = "textline.html" template = "textline.html"
tags = ['textline'] tags = ['textline']
@classmethod @classmethod
def get_attributes(cls): def get_attributes(cls):
""" """
...@@ -474,12 +478,12 @@ class TextLine(InputTypeBase): ...@@ -474,12 +478,12 @@ class TextLine(InputTypeBase):
# Attributes below used in setup(), not rendered directly. # Attributes below used in setup(), not rendered directly.
Attribute('math', None, render=False), Attribute('math', None, render=False),
# TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x # TODO: 'dojs' flag is temporary, for backwards compatibility with
# 8.02x
Attribute('dojs', None, render=False), Attribute('dojs', None, render=False),
Attribute('preprocessorClassName', None, render=False), Attribute('preprocessorClassName', None, render=False),
Attribute('preprocessorSrc', None, render=False), Attribute('preprocessorSrc', None, render=False),
] ]
def setup(self): def setup(self):
self.do_math = bool(self.loaded_attributes['math'] or self.do_math = bool(self.loaded_attributes['math'] or
...@@ -490,12 +494,12 @@ class TextLine(InputTypeBase): ...@@ -490,12 +494,12 @@ class TextLine(InputTypeBase):
self.preprocessor = None self.preprocessor = None
if self.do_math: if self.do_math:
# Preprocessor to insert between raw input and Mathjax # Preprocessor to insert between raw input and Mathjax
self.preprocessor = {'class_name': self.loaded_attributes['preprocessorClassName'], self.preprocessor = {
'script_src': self.loaded_attributes['preprocessorSrc']} 'class_name': self.loaded_attributes['preprocessorClassName'],
'script_src': self.loaded_attributes['preprocessorSrc']}
if None in self.preprocessor.values(): if None in self.preprocessor.values():
self.preprocessor = None self.preprocessor = None
def _extra_context(self): def _extra_context(self):
return {'do_math': self.do_math, return {'do_math': self.do_math,
'preprocessor': self.preprocessor, } 'preprocessor': self.preprocessor, }
...@@ -539,7 +543,8 @@ class FileSubmission(InputTypeBase): ...@@ -539,7 +543,8 @@ class FileSubmission(InputTypeBase):
""" """
# Check if problem has been queued # Check if problem has been queued
self.queue_len = 0 self.queue_len = 0
# 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 self.status == 'incomplete': if self.status == 'incomplete':
self.status = 'queued' self.status = 'queued'
self.queue_len = self.msg self.queue_len = self.msg
...@@ -547,7 +552,6 @@ class FileSubmission(InputTypeBase): ...@@ -547,7 +552,6 @@ class FileSubmission(InputTypeBase):
def _extra_context(self): def _extra_context(self):
return {'queue_len': self.queue_len, } return {'queue_len': self.queue_len, }
return context
registry.register(FileSubmission) registry.register(FileSubmission)
...@@ -562,8 +566,9 @@ class CodeInput(InputTypeBase): ...@@ -562,8 +566,9 @@ class CodeInput(InputTypeBase):
template = "codeinput.html" template = "codeinput.html"
tags = ['codeinput', tags = ['codeinput',
'textbox', # Another (older) name--at some point we may want to make it use a 'textbox',
# non-codemirror editor. # Another (older) name--at some point we may want to make it use a
# non-codemirror editor.
] ]
# pulled out for testing # pulled out for testing
...@@ -586,22 +591,29 @@ class CodeInput(InputTypeBase): ...@@ -586,22 +591,29 @@ class CodeInput(InputTypeBase):
Attribute('tabsize', 4, transform=int), Attribute('tabsize', 4, transform=int),
] ]
def setup(self): def setup_code_response_rendering(self):
""" """
Implement special logic: handle queueing state, and default input. Implement special logic: handle queueing state, and default input.
""" """
# 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
if not self.value: # problem
self.value = self.xml.text if not self.value and self.xml.text:
self.value = self.xml.text.strip()
# Check if problem has been queued # Check if problem has been queued
self.queue_len = 0 self.queue_len = 0
# 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 self.status == 'incomplete': if self.status == 'incomplete':
self.status = 'queued' self.status = 'queued'
self.queue_len = self.msg self.queue_len = self.msg
self.msg = self.submitted_msg self.msg = self.submitted_msg
def setup(self):
''' setup this input type '''
self.setup_code_response_rendering()
def _extra_context(self): def _extra_context(self):
"""Defined queue_len, add it """ """Defined queue_len, add it """
return {'queue_len': self.queue_len, } return {'queue_len': self.queue_len, }
...@@ -610,8 +622,164 @@ registry.register(CodeInput) ...@@ -610,8 +622,164 @@ registry.register(CodeInput)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
class MatlabInput(CodeInput):
'''
InputType for handling Matlab code input
TODO: API_KEY will go away once we have a way to specify it per-course
Example:
<matlabinput rows="10" cols="80" tabsize="4">
Initial Text
<plot_payload>
%api_key=API_KEY
</plot_payload>
</matlabinput>
'''
template = "matlabinput.html"
tags = ['matlabinput']
plot_submitted_msg = ("Submitted. As soon as a response is returned, "
"this message will be replaced by that feedback.")
def setup(self):
'''
Handle matlab-specific parsing
'''
self.setup_code_response_rendering()
xml = self.xml
self.plot_payload = xml.findtext('./plot_payload')
# Check if problem has been queued
self.queuename = 'matlab'
self.queue_msg = ''
if 'queue_msg' in self.input_state and self.status in ['queued','incomplete', 'unsubmitted']:
self.queue_msg = self.input_state['queue_msg']
if 'queued' in self.input_state and self.input_state['queuestate'] is not None:
self.status = 'queued'
self.queue_len = 1
self.msg = self.plot_submitted_msg
def handle_ajax(self, dispatch, get):
'''
Handle AJAX calls directed to this input
Args:
- dispatch (str) - indicates how we want this ajax call to be handled
- get (dict) - dictionary of key-value pairs that contain useful data
Returns:
'''
if dispatch == 'plot':
return self._plot_data(get)
return {}
def ungraded_response(self, queue_msg, queuekey):
'''
Handle the response from the XQueue
Stores the response in the input_state so it can be rendered later
Args:
- queue_msg (str) - message returned from the queue. The message to be rendered
- queuekey (str) - a key passed to the queue. Will be matched up to verify that this is the response we're waiting for
Returns:
nothing
'''
# check the queuekey against the saved queuekey
if('queuestate' in self.input_state and self.input_state['queuestate'] == 'queued'
and self.input_state['queuekey'] == queuekey):
msg = self._parse_data(queue_msg)
# save the queue message so that it can be rendered later
self.input_state['queue_msg'] = msg
self.input_state['queuestate'] = None
self.input_state['queuekey'] = None
def _extra_context(self):
''' Set up additional context variables'''
extra_context = {
'queue_len': self.queue_len,
'queue_msg': self.queue_msg
}
return extra_context
def _parse_data(self, queue_msg):
'''
Parses the message out of the queue message
Args:
queue_msg (str) - a JSON encoded string
Returns:
returns the value for the the key 'msg' in queue_msg
'''
try:
result = json.loads(queue_msg)
except (TypeError, ValueError):
log.error("External message should be a JSON serialized dict."
" Received queue_msg = %s" % queue_msg)
raise
msg = result['msg']
return msg
def _plot_data(self, get):
'''
AJAX handler for the plot button
Args:
get (dict) - should have key 'submission' which contains the student submission
Returns:
dict - 'success' - whether or not we successfully queued this submission
- 'message' - message to be rendered in case of error
'''
# only send data if xqueue exists
if self.system.xqueue is None:
return {'success': False, 'message': 'Cannot connect to the queue'}
# pull relevant info out of get
response = get['submission']
# construct xqueue headers
qinterface = self.system.xqueue['interface']
qtime = datetime.strftime(datetime.utcnow(), xqueue_interface.dateformat)
callback_url = self.system.xqueue['construct_callback']('ungraded_response')
anonymous_student_id = self.system.anonymous_student_id
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
anonymous_student_id +
self.id)
xheader = xqueue_interface.make_xheader(
lms_callback_url = callback_url,
lms_key = queuekey,
queue_name = self.queuename)
# save the input state
self.input_state['queuekey'] = queuekey
self.input_state['queuestate'] = 'queued'
# construct xqueue body
student_info = {'anonymous_student_id': anonymous_student_id,
'submission_time': qtime}
contents = {'grader_payload': self.plot_payload,
'student_info': json.dumps(student_info),
'student_response': response}
(error, msg) = qinterface.send_to_queue(header=xheader,
body = json.dumps(contents))
return {'success': error == 0, 'message': msg}
registry.register(MatlabInput)
#-----------------------------------------------------------------------------
class Schematic(InputTypeBase): class Schematic(InputTypeBase):
""" """
InputType for the schematic editor
""" """
template = "schematicinput.html" template = "schematicinput.html"
...@@ -630,7 +798,6 @@ class Schematic(InputTypeBase): ...@@ -630,7 +798,6 @@ class Schematic(InputTypeBase):
Attribute('initial_value', None), Attribute('initial_value', None),
Attribute('submit_analyses', None), ] Attribute('submit_analyses', None), ]
return context
registry.register(Schematic) registry.register(Schematic)
...@@ -660,12 +827,12 @@ class ImageInput(InputTypeBase): ...@@ -660,12 +827,12 @@ class ImageInput(InputTypeBase):
Attribute('height'), Attribute('height'),
Attribute('width'), ] Attribute('width'), ]
def setup(self): def setup(self):
""" """
if value is of the form [x,y] then parse it and send along coordinates of previous answer if value is of the form [x,y] then parse it and send along coordinates of previous answer
""" """
m = re.match('\[([0-9]+),([0-9]+)]', self.value.strip().replace(' ', '')) m = re.match('\[([0-9]+),([0-9]+)]',
self.value.strip().replace(' ', ''))
if m: if m:
# Note: we subtract 15 to compensate for the size of the dot on the screen. # Note: we subtract 15 to compensate for the size of the dot on the screen.
# (is a 30x30 image--lms/static/green-pointer.png). # (is a 30x30 image--lms/static/green-pointer.png).
...@@ -673,7 +840,6 @@ class ImageInput(InputTypeBase): ...@@ -673,7 +840,6 @@ class ImageInput(InputTypeBase):
else: else:
(self.gx, self.gy) = (0, 0) (self.gx, self.gy) = (0, 0)
def _extra_context(self): def _extra_context(self):
return {'gx': self.gx, return {'gx': self.gx,
...@@ -730,7 +896,7 @@ class VseprInput(InputTypeBase): ...@@ -730,7 +896,7 @@ class VseprInput(InputTypeBase):
registry.register(VseprInput) registry.register(VseprInput)
#-------------------------------------------------------------------------------- #-------------------------------------------------------------------------
class ChemicalEquationInput(InputTypeBase): class ChemicalEquationInput(InputTypeBase):
...@@ -794,7 +960,8 @@ class ChemicalEquationInput(InputTypeBase): ...@@ -794,7 +960,8 @@ class ChemicalEquationInput(InputTypeBase):
result['error'] = "Couldn't parse formula: {0}".format(p) result['error'] = "Couldn't parse formula: {0}".format(p)
except Exception: except Exception:
# this is unexpected, so log # this is unexpected, so log
log.warning("Error while previewing chemical formula", exc_info=True) log.warning(
"Error while previewing chemical formula", exc_info=True)
result['error'] = "Error while rendering preview" result['error'] = "Error while rendering preview"
return result return result
...@@ -843,16 +1010,16 @@ class DragAndDropInput(InputTypeBase): ...@@ -843,16 +1010,16 @@ class DragAndDropInput(InputTypeBase):
'can_reuse': ""} 'can_reuse': ""}
tag_attrs['target'] = {'id': Attribute._sentinel, tag_attrs['target'] = {'id': Attribute._sentinel,
'x': Attribute._sentinel, 'x': Attribute._sentinel,
'y': Attribute._sentinel, 'y': Attribute._sentinel,
'w': Attribute._sentinel, 'w': Attribute._sentinel,
'h': Attribute._sentinel} 'h': Attribute._sentinel}
dic = dict() dic = dict()
for attr_name in tag_attrs[tag_type].keys(): for attr_name in tag_attrs[tag_type].keys():
dic[attr_name] = Attribute(attr_name, dic[attr_name] = Attribute(attr_name,
default=tag_attrs[tag_type][attr_name]).parse_from_xml(tag) default=tag_attrs[tag_type][attr_name]).parse_from_xml(tag)
if tag_type == 'draggable' and not self.no_labels: if tag_type == 'draggable' and not self.no_labels:
dic['label'] = dic['label'] or dic['id'] dic['label'] = dic['label'] or dic['id']
...@@ -865,7 +1032,7 @@ class DragAndDropInput(InputTypeBase): ...@@ -865,7 +1032,7 @@ class DragAndDropInput(InputTypeBase):
# add labels to images?: # add labels to images?:
self.no_labels = Attribute('no_labels', self.no_labels = Attribute('no_labels',
default="False").parse_from_xml(self.xml) default="False").parse_from_xml(self.xml)
to_js = dict() to_js = dict()
...@@ -874,16 +1041,16 @@ class DragAndDropInput(InputTypeBase): ...@@ -874,16 +1041,16 @@ class DragAndDropInput(InputTypeBase):
# outline places on image where to drag adn drop # outline places on image where to drag adn drop
to_js['target_outline'] = Attribute('target_outline', to_js['target_outline'] = Attribute('target_outline',
default="False").parse_from_xml(self.xml) default="False").parse_from_xml(self.xml)
# one draggable per target? # one draggable per target?
to_js['one_per_target'] = Attribute('one_per_target', to_js['one_per_target'] = Attribute('one_per_target',
default="True").parse_from_xml(self.xml) default="True").parse_from_xml(self.xml)
# list of draggables # list of draggables
to_js['draggables'] = [parse(draggable, 'draggable') for draggable in to_js['draggables'] = [parse(draggable, 'draggable') for draggable in
self.xml.iterchildren('draggable')] self.xml.iterchildren('draggable')]
# list of targets # list of targets
to_js['targets'] = [parse(target, 'target') for target in to_js['targets'] = [parse(target, 'target') for target in
self.xml.iterchildren('target')] self.xml.iterchildren('target')]
# custom background color for labels: # custom background color for labels:
label_bg_color = Attribute('label_bg_color', label_bg_color = Attribute('label_bg_color',
...@@ -896,7 +1063,7 @@ class DragAndDropInput(InputTypeBase): ...@@ -896,7 +1063,7 @@ class DragAndDropInput(InputTypeBase):
registry.register(DragAndDropInput) registry.register(DragAndDropInput)
#-------------------------------------------------------------------------------------------------------------------- #-------------------------------------------------------------------------
class EditAMoleculeInput(InputTypeBase): class EditAMoleculeInput(InputTypeBase):
...@@ -934,6 +1101,7 @@ registry.register(EditAMoleculeInput) ...@@ -934,6 +1101,7 @@ registry.register(EditAMoleculeInput)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
class DesignProtein2dInput(InputTypeBase): class DesignProtein2dInput(InputTypeBase):
""" """
An input type for design of a protein in 2D. Integrates with the Protex java applet. An input type for design of a protein in 2D. Integrates with the Protex java applet.
...@@ -969,6 +1137,7 @@ registry.register(DesignProtein2dInput) ...@@ -969,6 +1137,7 @@ registry.register(DesignProtein2dInput)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
class EditAGeneInput(InputTypeBase): class EditAGeneInput(InputTypeBase):
""" """
An input type for editing a gene. Integrates with the genex java applet. An input type for editing a gene. Integrates with the genex java applet.
...@@ -1005,6 +1174,7 @@ registry.register(EditAGeneInput) ...@@ -1005,6 +1174,7 @@ registry.register(EditAGeneInput)
#--------------------------------------------------------------------- #---------------------------------------------------------------------
class AnnotationInput(InputTypeBase): class AnnotationInput(InputTypeBase):
""" """
Input type for annotations: students can enter some notes or other text Input type for annotations: students can enter some notes or other text
...@@ -1037,13 +1207,14 @@ class AnnotationInput(InputTypeBase): ...@@ -1037,13 +1207,14 @@ class AnnotationInput(InputTypeBase):
def setup(self): def setup(self):
xml = self.xml xml = self.xml
self.debug = False # set to True to display extra debug info with input self.debug = False # set to True to display extra debug info with input
self.return_to_annotation = True # return only works in conjunction with annotatable xmodule self.return_to_annotation = True # return only works in conjunction with annotatable xmodule
self.title = xml.findtext('./title', 'Annotation Exercise') self.title = xml.findtext('./title', 'Annotation Exercise')
self.text = xml.findtext('./text') self.text = xml.findtext('./text')
self.comment = xml.findtext('./comment') self.comment = xml.findtext('./comment')
self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:') self.comment_prompt = xml.findtext(
'./comment_prompt', 'Type a commentary below:')
self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:') self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:')
self.options = self._find_options() self.options = self._find_options()
...@@ -1061,7 +1232,7 @@ class AnnotationInput(InputTypeBase): ...@@ -1061,7 +1232,7 @@ class AnnotationInput(InputTypeBase):
'id': index, 'id': index,
'description': option.text, 'description': option.text,
'choice': option.get('choice') 'choice': option.get('choice')
} for (index, option) in enumerate(elements) ] } for (index, option) in enumerate(elements)]
def _validate_options(self): def _validate_options(self):
''' Raises a ValueError if the choice attribute is missing or invalid. ''' ''' Raises a ValueError if the choice attribute is missing or invalid. '''
...@@ -1071,7 +1242,8 @@ class AnnotationInput(InputTypeBase): ...@@ -1071,7 +1242,8 @@ class AnnotationInput(InputTypeBase):
if choice is None: if choice is None:
raise ValueError('Missing required choice attribute.') raise ValueError('Missing required choice attribute.')
elif choice not in valid_choices: elif choice not in valid_choices:
raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format(choice, ', '.join(valid_choices))) raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format(
choice, ', '.join(valid_choices)))
def _unpack(self, json_value): def _unpack(self, json_value):
''' Unpacks the json input state into a dict. ''' ''' Unpacks the json input state into a dict. '''
...@@ -1089,20 +1261,20 @@ class AnnotationInput(InputTypeBase): ...@@ -1089,20 +1261,20 @@ class AnnotationInput(InputTypeBase):
return { return {
'options_value': options_value, 'options_value': options_value,
'has_options_value': len(options_value) > 0, # for convenience 'has_options_value': len(options_value) > 0, # for convenience
'comment_value': comment_value, 'comment_value': comment_value,
} }
def _extra_context(self): def _extra_context(self):
extra_context = { extra_context = {
'title': self.title, 'title': self.title,
'text': self.text, 'text': self.text,
'comment': self.comment, 'comment': self.comment,
'comment_prompt': self.comment_prompt, 'comment_prompt': self.comment_prompt,
'tag_prompt': self.tag_prompt, 'tag_prompt': self.tag_prompt,
'options': self.options, 'options': self.options,
'return_to_annotation': self.return_to_annotation, 'return_to_annotation': self.return_to_annotation,
'debug': self.debug 'debug': self.debug
} }
extra_context.update(self._unpack(self.value)) extra_context.update(self._unpack(self.value))
...@@ -1110,4 +1282,3 @@ class AnnotationInput(InputTypeBase): ...@@ -1110,4 +1282,3 @@ class AnnotationInput(InputTypeBase):
return extra_context return extra_context
registry.register(AnnotationInput) registry.register(AnnotationInput)
...@@ -128,21 +128,25 @@ class LoncapaResponse(object): ...@@ -128,21 +128,25 @@ class LoncapaResponse(object):
for abox in inputfields: for abox in inputfields:
if abox.tag not in self.allowed_inputfields: if abox.tag not in self.allowed_inputfields:
msg = "%s: cannot have input field %s" % (unicode(self), abox.tag) msg = "%s: cannot have input field %s" % (
msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '<unavailable>') unicode(self), abox.tag)
msg += "\nSee XML source line %s" % getattr(
xml, 'sourceline', '<unavailable>')
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
if self.max_inputfields and len(inputfields) > self.max_inputfields: if self.max_inputfields and len(inputfields) > self.max_inputfields:
msg = "%s: cannot have more than %s input fields" % ( msg = "%s: cannot have more than %s input fields" % (
unicode(self), self.max_inputfields) unicode(self), self.max_inputfields)
msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '<unavailable>') msg += "\nSee XML source line %s" % getattr(
xml, 'sourceline', '<unavailable>')
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
for prop in self.required_attributes: for prop in self.required_attributes:
if not xml.get(prop): if not xml.get(prop):
msg = "Error in problem specification: %s missing required attribute %s" % ( msg = "Error in problem specification: %s missing required attribute %s" % (
unicode(self), prop) unicode(self), prop)
msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '<unavailable>') msg += "\nSee XML source line %s" % getattr(
xml, 'sourceline', '<unavailable>')
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
# ordered list of answer_id values for this response # ordered list of answer_id values for this response
...@@ -163,7 +167,8 @@ class LoncapaResponse(object): ...@@ -163,7 +167,8 @@ class LoncapaResponse(object):
for entry in self.inputfields: for entry in self.inputfields:
answer = entry.get('correct_answer') answer = entry.get('correct_answer')
if answer: if answer:
self.default_answer_map[entry.get('id')] = contextualize_text(answer, self.context) self.default_answer_map[entry.get(
'id')] = contextualize_text(answer, self.context)
if hasattr(self, 'setup_response'): if hasattr(self, 'setup_response'):
self.setup_response() self.setup_response()
...@@ -211,7 +216,8 @@ class LoncapaResponse(object): ...@@ -211,7 +216,8 @@ class LoncapaResponse(object):
Returns the new CorrectMap, with (correctness,msg,hint,hintmode) for each answer_id. Returns the new CorrectMap, with (correctness,msg,hint,hintmode) for each answer_id.
''' '''
new_cmap = self.get_score(student_answers) new_cmap = self.get_score(student_answers)
self.get_hints(convert_files_to_filenames(student_answers), new_cmap, old_cmap) self.get_hints(convert_files_to_filenames(
student_answers), new_cmap, old_cmap)
# log.debug('new_cmap = %s' % new_cmap) # log.debug('new_cmap = %s' % new_cmap)
return new_cmap return new_cmap
...@@ -241,14 +247,17 @@ class LoncapaResponse(object): ...@@ -241,14 +247,17 @@ class LoncapaResponse(object):
# callback procedure to a social hint generation system. # callback procedure to a social hint generation system.
if not hintfn in self.context: if not hintfn in self.context:
msg = 'missing specified hint function %s in script context' % hintfn msg = 'missing specified hint function %s in script context' % hintfn
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) raise LoncapaProblemError(msg)
try: try:
self.context[hintfn](self.answer_ids, student_answers, new_cmap, old_cmap) self.context[hintfn](
self.answer_ids, student_answers, new_cmap, old_cmap)
except Exception as err: except Exception as err:
msg = 'Error %s in evaluating hint function %s' % (err, hintfn) msg = 'Error %s in evaluating hint function %s' % (err, hintfn)
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>') msg += "\nSee XML source line %s" % getattr(
self.xml, 'sourceline', '<unavailable>')
raise ResponseError(msg) raise ResponseError(msg)
return return
...@@ -270,17 +279,19 @@ class LoncapaResponse(object): ...@@ -270,17 +279,19 @@ class LoncapaResponse(object):
if (self.hint_tag is not None if (self.hint_tag is not None
and hintgroup.find(self.hint_tag) is not None and hintgroup.find(self.hint_tag) is not None
and hasattr(self, 'check_hint_condition')): and hasattr(self, 'check_hint_condition')):
rephints = hintgroup.findall(self.hint_tag) rephints = hintgroup.findall(self.hint_tag)
hints_to_show = self.check_hint_condition(rephints, student_answers) hints_to_show = self.check_hint_condition(
rephints, student_answers)
# can be 'on_request' or 'always' (default) # can be 'on_request' or 'always' (default)
hintmode = hintgroup.get('mode', 'always') hintmode = hintgroup.get('mode', 'always')
for hintpart in hintgroup.findall('hintpart'): for hintpart in hintgroup.findall('hintpart'):
if hintpart.get('on') in hints_to_show: if hintpart.get('on') in hints_to_show:
hint_text = hintpart.find('text').text hint_text = hintpart.find('text').text
# 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] aid = self.answer_ids[-1]
new_cmap.set_hint_and_mode(aid, hint_text, hintmode) new_cmap.set_hint_and_mode(aid, hint_text, hintmode)
log.debug('after hint: new_cmap = %s' % new_cmap) log.debug('after hint: new_cmap = %s' % new_cmap)
...@@ -340,7 +351,6 @@ class LoncapaResponse(object): ...@@ -340,7 +351,6 @@ class LoncapaResponse(object):
response_msg_div = etree.Element('div') response_msg_div = etree.Element('div')
response_msg_div.text = str(response_msg) response_msg_div.text = str(response_msg)
# Set the css class of the message <div> # Set the css class of the message <div>
response_msg_div.set("class", "response_message") response_msg_div.set("class", "response_message")
...@@ -384,20 +394,20 @@ class JavascriptResponse(LoncapaResponse): ...@@ -384,20 +394,20 @@ class JavascriptResponse(LoncapaResponse):
# until we decide on exactly how to solve this issue. For now, files are # until we decide on exactly how to solve this issue. For now, files are
# manually being compiled to DATA_DIR/js/compiled. # manually being compiled to DATA_DIR/js/compiled.
#latestTimestamp = 0 # latestTimestamp = 0
#basepath = self.system.filestore.root_path + '/js/' # basepath = self.system.filestore.root_path + '/js/'
#for filename in (self.display_dependencies + [self.display]): # for filename in (self.display_dependencies + [self.display]):
# filepath = basepath + filename # filepath = basepath + filename
# timestamp = os.stat(filepath).st_mtime # timestamp = os.stat(filepath).st_mtime
# if timestamp > latestTimestamp: # if timestamp > latestTimestamp:
# latestTimestamp = timestamp # latestTimestamp = timestamp
# #
#h = hashlib.md5() # h = hashlib.md5()
#h.update(self.answer_id + str(self.display_dependencies)) # h.update(self.answer_id + str(self.display_dependencies))
#compiled_filename = 'compiled/' + h.hexdigest() + '.js' # compiled_filename = 'compiled/' + h.hexdigest() + '.js'
#compiled_filepath = basepath + compiled_filename # compiled_filepath = basepath + compiled_filename
#if not os.path.exists(compiled_filepath) or os.stat(compiled_filepath).st_mtime < latestTimestamp: # if not os.path.exists(compiled_filepath) or os.stat(compiled_filepath).st_mtime < latestTimestamp:
# outfile = open(compiled_filepath, 'w') # outfile = open(compiled_filepath, 'w')
# for filename in (self.display_dependencies + [self.display]): # for filename in (self.display_dependencies + [self.display]):
# filepath = basepath + filename # filepath = basepath + filename
...@@ -419,7 +429,7 @@ class JavascriptResponse(LoncapaResponse): ...@@ -419,7 +429,7 @@ class JavascriptResponse(LoncapaResponse):
id=self.xml.get('id'))[0] id=self.xml.get('id'))[0]
self.display_xml = self.xml.xpath('//*[@id=$id]//display', self.display_xml = self.xml.xpath('//*[@id=$id]//display',
id=self.xml.get('id'))[0] id=self.xml.get('id'))[0]
self.xml.remove(self.generator_xml) self.xml.remove(self.generator_xml)
self.xml.remove(self.grader_xml) self.xml.remove(self.grader_xml)
...@@ -430,17 +440,20 @@ class JavascriptResponse(LoncapaResponse): ...@@ -430,17 +440,20 @@ class JavascriptResponse(LoncapaResponse):
self.display = self.display_xml.get("src") self.display = self.display_xml.get("src")
if self.generator_xml.get("dependencies"): if self.generator_xml.get("dependencies"):
self.generator_dependencies = self.generator_xml.get("dependencies").split() self.generator_dependencies = self.generator_xml.get(
"dependencies").split()
else: else:
self.generator_dependencies = [] self.generator_dependencies = []
if self.grader_xml.get("dependencies"): if self.grader_xml.get("dependencies"):
self.grader_dependencies = self.grader_xml.get("dependencies").split() self.grader_dependencies = self.grader_xml.get(
"dependencies").split()
else: else:
self.grader_dependencies = [] self.grader_dependencies = []
if self.display_xml.get("dependencies"): if self.display_xml.get("dependencies"):
self.display_dependencies = self.display_xml.get("dependencies").split() self.display_dependencies = self.display_xml.get(
"dependencies").split()
else: else:
self.display_dependencies = [] self.display_dependencies = []
...@@ -461,10 +474,10 @@ class JavascriptResponse(LoncapaResponse): ...@@ -461,10 +474,10 @@ class JavascriptResponse(LoncapaResponse):
return subprocess.check_output(subprocess_args, env=self.get_node_env()) return subprocess.check_output(subprocess_args, env=self.get_node_env())
def generate_problem_state(self): def generate_problem_state(self):
generator_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_generator.js' generator_file = os.path.dirname(os.path.normpath(
__file__)) + '/javascript_problem_generator.js'
output = self.call_node([generator_file, output = self.call_node([generator_file,
self.generator, self.generator,
json.dumps(self.generator_dependencies), json.dumps(self.generator_dependencies),
...@@ -478,17 +491,18 @@ class JavascriptResponse(LoncapaResponse): ...@@ -478,17 +491,18 @@ class JavascriptResponse(LoncapaResponse):
params = {} params = {}
for param in self.xml.xpath('//*[@id=$id]//responseparam', for param in self.xml.xpath('//*[@id=$id]//responseparam',
id=self.xml.get('id')): id=self.xml.get('id')):
raw_param = param.get("value") raw_param = param.get("value")
params[param.get("name")] = json.loads(contextualize_text(raw_param, self.context)) params[param.get("name")] = json.loads(
contextualize_text(raw_param, self.context))
return params return params
def prepare_inputfield(self): def prepare_inputfield(self):
for inputfield in self.xml.xpath('//*[@id=$id]//javascriptinput', for inputfield in self.xml.xpath('//*[@id=$id]//javascriptinput',
id=self.xml.get('id')): id=self.xml.get('id')):
escapedict = {'"': '&quot;'} escapedict = {'"': '&quot;'}
...@@ -501,7 +515,7 @@ class JavascriptResponse(LoncapaResponse): ...@@ -501,7 +515,7 @@ class JavascriptResponse(LoncapaResponse):
escapedict) escapedict)
inputfield.set("problem_state", encoded_problem_state) inputfield.set("problem_state", encoded_problem_state)
inputfield.set("display_file", self.display_filename) inputfield.set("display_file", self.display_filename)
inputfield.set("display_class", self.display_class) inputfield.set("display_class", self.display_class)
def get_score(self, student_answers): def get_score(self, student_answers):
...@@ -519,7 +533,8 @@ class JavascriptResponse(LoncapaResponse): ...@@ -519,7 +533,8 @@ class JavascriptResponse(LoncapaResponse):
if submission is None or submission == '': if submission is None or submission == '':
submission = json.dumps(None) submission = json.dumps(None)
grader_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_grader.js' grader_file = os.path.dirname(os.path.normpath(
__file__)) + '/javascript_problem_grader.js'
outputs = self.call_node([grader_file, outputs = self.call_node([grader_file,
self.grader, self.grader,
json.dumps(self.grader_dependencies), json.dumps(self.grader_dependencies),
...@@ -528,8 +543,8 @@ class JavascriptResponse(LoncapaResponse): ...@@ -528,8 +543,8 @@ class JavascriptResponse(LoncapaResponse):
json.dumps(self.params)]).split('\n') json.dumps(self.params)]).split('\n')
all_correct = json.loads(outputs[0].strip()) all_correct = json.loads(outputs[0].strip())
evaluation = outputs[1].strip() evaluation = outputs[1].strip()
solution = outputs[2].strip() solution = outputs[2].strip()
return (all_correct, evaluation, solution) return (all_correct, evaluation, solution)
def get_answers(self): def get_answers(self):
...@@ -539,9 +554,7 @@ class JavascriptResponse(LoncapaResponse): ...@@ -539,9 +554,7 @@ class JavascriptResponse(LoncapaResponse):
return {self.answer_id: self.solution} return {self.answer_id: self.solution}
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
class ChoiceResponse(LoncapaResponse): class ChoiceResponse(LoncapaResponse):
""" """
This response type is used when the student chooses from a discrete set of This response type is used when the student chooses from a discrete set of
...@@ -599,9 +612,10 @@ class ChoiceResponse(LoncapaResponse): ...@@ -599,9 +612,10 @@ class ChoiceResponse(LoncapaResponse):
self.assign_choice_names() self.assign_choice_names()
correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]', correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]',
id=self.xml.get('id')) id=self.xml.get('id'))
self.correct_choices = set([choice.get('name') for choice in correct_xml]) self.correct_choices = set([choice.get(
'name') for choice in correct_xml])
def assign_choice_names(self): def assign_choice_names(self):
''' '''
...@@ -654,7 +668,8 @@ class MultipleChoiceResponse(LoncapaResponse): ...@@ -654,7 +668,8 @@ class MultipleChoiceResponse(LoncapaResponse):
allowed_inputfields = ['choicegroup'] allowed_inputfields = ['choicegroup']
def setup_response(self): def setup_response(self):
# call secondary setup for MultipleChoice questions, to set name attributes # call secondary setup for MultipleChoice questions, to set name
# attributes
self.mc_setup_response() self.mc_setup_response()
# define correct choices (after calling secondary setup) # define correct choices (after calling secondary setup)
...@@ -692,7 +707,7 @@ class MultipleChoiceResponse(LoncapaResponse): ...@@ -692,7 +707,7 @@ class MultipleChoiceResponse(LoncapaResponse):
# log.debug('%s: student_answers=%s, correct_choices=%s' % ( # log.debug('%s: student_answers=%s, correct_choices=%s' % (
# unicode(self), student_answers, self.correct_choices)) # unicode(self), student_answers, self.correct_choices))
if (self.answer_id in student_answers if (self.answer_id in student_answers
and student_answers[self.answer_id] in self.correct_choices): and student_answers[self.answer_id] in self.correct_choices):
return CorrectMap(self.answer_id, 'correct') return CorrectMap(self.answer_id, 'correct')
else: else:
return CorrectMap(self.answer_id, 'incorrect') return CorrectMap(self.answer_id, 'incorrect')
...@@ -760,7 +775,8 @@ class OptionResponse(LoncapaResponse): ...@@ -760,7 +775,8 @@ class OptionResponse(LoncapaResponse):
return cmap return cmap
def get_answers(self): def get_answers(self):
amap = dict([(af.get('id'), contextualize_text(af.get('correct'), self.context)) for af in self.answer_fields]) amap = dict([(af.get('id'), contextualize_text(af.get(
'correct'), self.context)) for af in self.answer_fields])
# log.debug('%s: expected answers=%s' % (unicode(self),amap)) # log.debug('%s: expected answers=%s' % (unicode(self),amap))
return amap return amap
...@@ -780,8 +796,9 @@ class NumericalResponse(LoncapaResponse): ...@@ -780,8 +796,9 @@ class NumericalResponse(LoncapaResponse):
context = self.context context = self.context
self.correct_answer = contextualize_text(xml.get('answer'), context) self.correct_answer = contextualize_text(xml.get('answer'), context)
try: try:
self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default', self.tolerance_xml = xml.xpath(
id=xml.get('id'))[0] '//*[@id=$id]//responseparam[@type="tolerance"]/@default',
id=xml.get('id'))[0]
self.tolerance = contextualize_text(self.tolerance_xml, context) self.tolerance = contextualize_text(self.tolerance_xml, context)
except Exception: except Exception:
self.tolerance = '0' self.tolerance = '0'
...@@ -798,17 +815,21 @@ class NumericalResponse(LoncapaResponse): ...@@ -798,17 +815,21 @@ class NumericalResponse(LoncapaResponse):
try: try:
correct_ans = complex(self.correct_answer) correct_ans = complex(self.correct_answer)
except ValueError: except ValueError:
log.debug("Content error--answer '{0}' is not a valid complex number".format(self.correct_answer)) log.debug("Content error--answer '{0}' is not a valid complex number".format(
raise StudentInputError("There was a problem with the staff answer to this problem") self.correct_answer))
raise StudentInputError(
"There was a problem with the staff answer to this problem")
try: try:
correct = compare_with_tolerance(evaluator(dict(), dict(), student_answer), correct = compare_with_tolerance(
correct_ans, self.tolerance) evaluator(dict(), dict(), student_answer),
correct_ans, self.tolerance)
# We should catch this explicitly. # We should catch this explicitly.
# I think this is just pyparsing.ParseException, calc.UndefinedVariable: # I think this is just pyparsing.ParseException, calc.UndefinedVariable:
# But we'd need to confirm # But we'd need to confirm
except: except:
# Use the traceback-preserving version of re-raising with a different type # Use the traceback-preserving version of re-raising with a
# different type
import sys import sys
type, value, traceback = sys.exc_info() type, value, traceback = sys.exc_info()
...@@ -837,7 +858,8 @@ class StringResponse(LoncapaResponse): ...@@ -837,7 +858,8 @@ class StringResponse(LoncapaResponse):
max_inputfields = 1 max_inputfields = 1
def setup_response(self): def setup_response(self):
self.correct_answer = contextualize_text(self.xml.get('answer'), self.context).strip() self.correct_answer = contextualize_text(
self.xml.get('answer'), self.context).strip()
def get_score(self, student_answers): def get_score(self, student_answers):
'''Grade a string response ''' '''Grade a string response '''
...@@ -846,7 +868,8 @@ class StringResponse(LoncapaResponse): ...@@ -846,7 +868,8 @@ class StringResponse(LoncapaResponse):
return CorrectMap(self.answer_id, 'correct' if correct else 'incorrect') return CorrectMap(self.answer_id, 'correct' if correct else 'incorrect')
def check_string(self, expected, given): def check_string(self, expected, given):
if self.xml.get('type') == 'ci': return given.lower() == expected.lower() if self.xml.get('type') == 'ci':
return given.lower() == expected.lower()
return given == expected return given == expected
def check_hint_condition(self, hxml_set, student_answers): def check_hint_condition(self, hxml_set, student_answers):
...@@ -854,8 +877,10 @@ class StringResponse(LoncapaResponse): ...@@ -854,8 +877,10 @@ class StringResponse(LoncapaResponse):
hints_to_show = [] hints_to_show = []
for hxml in hxml_set: for hxml in hxml_set:
name = hxml.get('name') name = hxml.get('name')
correct_answer = contextualize_text(hxml.get('answer'), self.context).strip() correct_answer = contextualize_text(
if self.check_string(correct_answer, given): hints_to_show.append(name) hxml.get('answer'), self.context).strip()
if self.check_string(correct_answer, given):
hints_to_show.append(name)
log.debug('hints_to_show = %s' % hints_to_show) log.debug('hints_to_show = %s' % hints_to_show)
return hints_to_show return hints_to_show
...@@ -889,7 +914,7 @@ class CustomResponse(LoncapaResponse): ...@@ -889,7 +914,7 @@ class CustomResponse(LoncapaResponse):
correct[0] ='incorrect' correct[0] ='incorrect'
</answer> </answer>
</customresponse>"""}, </customresponse>"""},
{'snippet': """<script type="loncapa/python"><![CDATA[ {'snippet': """<script type="loncapa/python"><![CDATA[
def sympy_check2(): def sympy_check2():
messages[0] = '%s:%s' % (submission[0],fromjs[0].replace('<','&lt;')) messages[0] = '%s:%s' % (submission[0],fromjs[0].replace('<','&lt;'))
...@@ -907,15 +932,16 @@ def sympy_check2(): ...@@ -907,15 +932,16 @@ def sympy_check2():
response_tag = 'customresponse' response_tag = 'customresponse'
allowed_inputfields = ['textline', 'textbox', 'crystallography', allowed_inputfields = ['textline', 'textbox', 'crystallography',
'chemicalequationinput', 'vsepr_input', 'chemicalequationinput', 'vsepr_input',
'drag_and_drop_input', 'editamoleculeinput', 'drag_and_drop_input', 'editamoleculeinput',
'designprotein2dinput', 'editageneinput', 'designprotein2dinput', 'editageneinput',
'annotationinput'] 'annotationinput']
def setup_response(self): def setup_response(self):
xml = self.xml xml = self.xml
# if <customresponse> has an "expect" (or "answer") attribute then save that # if <customresponse> has an "expect" (or "answer") attribute then save
# that
self.expect = xml.get('expect') or xml.get('answer') self.expect = xml.get('expect') or xml.get('answer')
self.myid = xml.get('id') self.myid = xml.get('id')
...@@ -939,7 +965,8 @@ def sympy_check2(): ...@@ -939,7 +965,8 @@ def sympy_check2():
if cfn in self.context: if cfn in self.context:
self.code = self.context[cfn] self.code = self.context[cfn]
else: else:
msg = "%s: can't find cfn %s in context" % (unicode(self), cfn) msg = "%s: can't find cfn %s in context" % (
unicode(self), cfn)
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline',
'<unavailable>') '<unavailable>')
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
...@@ -952,7 +979,8 @@ def sympy_check2(): ...@@ -952,7 +979,8 @@ def sympy_check2():
else: else:
answer_src = answer.get('src') answer_src = answer.get('src')
if answer_src is not None: if answer_src is not None:
self.code = self.system.filesystem.open('src/' + answer_src).read() self.code = self.system.filesystem.open(
'src/' + answer_src).read()
else: else:
self.code = answer.text self.code = answer.text
...@@ -1032,7 +1060,7 @@ def sympy_check2(): ...@@ -1032,7 +1060,7 @@ def sympy_check2():
# any options to be passed to the cfn # any options to be passed to the cfn
'options': self.xml.get('options'), 'options': self.xml.get('options'),
'testdat': 'hello world', 'testdat': 'hello world',
}) })
# pass self.system.debug to cfn # pass self.system.debug to cfn
self.context['debug'] = self.system.DEBUG self.context['debug'] = self.system.DEBUG
...@@ -1049,7 +1077,8 @@ def sympy_check2(): ...@@ -1049,7 +1077,8 @@ def sympy_check2():
print "context = ", self.context print "context = ", self.context
print traceback.format_exc() print traceback.format_exc()
# Notify student # Notify student
raise StudentInputError("Error: Problem could not be evaluated with your input") raise StudentInputError(
"Error: Problem could not be evaluated with your input")
else: else:
# self.code is not a string; assume its a function # self.code is not a string; assume its a function
...@@ -1058,18 +1087,22 @@ def sympy_check2(): ...@@ -1058,18 +1087,22 @@ def sympy_check2():
ret = None ret = None
log.debug(" submission = %s" % submission) log.debug(" submission = %s" % submission)
try: try:
answer_given = submission[0] if (len(idset) == 1) else submission answer_given = submission[0] if (
len(idset) == 1) else submission
# handle variable number of arguments in check function, for backwards compatibility # handle variable number of arguments in check function, for backwards compatibility
# with various Tutor2 check functions # with various Tutor2 check functions
args = [self.expect, answer_given, student_answers, self.answer_ids[0]] args = [self.expect, answer_given,
student_answers, self.answer_ids[0]]
argspec = inspect.getargspec(fn) argspec = inspect.getargspec(fn)
nargs = len(argspec.args) - len(argspec.defaults or []) nargs = len(argspec.args) - len(argspec.defaults or [])
kwargs = {} kwargs = {}
for argname in argspec.args[nargs:]: for argname in argspec.args[nargs:]:
kwargs[argname] = self.context[argname] if argname in self.context else None kwargs[argname] = self.context[
argname] if argname in self.context else None
log.debug('[customresponse] answer_given=%s' % answer_given) log.debug('[customresponse] answer_given=%s' % answer_given)
log.debug('nargs=%d, args=%s, kwargs=%s' % (nargs, args, kwargs)) log.debug('nargs=%d, args=%s, kwargs=%s' % (
nargs, args, kwargs))
ret = fn(*args[:nargs], **kwargs) ret = fn(*args[:nargs], **kwargs)
except Exception as err: except Exception as err:
...@@ -1077,7 +1110,8 @@ def sympy_check2(): ...@@ -1077,7 +1110,8 @@ def sympy_check2():
# print "context = ",self.context # print "context = ",self.context
log.error(traceback.format_exc()) log.error(traceback.format_exc())
raise Exception("oops in customresponse (cfn) error %s" % err) raise Exception("oops in customresponse (cfn) error %s" % err)
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret) log.debug(
"[courseware.capa.responsetypes.customresponse.get_score] ret = %s" % ret)
if type(ret) == dict: if type(ret) == dict:
...@@ -1086,7 +1120,8 @@ def sympy_check2(): ...@@ -1086,7 +1120,8 @@ def sympy_check2():
# If there are multiple inputs, they all get marked # If there are multiple inputs, they all get marked
# to the same correct/incorrect value # to the same correct/incorrect value
if 'ok' in ret: if 'ok' in ret:
correct = ['correct'] * len(idset) if ret['ok'] else ['incorrect'] * len(idset) correct = ['correct'] * len(idset) if ret[
'ok'] else ['incorrect'] * len(idset)
msg = ret.get('msg', None) msg = ret.get('msg', None)
msg = self.clean_message_html(msg) msg = self.clean_message_html(msg)
...@@ -1097,7 +1132,6 @@ def sympy_check2(): ...@@ -1097,7 +1132,6 @@ def sympy_check2():
else: else:
messages[0] = msg messages[0] = msg
# Another kind of dictionary the check function can return has # Another kind of dictionary the check function can return has
# the form: # the form:
# {'overall_message': STRING, # {'overall_message': STRING,
...@@ -1113,21 +1147,25 @@ def sympy_check2(): ...@@ -1113,21 +1147,25 @@ def sympy_check2():
correct = [] correct = []
messages = [] messages = []
for input_dict in input_list: for input_dict in input_list:
correct.append('correct' if input_dict['ok'] else 'incorrect') correct.append('correct'
msg = self.clean_message_html(input_dict['msg']) if 'msg' in input_dict else None if input_dict['ok'] else 'incorrect')
msg = (self.clean_message_html(input_dict['msg'])
if 'msg' in input_dict else None)
messages.append(msg) messages.append(msg)
# Otherwise, we do not recognize the dictionary # Otherwise, we do not recognize the dictionary
# Raise an exception # Raise an exception
else: else:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
raise Exception("CustomResponse: check function returned an invalid dict") raise Exception(
"CustomResponse: check function returned an invalid dict")
# The check function can return a boolean value, # The check function can return a boolean value,
# indicating whether all inputs should be marked # indicating whether all inputs should be marked
# correct or incorrect # correct or incorrect
else: else:
correct = ['correct'] * len(idset) if ret else ['incorrect'] * len(idset) n = len(idset)
correct = ['correct'] * n if ret else ['incorrect'] * n
# build map giving "correct"ness of the answer(s) # build map giving "correct"ness of the answer(s)
correct_map = CorrectMap() correct_map = CorrectMap()
...@@ -1136,7 +1174,8 @@ def sympy_check2(): ...@@ -1136,7 +1174,8 @@ def sympy_check2():
correct_map.set_overall_message(overall_message) correct_map.set_overall_message(overall_message)
for k in range(len(idset)): for k in range(len(idset)):
npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0 npoints = (self.maxpoints[idset[k]]
if correct[k] == 'correct' else 0)
correct_map.set(idset[k], correct[k], msg=messages[k], correct_map.set(idset[k], correct[k], msg=messages[k],
npoints=npoints) npoints=npoints)
return correct_map return correct_map
...@@ -1232,8 +1271,9 @@ class CodeResponse(LoncapaResponse): ...@@ -1232,8 +1271,9 @@ class CodeResponse(LoncapaResponse):
Expects 'xqueue' dict in ModuleSystem with the following keys that are needed by CodeResponse: Expects 'xqueue' dict in ModuleSystem with the following keys that are needed by CodeResponse:
system.xqueue = { 'interface': XqueueInterface object, system.xqueue = { 'interface': XqueueInterface object,
'callback_url': Per-StudentModule callback URL 'construct_callback': Per-StudentModule callback URL
where results are posted (string), constructor, defaults to using 'score_update'
as the correct dispatch (function),
'default_queuename': Default queuename to submit request (string) 'default_queuename': Default queuename to submit request (string)
} }
...@@ -1242,7 +1282,7 @@ class CodeResponse(LoncapaResponse): ...@@ -1242,7 +1282,7 @@ class CodeResponse(LoncapaResponse):
""" """
response_tag = 'coderesponse' response_tag = 'coderesponse'
allowed_inputfields = ['textbox', 'filesubmission'] allowed_inputfields = ['textbox', 'filesubmission', 'matlabinput']
max_inputfields = 1 max_inputfields = 1
def setup_response(self): def setup_response(self):
...@@ -1263,7 +1303,8 @@ class CodeResponse(LoncapaResponse): ...@@ -1263,7 +1303,8 @@ class CodeResponse(LoncapaResponse):
self.queue_name = xml.get('queuename', default_queuename) self.queue_name = xml.get('queuename', default_queuename)
# VS[compat]: # VS[compat]:
# Check if XML uses the ExternalResponse format or the generic CodeResponse format # Check if XML uses the ExternalResponse format or the generic
# CodeResponse format
codeparam = self.xml.find('codeparam') codeparam = self.xml.find('codeparam')
if codeparam is None: if codeparam is None:
self._parse_externalresponse_xml() self._parse_externalresponse_xml()
...@@ -1277,12 +1318,14 @@ class CodeResponse(LoncapaResponse): ...@@ -1277,12 +1318,14 @@ class CodeResponse(LoncapaResponse):
self.answer (an answer to display to the student in the LMS) self.answer (an answer to display to the student in the LMS)
self.payload self.payload
''' '''
# Note that CodeResponse is agnostic to the specific contents of grader_payload # Note that CodeResponse is agnostic to the specific contents of
# grader_payload
grader_payload = codeparam.find('grader_payload') grader_payload = codeparam.find('grader_payload')
grader_payload = grader_payload.text if grader_payload is not None else '' grader_payload = grader_payload.text if grader_payload is not None else ''
self.payload = {'grader_payload': grader_payload} self.payload = {'grader_payload': grader_payload}
self.initial_display = find_with_default(codeparam, 'initial_display', '') self.initial_display = find_with_default(
codeparam, 'initial_display', '')
self.answer = find_with_default(codeparam, 'answer_display', self.answer = find_with_default(codeparam, 'answer_display',
'No answer provided.') 'No answer provided.')
...@@ -1304,8 +1347,10 @@ class CodeResponse(LoncapaResponse): ...@@ -1304,8 +1347,10 @@ class CodeResponse(LoncapaResponse):
else: # no <answer> stanza; get code from <script> else: # no <answer> stanza; get code from <script>
code = self.context['script_code'] code = self.context['script_code']
if not code: if not code:
msg = '%s: Missing answer script code for coderesponse' % unicode(self) msg = '%s: Missing answer script code for coderesponse' % unicode(
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>') self)
msg += "\nSee XML source line %s" % getattr(
self.xml, 'sourceline', '<unavailable>')
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
tests = self.xml.get('tests') tests = self.xml.get('tests')
...@@ -1320,7 +1365,8 @@ class CodeResponse(LoncapaResponse): ...@@ -1320,7 +1365,8 @@ class CodeResponse(LoncapaResponse):
try: try:
exec(code, penv, penv) exec(code, penv, penv)
except Exception as err: except Exception as err:
log.error('Error in CodeResponse %s: Error in problem reference code' % err) log.error(
'Error in CodeResponse %s: Error in problem reference code' % err)
raise Exception(err) raise Exception(err)
try: try:
self.answer = penv['answer'] self.answer = penv['answer']
...@@ -1333,7 +1379,7 @@ class CodeResponse(LoncapaResponse): ...@@ -1333,7 +1379,7 @@ class CodeResponse(LoncapaResponse):
# Finally, make the ExternalResponse input XML format conform to the generic # Finally, make the ExternalResponse input XML format conform to the generic
# exteral grader interface # exteral grader interface
# The XML tagging of grader_payload is pyxserver-specific # The XML tagging of grader_payload is pyxserver-specific
grader_payload = '<pyxserver>' grader_payload = '<pyxserver>'
grader_payload += '<tests>' + tests + '</tests>\n' grader_payload += '<tests>' + tests + '</tests>\n'
grader_payload += '<processor>' + code + '</processor>' grader_payload += '<processor>' + code + '</processor>'
grader_payload += '</pyxserver>' grader_payload += '</pyxserver>'
...@@ -1346,14 +1392,14 @@ class CodeResponse(LoncapaResponse): ...@@ -1346,14 +1392,14 @@ class CodeResponse(LoncapaResponse):
except Exception as err: except Exception as err:
log.error('Error in CodeResponse %s: cannot get student answer for %s;' log.error('Error in CodeResponse %s: cannot get student answer for %s;'
' student_answers=%s' % ' student_answers=%s' %
(err, self.answer_id, convert_files_to_filenames(student_answers))) (err, self.answer_id, convert_files_to_filenames(student_answers)))
raise Exception(err) raise Exception(err)
# We do not support xqueue within Studio. # We do not support xqueue within Studio.
if self.system.xqueue is None: if self.system.xqueue is None:
cmap = CorrectMap() cmap = CorrectMap()
cmap.set(self.answer_id, queuestate=None, cmap.set(self.answer_id, queuestate=None,
msg='Error checking problem: no external queueing server is configured.') msg='Error checking problem: no external queueing server is configured.')
return cmap return cmap
# Prepare xqueue request # Prepare xqueue request
...@@ -1368,9 +1414,11 @@ class CodeResponse(LoncapaResponse): ...@@ -1368,9 +1414,11 @@ class CodeResponse(LoncapaResponse):
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime + queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
anonymous_student_id + anonymous_student_id +
self.answer_id) self.answer_id)
xheader = xqueue_interface.make_xheader(lms_callback_url=self.system.xqueue['callback_url'], callback_url = self.system.xqueue['construct_callback']()
lms_key=queuekey, xheader = xqueue_interface.make_xheader(
queue_name=self.queue_name) lms_callback_url=callback_url,
lms_key=queuekey,
queue_name=self.queue_name)
# Generate body # Generate body
if is_list_of_files(submission): if is_list_of_files(submission):
...@@ -1381,13 +1429,16 @@ class CodeResponse(LoncapaResponse): ...@@ -1381,13 +1429,16 @@ class CodeResponse(LoncapaResponse):
contents = self.payload.copy() contents = self.payload.copy()
# Metadata related to the student submission revealed to the external grader # Metadata related to the student submission revealed to the external
# grader
student_info = {'anonymous_student_id': anonymous_student_id, student_info = {'anonymous_student_id': anonymous_student_id,
'submission_time': qtime, 'submission_time': qtime,
} }
contents.update({'student_info': json.dumps(student_info)}) contents.update({'student_info': json.dumps(student_info)})
# Submit request. When successful, 'msg' is the prior length of the queue # Submit request. When successful, 'msg' is the prior length of the
# queue
if is_list_of_files(submission): if is_list_of_files(submission):
# TODO: Is there any information we want to send here? # TODO: Is there any information we want to send here?
contents.update({'student_response': ''}) contents.update({'student_response': ''})
...@@ -1415,13 +1466,15 @@ class CodeResponse(LoncapaResponse): ...@@ -1415,13 +1466,15 @@ class CodeResponse(LoncapaResponse):
# 2) Frontend: correctness='incomplete' eventually trickles down # 2) Frontend: correctness='incomplete' eventually trickles down
# through inputtypes.textbox and .filesubmission to inform the # through inputtypes.textbox and .filesubmission to inform the
# browser to poll the LMS # browser to poll the LMS
cmap.set(self.answer_id, queuestate=queuestate, correctness='incomplete', msg=msg) cmap.set(self.answer_id, queuestate=queuestate,
correctness='incomplete', msg=msg)
return cmap return cmap
def update_score(self, score_msg, oldcmap, queuekey): def update_score(self, score_msg, oldcmap, queuekey):
(valid_score_msg, correct, points, msg) = self._parse_score_msg(score_msg) (valid_score_msg, correct, points,
msg) = self._parse_score_msg(score_msg)
if not valid_score_msg: if not valid_score_msg:
oldcmap.set(self.answer_id, oldcmap.set(self.answer_id,
msg='Invalid grader reply. Please contact the course staff.') msg='Invalid grader reply. Please contact the course staff.')
...@@ -1433,14 +1486,16 @@ class CodeResponse(LoncapaResponse): ...@@ -1433,14 +1486,16 @@ class CodeResponse(LoncapaResponse):
self.context['correct'] = correctness self.context['correct'] = correctness
# Replace 'oldcmap' with new grading results if queuekey matches. If queuekey # 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 # does not match, we keep waiting for the score_msg whose key actually
# matches
if oldcmap.is_right_queuekey(self.answer_id, queuekey): if oldcmap.is_right_queuekey(self.answer_id, queuekey):
# Sanity check on returned points # Sanity check on returned points
if points < 0: if points < 0:
points = 0 points = 0
# Queuestate is consumed # Queuestate is consumed
oldcmap.set(self.answer_id, npoints=points, correctness=correctness, oldcmap.set(
msg=msg.replace('&nbsp;', '&#160;'), queuestate=None) self.answer_id, npoints=points, correctness=correctness,
msg=msg.replace('&nbsp;', '&#160;'), queuestate=None)
else: else:
log.debug('CodeResponse: queuekey %s does not match for answer_id=%s.' % log.debug('CodeResponse: queuekey %s does not match for answer_id=%s.' %
(queuekey, self.answer_id)) (queuekey, self.answer_id))
...@@ -1560,15 +1615,18 @@ main() ...@@ -1560,15 +1615,18 @@ main()
if answer is not None: if answer is not None:
answer_src = answer.get('src') answer_src = answer.get('src')
if answer_src is not None: if answer_src is not None:
self.code = self.system.filesystem.open('src/' + answer_src).read() self.code = self.system.filesystem.open(
'src/' + answer_src).read()
else: else:
self.code = answer.text self.code = answer.text
else: else:
# no <answer> stanza; get code from <script> # no <answer> stanza; get code from <script>
self.code = self.context['script_code'] self.code = self.context['script_code']
if not self.code: if not self.code:
msg = '%s: Missing answer script code for externalresponse' % unicode(self) msg = '%s: Missing answer script code for externalresponse' % unicode(
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>') self)
msg += "\nSee XML source line %s" % getattr(
self.xml, 'sourceline', '<unavailable>')
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
self.tests = xml.get('tests') self.tests = xml.get('tests')
...@@ -1591,10 +1649,12 @@ main() ...@@ -1591,10 +1649,12 @@ main()
payload.update(extra_payload) payload.update(extra_payload)
try: try:
# call external server. TODO: synchronous call, can block for a long time # call external server. TODO: synchronous call, can block for a
# long time
r = requests.post(self.url, data=payload) r = requests.post(self.url, data=payload)
except Exception as err: except Exception as err:
msg = 'Error %s - cannot connect to external server url=%s' % (err, self.url) msg = 'Error %s - cannot connect to external server url=%s' % (
err, self.url)
log.error(msg) log.error(msg)
raise Exception(msg) raise Exception(msg)
...@@ -1602,13 +1662,15 @@ main() ...@@ -1602,13 +1662,15 @@ main()
log.info('response = %s' % r.text) log.info('response = %s' % r.text)
if (not r.text) or (not r.text.strip()): if (not r.text) or (not r.text.strip()):
raise Exception('Error: no response from external server url=%s' % self.url) raise Exception(
'Error: no response from external server url=%s' % self.url)
try: try:
# response is XML; parse it # response is XML; parse it
rxml = etree.fromstring(r.text) rxml = etree.fromstring(r.text)
except Exception as err: except Exception as err:
msg = 'Error %s - cannot parse response from external server r.text=%s' % (err, r.text) msg = 'Error %s - cannot parse response from external server r.text=%s' % (
err, r.text)
log.error(msg) log.error(msg)
raise Exception(msg) raise Exception(msg)
...@@ -1633,7 +1695,8 @@ main() ...@@ -1633,7 +1695,8 @@ main()
except Exception as err: except Exception as err:
log.error('Error %s' % err) log.error('Error %s' % err)
if self.system.DEBUG: if self.system.DEBUG:
cmap.set_dict(dict(zip(sorted(self.answer_ids), ['incorrect'] * len(idset)))) cmap.set_dict(dict(zip(sorted(
self.answer_ids), ['incorrect'] * len(idset))))
cmap.set_property( cmap.set_property(
self.answer_ids[0], 'msg', self.answer_ids[0], 'msg',
'<span class="inline-error">%s</span>' % str(err).replace('<', '&lt;')) '<span class="inline-error">%s</span>' % str(err).replace('<', '&lt;'))
...@@ -1650,7 +1713,8 @@ main() ...@@ -1650,7 +1713,8 @@ main()
# create CorrectMap # create CorrectMap
for key in idset: for key in idset:
idx = idset.index(key) idx = idset.index(key)
msg = rxml.find('message').text.replace('&nbsp;', '&#160;') if idx == 0 else None msg = rxml.find('message').text.replace(
'&nbsp;', '&#160;') if idx == 0 else None
cmap.set(key, self.context['correct'][idx], msg=msg) cmap.set(key, self.context['correct'][idx], msg=msg)
return cmap return cmap
...@@ -1665,7 +1729,8 @@ main() ...@@ -1665,7 +1729,8 @@ main()
except Exception as err: except Exception as err:
log.error('Error %s' % err) log.error('Error %s' % err)
if self.system.DEBUG: if self.system.DEBUG:
msg = '<span class="inline-error">%s</span>' % str(err).replace('<', '&lt;') msg = '<span class="inline-error">%s</span>' % str(
err).replace('<', '&lt;')
exans = [''] * len(self.answer_ids) exans = [''] * len(self.answer_ids)
exans[0] = msg exans[0] = msg
...@@ -1712,8 +1777,9 @@ class FormulaResponse(LoncapaResponse): ...@@ -1712,8 +1777,9 @@ class FormulaResponse(LoncapaResponse):
self.correct_answer = contextualize_text(xml.get('answer'), context) self.correct_answer = contextualize_text(xml.get('answer'), context)
self.samples = contextualize_text(xml.get('samples'), context) self.samples = contextualize_text(xml.get('samples'), context)
try: try:
self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default', self.tolerance_xml = xml.xpath(
id=xml.get('id'))[0] '//*[@id=$id]//responseparam[@type="tolerance"]/@default',
id=xml.get('id'))[0]
self.tolerance = contextualize_text(self.tolerance_xml, context) self.tolerance = contextualize_text(self.tolerance_xml, context)
except Exception: except Exception:
self.tolerance = '0.00001' self.tolerance = '0.00001'
...@@ -1735,14 +1801,15 @@ class FormulaResponse(LoncapaResponse): ...@@ -1735,14 +1801,15 @@ class FormulaResponse(LoncapaResponse):
def get_score(self, student_answers): def get_score(self, student_answers):
given = student_answers[self.answer_id] given = student_answers[self.answer_id]
correctness = self.check_formula(self.correct_answer, given, self.samples) correctness = self.check_formula(
self.correct_answer, given, self.samples)
return CorrectMap(self.answer_id, correctness) return CorrectMap(self.answer_id, correctness)
def check_formula(self, expected, given, samples): def check_formula(self, expected, given, samples):
variables = samples.split('@')[0].split(',') variables = samples.split('@')[0].split(',')
numsamples = int(samples.split('@')[1].split('#')[1]) numsamples = int(samples.split('@')[1].split('#')[1])
sranges = zip(*map(lambda x: map(float, x.split(",")), sranges = zip(*map(lambda x: map(float, x.split(",")),
samples.split('@')[1].split('#')[0].split(':'))) samples.split('@')[1].split('#')[0].split(':')))
ranges = dict(zip(variables, sranges)) ranges = dict(zip(variables, sranges))
for i in range(numsamples): for i in range(numsamples):
...@@ -1753,22 +1820,26 @@ class FormulaResponse(LoncapaResponse): ...@@ -1753,22 +1820,26 @@ class FormulaResponse(LoncapaResponse):
value = random.uniform(*ranges[var]) value = random.uniform(*ranges[var])
instructor_variables[str(var)] = value instructor_variables[str(var)] = value
student_variables[str(var)] = value student_variables[str(var)] = value
#log.debug('formula: instructor_vars=%s, expected=%s' % (instructor_variables,expected)) # log.debug('formula: instructor_vars=%s, expected=%s' %
# (instructor_variables,expected))
instructor_result = evaluator(instructor_variables, dict(), instructor_result = evaluator(instructor_variables, dict(),
expected, cs=self.case_sensitive) expected, cs=self.case_sensitive)
try: try:
#log.debug('formula: student_vars=%s, given=%s' % (student_variables,given)) # log.debug('formula: student_vars=%s, given=%s' %
# (student_variables,given))
student_result = evaluator(student_variables, student_result = evaluator(student_variables,
dict(), dict(),
given, given,
cs=self.case_sensitive) cs=self.case_sensitive)
except UndefinedVariable as uv: except UndefinedVariable as uv:
log.debug('formularesponse: undefined variable in given=%s' % given) log.debug(
raise StudentInputError("Invalid input: " + uv.message + " not permitted in answer") 'formularesponse: undefined variable in given=%s' % given)
raise StudentInputError(
"Invalid input: " + uv.message + " not permitted in answer")
except Exception as err: except Exception as err:
#traceback.print_exc() # traceback.print_exc()
log.debug('formularesponse: error %s in formula' % err) log.debug('formularesponse: error %s in formula' % err)
raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %\ raise StudentInputError("Invalid input: Could not parse '%s' as a formula" %
cgi.escape(given)) cgi.escape(given))
if numpy.isnan(student_result) or numpy.isinf(student_result): if numpy.isnan(student_result) or numpy.isinf(student_result):
return "incorrect" return "incorrect"
...@@ -1792,9 +1863,11 @@ class FormulaResponse(LoncapaResponse): ...@@ -1792,9 +1863,11 @@ class FormulaResponse(LoncapaResponse):
for hxml in hxml_set: for hxml in hxml_set:
samples = hxml.get('samples') samples = hxml.get('samples')
name = hxml.get('name') name = hxml.get('name')
correct_answer = contextualize_text(hxml.get('answer'), self.context) correct_answer = contextualize_text(
hxml.get('answer'), self.context)
try: try:
correctness = self.check_formula(correct_answer, given, samples) correctness = self.check_formula(
correct_answer, given, samples)
except Exception: except Exception:
correctness = 'incorrect' correctness = 'incorrect'
if correctness == 'correct': if correctness == 'correct':
...@@ -1825,11 +1898,13 @@ class SchematicResponse(LoncapaResponse): ...@@ -1825,11 +1898,13 @@ class SchematicResponse(LoncapaResponse):
def get_score(self, student_answers): def get_score(self, student_answers):
from capa_problem import global_context from capa_problem import global_context
submission = [json.loads(student_answers[k]) for k in sorted(self.answer_ids)] submission = [json.loads(student_answers[
k]) for k in sorted(self.answer_ids)]
self.context.update({'submission': submission}) self.context.update({'submission': submission})
exec self.code in global_context, self.context exec self.code in global_context, self.context
cmap = CorrectMap() cmap = CorrectMap()
cmap.set_dict(dict(zip(sorted(self.answer_ids), self.context['correct']))) cmap.set_dict(dict(zip(sorted(
self.answer_ids), self.context['correct'])))
return cmap return cmap
def get_answers(self): def get_answers(self):
...@@ -1891,12 +1966,14 @@ class ImageResponse(LoncapaResponse): ...@@ -1891,12 +1966,14 @@ class ImageResponse(LoncapaResponse):
expectedset = self.get_answers() expectedset = self.get_answers()
for aid in self.answer_ids: # loop through IDs of <imageinput> for aid in self.answer_ids: # loop through IDs of <imageinput>
# fields in our stanza # fields in our stanza
given = student_answers[aid] # this should be a string of the form '[x,y]' given = student_answers[
aid] # this should be a string of the form '[x,y]'
correct_map.set(aid, 'incorrect') correct_map.set(aid, 'incorrect')
if not given: # No answer to parse. Mark as incorrect and move on if not given: # No answer to parse. Mark as incorrect and move on
continue continue
# parse given answer # parse given answer
m = re.match('\[([0-9]+),([0-9]+)]', given.strip().replace(' ', '')) m = re.match(
'\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
if not m: if not m:
raise Exception('[capamodule.capa.responsetypes.imageinput] ' raise Exception('[capamodule.capa.responsetypes.imageinput] '
'error grading %s (input=%s)' % (aid, given)) 'error grading %s (input=%s)' % (aid, given))
...@@ -1904,20 +1981,24 @@ class ImageResponse(LoncapaResponse): ...@@ -1904,20 +1981,24 @@ class ImageResponse(LoncapaResponse):
rectangles, regions = expectedset rectangles, regions = expectedset
if rectangles[aid]: # rectangles part - for backward compatibility if rectangles[aid]: # rectangles part - for backward compatibility
# Check whether given point lies in any of the solution rectangles # Check whether given point lies in any of the solution
# rectangles
solution_rectangles = rectangles[aid].split(';') solution_rectangles = rectangles[aid].split(';')
for solution_rectangle in solution_rectangles: for solution_rectangle in solution_rectangles:
# parse expected answer # parse expected answer
# TODO: Compile regexp on file load # TODO: Compile regexp on file load
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]', m = re.match(
solution_rectangle.strip().replace(' ', '')) '[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
solution_rectangle.strip().replace(' ', ''))
if not m: if not m:
msg = 'Error in problem specification! cannot parse rectangle in %s' % ( msg = 'Error in problem specification! cannot parse rectangle in %s' % (
etree.tostring(self.ielements[aid], pretty_print=True)) etree.tostring(self.ielements[aid], pretty_print=True))
raise Exception('[capamodule.capa.responsetypes.imageinput] ' + msg) raise Exception(
'[capamodule.capa.responsetypes.imageinput] ' + msg)
(llx, lly, urx, ury) = [int(x) for x in m.groups()] (llx, lly, urx, ury) = [int(x) for x in m.groups()]
# answer is correct if (x,y) is within the specified rectangle # answer is correct if (x,y) is within the specified
# rectangle
if (llx <= gx <= urx) and (lly <= gy <= ury): if (llx <= gx <= urx) and (lly <= gy <= ury):
correct_map.set(aid, 'correct') correct_map.set(aid, 'correct')
break break
...@@ -1938,10 +2019,13 @@ class ImageResponse(LoncapaResponse): ...@@ -1938,10 +2019,13 @@ class ImageResponse(LoncapaResponse):
return correct_map return correct_map
def get_answers(self): def get_answers(self):
return (dict([(ie.get('id'), ie.get('rectangle')) for ie in self.ielements]), return (
dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements])) dict([(ie.get('id'), ie.get(
'rectangle')) for ie in self.ielements]),
dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements]))
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
class AnnotationResponse(LoncapaResponse): class AnnotationResponse(LoncapaResponse):
''' '''
Checking of annotation responses. Checking of annotation responses.
...@@ -1952,7 +2036,8 @@ class AnnotationResponse(LoncapaResponse): ...@@ -1952,7 +2036,8 @@ class AnnotationResponse(LoncapaResponse):
response_tag = 'annotationresponse' response_tag = 'annotationresponse'
allowed_inputfields = ['annotationinput'] allowed_inputfields = ['annotationinput']
max_inputfields = 1 max_inputfields = 1
default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2 } default_scoring = {'incorrect': 0, 'partially-correct': 1, 'correct': 2}
def setup_response(self): def setup_response(self):
xml = self.xml xml = self.xml
self.scoring_map = self._get_scoring_map() self.scoring_map = self._get_scoring_map()
...@@ -1966,7 +2051,8 @@ class AnnotationResponse(LoncapaResponse): ...@@ -1966,7 +2051,8 @@ class AnnotationResponse(LoncapaResponse):
student_option = self._get_submitted_option_id(student_answer) student_option = self._get_submitted_option_id(student_answer)
scoring = self.scoring_map[self.answer_id] scoring = self.scoring_map[self.answer_id]
is_valid = student_option is not None and student_option in scoring.keys() is_valid = student_option is not None and student_option in scoring.keys(
)
(correctness, points) = ('incorrect', None) (correctness, points) = ('incorrect', None)
if is_valid: if is_valid:
...@@ -1981,7 +2067,7 @@ class AnnotationResponse(LoncapaResponse): ...@@ -1981,7 +2067,7 @@ class AnnotationResponse(LoncapaResponse):
def _get_scoring_map(self): def _get_scoring_map(self):
''' Returns a dict of option->scoring for each input. ''' ''' Returns a dict of option->scoring for each input. '''
scoring = self.default_scoring scoring = self.default_scoring
choices = dict([(choice,choice) for choice in scoring]) choices = dict([(choice, choice) for choice in scoring])
scoring_map = {} scoring_map = {}
for inputfield in self.inputfields: for inputfield in self.inputfields:
...@@ -1998,9 +2084,11 @@ class AnnotationResponse(LoncapaResponse): ...@@ -1998,9 +2084,11 @@ class AnnotationResponse(LoncapaResponse):
''' Returns a dict of answers for each input.''' ''' Returns a dict of answers for each input.'''
answer_map = {} answer_map = {}
for inputfield in self.inputfields: for inputfield in self.inputfields:
correct_option = self._find_option_with_choice(inputfield, 'correct') correct_option = self._find_option_with_choice(
inputfield, 'correct')
if correct_option is not None: if correct_option is not None:
answer_map[inputfield.get('id')] = correct_option.get('description') answer_map[inputfield.get(
'id')] = correct_option.get('description')
return answer_map return answer_map
def _get_max_points(self): def _get_max_points(self):
...@@ -2016,7 +2104,7 @@ class AnnotationResponse(LoncapaResponse): ...@@ -2016,7 +2104,7 @@ class AnnotationResponse(LoncapaResponse):
'id': index, 'id': index,
'description': option.text, 'description': option.text,
'choice': option.get('choice') 'choice': option.get('choice')
} for (index, option) in enumerate(elements) ] } for (index, option) in enumerate(elements)]
def _find_option_with_choice(self, inputfield, choice): def _find_option_with_choice(self, inputfield, choice):
''' Returns the option with the given choice value, otherwise None. ''' ''' Returns the option with the given choice value, otherwise None. '''
......
<section id="textbox_${id}" class="textbox">
<textarea rows="${rows}" cols="${cols}" name="input_${id}" id="input_${id}"
% if hidden:
style="display:none;"
% endif
>${value|h}</textarea>
<div class="grader-status">
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
% elif status == 'correct':
<span class="correct" id="status_${id}">Correct</span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}">Incorrect</span>
% elif status == 'queued':
<span class="processing" id="status_${id}">Queued</span>
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
% endif
% if hidden:
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
% endif
<p class="debug">${status}</p>
</div>
<span id="answer_${id}"></span>
<div class="external-grader-message">
${msg|n}
</div>
<div class="external-grader-message">
${queue_msg|n}
</div>
<div class="plot-button">
<input type="button" class="save" name="plot-button" id="plot_${id}" value="Plot" />
</div>
<script>
// Note: We need to make the area follow the CodeMirror for this to work.
$(function(){
var cm = CodeMirror.fromTextArea(document.getElementById("input_${id}"), {
% if linenumbers == 'true':
lineNumbers: true,
% endif
mode: "matlab",
matchBrackets: true,
lineWrapping: true,
indentUnit: "${tabsize}",
tabSize: "${tabsize}",
indentWithTabs: false,
extraKeys: {
"Tab": function(cm) {
cm.replaceSelection("${' '*tabsize}", "end");
}
},
smartIndent: false
});
$("#textbox_${id}").find('.CodeMirror-scroll').height(${int(13.5*eval(rows))});
var gentle_alert = function (parent_elt, msg) {
if($(parent_elt).find('.capa_alert').length) {
$(parent_elt).find('.capa_alert').remove();
}
var alert_elem = "<div>" + msg + "</div>";
alert_elem = $(alert_elem).addClass('capa_alert');
$(parent_elt).find('.action').after(alert_elem);
$(parent_elt).find('.capa_alert').css({opacity: 0}).animate({opacity: 1}, 700);
}
// hook up the plot button
var plot = function(event) {
var problem_elt = $(event.target).closest('.problems-wrapper');
url = $(event.target).closest('.problems-wrapper').data('url');
input_id = "${id}";
// save the codemirror text to the textarea
cm.save();
var input = $("#input_${id}");
// pull out the coded text
submission = input.val();
answer = input.serialize();
// setup callback for after we send information to plot
var plot_callback = function(response) {
if(response.success) {
window.location.reload();
}
else {
gentle_alert(problem_elt, msg);
}
}
var save_callback = function(response) {
if(response.success) {
// send information to the problem's plot functionality
Problem.inputAjax(url, input_id, 'plot',
{'submission': submission}, plot_callback);
}
else {
gentle_alert(problem_elt, msg);
}
}
// save the answer
$.postWithPrefix(url + '/problem_save', answer, save_callback);
}
$('#plot_${id}').click(plot);
});
</script>
</section>
...@@ -2,7 +2,7 @@ import fs ...@@ -2,7 +2,7 @@ import fs
import fs.osfs import fs.osfs
import os import os
from mock import Mock from mock import Mock, MagicMock
import xml.sax.saxutils as saxutils import xml.sax.saxutils as saxutils
...@@ -16,6 +16,11 @@ def tst_render_template(template, context): ...@@ -16,6 +16,11 @@ def tst_render_template(template, context):
""" """
return '<div>{0}</div>'.format(saxutils.escape(repr(context))) return '<div>{0}</div>'.format(saxutils.escape(repr(context)))
def calledback_url(dispatch = 'score_update'):
return dispatch
xqueue_interface = MagicMock()
xqueue_interface.send_to_queue.return_value = (0, 'Success!')
test_system = Mock( test_system = Mock(
ajax_url='courses/course_id/modx/a_location', ajax_url='courses/course_id/modx/a_location',
...@@ -26,7 +31,7 @@ test_system = Mock( ...@@ -26,7 +31,7 @@ test_system = Mock(
user=Mock(), user=Mock(),
filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")),
debug=True, debug=True,
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10}, xqueue={'interface': xqueue_interface, 'construct_callback': calledback_url, 'default_queuename': 'testqueue', 'waittime': 10},
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
anonymous_student_id='student' anonymous_student_id='student'
) )
...@@ -23,6 +23,7 @@ import xml.sax.saxutils as saxutils ...@@ -23,6 +23,7 @@ import xml.sax.saxutils as saxutils
from . import test_system from . import test_system
from capa import inputtypes from capa import inputtypes
from mock import ANY
# just a handy shortcut # just a handy shortcut
lookup_tag = inputtypes.registry.get_class_for_tag lookup_tag = inputtypes.registry.get_class_for_tag
...@@ -300,6 +301,98 @@ class CodeInputTest(unittest.TestCase): ...@@ -300,6 +301,98 @@ class CodeInputTest(unittest.TestCase):
self.assertEqual(context, expected) self.assertEqual(context, expected)
class MatlabTest(unittest.TestCase):
'''
Test Matlab input types
'''
def setUp(self):
self.rows = '10'
self.cols = '80'
self.tabsize = '4'
self.mode = ""
self.payload = "payload"
self.linenumbers = 'true'
self.xml = """<matlabinput id="prob_1_2"
rows="{r}" cols="{c}"
tabsize="{tabsize}" mode="{m}"
linenumbers="{ln}">
<plot_payload>
{payload}
</plot_payload>
</matlabinput>""".format(r = self.rows,
c = self.cols,
tabsize = self.tabsize,
m = self.mode,
payload = self.payload,
ln = self.linenumbers)
elt = etree.fromstring(self.xml)
state = {'value': 'print "good evening"',
'status': 'incomplete',
'feedback': {'message': '3'}, }
self.input_class = lookup_tag('matlabinput')
self.the_input = self.input_class(test_system, elt, state)
def test_rendering(self):
context = self.the_input._get_render_context()
expected = {'id': 'prob_1_2',
'value': 'print "good evening"',
'status': 'queued',
'msg': self.input_class.submitted_msg,
'mode': self.mode,
'rows': self.rows,
'cols': self.cols,
'queue_msg': '',
'linenumbers': 'true',
'hidden': '',
'tabsize': int(self.tabsize),
'queue_len': '3',
}
self.assertEqual(context, expected)
def test_rendering_with_state(self):
state = {'value': 'print "good evening"',
'status': 'incomplete',
'input_state': {'queue_msg': 'message'},
'feedback': {'message': '3'}, }
elt = etree.fromstring(self.xml)
input_class = lookup_tag('matlabinput')
the_input = self.input_class(test_system, elt, state)
context = the_input._get_render_context()
expected = {'id': 'prob_1_2',
'value': 'print "good evening"',
'status': 'queued',
'msg': self.input_class.submitted_msg,
'mode': self.mode,
'rows': self.rows,
'cols': self.cols,
'queue_msg': 'message',
'linenumbers': 'true',
'hidden': '',
'tabsize': int(self.tabsize),
'queue_len': '3',
}
self.assertEqual(context, expected)
def test_plot_data(self):
get = {'submission': 'x = 1234;'}
response = self.the_input.handle_ajax("plot", get)
test_system.xqueue['interface'].send_to_queue.assert_called_with(header=ANY, body=ANY)
self.assertTrue(response['success'])
self.assertTrue(self.the_input.input_state['queuekey'] is not None)
self.assertEqual(self.the_input.input_state['queuestate'], 'queued')
class SchematicTest(unittest.TestCase): class SchematicTest(unittest.TestCase):
''' '''
......
...@@ -93,6 +93,7 @@ class CapaFields(object): ...@@ -93,6 +93,7 @@ class CapaFields(object):
rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings) rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings)
data = String(help="XML data for the problem", scope=Scope.content) data = String(help="XML data for the problem", scope=Scope.content)
correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state, default={}) correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state, default={})
input_state = Object(help="Dictionary for maintaining the state of inputtypes", scope=Scope.student_state, default={})
student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state) student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state)
done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state)
display_name = String(help="Display name for this module", scope=Scope.settings) display_name = String(help="Display name for this module", scope=Scope.settings)
...@@ -188,6 +189,7 @@ class CapaModule(CapaFields, XModule): ...@@ -188,6 +189,7 @@ class CapaModule(CapaFields, XModule):
'done': self.done, 'done': self.done,
'correct_map': self.correct_map, 'correct_map': self.correct_map,
'student_answers': self.student_answers, 'student_answers': self.student_answers,
'input_state': self.input_state,
'seed': self.seed, 'seed': self.seed,
} }
...@@ -195,6 +197,7 @@ class CapaModule(CapaFields, XModule): ...@@ -195,6 +197,7 @@ class CapaModule(CapaFields, XModule):
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']
self.input_state = lcp_state['input_state']
self.student_answers = lcp_state['student_answers'] self.student_answers = lcp_state['student_answers']
self.seed = lcp_state['seed'] self.seed = lcp_state['seed']
...@@ -443,7 +446,8 @@ class CapaModule(CapaFields, XModule): ...@@ -443,7 +446,8 @@ class CapaModule(CapaFields, XModule):
'problem_save': self.save_problem, 'problem_save': self.save_problem,
'problem_show': self.get_answer, 'problem_show': self.get_answer,
'score_update': self.update_score, 'score_update': self.update_score,
'input_ajax': self.lcp.handle_input_ajax 'input_ajax': self.handle_input_ajax,
'ungraded_response': self.handle_ungraded_response
} }
if dispatch not in handlers: if dispatch not in handlers:
...@@ -537,6 +541,43 @@ class CapaModule(CapaFields, XModule): ...@@ -537,6 +541,43 @@ class CapaModule(CapaFields, XModule):
return dict() # No AJAX return is needed return dict() # No AJAX return is needed
def handle_ungraded_response(self, get):
'''
Delivers a response from the XQueue to the capa problem
The score of the problem will not be updated
Args:
- get (dict) must contain keys:
queuekey - a key specific to this response
xqueue_body - the body of the response
Returns:
empty dictionary
No ajax return is needed, so an empty dict is returned
'''
queuekey = get['queuekey']
score_msg = get['xqueue_body']
# pass along the xqueue message to the problem
self.lcp.ungraded_response(score_msg, queuekey)
self.set_state_from_lcp()
return dict()
def handle_input_ajax(self, get):
'''
Handle ajax calls meant for a particular input in the problem
Args:
- get (dict) - data that should be passed to the input
Returns:
- dict containing the response from the input
'''
response = self.lcp.handle_input_ajax(get)
# save any state changes that may occur
self.set_state_from_lcp()
return response
def get_answer(self, get): def get_answer(self, get):
''' '''
For the "show answer" button. For the "show answer" button.
......
...@@ -41,6 +41,11 @@ class @Problem ...@@ -41,6 +41,11 @@ class @Problem
@el.attr progress: response.progress_status @el.attr progress: response.progress_status
@el.trigger('progressChanged') @el.trigger('progressChanged')
forceUpdate: (response) =>
@el.attr progress: response.progress_status
@el.trigger('progressChanged')
queueing: => queueing: =>
@queued_items = @$(".xqueue") @queued_items = @$(".xqueue")
@num_queued_items = @queued_items.length @num_queued_items = @queued_items.length
...@@ -71,6 +76,7 @@ class @Problem ...@@ -71,6 +76,7 @@ class @Problem
@num_queued_items = @new_queued_items.length @num_queued_items = @new_queued_items.length
if @num_queued_items == 0 if @num_queued_items == 0
@forceUpdate response
delete window.queuePollerID delete window.queuePollerID
else else
# TODO: Some logic to dynamically adjust polling rate based on queuelen # TODO: Some logic to dynamically adjust polling rate based on queuelen
......
...@@ -174,7 +174,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -174,7 +174,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
str(len(self.child_history))) str(len(self.child_history)))
xheader = xqueue_interface.make_xheader( xheader = xqueue_interface.make_xheader(
lms_callback_url=system.xqueue['callback_url'], lms_callback_url=system.xqueue['construct_callback'](),
lms_key=queuekey, lms_key=queuekey,
queue_name=self.message_queue_name queue_name=self.message_queue_name
) )
...@@ -224,7 +224,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): ...@@ -224,7 +224,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
anonymous_student_id + anonymous_student_id +
str(len(self.child_history))) str(len(self.child_history)))
xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['callback_url'], xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['construct_callback'](),
lms_key=queuekey, lms_key=queuekey,
queue_name=self.queue_name) queue_name=self.queue_name)
......
...@@ -183,7 +183,10 @@ class OpenEndedModuleTest(unittest.TestCase): ...@@ -183,7 +183,10 @@ class OpenEndedModuleTest(unittest.TestCase):
self.test_system.location = self.location self.test_system.location = self.location
self.mock_xqueue = MagicMock() self.mock_xqueue = MagicMock()
self.mock_xqueue.send_to_queue.return_value = (None, "Message") self.mock_xqueue.send_to_queue.return_value = (None, "Message")
self.test_system.xqueue = {'interface': self.mock_xqueue, 'callback_url': '/', 'default_queuename': 'testqueue', def constructed_callback(dispatch="score_update"):
return dispatch
self.test_system.xqueue = {'interface': self.mock_xqueue, 'construct_callback': constructed_callback, 'default_queuename': 'testqueue',
'waittime': 1} 'waittime': 1}
self.openendedmodule = OpenEndedModule(self.test_system, self.location, self.openendedmodule = OpenEndedModule(self.test_system, self.location,
self.definition, self.descriptor, self.static_data, self.metadata) self.definition, self.descriptor, self.static_data, self.metadata)
......
...@@ -181,12 +181,21 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours ...@@ -181,12 +181,21 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours
host=request.get_host(), host=request.get_host(),
proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http') proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http')
) )
xqueue_callback_url += reverse('xqueue_callback',
kwargs=dict(course_id=course_id, def make_xqueue_callback(dispatch='score_update'):
userid=str(user.id), # Fully qualified callback URL for external queueing system
id=descriptor.location.url(), xqueue_callback_url = '{proto}://{host}'.format(
dispatch='score_update'), host=request.get_host(),
) proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http')
)
xqueue_callback_url += reverse('xqueue_callback',
kwargs=dict(course_id=course_id,
userid=str(user.id),
id=descriptor.location.url(),
dispatch=dispatch),
)
return xqueue_callback_url
# Default queuename is course-specific and is derived from the course that # Default queuename is course-specific and is derived from the course that
# contains the current module. # contains the current module.
...@@ -194,7 +203,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours ...@@ -194,7 +203,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours
xqueue_default_queuename = descriptor.location.org + '-' + descriptor.location.course xqueue_default_queuename = descriptor.location.org + '-' + descriptor.location.course
xqueue = {'interface': xqueue_interface, xqueue = {'interface': xqueue_interface,
'callback_url': xqueue_callback_url, 'construct_callback': make_xqueue_callback,
'default_queuename': xqueue_default_queuename.replace(' ', '_'), 'default_queuename': xqueue_default_queuename.replace(' ', '_'),
'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS 'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS
} }
......
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