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
if state: state = state if state else {}
if 'seed' in state:
self.seed = state['seed'] # Set seed according to the following priority:
if 'student_answers' in state: # 1. Contained in problem's state
self.student_answers = state['student_answers'] # 2. Passed into capa_problem via constructor
# 3. Assign from the OS's random number generator
self.seed = state.get('seed', seed)
if self.seed is None:
self.seed = struct.unpack('i', os.urandom(4))
self.student_answers = state.get('student_answers', {})
if 'correct_map' in state: if 'correct_map' in state:
self.correct_map.set_dict(state['correct_map']) self.correct_map.set_dict(state['correct_map'])
if 'done' in state: self.done = state.get('done', False)
self.done = state['done'] self.input_state = state.get('input_state', {})
# TODO: Does this deplete the Linux entropy pool? Is this fast enough?
if not self.seed:
self.seed = struct.unpack('i', os.urandom(4))[0]
# 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 {}
...@@ -528,10 +544,14 @@ class LoncapaProblem(object): ...@@ -528,10 +544,14 @@ class LoncapaProblem(object):
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, }}
......
<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')
) )
def make_xqueue_callback(dispatch='score_update'):
# Fully qualified callback URL for external queueing system
xqueue_callback_url = '{proto}://{host}'.format(
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', xqueue_callback_url += reverse('xqueue_callback',
kwargs=dict(course_id=course_id, kwargs=dict(course_id=course_id,
userid=str(user.id), userid=str(user.id),
id=descriptor.location.url(), id=descriptor.location.url(),
dispatch='score_update'), 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