diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 42753fc90b..68f80006f6 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -16,7 +16,6 @@ This is used by capa_module. from __future__ import division from datetime import datetime -import json import logging import math import numpy @@ -32,8 +31,6 @@ from xml.sax.saxutils import unescape from copy import deepcopy import chem -import chem.chemcalc -import chem.chemtools import chem.miller import verifiers import verifiers.draganddrop @@ -70,9 +67,6 @@ global_context = {'random': random, 'scipy': scipy, 'calc': calc, 'eia': eia, - 'chemcalc': chem.chemcalc, - 'chemtools': chem.chemtools, - 'miller': chem.miller, 'draganddrop': verifiers.draganddrop} # These should be removed from HTML output, including all subelements @@ -97,8 +91,13 @@ class LoncapaProblem(object): - problem_text (string): xml defining the problem - 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, rendering, and user context @@ -110,21 +109,23 @@ class LoncapaProblem(object): self.system = system if self.system is None: raise Exception() - self.seed = seed - if state: - if 'seed' in state: - self.seed = state['seed'] - if 'student_answers' in state: - self.student_answers = state['student_answers'] - if 'correct_map' in state: - self.correct_map.set_dict(state['correct_map']) - if 'done' in state: - self.done = state['done'] + state = state if state else {} + + # Set seed according to the following priority: + # 1. Contained in problem's state + # 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: + self.correct_map.set_dict(state['correct_map']) + self.done = state.get('done', False) + 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 problem_text = re.sub("startouttext\s*/", "text", problem_text) @@ -188,6 +189,7 @@ class LoncapaProblem(object): return {'seed': self.seed, 'student_answers': self.student_answers, 'correct_map': self.correct_map.get_dict(), + 'input_state': self.input_state, 'done': self.done} def get_max_score(self): @@ -237,6 +239,20 @@ class LoncapaProblem(object): self.correct_map.set_dict(cmap.get_dict()) 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): ''' Returns True if any part of the problem has been submitted to an external queue @@ -351,7 +367,7 @@ class LoncapaProblem(object): dispatch = get['dispatch'] return self.inputs[input_id].handle_ajax(dispatch, get) else: - log.warning("Could not find matching input for id: %s" % problem_id) + log.warning("Could not find matching input for id: %s" % input_id) return {} @@ -527,11 +543,15 @@ class LoncapaProblem(object): value = "" if self.student_answers and problemid in self.student_answers: value = self.student_answers[problemid] - + + if input_id not in self.input_state: + self.input_state[input_id] = {} + # do the rendering state = {'value': value, 'status': status, 'id': input_id, + 'input_state': self.input_state[input_id], 'feedback': {'message': msg, 'hint': hint, 'hintmode': hintmode, }} diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index c2babfa479..2febfbd5d2 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -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 # general css and layout strategy for capa, document it, then implement it. -from collections import namedtuple import json import logging from lxml import etree import re import shlex # for splitting quoted strings import sys -import os import pyparsing from .registry import TagRegistry from capa.chem import chemcalc +import xqueue_interface +from datetime import datetime log = logging.getLogger(__name__) @@ -97,7 +97,8 @@ class Attribute(object): """ val = element.get(self.name) 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: # not required, so return default @@ -132,6 +133,8 @@ class InputTypeBase(object): * 'id' -- the id of this input, typically "{problem-location}_{response-num}_{input-num}" * '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 from previous attempt. Specifically 'message', 'hint', 'hintmode'. If 'hintmode' is 'always', the hint is always displayed.) @@ -149,7 +152,8 @@ class InputTypeBase(object): self.id = state.get('id', xml.get('id')) 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', '') @@ -157,6 +161,7 @@ class InputTypeBase(object): self.msg = feedback.get('message', '') self.hint = feedback.get('hint', '') self.hintmode = feedback.get('hintmode', None) + self.input_state = state.get('input_state', {}) # put hint above msg if it should be displayed if self.hintmode == 'always': @@ -169,14 +174,15 @@ class InputTypeBase(object): self.process_requirements() # 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() except Exception as err: # 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] - @classmethod def get_attributes(cls): """ @@ -186,7 +192,6 @@ class InputTypeBase(object): """ return [] - def process_requirements(self): """ Subclasses can declare lists of required and optional attributes. This @@ -196,7 +201,8 @@ class InputTypeBase(object): 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. """ - # 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 = {} to_render = set() for a in self.get_attributes(): @@ -226,7 +232,7 @@ class InputTypeBase(object): get: a dictionary containing the data that was sent with the ajax call Output: - a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. + a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. """ pass @@ -247,8 +253,9 @@ class InputTypeBase(object): 'value': self.value, 'status': self.status, '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()) return context @@ -371,7 +378,6 @@ class ChoiceGroup(InputTypeBase): return [Attribute("show_correctness", "always"), Attribute("submitted_message", "Answer received.")] - def _extra_context(self): return {'input_type': self.html_input_type, 'choices': self.choices, @@ -436,7 +442,6 @@ class JavascriptInput(InputTypeBase): Attribute('display_class', None), Attribute('display_file', None), ] - def setup(self): # Need to provide a value that JSON can parse if there is no # student-supplied value yet. @@ -459,7 +464,6 @@ class TextLine(InputTypeBase): template = "textline.html" tags = ['textline'] - @classmethod def get_attributes(cls): """ @@ -474,12 +478,12 @@ class TextLine(InputTypeBase): # Attributes below used in setup(), not rendered directly. 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('preprocessorClassName', None, render=False), Attribute('preprocessorSrc', None, render=False), - ] - + ] def setup(self): self.do_math = bool(self.loaded_attributes['math'] or @@ -490,12 +494,12 @@ class TextLine(InputTypeBase): self.preprocessor = None if self.do_math: # Preprocessor to insert between raw input and Mathjax - self.preprocessor = {'class_name': self.loaded_attributes['preprocessorClassName'], - 'script_src': self.loaded_attributes['preprocessorSrc']} + self.preprocessor = { + 'class_name': self.loaded_attributes['preprocessorClassName'], + 'script_src': self.loaded_attributes['preprocessorSrc']} if None in self.preprocessor.values(): self.preprocessor = None - def _extra_context(self): return {'do_math': self.do_math, 'preprocessor': self.preprocessor, } @@ -539,7 +543,8 @@ class FileSubmission(InputTypeBase): """ # Check if problem has been queued 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': self.status = 'queued' self.queue_len = self.msg @@ -547,7 +552,6 @@ class FileSubmission(InputTypeBase): def _extra_context(self): return {'queue_len': self.queue_len, } - return context registry.register(FileSubmission) @@ -562,8 +566,9 @@ class CodeInput(InputTypeBase): template = "codeinput.html" tags = ['codeinput', - 'textbox', # Another (older) name--at some point we may want to make it use a - # non-codemirror editor. + 'textbox', + # Another (older) name--at some point we may want to make it use a + # non-codemirror editor. ] # pulled out for testing @@ -586,22 +591,29 @@ class CodeInput(InputTypeBase): Attribute('tabsize', 4, transform=int), ] - def setup(self): + def setup_code_response_rendering(self): """ Implement special logic: handle queueing state, and default input. """ - # if no student input yet, then use the default input given by the problem - if not self.value: - self.value = self.xml.text + # if no student input yet, then use the default input given by the + # problem + if not self.value and self.xml.text: + self.value = self.xml.text.strip() # Check if problem has been queued 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': self.status = 'queued' self.queue_len = self.msg self.msg = self.submitted_msg + + def setup(self): + ''' setup this input type ''' + self.setup_code_response_rendering() + def _extra_context(self): """Defined queue_len, add it """ return {'queue_len': self.queue_len, } @@ -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: + + Initial Text + + %api_key=API_KEY + + + ''' + 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): """ + InputType for the schematic editor """ template = "schematicinput.html" @@ -630,7 +798,6 @@ class Schematic(InputTypeBase): Attribute('initial_value', None), Attribute('submit_analyses', None), ] - return context registry.register(Schematic) @@ -660,12 +827,12 @@ class ImageInput(InputTypeBase): Attribute('height'), Attribute('width'), ] - def setup(self): """ 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: # Note: we subtract 15 to compensate for the size of the dot on the screen. # (is a 30x30 image--lms/static/green-pointer.png). @@ -673,7 +840,6 @@ class ImageInput(InputTypeBase): else: (self.gx, self.gy) = (0, 0) - def _extra_context(self): return {'gx': self.gx, @@ -730,7 +896,7 @@ class VseprInput(InputTypeBase): registry.register(VseprInput) -#-------------------------------------------------------------------------------- +#------------------------------------------------------------------------- class ChemicalEquationInput(InputTypeBase): @@ -794,7 +960,8 @@ class ChemicalEquationInput(InputTypeBase): result['error'] = "Couldn't parse formula: {0}".format(p) except Exception: # this is unexpected, so log - log.warning("Error while previewing chemical formula", exc_info=True) + log.warning( + "Error while previewing chemical formula", exc_info=True) result['error'] = "Error while rendering preview" return result @@ -843,16 +1010,16 @@ class DragAndDropInput(InputTypeBase): 'can_reuse': ""} tag_attrs['target'] = {'id': Attribute._sentinel, - 'x': Attribute._sentinel, - 'y': Attribute._sentinel, - 'w': Attribute._sentinel, - 'h': Attribute._sentinel} + 'x': Attribute._sentinel, + 'y': Attribute._sentinel, + 'w': Attribute._sentinel, + 'h': Attribute._sentinel} dic = dict() for attr_name in tag_attrs[tag_type].keys(): 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: dic['label'] = dic['label'] or dic['id'] @@ -865,7 +1032,7 @@ class DragAndDropInput(InputTypeBase): # add labels to images?: self.no_labels = Attribute('no_labels', - default="False").parse_from_xml(self.xml) + default="False").parse_from_xml(self.xml) to_js = dict() @@ -874,16 +1041,16 @@ class DragAndDropInput(InputTypeBase): # outline places on image where to drag adn drop 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? 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 to_js['draggables'] = [parse(draggable, 'draggable') for draggable in - self.xml.iterchildren('draggable')] + self.xml.iterchildren('draggable')] # list of targets to_js['targets'] = [parse(target, 'target') for target in - self.xml.iterchildren('target')] + self.xml.iterchildren('target')] # custom background color for labels: label_bg_color = Attribute('label_bg_color', @@ -896,7 +1063,7 @@ class DragAndDropInput(InputTypeBase): registry.register(DragAndDropInput) -#-------------------------------------------------------------------------------------------------------------------- +#------------------------------------------------------------------------- class EditAMoleculeInput(InputTypeBase): @@ -934,6 +1101,7 @@ registry.register(EditAMoleculeInput) #----------------------------------------------------------------------------- + class DesignProtein2dInput(InputTypeBase): """ An input type for design of a protein in 2D. Integrates with the Protex java applet. @@ -969,6 +1137,7 @@ registry.register(DesignProtein2dInput) #----------------------------------------------------------------------------- + class EditAGeneInput(InputTypeBase): """ An input type for editing a gene. Integrates with the genex java applet. @@ -1005,6 +1174,7 @@ registry.register(EditAGeneInput) #--------------------------------------------------------------------- + class AnnotationInput(InputTypeBase): """ Input type for annotations: students can enter some notes or other text @@ -1037,13 +1207,14 @@ class AnnotationInput(InputTypeBase): def setup(self): xml = self.xml - self.debug = False # set to True to display extra debug info with input - self.return_to_annotation = True # return only works in conjunction with annotatable xmodule + self.debug = False # set to True to display extra debug info with input + self.return_to_annotation = True # return only works in conjunction with annotatable xmodule self.title = xml.findtext('./title', 'Annotation Exercise') self.text = xml.findtext('./text') self.comment = xml.findtext('./comment') - self.comment_prompt = xml.findtext('./comment_prompt', 'Type a commentary below:') + self.comment_prompt = xml.findtext( + './comment_prompt', 'Type a commentary below:') self.tag_prompt = xml.findtext('./tag_prompt', 'Select one tag:') self.options = self._find_options() @@ -1061,7 +1232,7 @@ class AnnotationInput(InputTypeBase): 'id': index, 'description': option.text, 'choice': option.get('choice') - } for (index, option) in enumerate(elements) ] + } for (index, option) in enumerate(elements)] def _validate_options(self): ''' Raises a ValueError if the choice attribute is missing or invalid. ''' @@ -1071,7 +1242,8 @@ class AnnotationInput(InputTypeBase): if choice is None: raise ValueError('Missing required choice attribute.') elif choice not in valid_choices: - raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format(choice, ', '.join(valid_choices))) + raise ValueError('Invalid choice attribute: {0}. Must be one of: {1}'.format( + choice, ', '.join(valid_choices))) def _unpack(self, json_value): ''' Unpacks the json input state into a dict. ''' @@ -1089,20 +1261,20 @@ class AnnotationInput(InputTypeBase): return { '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, } def _extra_context(self): extra_context = { - 'title': self.title, - 'text': self.text, - 'comment': self.comment, - 'comment_prompt': self.comment_prompt, - 'tag_prompt': self.tag_prompt, - 'options': self.options, - 'return_to_annotation': self.return_to_annotation, - 'debug': self.debug + 'title': self.title, + 'text': self.text, + 'comment': self.comment, + 'comment_prompt': self.comment_prompt, + 'tag_prompt': self.tag_prompt, + 'options': self.options, + 'return_to_annotation': self.return_to_annotation, + 'debug': self.debug } extra_context.update(self._unpack(self.value)) @@ -1110,4 +1282,3 @@ class AnnotationInput(InputTypeBase): return extra_context registry.register(AnnotationInput) - diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 6bf98999d8..8ab716735c 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -128,21 +128,25 @@ class LoncapaResponse(object): for abox in inputfields: if abox.tag not in self.allowed_inputfields: - msg = "%s: cannot have input field %s" % (unicode(self), abox.tag) - msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '') + msg = "%s: cannot have input field %s" % ( + unicode(self), abox.tag) + msg += "\nSee XML source line %s" % getattr( + xml, 'sourceline', '') raise LoncapaProblemError(msg) if self.max_inputfields and len(inputfields) > self.max_inputfields: msg = "%s: cannot have more than %s input fields" % ( unicode(self), self.max_inputfields) - msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '') + msg += "\nSee XML source line %s" % getattr( + xml, 'sourceline', '') raise LoncapaProblemError(msg) for prop in self.required_attributes: if not xml.get(prop): msg = "Error in problem specification: %s missing required attribute %s" % ( unicode(self), prop) - msg += "\nSee XML source line %s" % getattr(xml, 'sourceline', '') + msg += "\nSee XML source line %s" % getattr( + xml, 'sourceline', '') raise LoncapaProblemError(msg) # ordered list of answer_id values for this response @@ -163,7 +167,8 @@ class LoncapaResponse(object): for entry in self.inputfields: answer = entry.get('correct_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'): self.setup_response() @@ -211,7 +216,8 @@ class LoncapaResponse(object): Returns the new CorrectMap, with (correctness,msg,hint,hintmode) for each answer_id. ''' 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) return new_cmap @@ -241,14 +247,17 @@ class LoncapaResponse(object): # callback procedure to a social hint generation system. if not hintfn in self.context: msg = 'missing specified hint function %s in script context' % hintfn - msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '') + msg += "\nSee XML source line %s" % getattr( + self.xml, 'sourceline', '') raise LoncapaProblemError(msg) 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: msg = 'Error %s in evaluating hint function %s' % (err, hintfn) - msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '') + msg += "\nSee XML source line %s" % getattr( + self.xml, 'sourceline', '') raise ResponseError(msg) return @@ -270,17 +279,19 @@ class LoncapaResponse(object): if (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) - 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) hintmode = hintgroup.get('mode', 'always') for hintpart in hintgroup.findall('hintpart'): if hintpart.get('on') in hints_to_show: hint_text = hintpart.find('text').text - # make the hint appear after the last answer box in this response + # make the hint appear after the last answer box in this + # response aid = self.answer_ids[-1] new_cmap.set_hint_and_mode(aid, hint_text, hintmode) log.debug('after hint: new_cmap = %s' % new_cmap) @@ -340,7 +351,6 @@ class LoncapaResponse(object): response_msg_div = etree.Element('div') response_msg_div.text = str(response_msg) - # Set the css class of the message
response_msg_div.set("class", "response_message") @@ -384,20 +394,20 @@ class JavascriptResponse(LoncapaResponse): # until we decide on exactly how to solve this issue. For now, files are # manually being compiled to DATA_DIR/js/compiled. - #latestTimestamp = 0 - #basepath = self.system.filestore.root_path + '/js/' - #for filename in (self.display_dependencies + [self.display]): + # latestTimestamp = 0 + # basepath = self.system.filestore.root_path + '/js/' + # for filename in (self.display_dependencies + [self.display]): # filepath = basepath + filename # timestamp = os.stat(filepath).st_mtime # if timestamp > latestTimestamp: # latestTimestamp = timestamp # - #h = hashlib.md5() - #h.update(self.answer_id + str(self.display_dependencies)) - #compiled_filename = 'compiled/' + h.hexdigest() + '.js' - #compiled_filepath = basepath + compiled_filename + # h = hashlib.md5() + # h.update(self.answer_id + str(self.display_dependencies)) + # compiled_filename = 'compiled/' + h.hexdigest() + '.js' + # 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') # for filename in (self.display_dependencies + [self.display]): # filepath = basepath + filename @@ -419,7 +429,7 @@ class JavascriptResponse(LoncapaResponse): id=self.xml.get('id'))[0] 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.grader_xml) @@ -430,17 +440,20 @@ class JavascriptResponse(LoncapaResponse): self.display = self.display_xml.get("src") 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: self.generator_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: self.grader_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: self.display_dependencies = [] @@ -461,10 +474,10 @@ class JavascriptResponse(LoncapaResponse): return subprocess.check_output(subprocess_args, env=self.get_node_env()) - 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, self.generator, json.dumps(self.generator_dependencies), @@ -478,17 +491,18 @@ class JavascriptResponse(LoncapaResponse): params = {} for param in self.xml.xpath('//*[@id=$id]//responseparam', - id=self.xml.get('id')): + id=self.xml.get('id')): 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 def prepare_inputfield(self): for inputfield in self.xml.xpath('//*[@id=$id]//javascriptinput', - id=self.xml.get('id')): + id=self.xml.get('id')): escapedict = {'"': '"'} @@ -501,7 +515,7 @@ class JavascriptResponse(LoncapaResponse): escapedict) 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) def get_score(self, student_answers): @@ -519,7 +533,8 @@ class JavascriptResponse(LoncapaResponse): if submission is None or submission == '': 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, self.grader, json.dumps(self.grader_dependencies), @@ -528,8 +543,8 @@ class JavascriptResponse(LoncapaResponse): json.dumps(self.params)]).split('\n') all_correct = json.loads(outputs[0].strip()) - evaluation = outputs[1].strip() - solution = outputs[2].strip() + evaluation = outputs[1].strip() + solution = outputs[2].strip() return (all_correct, evaluation, solution) def get_answers(self): @@ -539,9 +554,7 @@ class JavascriptResponse(LoncapaResponse): return {self.answer_id: self.solution} - #----------------------------------------------------------------------------- - class ChoiceResponse(LoncapaResponse): """ This response type is used when the student chooses from a discrete set of @@ -599,9 +612,10 @@ class ChoiceResponse(LoncapaResponse): self.assign_choice_names() 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): ''' @@ -654,7 +668,8 @@ class MultipleChoiceResponse(LoncapaResponse): allowed_inputfields = ['choicegroup'] 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() # define correct choices (after calling secondary setup) @@ -692,7 +707,7 @@ class MultipleChoiceResponse(LoncapaResponse): # log.debug('%s: student_answers=%s, correct_choices=%s' % ( # unicode(self), student_answers, self.correct_choices)) if (self.answer_id in student_answers - and student_answers[self.answer_id] in self.correct_choices): + and student_answers[self.answer_id] in self.correct_choices): return CorrectMap(self.answer_id, 'correct') else: return CorrectMap(self.answer_id, 'incorrect') @@ -760,7 +775,8 @@ class OptionResponse(LoncapaResponse): return cmap 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)) return amap @@ -780,8 +796,9 @@ class NumericalResponse(LoncapaResponse): context = self.context self.correct_answer = contextualize_text(xml.get('answer'), context) try: - self.tolerance_xml = xml.xpath('//*[@id=$id]//responseparam[@type="tolerance"]/@default', - id=xml.get('id'))[0] + self.tolerance_xml = xml.xpath( + '//*[@id=$id]//responseparam[@type="tolerance"]/@default', + id=xml.get('id'))[0] self.tolerance = contextualize_text(self.tolerance_xml, context) except Exception: self.tolerance = '0' @@ -798,17 +815,21 @@ class NumericalResponse(LoncapaResponse): try: correct_ans = complex(self.correct_answer) except ValueError: - log.debug("Content error--answer '{0}' is not a valid complex number".format(self.correct_answer)) - raise StudentInputError("There was a problem with the staff answer to this problem") + log.debug("Content error--answer '{0}' is not a valid complex number".format( + self.correct_answer)) + raise StudentInputError( + "There was a problem with the staff answer to this problem") try: - correct = compare_with_tolerance(evaluator(dict(), dict(), student_answer), - correct_ans, self.tolerance) + correct = compare_with_tolerance( + evaluator(dict(), dict(), student_answer), + correct_ans, self.tolerance) # We should catch this explicitly. # I think this is just pyparsing.ParseException, calc.UndefinedVariable: # But we'd need to confirm 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 type, value, traceback = sys.exc_info() @@ -837,7 +858,8 @@ class StringResponse(LoncapaResponse): max_inputfields = 1 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): '''Grade a string response ''' @@ -846,7 +868,8 @@ class StringResponse(LoncapaResponse): return CorrectMap(self.answer_id, 'correct' if correct else 'incorrect') 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 def check_hint_condition(self, hxml_set, student_answers): @@ -854,8 +877,10 @@ class StringResponse(LoncapaResponse): hints_to_show = [] for hxml in hxml_set: name = hxml.get('name') - correct_answer = contextualize_text(hxml.get('answer'), self.context).strip() - if self.check_string(correct_answer, given): hints_to_show.append(name) + correct_answer = contextualize_text( + 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) return hints_to_show @@ -889,7 +914,7 @@ class CustomResponse(LoncapaResponse): correct[0] ='incorrect' """}, - {'snippet': """ + diff --git a/common/lib/capa/capa/tests/__init__.py b/common/lib/capa/capa/tests/__init__.py index 89cb5a5ee9..72d82c683b 100644 --- a/common/lib/capa/capa/tests/__init__.py +++ b/common/lib/capa/capa/tests/__init__.py @@ -2,7 +2,7 @@ import fs import fs.osfs import os -from mock import Mock +from mock import Mock, MagicMock import xml.sax.saxutils as saxutils @@ -16,6 +16,11 @@ def tst_render_template(template, context): """ return '
{0}
'.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( ajax_url='courses/course_id/modx/a_location', @@ -26,7 +31,7 @@ test_system = Mock( user=Mock(), filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")), 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"), anonymous_student_id='student' ) diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py index 360fd9f2f6..250cedd549 100644 --- a/common/lib/capa/capa/tests/test_inputtypes.py +++ b/common/lib/capa/capa/tests/test_inputtypes.py @@ -23,6 +23,7 @@ import xml.sax.saxutils as saxutils from . import test_system from capa import inputtypes +from mock import ANY # just a handy shortcut lookup_tag = inputtypes.registry.get_class_for_tag @@ -300,6 +301,98 @@ class CodeInputTest(unittest.TestCase): 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 = """ + + {payload} + + """.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): ''' diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index e66b1d3495..da8b5b4f96 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -93,6 +93,7 @@ class CapaFields(object): rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings) 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={}) + 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) 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) @@ -188,6 +189,7 @@ class CapaModule(CapaFields, XModule): 'done': self.done, 'correct_map': self.correct_map, 'student_answers': self.student_answers, + 'input_state': self.input_state, 'seed': self.seed, } @@ -195,6 +197,7 @@ class CapaModule(CapaFields, XModule): lcp_state = self.lcp.get_state() self.done = lcp_state['done'] self.correct_map = lcp_state['correct_map'] + self.input_state = lcp_state['input_state'] self.student_answers = lcp_state['student_answers'] self.seed = lcp_state['seed'] @@ -443,7 +446,8 @@ class CapaModule(CapaFields, XModule): 'problem_save': self.save_problem, 'problem_show': self.get_answer, '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: @@ -537,6 +541,43 @@ class CapaModule(CapaFields, XModule): 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): ''' For the "show answer" button. diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 158c2b98d0..70704ab247 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -41,6 +41,11 @@ class @Problem @el.attr progress: response.progress_status @el.trigger('progressChanged') + forceUpdate: (response) => + @el.attr progress: response.progress_status + @el.trigger('progressChanged') + + queueing: => @queued_items = @$(".xqueue") @num_queued_items = @queued_items.length @@ -71,6 +76,7 @@ class @Problem @num_queued_items = @new_queued_items.length if @num_queued_items == 0 + @forceUpdate response delete window.queuePollerID else # TODO: Some logic to dynamically adjust polling rate based on queuelen diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index 1f84d2ab8c..8373700837 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -174,7 +174,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): str(len(self.child_history))) xheader = xqueue_interface.make_xheader( - lms_callback_url=system.xqueue['callback_url'], + lms_callback_url=system.xqueue['construct_callback'](), lms_key=queuekey, queue_name=self.message_queue_name ) @@ -224,7 +224,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): anonymous_student_id + 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, queue_name=self.queue_name) diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py index 09c86baf27..55c31ded58 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -183,7 +183,10 @@ class OpenEndedModuleTest(unittest.TestCase): self.test_system.location = self.location self.mock_xqueue = MagicMock() 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} self.openendedmodule = OpenEndedModule(self.test_system, self.location, self.definition, self.descriptor, self.static_data, self.metadata) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 08df7bfb8c..973940d784 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -181,12 +181,21 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours 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='score_update'), - ) + + 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', + 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 # contains the current module. @@ -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 = {'interface': xqueue_interface, - 'callback_url': xqueue_callback_url, + 'construct_callback': make_xqueue_callback, 'default_queuename': xqueue_default_queuename.replace(' ', '_'), 'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS }