diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py
index efc96fc717..2eaa0e4286 100644
--- a/common/lib/capa/capa/capa_problem.py
+++ b/common/lib/capa/capa/capa_problem.py
@@ -186,24 +186,6 @@ class LoncapaProblem(object):
maxscore += responder.get_max_score()
return maxscore
- def message_post(self,event_info):
- """
- Handle an ajax post that contains feedback on feedback
- Returns a boolean success variable
- Note: This only allows for feedback to be posted back to the grading controller for the first
- open ended response problem on each page. Multiple problems will cause some sync issues.
- TODO: Handle multiple problems on one page sync issues.
- """
- success=False
- message = "Could not find a valid responder."
- log.debug("in lcp")
- for responder in self.responders.values():
- if hasattr(responder, 'handle_message_post'):
- success, message = responder.handle_message_post(event_info)
- if success:
- break
- return success, message
-
def get_score(self):
"""
Compute score for this problem. The score is the number of points awarded.
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index 3a953f25f3..b805084ce4 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -736,54 +736,6 @@ registry.register(ChemicalEquationInput)
#-----------------------------------------------------------------------------
-class OpenEndedInput(InputTypeBase):
- """
- A text area input for code--uses codemirror, does syntax highlighting, special tab handling,
- etc.
- """
-
- template = "openendedinput.html"
- tags = ['openendedinput']
-
- # pulled out for testing
- submitted_msg = ("Feedback not yet available. Reload to check again. "
- "Once the problem is graded, this message will be "
- "replaced with the grader's feedback.")
-
- @classmethod
- def get_attributes(cls):
- """
- Convert options to a convenient format.
- """
- return [Attribute('rows', '30'),
- Attribute('cols', '80'),
- Attribute('hidden', ''),
- ]
-
- def setup(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
-
- # Check if problem has been queued
- self.queue_len = 0
- # 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 _extra_context(self):
- """Defined queue_len, add it """
- return {'queue_len': self.queue_len,}
-
-registry.register(OpenEndedInput)
-
-#-----------------------------------------------------------------------------
-
class RubricInput(InputTypeBase):
"""
This is the logic for parsing and displaying a rubric of type
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py
index 1bc34b70a3..3d97cb0bea 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -1815,436 +1815,6 @@ class ImageResponse(LoncapaResponse):
return (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 OpenEndedResponse(LoncapaResponse):
- """
- Grade student open ended responses using an external grading system,
- accessed through the xqueue system.
-
- Expects 'xqueue' dict in ModuleSystem with the following keys that are
- needed by OpenEndedResponse:
-
- system.xqueue = { 'interface': XqueueInterface object,
- 'callback_url': Per-StudentModule callback URL
- where results are posted (string),
- }
-
- External requests are only submitted for student submission grading
- (i.e. and not for getting reference answers)
-
- By default, uses the OpenEndedResponse.DEFAULT_QUEUE queue.
- """
-
- DEFAULT_QUEUE = 'open-ended'
- DEFAULT_MESSAGE_QUEUE = 'open-ended-message'
- response_tag = 'openendedresponse'
- allowed_inputfields = ['openendedinput']
- max_inputfields = 1
-
- def setup_response(self):
- '''
- Configure OpenEndedResponse from XML.
- '''
- xml = self.xml
- self.url = xml.get('url', None)
- self.queue_name = xml.get('queuename', self.DEFAULT_QUEUE)
- self.message_queue_name = xml.get('message-queuename', self.DEFAULT_MESSAGE_QUEUE)
-
- # The openendedparam tag encapsulates all grader settings
- oeparam = self.xml.find('openendedparam')
- prompt = self.xml.find('prompt')
- rubric = self.xml.find('openendedrubric')
-
- #This is needed to attach feedback to specific responses later
- self.submission_id=None
- self.grader_id=None
-
- if oeparam is None:
- raise ValueError("No oeparam found in problem xml.")
- if prompt is None:
- raise ValueError("No prompt found in problem xml.")
- if rubric is None:
- raise ValueError("No rubric found in problem xml.")
-
- self._parse(oeparam, prompt, rubric)
-
- @staticmethod
- def stringify_children(node):
- """
- Modify code from stringify_children in xmodule. Didn't import directly
- in order to avoid capa depending on xmodule (seems to be avoided in
- code)
- """
- parts=[node.text if node.text is not None else '']
- for p in node.getchildren():
- parts.append(etree.tostring(p, with_tail=True, encoding='unicode'))
-
- return ' '.join(parts)
-
- def _parse(self, oeparam, prompt, rubric):
- '''
- Parse OpenEndedResponse XML:
- self.initial_display
- self.payload - dict containing keys --
- 'grader' : path to grader settings file, 'problem_id' : id of the problem
-
- self.answer - What to display when show answer is clicked
- '''
- # Note that OpenEndedResponse is agnostic to the specific contents of grader_payload
- prompt_string = self.stringify_children(prompt)
- rubric_string = self.stringify_children(rubric)
-
- grader_payload = oeparam.find('grader_payload')
- grader_payload = grader_payload.text if grader_payload is not None else ''
-
- #Update grader payload with student id. If grader payload not json, error.
- try:
- parsed_grader_payload = json.loads(grader_payload)
- # NOTE: self.system.location is valid because the capa_module
- # __init__ adds it (easiest way to get problem location into
- # response types)
- except TypeError, ValueError:
- log.exception("Grader payload %r is not a json object!", grader_payload)
-
- self.initial_display = find_with_default(oeparam, 'initial_display', '')
- self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.')
-
- parsed_grader_payload.update({
- 'location' : self.system.location,
- 'course_id' : self.system.course_id,
- 'prompt' : prompt_string,
- 'rubric' : rubric_string,
- 'initial_display' : self.initial_display,
- 'answer' : self.answer,
- })
- updated_grader_payload = json.dumps(parsed_grader_payload)
-
- self.payload = {'grader_payload': updated_grader_payload}
-
- try:
- self.max_score = int(find_with_default(oeparam, 'max_score', 1))
- except ValueError:
- self.max_score = 1
-
- def handle_message_post(self,event_info):
- """
- Handles a student message post (a reaction to the grade they received from an open ended grader type)
- Returns a boolean success/fail and an error message
- """
- survey_responses=event_info['survey_responses']
- for tag in ['feedback', 'submission_id', 'grader_id', 'score']:
- if tag not in survey_responses:
- return False, "Could not find needed tag {0}".format(tag)
- try:
- submission_id=int(survey_responses['submission_id'])
- grader_id = int(survey_responses['grader_id'])
- feedback = str(survey_responses['feedback'].encode('ascii', 'ignore'))
- score = int(survey_responses['score'])
- except:
- error_message=("Could not parse submission id, grader id, "
- "or feedback from message_post ajax call. Here is the message data: {0}".format(survey_responses))
- log.exception(error_message)
- return False, "There was an error saving your feedback. Please contact course staff."
-
- qinterface = self.system.xqueue['interface']
- qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
- anonymous_student_id = self.system.anonymous_student_id
- queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
- anonymous_student_id +
- self.answer_id)
-
- xheader = xqueue_interface.make_xheader(
- lms_callback_url=self.system.xqueue['callback_url'],
- lms_key=queuekey,
- queue_name=self.message_queue_name
- )
-
- student_info = {'anonymous_student_id': anonymous_student_id,
- 'submission_time': qtime,
- }
- contents= {
- 'feedback' : feedback,
- 'submission_id' : submission_id,
- 'grader_id' : grader_id,
- 'score': score,
- 'student_info' : json.dumps(student_info),
- }
-
- (error, msg) = qinterface.send_to_queue(header=xheader,
- body=json.dumps(contents))
-
- #Convert error to a success value
- success=True
- if error:
- success=False
-
- return success, "Successfully submitted your feedback."
-
- def get_score(self, student_answers):
-
- try:
- submission = student_answers[self.answer_id]
- except KeyError:
- msg = ('Cannot get student answer for answer_id: {0}. student_answers {1}'
- .format(self.answer_id, student_answers))
- log.exception(msg)
- raise LoncapaProblemError(msg)
-
- # Prepare xqueue request
- #------------------------------------------------------------
-
- qinterface = self.system.xqueue['interface']
- qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
-
- anonymous_student_id = self.system.anonymous_student_id
-
- # Generate header
- queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
- anonymous_student_id +
- self.answer_id)
-
- xheader = xqueue_interface.make_xheader(lms_callback_url=self.system.xqueue['callback_url'],
- lms_key=queuekey,
- queue_name=self.queue_name)
-
- self.context.update({'submission': submission})
-
- contents = self.payload.copy()
-
- # Metadata related to the student submission revealed to the external grader
- student_info = {'anonymous_student_id': anonymous_student_id,
- 'submission_time': qtime,
- }
-
- #Update contents with student response and student info
- contents.update({
- 'student_info': json.dumps(student_info),
- 'student_response': submission,
- 'max_score' : self.max_score,
- })
-
- # Submit request. When successful, 'msg' is the prior length of the queue
- (error, msg) = qinterface.send_to_queue(header=xheader,
- body=json.dumps(contents))
-
- # State associated with the queueing request
- queuestate = {'key': queuekey,
- 'time': qtime,}
-
- cmap = CorrectMap()
- if error:
- cmap.set(self.answer_id, queuestate=None,
- msg='Unable to deliver your submission to grader. (Reason: {0}.)'
- ' Please try again later.'.format(msg))
- else:
- # Queueing mechanism flags:
- # 1) Backend: Non-null CorrectMap['queuestate'] indicates that
- # the problem has been queued
- # 2) Frontend: correctness='incomplete' eventually trickles down
- # through inputtypes.textbox and .filesubmission to inform the
- # browser that the submission is queued (and it could e.g. poll)
- cmap.set(self.answer_id, queuestate=queuestate,
- correctness='incomplete', msg=msg)
-
- return cmap
-
- def update_score(self, score_msg, oldcmap, queuekey):
- log.debug(score_msg)
- score_msg = self._parse_score_msg(score_msg)
- if not score_msg.valid:
- oldcmap.set(self.answer_id,
- msg = 'Invalid grader reply. Please contact the course staff.')
- return oldcmap
-
- correctness = 'correct' if score_msg.correct else 'incorrect'
-
- # TODO: Find out how this is used elsewhere, if any
- self.context['correct'] = correctness
-
- # Replace 'oldcmap' with new grading results if queuekey matches. If queuekey
- # does not match, we keep waiting for the score_msg whose key actually matches
- if oldcmap.is_right_queuekey(self.answer_id, queuekey):
- # Sanity check on returned points
- points = score_msg.points
- if points < 0:
- points = 0
-
- # Queuestate is consumed, so reset it to None
- oldcmap.set(self.answer_id, npoints=points, correctness=correctness,
- msg = score_msg.msg.replace(' ', ' '), queuestate=None)
- else:
- log.debug('OpenEndedResponse: queuekey {0} does not match for answer_id={1}.'.format(
- queuekey, self.answer_id))
-
- return oldcmap
-
- def get_answers(self):
- anshtml = '
{0}
'.format(self.answer)
- return {self.answer_id: anshtml}
-
- def get_initial_display(self):
- return {self.answer_id: self.initial_display}
-
- def _convert_longform_feedback_to_html(self, response_items):
- """
- Take in a dictionary, and return html strings for display to student.
- Input:
- response_items: Dictionary with keys success, feedback.
- if success is True, feedback should be a dictionary, with keys for
- types of feedback, and the corresponding feedback values.
- if success is False, feedback is actually an error string.
-
- NOTE: this will need to change when we integrate peer grading, because
- that will have more complex feedback.
-
- Output:
- String -- html that can be displayed to the student.
- """
-
- # We want to display available feedback in a particular order.
- # This dictionary specifies which goes first--lower first.
- priorities = {# These go at the start of the feedback
- 'spelling': 0,
- 'grammar': 1,
- # needs to be after all the other feedback
- 'markup_text': 3}
-
- default_priority = 2
-
- def get_priority(elt):
- """
- Args:
- elt: a tuple of feedback-type, feedback
- Returns:
- the priority for this feedback type
- """
- return priorities.get(elt[0], default_priority)
-
- def encode_values(feedback_type,value):
- feedback_type=str(feedback_type).encode('ascii', 'ignore')
- if not isinstance(value,basestring):
- value=str(value)
- value=value.encode('ascii', 'ignore')
- return feedback_type,value
-
- def format_feedback(feedback_type, value):
- feedback_type,value=encode_values(feedback_type,value)
- feedback= """
-
-
diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py
index d3889bc388..c867fca228 100644
--- a/common/lib/xmodule/setup.py
+++ b/common/lib/xmodule/setup.py
@@ -19,6 +19,7 @@ setup(
"abtest = xmodule.abtest_module:ABTestDescriptor",
"book = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"chapter = xmodule.seq_module:SequenceDescriptor",
+ "combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor",
"course = xmodule.course_module:CourseDescriptor",
"customtag = xmodule.template_module:CustomTagDescriptor",
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
@@ -28,7 +29,6 @@ setup(
"problem = xmodule.capa_module:CapaDescriptor",
"problemset = xmodule.seq_module:SequenceDescriptor",
"section = xmodule.backcompat_module:SemanticSectionDescriptor",
- "selfassessment = xmodule.self_assessment_module:SelfAssessmentDescriptor",
"sequential = xmodule.seq_module:SequenceDescriptor",
"slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"vertical = xmodule.vertical_module:VerticalDescriptor",
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index d65fa1f40a..1da271072a 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -380,7 +380,6 @@ class CapaModule(XModule):
'problem_save': self.save_problem,
'problem_show': self.get_answer,
'score_update': self.update_score,
- 'message_post' : self.message_post,
}
if dispatch not in handlers:
@@ -395,20 +394,6 @@ class CapaModule(XModule):
})
return json.dumps(d, cls=ComplexEncoder)
- def message_post(self, get):
- """
- Posts a message from a form to an appropriate location
- """
- event_info = dict()
- event_info['state'] = self.lcp.get_state()
- event_info['problem_id'] = self.location.url()
- event_info['student_id'] = self.system.anonymous_student_id
- event_info['survey_responses']= get
-
- success, message = self.lcp.message_post(event_info)
-
- return {'success' : success, 'message' : message}
-
def closed(self):
''' Is the student still allowed to submit answers? '''
if self.attempts == self.max_attempts:
@@ -445,6 +430,7 @@ class CapaModule(XModule):
return False
+
def update_score(self, get):
"""
Delivers grading response (e.g. from asynchronous code checking) to
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
new file mode 100644
index 0000000000..a88acc6ffd
--- /dev/null
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -0,0 +1,598 @@
+import copy
+from fs.errors import ResourceNotFoundError
+import itertools
+import json
+import logging
+from lxml import etree
+from lxml.html import rewrite_links
+from path import path
+import os
+import sys
+
+from pkg_resources import resource_string
+
+from .capa_module import only_one, ComplexEncoder
+from .editing_module import EditingDescriptor
+from .html_checker import check_html
+from progress import Progress
+from .stringify import stringify_children
+from .x_module import XModule
+from .xml_module import XmlDescriptor
+from xmodule.modulestore import Location
+import self_assessment_module
+import open_ended_module
+
+from mitxmako.shortcuts import render_to_string
+
+log = logging.getLogger("mitx.courseware")
+
+# Set the default number of max attempts. Should be 1 for production
+# Set higher for debugging/testing
+# attempts specified in xml definition overrides this.
+MAX_ATTEMPTS = 10000
+
+# Set maximum available number of points.
+# Overriden by max_score specified in xml.
+MAX_SCORE = 1
+
+class CombinedOpenEndedModule(XModule):
+ """
+ This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
+ It transitions between problems, and support arbitrary ordering.
+ Each combined open ended module contains one or multiple "child" modules.
+ Child modules track their own state, and can transition between states. They also implement get_html and
+ handle_ajax.
+ The combined open ended module transitions between child modules as appropriate, tracks its own state, and passess
+ ajax requests from the browser to the child module or handles them itself (in the cases of reset and next problem)
+ ajax actions implemented by all children are:
+ 'save_answer' -- Saves the student answer
+ 'save_assessment' -- Saves the student assessment (or external grader assessment)
+ 'save_post_assessment' -- saves a post assessment (hint, feedback on feedback, etc)
+ ajax actions implemented by combined open ended module are:
+ 'reset' -- resets the whole combined open ended module and returns to the first child module
+ 'next_problem' -- moves to the next child module
+ 'get_results' -- gets results from a given child module
+
+ Types of children. Task is synonymous with child module, so each combined open ended module
+ incorporates multiple children (tasks):
+ openendedmodule
+ selfassessmentmodule
+ """
+ STATE_VERSION = 1
+
+ # states
+ INITIAL = 'initial'
+ ASSESSING = 'assessing'
+ INTERMEDIATE_DONE = 'intermediate_done'
+ DONE = 'done'
+
+ js = {'coffee': [resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
+ resource_string(__name__, 'js/src/collapsible.coffee'),
+ resource_string(__name__, 'js/src/javascript_loader.coffee'),
+ ]}
+ js_module_name = "CombinedOpenEnded"
+
+ css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
+
+ def __init__(self, system, location, definition, descriptor,
+ instance_state=None, shared_state=None, **kwargs):
+ XModule.__init__(self, system, location, definition, descriptor,
+ instance_state, shared_state, **kwargs)
+
+ """
+ Definition file should have one or many task blocks, a rubric block, and a prompt block:
+
+ Sample file:
+
+
+ Blah blah rubric.
+
+
+ Some prompt.
+
+
+
+
+ What hint about this problem would you give to someone?
+
+
+ Save Succcesful. Thanks for participating!
+
+
+
+
+
+
+ Enter essay here.
+ This is the answer.
+ {"grader_settings" : "ml_grading.conf",
+ "problem_id" : "6.002x/Welcome/OETest"}
+
+
+
+
+
+ """
+
+ # Load instance state
+ if instance_state is not None:
+ instance_state = json.loads(instance_state)
+ else:
+ instance_state = {}
+
+ #We need to set the location here so the child modules can use it
+ system.set('location', location)
+
+ #Tells the system which xml definition to load
+ self.current_task_number = instance_state.get('current_task_number', 0)
+ #This loads the states of the individual children
+ self.task_states = instance_state.get('task_states', [])
+ #Overall state of the combined open ended module
+ self.state = instance_state.get('state', self.INITIAL)
+
+ self.attempts = instance_state.get('attempts', 0)
+
+ #Allow reset is true if student has failed the criteria to move to the next child task
+ self.allow_reset = instance_state.get('ready_to_reset', False)
+ self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
+
+ # Used for progress / grading. Currently get credit just for
+ # completion (doesn't matter if you self-assessed correct/incorrect).
+ self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
+
+ #Static data is passed to the child modules to render
+ self.static_data = {
+ 'max_score': self._max_score,
+ 'max_attempts': self.max_attempts,
+ 'prompt': definition['prompt'],
+ 'rubric': definition['rubric']
+ }
+
+ self.task_xml = definition['task_xml']
+ self.setup_next_task()
+
+ def get_tag_name(self, xml):
+ """
+ Gets the tag name of a given xml block.
+ Input: XML string
+ Output: The name of the root tag
+ """
+ tag = etree.fromstring(xml).tag
+ return tag
+
+ def overwrite_state(self, current_task_state):
+ """
+ Overwrites an instance state and sets the latest response to the current response. This is used
+ to ensure that the student response is carried over from the first child to the rest.
+ Input: Task state json string
+ Output: Task state json string
+ """
+ last_response_data = self.get_last_response(self.current_task_number - 1)
+ last_response = last_response_data['response']
+
+ loaded_task_state = json.loads(current_task_state)
+ if loaded_task_state['state'] == self.INITIAL:
+ loaded_task_state['state'] = self.ASSESSING
+ loaded_task_state['created'] = True
+ loaded_task_state['history'].append({'answer': last_response})
+ current_task_state = json.dumps(loaded_task_state)
+ return current_task_state
+
+ def child_modules(self):
+ """
+ Returns the constructors associated with the child modules in a dictionary. This makes writing functions
+ simpler (saves code duplication)
+ Input: None
+ Output: A dictionary of dictionaries containing the descriptor functions and module functions
+ """
+ child_modules = {
+ 'openended': open_ended_module.OpenEndedModule,
+ 'selfassessment': self_assessment_module.SelfAssessmentModule,
+ }
+ child_descriptors = {
+ 'openended': open_ended_module.OpenEndedDescriptor,
+ 'selfassessment': self_assessment_module.SelfAssessmentDescriptor,
+ }
+ children = {
+ 'modules': child_modules,
+ 'descriptors': child_descriptors,
+ }
+ return children
+
+ def setup_next_task(self, reset=False):
+ """
+ Sets up the next task for the module. Creates an instance state if none exists, carries over the answer
+ from the last instance state to the next if needed.
+ Input: A boolean indicating whether or not the reset function is calling.
+ Output: Boolean True (not useful right now)
+ """
+ current_task_state = None
+ if len(self.task_states) > self.current_task_number:
+ current_task_state = self.task_states[self.current_task_number]
+
+ self.current_task_xml = self.task_xml[self.current_task_number]
+
+ if self.current_task_number > 0:
+ self.allow_reset = self.check_allow_reset()
+ if self.allow_reset:
+ self.current_task_number = self.current_task_number - 1
+
+ current_task_type = self.get_tag_name(self.current_task_xml)
+
+ children = self.child_modules()
+ child_task_module = children['modules'][current_task_type]
+
+ self.current_task_descriptor = children['descriptors'][current_task_type](self.system)
+
+ #This is the xml object created from the xml definition of the current task
+ etree_xml = etree.fromstring(self.current_task_xml)
+
+ #This sends the etree_xml object through the descriptor module of the current task, and
+ #returns the xml parsed by the descriptor
+ self.current_task_parsed_xml = self.current_task_descriptor.definition_from_xml(etree_xml, self.system)
+ if current_task_state is None and self.current_task_number == 0:
+ self.current_task = child_task_module(self.system, self.location,
+ self.current_task_parsed_xml, self.current_task_descriptor, self.static_data)
+ self.task_states.append(self.current_task.get_instance_state())
+ self.state = self.ASSESSING
+ elif current_task_state is None and self.current_task_number > 0:
+ last_response_data = self.get_last_response(self.current_task_number - 1)
+ last_response = last_response_data['response']
+ current_task_state=json.dumps({
+ 'state' : self.ASSESSING,
+ 'version' : self.STATE_VERSION,
+ 'max_score' : self._max_score,
+ 'attempts' : 0,
+ 'created' : True,
+ 'history' : [{'answer' : str(last_response)}],
+ })
+ self.current_task = child_task_module(self.system, self.location,
+ self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
+ instance_state=current_task_state)
+ self.task_states.append(self.current_task.get_instance_state())
+ self.state = self.ASSESSING
+ else:
+ if self.current_task_number > 0 and not reset:
+ current_task_state = self.overwrite_state(current_task_state)
+ self.current_task = child_task_module(self.system, self.location,
+ self.current_task_parsed_xml, self.current_task_descriptor, self.static_data,
+ instance_state=current_task_state)
+
+ log.debug(current_task_state)
+ return True
+
+ def check_allow_reset(self):
+ """
+ Checks to see if the student has passed the criteria to move to the next module. If not, sets
+ allow_reset to true and halts the student progress through the tasks.
+ Input: None
+ Output: the allow_reset attribute of the current module.
+ """
+ if not self.allow_reset:
+ if self.current_task_number > 0:
+ last_response_data = self.get_last_response(self.current_task_number - 1)
+ current_response_data = self.get_current_attributes(self.current_task_number)
+
+ if(current_response_data['min_score_to_attempt'] > last_response_data['score']
+ or current_response_data['max_score_to_attempt'] < last_response_data['score']):
+ self.state = self.DONE
+ self.allow_reset = True
+
+ return self.allow_reset
+
+ def get_context(self):
+ """
+ Generates a context dictionary that is used to render html.
+ Input: None
+ Output: A dictionary that can be rendered into the combined open ended template.
+ """
+ task_html = self.get_html_base()
+ #set context variables and render template
+
+ context = {
+ 'items': [{'content': task_html}],
+ 'ajax_url': self.system.ajax_url,
+ 'allow_reset': self.allow_reset,
+ 'state': self.state,
+ 'task_count': len(self.task_xml),
+ 'task_number': self.current_task_number + 1,
+ 'status': self.get_status(),
+ }
+
+ return context
+
+ def get_html(self):
+ """
+ Gets HTML for rendering.
+ Input: None
+ Output: rendered html
+ """
+ context = self.get_context()
+ html = self.system.render_template('combined_open_ended.html', context)
+ return html
+
+ def get_html_nonsystem(self):
+ """
+ Gets HTML for rendering via AJAX. Does not use system, because system contains some additional
+ html, which is not appropriate for returning via ajax calls.
+ Input: None
+ Output: HTML rendered directly via Mako
+ """
+ context = self.get_context()
+ html = render_to_string('combined_open_ended.html', context)
+ return html
+
+ def get_html_base(self):
+ """
+ Gets the HTML associated with the current child task
+ Input: None
+ Output: Child task HTML
+ """
+ self.update_task_states()
+ html = self.current_task.get_html(self.system)
+ return_html = rewrite_links(html, self.rewrite_content_links)
+ return return_html
+
+ def get_current_attributes(self, task_number):
+ """
+ Gets the min and max score to attempt attributes of the specified task.
+ Input: The number of the task.
+ Output: The minimum and maximum scores needed to move on to the specified task.
+ """
+ task_xml = self.task_xml[task_number]
+ etree_xml = etree.fromstring(task_xml)
+ min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
+ max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
+ return {'min_score_to_attempt': min_score_to_attempt, 'max_score_to_attempt': max_score_to_attempt}
+
+ def get_last_response(self, task_number):
+ """
+ Returns data associated with the specified task number, such as the last response, score, etc.
+ Input: The number of the task.
+ Output: A dictionary that contains information about the specified task.
+ """
+ last_response = ""
+ task_state = self.task_states[task_number]
+ task_xml = self.task_xml[task_number]
+ task_type = self.get_tag_name(task_xml)
+
+ children = self.child_modules()
+
+ task_descriptor = children['descriptors'][task_type](self.system)
+ etree_xml = etree.fromstring(task_xml)
+
+ min_score_to_attempt = int(etree_xml.attrib.get('min_score_to_attempt', 0))
+ max_score_to_attempt = int(etree_xml.attrib.get('max_score_to_attempt', self._max_score))
+
+ task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system)
+ task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor,
+ self.static_data, instance_state=task_state)
+ last_response = task.latest_answer()
+ last_score = task.latest_score()
+ last_post_assessment = task.latest_post_assessment()
+ last_post_feedback = ""
+ if task_type == "openended":
+ last_post_assessment = task.latest_post_assessment(short_feedback=False, join_feedback=False)
+ if isinstance(last_post_assessment, list):
+ eval_list = []
+ for i in xrange(0, len(last_post_assessment)):
+ eval_list.append(task.format_feedback_with_evaluation(last_post_assessment[i]))
+ last_post_evaluation = "".join(eval_list)
+ else:
+ last_post_evaluation = task.format_feedback_with_evaluation(last_post_assessment)
+ last_post_assessment = last_post_evaluation
+ last_correctness = task.is_last_response_correct()
+ max_score = task.max_score()
+ state = task.state
+ last_response_dict = {
+ 'response': last_response,
+ 'score': last_score,
+ 'post_assessment': last_post_assessment,
+ 'type': task_type,
+ 'max_score': max_score,
+ 'state': state,
+ 'human_state': task.HUMAN_NAMES[state],
+ 'correct': last_correctness,
+ 'min_score_to_attempt': min_score_to_attempt,
+ 'max_score_to_attempt': max_score_to_attempt,
+ }
+
+ return last_response_dict
+
+ def update_task_states(self):
+ """
+ Updates the task state of the combined open ended module with the task state of the current child module.
+ Input: None
+ Output: boolean indicating whether or not the task state changed.
+ """
+ changed = False
+ if not self.allow_reset:
+ self.task_states[self.current_task_number] = self.current_task.get_instance_state()
+ current_task_state = json.loads(self.task_states[self.current_task_number])
+ if current_task_state['state'] == self.DONE:
+ self.current_task_number += 1
+ if self.current_task_number >= (len(self.task_xml)):
+ self.state = self.DONE
+ self.current_task_number = len(self.task_xml) - 1
+ else:
+ self.state = self.INITIAL
+ changed = True
+ self.setup_next_task()
+ return changed
+
+ def update_task_states_ajax(self, return_html):
+ """
+ Runs the update task states function for ajax calls. Currently the same as update_task_states
+ Input: The html returned by the handle_ajax function of the child
+ Output: New html that should be rendered
+ """
+ changed = self.update_task_states()
+ if changed:
+ #return_html=self.get_html()
+ pass
+ return return_html
+
+ def get_results(self, get):
+ """
+ Gets the results of a given grader via ajax.
+ Input: AJAX get dictionary
+ Output: Dictionary to be rendered via ajax that contains the result html.
+ """
+ task_number = int(get['task_number'])
+ self.update_task_states()
+ response_dict = self.get_last_response(task_number)
+ context = {'results': response_dict['post_assessment'], 'task_number': task_number + 1}
+ html = render_to_string('combined_open_ended_results.html', context)
+ return {'html': html, 'success': True}
+
+ def handle_ajax(self, dispatch, get):
+ """
+ This is called by courseware.module_render, to handle an AJAX call.
+ "get" is request.POST.
+
+ Returns a json dictionary:
+ { 'progress_changed' : True/False,
+ 'progress': 'none'/'in_progress'/'done',
+ }
+ """
+
+ handlers = {
+ 'next_problem': self.next_problem,
+ 'reset': self.reset,
+ 'get_results': self.get_results
+ }
+
+ if dispatch not in handlers:
+ return_html = self.current_task.handle_ajax(dispatch, get, self.system)
+ return self.update_task_states_ajax(return_html)
+
+ d = handlers[dispatch](get)
+ return json.dumps(d, cls=ComplexEncoder)
+
+ def next_problem(self, get):
+ """
+ Called via ajax to advance to the next problem.
+ Input: AJAX get request.
+ Output: Dictionary to be rendered
+ """
+ self.update_task_states()
+ return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset}
+
+ def reset(self, get):
+ """
+ If resetting is allowed, reset the state of the combined open ended module.
+ Input: AJAX get dictionary
+ Output: AJAX dictionary to tbe rendered
+ """
+ if self.state != self.DONE:
+ if not self.allow_reset:
+ return self.out_of_sync_error(get)
+
+ if self.attempts > self.max_attempts:
+ return {
+ 'success': False,
+ 'error': 'Too many attempts.'
+ }
+ self.state = self.INITIAL
+ self.allow_reset = False
+ for i in xrange(0, len(self.task_xml)):
+ self.current_task_number = i
+ self.setup_next_task(reset=True)
+ self.current_task.reset(self.system)
+ self.task_states[self.current_task_number] = self.current_task.get_instance_state()
+ self.current_task_number = 0
+ self.allow_reset = False
+ self.setup_next_task()
+ return {'success': True, 'html': self.get_html_nonsystem()}
+
+ def get_instance_state(self):
+ """
+ Returns the current instance state. The module can be recreated from the instance state.
+ Input: None
+ Output: A dictionary containing the instance state.
+ """
+
+ state = {
+ 'version': self.STATE_VERSION,
+ 'current_task_number': self.current_task_number,
+ 'state': self.state,
+ 'task_states': self.task_states,
+ 'attempts': self.attempts,
+ 'ready_to_reset': self.allow_reset,
+ }
+
+ return json.dumps(state)
+
+ def get_status(self):
+ """
+ Gets the status panel to be displayed at the top right.
+ Input: None
+ Output: The status html to be rendered
+ """
+ status = []
+ for i in xrange(0, self.current_task_number + 1):
+ task_data = self.get_last_response(i)
+ task_data.update({'task_number': i + 1})
+ status.append(task_data)
+ context = {'status_list': status}
+ status_html = self.system.render_template("combined_open_ended_status.html", context)
+
+ return status_html
+
+
+class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
+ """
+ Module for adding combined open ended questions
+ """
+ mako_template = "widgets/html-edit.html"
+ module_class = CombinedOpenEndedModule
+ filename_extension = "xml"
+
+ stores_state = True
+ has_score = True
+ template_dir_name = "combinedopenended"
+
+ js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
+ js_module_name = "HTMLEditingDescriptor"
+
+ @classmethod
+ def definition_from_xml(cls, xml_object, system):
+ """
+ Pull out the individual tasks, the rubric, and the prompt, and parse
+
+ Returns:
+ {
+ 'rubric': 'some-html',
+ 'prompt': 'some-html',
+ 'task_xml': dictionary of xml strings,
+ }
+ """
+ expected_children = ['task', 'rubric', 'prompt']
+ for child in expected_children:
+ if len(xml_object.xpath(child)) == 0:
+ raise ValueError("Combined Open Ended definition must include at least one '{0}' tag".format(child))
+
+ def parse_task(k):
+ """Assumes that xml_object has child k"""
+ return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
+
+ def parse(k):
+ """Assumes that xml_object has child k"""
+ return xml_object.xpath(k)[0]
+
+ return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')}
+
+
+ def definition_to_xml(self, resource_fs):
+ '''Return an xml element representing this definition.'''
+ elt = etree.Element('combinedopenended')
+
+ def add_child(k):
+ child_str = '<{tag}>{body}{tag}>'.format(tag=k, body=self.definition[k])
+ child_node = etree.fromstring(child_str)
+ elt.append(child_node)
+
+ for child in ['task']:
+ add_child(child)
+
+ return elt
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/combined_open_ended_rubric.py
new file mode 100644
index 0000000000..0b2ca1ca2c
--- /dev/null
+++ b/common/lib/xmodule/xmodule/combined_open_ended_rubric.py
@@ -0,0 +1,129 @@
+from mitxmako.shortcuts import render_to_string
+import logging
+from lxml import etree
+
+log=logging.getLogger(__name__)
+
+class CombinedOpenEndedRubric:
+
+ @staticmethod
+ def render_rubric(rubric_xml):
+ try:
+ rubric_categories = CombinedOpenEndedRubric.extract_rubric_categories(rubric_xml)
+ html = render_to_string('open_ended_rubric.html', {'rubric_categories' : rubric_categories})
+ except:
+ log.exception("Could not parse the rubric.")
+ html = rubric_xml
+ return html
+
+ @staticmethod
+ def extract_rubric_categories(element):
+ '''
+ Contstruct a list of categories such that the structure looks like:
+ [ { category: "Category 1 Name",
+ options: [{text: "Option 1 Name", points: 0}, {text:"Option 2 Name", points: 5}]
+ },
+ { category: "Category 2 Name",
+ options: [{text: "Option 1 Name", points: 0},
+ {text: "Option 2 Name", points: 1},
+ {text: "Option 3 Name", points: 2]}]
+
+ '''
+ element = etree.fromstring(element)
+ categories = []
+ for category in element:
+ if category.tag != 'category':
+ raise Exception("[capa.inputtypes.extract_categories] Expected a tag: got {0} instead".format(category.tag))
+ else:
+ categories.append(CombinedOpenEndedRubric.extract_category(category))
+ return categories
+
+ @staticmethod
+ def extract_category(category):
+ '''
+ construct an individual category
+ {category: "Category 1 Name",
+ options: [{text: "Option 1 text", points: 1},
+ {text: "Option 2 text", points: 2}]}
+
+ all sorting and auto-point generation occurs in this function
+ '''
+
+ has_score=False
+ descriptionxml = category[0]
+ scorexml = category[1]
+ if scorexml.tag == "option":
+ optionsxml = category[1:]
+ else:
+ optionsxml = category[2:]
+ has_score=True
+
+ # parse description
+ if descriptionxml.tag != 'description':
+ raise Exception("[extract_category]: expected description tag, got {0} instead".format(descriptionxml.tag))
+
+ if has_score:
+ if scorexml.tag != 'score':
+ raise Exception("[extract_category]: expected score tag, got {0} instead".format(scorexml.tag))
+
+ for option in optionsxml:
+ if option.tag != "option":
+ raise Exception("[extract_category]: expected option tag, got {0} instead".format(option.tag))
+
+ description = descriptionxml.text
+
+ if has_score:
+ score = int(scorexml.text)
+ else:
+ score = 0
+
+ cur_points = 0
+ options = []
+ autonumbering = True
+ # parse options
+ for option in optionsxml:
+ if option.tag != 'option':
+ raise Exception("[extract_category]: expected option tag, got {0} instead".format(option.tag))
+ else:
+ pointstr = option.get("points")
+ if pointstr:
+ autonumbering = False
+ # try to parse this into an int
+ try:
+ points = int(pointstr)
+ except ValueError:
+ raise Exception("[extract_category]: expected points to have int, got {0} instead".format(pointstr))
+ elif autonumbering:
+ # use the generated one if we're in the right mode
+ points = cur_points
+ cur_points = cur_points + 1
+ else:
+ raise Exception("[extract_category]: missing points attribute. Cannot continue to auto-create points values after a points value is explicitly dfined.")
+ optiontext = option.text
+ selected = False
+ if has_score:
+ if points == score:
+ selected = True
+ options.append({'text': option.text, 'points': points, 'selected' : selected})
+
+ # sort and check for duplicates
+ options = sorted(options, key=lambda option: option['points'])
+ CombinedOpenEndedRubric.validate_options(options)
+
+ return {'description': description, 'options': options, 'score' : score, 'has_score' : has_score}
+
+ @staticmethod
+ def validate_options(options):
+ '''
+ Validates a set of options. This can and should be extended to filter out other bad edge cases
+ '''
+ if len(options) == 0:
+ raise Exception("[extract_category]: no options associated with this category")
+ if len(options) == 1:
+ return
+ prev = options[0]['points']
+ for option in options[1:]:
+ if prev == option['points']:
+ raise Exception("[extract_category]: found duplicate point values between two different options")
+ else:
+ prev = option['points']
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss
new file mode 100644
index 0000000000..a58e30f1e2
--- /dev/null
+++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss
@@ -0,0 +1,626 @@
+h2 {
+ margin-top: 0;
+ margin-bottom: 15px;
+
+ &.problem-header {
+ section.staff {
+ margin-top: 30px;
+ font-size: 80%;
+ }
+ }
+
+ @media print {
+ display: block;
+ width: auto;
+ border-right: 0;
+ }
+}
+
+.inline-error {
+ color: darken($error-red, 10%);
+}
+
+section.combined-open-ended {
+ @include clearfix;
+ .status-container
+ {
+ float:right;
+ width:40%;
+ }
+ .item-container
+ {
+ float:left;
+ width: 53%;
+ padding-bottom: 50px;
+ }
+
+ .result-container
+ {
+ float:left;
+ width: 93%;
+ position:relative;
+ }
+}
+
+section.combined-open-ended-status {
+
+ .statusitem {
+ background-color: #FAFAFA;
+ color: #2C2C2C;
+ font-family: monospace;
+ font-size: 1em;
+ padding-top: 10px;
+ }
+
+ .statusitem-current {
+ background-color: #BEBEBE;
+ color: #2C2C2C;
+ font-family: monospace;
+ font-size: 1em;
+ padding-top: 10px;
+ }
+
+ span {
+ &.unanswered {
+ @include inline-block();
+ background: url('../images/unanswered-icon.png') center center no-repeat;
+ height: 14px;
+ position: relative;
+ width: 14px;
+ float: right;
+ }
+
+ &.correct {
+ @include inline-block();
+ background: url('../images/correct-icon.png') center center no-repeat;
+ height: 20px;
+ position: relative;
+ width: 25px;
+ float: right;
+ }
+
+ &.incorrect {
+ @include inline-block();
+ background: url('../images/incorrect-icon.png') center center no-repeat;
+ height: 20px;
+ width: 20px;
+ position: relative;
+ float: right;
+ }
+ }
+}
+
+div.result-container {
+
+ .evaluation {
+ p {
+ margin-bottom: 1px;
+ }
+ }
+
+ .feedback-on-feedback {
+ height: 100px;
+ margin-right: 0px;
+ }
+
+ .evaluation-response {
+ header {
+ text-align: right;
+ a {
+ font-size: .85em;
+ }
+ }
+ }
+ .evaluation-scoring {
+ .scoring-list {
+ list-style-type: none;
+ margin-left: 3px;
+
+ li {
+ &:first-child {
+ margin-left: 0px;
+ }
+ display:inline;
+ margin-left: 0px;
+
+ label {
+ font-size: .9em;
+ }
+ }
+ }
+ }
+ .submit-message-container {
+ margin: 10px 0px ;
+ }
+
+ .external-grader-message {
+ section {
+ padding-left: 20px;
+ background-color: #FAFAFA;
+ color: #2C2C2C;
+ font-family: monospace;
+ font-size: 1em;
+ padding-top: 10px;
+ header {
+ font-size: 1.4em;
+ }
+
+ .shortform {
+ font-weight: bold;
+ }
+
+ .longform {
+ padding: 0px;
+ margin: 0px;
+
+ .result-errors {
+ margin: 5px;
+ padding: 10px 10px 10px 40px;
+ background: url('../images/incorrect-icon.png') center left no-repeat;
+ li {
+ color: #B00;
+ }
+ }
+
+ .result-output {
+ margin: 5px;
+ padding: 20px 0px 15px 50px;
+ border-top: 1px solid #DDD;
+ border-left: 20px solid #FAFAFA;
+
+ h4 {
+ font-family: monospace;
+ font-size: 1em;
+ }
+
+ dl {
+ margin: 0px;
+ }
+
+ dt {
+ margin-top: 20px;
+ }
+
+ dd {
+ margin-left: 24pt;
+ }
+ }
+
+ .result-correct {
+ background: url('../images/correct-icon.png') left 20px no-repeat;
+ .result-actual-output {
+ color: #090;
+ }
+ }
+
+ .result-incorrect {
+ background: url('../images/incorrect-icon.png') left 20px no-repeat;
+ .result-actual-output {
+ color: #B00;
+ }
+ }
+
+ .markup-text{
+ margin: 5px;
+ padding: 20px 0px 15px 50px;
+ border-top: 1px solid #DDD;
+ border-left: 20px solid #FAFAFA;
+
+ bs {
+ color: #BB0000;
+ }
+
+ bg {
+ color: #BDA046;
+ }
+ }
+ }
+ }
+ }
+}
+
+div.result-container, section.open-ended-child {
+ .rubric {
+ tr {
+ margin:10px 0px;
+ height: 100%;
+ }
+ td {
+ padding: 20px 0px;
+ margin: 10px 0px;
+ height: 100%;
+ }
+ th {
+ padding: 5px;
+ margin: 5px;
+ }
+ label,
+ .view-only {
+ margin:10px;
+ position: relative;
+ padding: 15px;
+ width: 200px;
+ height:100%;
+ display: inline-block;
+ min-height: 50px;
+ min-width: 50px;
+ background-color: #CCC;
+ font-size: 1em;
+ }
+ .grade {
+ position: absolute;
+ bottom:0px;
+ right:0px;
+ margin:10px;
+ }
+ .selected-grade {
+ background: #666;
+ color: white;
+ }
+ input[type=radio]:checked + label {
+ background: #666;
+ color: white; }
+ input[class='score-selection'] {
+ display: none;
+ }
+ }
+}
+
+section.open-ended-child {
+ @media print {
+ display: block;
+ width: auto;
+ padding: 0;
+
+ canvas, img {
+ page-break-inside: avoid;
+ }
+ }
+
+ .inline {
+ display: inline;
+ }
+
+ ol.enumerate {
+ li {
+ &:before {
+ content: " ";
+ display: block;
+ height: 0;
+ visibility: hidden;
+ }
+ }
+ }
+
+ .solution-span {
+ > span {
+ margin: 20px 0;
+ display: block;
+ border: 1px solid #ddd;
+ padding: 9px 15px 20px;
+ background: #FFF;
+ position: relative;
+ @include box-shadow(inset 0 0 0 1px #eee);
+ @include border-radius(3px);
+
+ &:empty {
+ display: none;
+ }
+ }
+ }
+
+ p {
+ &.answer {
+ margin-top: -2px;
+ }
+ &.status {
+ text-indent: -9999px;
+ margin: 8px 0 0 10px;
+ }
+ }
+
+ div.unanswered {
+ p.status {
+ @include inline-block();
+ background: url('../images/unanswered-icon.png') center center no-repeat;
+ height: 14px;
+ width: 14px;
+ }
+ }
+
+ div.correct, div.ui-icon-check {
+ p.status {
+ @include inline-block();
+ background: url('../images/correct-icon.png') center center no-repeat;
+ height: 20px;
+ width: 25px;
+ }
+
+ input {
+ border-color: green;
+ }
+ }
+
+ div.processing {
+ p.status {
+ @include inline-block();
+ background: url('../images/spinner.gif') center center no-repeat;
+ height: 20px;
+ width: 20px;
+ }
+
+ input {
+ border-color: #aaa;
+ }
+ }
+
+ div.incorrect, div.ui-icon-close {
+ p.status {
+ @include inline-block();
+ background: url('../images/incorrect-icon.png') center center no-repeat;
+ height: 20px;
+ width: 20px;
+ text-indent: -9999px;
+ }
+
+ input {
+ border-color: red;
+ }
+ }
+
+ > span {
+ display: block;
+ margin-bottom: lh(.5);
+ }
+
+ p.answer {
+ @include inline-block();
+ margin-bottom: 0;
+ margin-left: 10px;
+
+ &:before {
+ content: "Answer: ";
+ font-weight: bold;
+ display: inline;
+
+ }
+ &:empty {
+ &:before {
+ display: none;
+ }
+ }
+ }
+
+ span {
+ &.unanswered, &.ui-icon-bullet {
+ @include inline-block();
+ background: url('../images/unanswered-icon.png') center center no-repeat;
+ height: 14px;
+ position: relative;
+ top: 4px;
+ width: 14px;
+ }
+
+ &.processing, &.ui-icon-processing {
+ @include inline-block();
+ background: url('../images/spinner.gif') center center no-repeat;
+ height: 20px;
+ position: relative;
+ top: 6px;
+ width: 25px;
+ }
+
+ &.correct, &.ui-icon-check {
+ @include inline-block();
+ background: url('../images/correct-icon.png') center center no-repeat;
+ height: 20px;
+ position: relative;
+ top: 6px;
+ width: 25px;
+ }
+
+ &.incorrect, &.ui-icon-close {
+ @include inline-block();
+ background: url('../images/incorrect-icon.png') center center no-repeat;
+ height: 20px;
+ width: 20px;
+ position: relative;
+ top: 6px;
+ }
+ }
+
+ .reload
+ {
+ float:right;
+ margin: 10px;
+ }
+
+
+ .grader-status {
+ padding: 9px;
+ background: #F6F6F6;
+ border: 1px solid #ddd;
+ border-top: 0;
+ margin-bottom: 20px;
+ @include clearfix;
+
+ span {
+ text-indent: -9999px;
+ overflow: hidden;
+ display: block;
+ float: left;
+ margin: -7px 7px 0 0;
+ }
+
+ .grading {
+ background: url('../images/info-icon.png') left center no-repeat;
+ padding-left: 25px;
+ text-indent: 0px;
+ margin: 0px 7px 0 0;
+ }
+
+ p {
+ line-height: 20px;
+ text-transform: capitalize;
+ margin-bottom: 0;
+ float: left;
+ }
+
+ &.file {
+ background: #FFF;
+ margin-top: 20px;
+ padding: 20px 0 0 0;
+
+ border: {
+ top: 1px solid #eee;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ }
+
+ p.debug {
+ display: none;
+ }
+
+ input {
+ float: left;
+ }
+ }
+
+ }
+
+ form.option-input {
+ margin: -10px 0 20px;
+ padding-bottom: 20px;
+
+ select {
+ margin-right: flex-gutter();
+ }
+ }
+
+ ul {
+ list-style: disc outside none;
+ margin-bottom: lh();
+ margin-left: .75em;
+ margin-left: .75rem;
+ }
+
+ ol {
+ list-style: decimal outside none;
+ margin-bottom: lh();
+ margin-left: .75em;
+ margin-left: .75rem;
+ }
+
+ dl {
+ line-height: 1.4em;
+ }
+
+ dl dt {
+ font-weight: bold;
+ }
+
+ dl dd {
+ margin-bottom: 0;
+ }
+
+ dd {
+ margin-left: .5em;
+ margin-left: .5rem;
+ }
+
+ li {
+ line-height: 1.4em;
+ margin-bottom: lh(.5);
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ p {
+ margin-bottom: lh();
+ }
+
+ hr {
+ background: #ddd;
+ border: none;
+ clear: both;
+ color: #ddd;
+ float: none;
+ height: 1px;
+ margin: 0 0 .75rem;
+ width: 100%;
+ }
+
+ .hidden {
+ display: none;
+ visibility: hidden;
+ }
+
+ #{$all-text-inputs} {
+ display: inline;
+ width: auto;
+ }
+
+ section.action {
+ margin-top: 20px;
+
+ input.save {
+ @extend .blue-button;
+ }
+
+ .submission_feedback {
+ // background: #F3F3F3;
+ // border: 1px solid #ddd;
+ // @include border-radius(3px);
+ // padding: 8px 12px;
+ // margin-top: 10px;
+ @include inline-block;
+ font-style: italic;
+ margin: 8px 0 0 10px;
+ color: #777;
+ -webkit-font-smoothing: antialiased;
+ }
+ }
+
+ .detailed-solution {
+ > p:first-child {
+ font-size: 0.9em;
+ font-weight: bold;
+ font-style: normal;
+ text-transform: uppercase;
+ color: #AAA;
+ }
+
+ p:last-child {
+ margin-bottom: 0;
+ }
+ }
+
+ div.open-ended-alert {
+ padding: 8px 12px;
+ border: 1px solid #EBE8BF;
+ border-radius: 3px;
+ background: #FFFCDD;
+ font-size: 0.9em;
+ margin-top: 10px;
+ }
+
+ div.capa_reset {
+ padding: 25px;
+ border: 1px solid $error-red;
+ background-color: lighten($error-red, 25%);
+ border-radius: 3px;
+ font-size: 1em;
+ margin-top: 10px;
+ margin-bottom: 10px;
+ }
+ .capa_reset>h2 {
+ color: #AA0000;
+ }
+ .capa_reset li {
+ font-size: 0.9em;
+ }
+
+}
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
index ba746fecb8..1c0ace9e59 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
@@ -25,7 +25,6 @@ class @Problem
@$('section.action input.reset').click @reset
@$('section.action input.show').click @show
@$('section.action input.save').click @save
- @$('section.evaluation input.submit-message').click @message_post
# Collapsibles
Collapsible.setCollapsibles(@el)
@@ -198,35 +197,6 @@ class @Problem
else
@gentle_alert response.success
- message_post: =>
- Logger.log 'message_post', @answers
-
- fd = new FormData()
- feedback = @$('section.evaluation textarea.feedback-on-feedback')[0].value
- submission_id = $('div.external-grader-message div.submission_id')[0].innerHTML
- grader_id = $('div.external-grader-message div.grader_id')[0].innerHTML
- score = $(".evaluation-scoring input:radio[name='evaluation-score']:checked").val()
- fd.append('feedback', feedback)
- fd.append('submission_id', submission_id)
- fd.append('grader_id', grader_id)
- if(!score)
- @gentle_alert "You need to pick a rating before you can submit."
- return
- else
- fd.append('score', score)
-
-
- settings =
- type: "POST"
- data: fd
- processData: false
- contentType: false
- success: (response) =>
- @gentle_alert response.message
- @$('section.evaluation').slideToggle()
-
- $.ajaxWithPrefix("#{@url}/message_post", settings)
-
reset: =>
Logger.log 'problem_reset', @answers
$.postWithPrefix "#{@url}/problem_reset", id: @id, (response) =>
diff --git a/common/lib/xmodule/xmodule/js/src/collapsible.coffee b/common/lib/xmodule/xmodule/js/src/collapsible.coffee
index 18a186e106..e414935784 100644
--- a/common/lib/xmodule/xmodule/js/src/collapsible.coffee
+++ b/common/lib/xmodule/xmodule/js/src/collapsible.coffee
@@ -22,7 +22,7 @@ class @Collapsible
if $(event.target).text() == 'See full output'
new_text = 'Hide output'
else
- new_text = 'See full ouput'
+ new_text = 'See full output'
$(event.target).text(new_text)
@toggleHint: (event) =>
diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee
new file mode 100644
index 0000000000..2cbba143a3
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee
@@ -0,0 +1,282 @@
+class @CombinedOpenEnded
+ constructor: (element) ->
+ @element=element
+ @reinitialize(element)
+
+ reinitialize: (element) ->
+ @wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule')
+ @el = $(element).find('section.combined-open-ended')
+ @combined_open_ended=$(element).find('section.combined-open-ended')
+ @id = @el.data('id')
+ @ajax_url = @el.data('ajax-url')
+ @state = @el.data('state')
+ @task_count = @el.data('task-count')
+ @task_number = @el.data('task-number')
+
+ @allow_reset = @el.data('allow_reset')
+ @reset_button = @$('.reset-button')
+ @reset_button.click @reset
+ @next_problem_button = @$('.next-step-button')
+ @next_problem_button.click @next_problem
+
+ @show_results_button=@$('.show-results-button')
+ @show_results_button.click @show_results
+
+ # valid states: 'initial', 'assessing', 'post_assessment', 'done'
+ Collapsible.setCollapsibles(@el)
+ @submit_evaluation_button = $('.submit-evaluation-button')
+ @submit_evaluation_button.click @message_post
+
+ @results_container = $('.result-container')
+
+ # Where to put the rubric once we load it
+ @el = $(element).find('section.open-ended-child')
+ @errors_area = @$('.error')
+ @answer_area = @$('textarea.answer')
+
+ @rubric_wrapper = @$('.rubric-wrapper')
+ @hint_wrapper = @$('.hint-wrapper')
+ @message_wrapper = @$('.message-wrapper')
+ @submit_button = @$('.submit-button')
+ @child_state = @el.data('state')
+ @child_type = @el.data('child-type')
+ if @child_type=="openended"
+ @skip_button = @$('.skip-button')
+ @skip_button.click @skip_post_assessment
+
+ @open_ended_child= @$('.open-ended-child')
+
+ @find_assessment_elements()
+ @find_hint_elements()
+
+ @rebind()
+
+ # locally scoped jquery.
+ $: (selector) ->
+ $(selector, @el)
+
+ show_results: (event) =>
+ status_item = $(event.target).parent().parent()
+ status_number = status_item.data('status-number')
+ data = {'task_number' : status_number}
+ $.postWithPrefix "#{@ajax_url}/get_results", data, (response) =>
+ if response.success
+ @results_container.after(response.html).remove()
+ @results_container = $('div.result-container')
+ @submit_evaluation_button = $('.submit-evaluation-button')
+ @submit_evaluation_button.click @message_post
+ Collapsible.setCollapsibles(@results_container)
+ else
+ @errors_area.html(response.error)
+
+ message_post: (event)=>
+ Logger.log 'message_post', @answers
+ external_grader_message=$(event.target).parent().parent().parent()
+ evaluation_scoring = $(event.target).parent()
+
+ fd = new FormData()
+ feedback = evaluation_scoring.find('textarea.feedback-on-feedback')[0].value
+ submission_id = external_grader_message.find('input.submission_id')[0].value
+ grader_id = external_grader_message.find('input.grader_id')[0].value
+ score = evaluation_scoring.find("input:radio[name='evaluation-score']:checked").val()
+
+ fd.append('feedback', feedback)
+ fd.append('submission_id', submission_id)
+ fd.append('grader_id', grader_id)
+ if(!score)
+ @gentle_alert "You need to pick a rating before you can submit."
+ return
+ else
+ fd.append('score', score)
+
+ settings =
+ type: "POST"
+ data: fd
+ processData: false
+ contentType: false
+ success: (response) =>
+ @gentle_alert response.msg
+ $('section.evaluation').slideToggle()
+ @message_wrapper.html(response.message_html)
+
+ $.ajaxWithPrefix("#{@ajax_url}/save_post_assessment", settings)
+
+
+ rebind: () =>
+ # rebind to the appropriate function for the current state
+ @submit_button.unbind('click')
+ @submit_button.show()
+ @reset_button.hide()
+ @next_problem_button.hide()
+ @hint_area.attr('disabled', false)
+
+ if @child_type=="openended"
+ @skip_button.hide()
+ if @allow_reset=="True"
+ @reset_button.show()
+ @submit_button.hide()
+ @answer_area.attr("disabled", true)
+ @hint_area.attr('disabled', true)
+ else if @child_state == 'initial'
+ @answer_area.attr("disabled", false)
+ @submit_button.prop('value', 'Submit')
+ @submit_button.click @save_answer
+ else if @child_state == 'assessing'
+ @answer_area.attr("disabled", true)
+ @submit_button.prop('value', 'Submit assessment')
+ @submit_button.click @save_assessment
+ if @child_type == "openended"
+ @submit_button.hide()
+ @queueing()
+ else if @child_state == 'post_assessment'
+ if @child_type=="openended"
+ @skip_button.show()
+ @skip_post_assessment()
+ @answer_area.attr("disabled", true)
+ @submit_button.prop('value', 'Submit post-assessment')
+ if @child_type=="selfassessment"
+ @submit_button.click @save_hint
+ else
+ @submit_button.click @message_post
+ else if @child_state == 'done'
+ @answer_area.attr("disabled", true)
+ @hint_area.attr('disabled', true)
+ @submit_button.hide()
+ if @child_type=="openended"
+ @skip_button.hide()
+ if @task_number<@task_count
+ @next_problem()
+ else
+ @reset_button.show()
+
+
+ find_assessment_elements: ->
+ @assessment = @$('select.assessment')
+
+ find_hint_elements: ->
+ @hint_area = @$('textarea.post_assessment')
+
+ save_answer: (event) =>
+ event.preventDefault()
+ if @child_state == 'initial'
+ data = {'student_answer' : @answer_area.val()}
+ $.postWithPrefix "#{@ajax_url}/save_answer", data, (response) =>
+ if response.success
+ @rubric_wrapper.html(response.rubric_html)
+ @child_state = 'assessing'
+ @find_assessment_elements()
+ @rebind()
+ else
+ @errors_area.html(response.error)
+ else
+ @errors_area.html('Problem state got out of sync. Try reloading the page.')
+
+ save_assessment: (event) =>
+ event.preventDefault()
+ if @child_state == 'assessing'
+ data = {'assessment' : @assessment.find(':selected').text()}
+ $.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) =>
+ if response.success
+ @child_state = response.state
+
+ if @child_state == 'post_assessment'
+ @hint_wrapper.html(response.hint_html)
+ @find_hint_elements()
+ else if @child_state == 'done'
+ @message_wrapper.html(response.message_html)
+
+ @rebind()
+ else
+ @errors_area.html(response.error)
+ else
+ @errors_area.html('Problem state got out of sync. Try reloading the page.')
+
+ save_hint: (event) =>
+ event.preventDefault()
+ if @child_state == 'post_assessment'
+ data = {'hint' : @hint_area.val()}
+
+ $.postWithPrefix "#{@ajax_url}/save_post_assessment", data, (response) =>
+ if response.success
+ @message_wrapper.html(response.message_html)
+ @child_state = 'done'
+ @rebind()
+ else
+ @errors_area.html(response.error)
+ else
+ @errors_area.html('Problem state got out of sync. Try reloading the page.')
+
+ skip_post_assessment: =>
+ if @child_state == 'post_assessment'
+
+ $.postWithPrefix "#{@ajax_url}/skip_post_assessment", {}, (response) =>
+ if response.success
+ @child_state = 'done'
+ @rebind()
+ else
+ @errors_area.html(response.error)
+ else
+ @errors_area.html('Problem state got out of sync. Try reloading the page.')
+
+ reset: (event) =>
+ event.preventDefault()
+ if @child_state == 'done' or @allow_reset=="True"
+ $.postWithPrefix "#{@ajax_url}/reset", {}, (response) =>
+ if response.success
+ @answer_area.val('')
+ @rubric_wrapper.html('')
+ @hint_wrapper.html('')
+ @message_wrapper.html('')
+ @child_state = 'initial'
+ @combined_open_ended.after(response.html).remove()
+ @allow_reset="False"
+ @reinitialize(@element)
+ @rebind()
+ @reset_button.hide()
+ else
+ @errors_area.html(response.error)
+ else
+ @errors_area.html('Problem state got out of sync. Try reloading the page.')
+
+ next_problem: =>
+ if @child_state == 'done'
+ $.postWithPrefix "#{@ajax_url}/next_problem", {}, (response) =>
+ if response.success
+ @answer_area.val('')
+ @rubric_wrapper.html('')
+ @hint_wrapper.html('')
+ @message_wrapper.html('')
+ @child_state = 'initial'
+ @combined_open_ended.after(response.html).remove()
+ @reinitialize(@element)
+ @rebind()
+ @next_problem_button.hide()
+ if !response.allow_reset
+ @gentle_alert "Moved to next step."
+ else
+ @gentle_alert "Your score did not meet the criteria to move to the next step."
+ else
+ @errors_area.html(response.error)
+ else
+ @errors_area.html('Problem state got out of sync. Try reloading the page.')
+
+ gentle_alert: (msg) =>
+ if @el.find('.open-ended-alert').length
+ @el.find('.open-ended-alert').remove()
+ alert_elem = "
" + msg + "
"
+ @el.find('.open-ended-action').after(alert_elem)
+ @el.find('.open-ended-alert').css(opacity: 0).animate(opacity: 1, 700)
+
+ queueing: =>
+ if @child_state=="assessing" and @child_type=="openended"
+ if window.queuePollerID # Only one poller 'thread' per Problem
+ window.clearTimeout(window.queuePollerID)
+ window.queuePollerID = window.setTimeout(@poll, 10000)
+
+ poll: =>
+ $.postWithPrefix "#{@ajax_url}/check_for_score", (response) =>
+ if response.state == "done" or response.state=="post_assessment"
+ delete window.queuePollerID
+ location.reload()
+ else
+ window.queuePollerID = window.setTimeout(@poll, 10000)
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee b/common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee
deleted file mode 100644
index 5b70ab29aa..0000000000
--- a/common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee
+++ /dev/null
@@ -1,133 +0,0 @@
-class @SelfAssessment
- constructor: (element) ->
- @el = $(element).find('section.self-assessment')
- @id = @el.data('id')
- @ajax_url = @el.data('ajax-url')
- @state = @el.data('state')
- @allow_reset = @el.data('allow_reset')
- # valid states: 'initial', 'assessing', 'request_hint', 'done'
-
- # Where to put the rubric once we load it
- @errors_area = @$('.error')
- @answer_area = @$('textarea.answer')
-
- @rubric_wrapper = @$('.rubric-wrapper')
- @hint_wrapper = @$('.hint-wrapper')
- @message_wrapper = @$('.message-wrapper')
- @submit_button = @$('.submit-button')
- @reset_button = @$('.reset-button')
- @reset_button.click @reset
-
- @find_assessment_elements()
- @find_hint_elements()
-
- @rebind()
-
- # locally scoped jquery.
- $: (selector) ->
- $(selector, @el)
-
- rebind: () =>
- # rebind to the appropriate function for the current state
- @submit_button.unbind('click')
- @submit_button.show()
- @reset_button.hide()
- @hint_area.attr('disabled', false)
- if @state == 'initial'
- @answer_area.attr("disabled", false)
- @submit_button.prop('value', 'Submit')
- @submit_button.click @save_answer
- else if @state == 'assessing'
- @answer_area.attr("disabled", true)
- @submit_button.prop('value', 'Submit assessment')
- @submit_button.click @save_assessment
- else if @state == 'request_hint'
- @answer_area.attr("disabled", true)
- @submit_button.prop('value', 'Submit hint')
- @submit_button.click @save_hint
- else if @state == 'done'
- @answer_area.attr("disabled", true)
- @hint_area.attr('disabled', true)
- @submit_button.hide()
- if @allow_reset
- @reset_button.show()
- else
- @reset_button.hide()
-
-
- find_assessment_elements: ->
- @assessment = @$('select.assessment')
-
- find_hint_elements: ->
- @hint_area = @$('textarea.hint')
-
- save_answer: (event) =>
- event.preventDefault()
- if @state == 'initial'
- data = {'student_answer' : @answer_area.val()}
- $.postWithPrefix "#{@ajax_url}/save_answer", data, (response) =>
- if response.success
- @rubric_wrapper.html(response.rubric_html)
- @state = 'assessing'
- @find_assessment_elements()
- @rebind()
- else
- @errors_area.html(response.error)
- else
- @errors_area.html('Problem state got out of sync. Try reloading the page.')
-
- save_assessment: (event) =>
- event.preventDefault()
- if @state == 'assessing'
- data = {'assessment' : @assessment.find(':selected').text()}
- $.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) =>
- if response.success
- @state = response.state
-
- if @state == 'request_hint'
- @hint_wrapper.html(response.hint_html)
- @find_hint_elements()
- else if @state == 'done'
- @message_wrapper.html(response.message_html)
- @allow_reset = response.allow_reset
-
- @rebind()
- else
- @errors_area.html(response.error)
- else
- @errors_area.html('Problem state got out of sync. Try reloading the page.')
-
-
- save_hint: (event) =>
- event.preventDefault()
- if @state == 'request_hint'
- data = {'hint' : @hint_area.val()}
-
- $.postWithPrefix "#{@ajax_url}/save_hint", data, (response) =>
- if response.success
- @message_wrapper.html(response.message_html)
- @state = 'done'
- @allow_reset = response.allow_reset
- @rebind()
- else
- @errors_area.html(response.error)
- else
- @errors_area.html('Problem state got out of sync. Try reloading the page.')
-
-
- reset: (event) =>
- event.preventDefault()
- if @state == 'done'
- $.postWithPrefix "#{@ajax_url}/reset", {}, (response) =>
- if response.success
- @answer_area.val('')
- @rubric_wrapper.html('')
- @hint_wrapper.html('')
- @message_wrapper.html('')
- @state = 'initial'
- @rebind()
- @reset_button.hide()
- else
- @errors_area.html(response.error)
- else
- @errors_area.html('Problem state got out of sync. Try reloading the page.')
diff --git a/common/lib/xmodule/xmodule/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_module.py
new file mode 100644
index 0000000000..11f96c9848
--- /dev/null
+++ b/common/lib/xmodule/xmodule/open_ended_module.py
@@ -0,0 +1,660 @@
+"""
+A Self Assessment module that allows students to write open-ended responses,
+submit, then see a rubric and rate themselves. Persists student supplied
+hints, answers, and assessment judgment (currently only correct/incorrect).
+Parses xml definition file--see below for exact format.
+"""
+
+import copy
+from fs.errors import ResourceNotFoundError
+import itertools
+import json
+import logging
+from lxml import etree
+from lxml.html import rewrite_links
+from path import path
+import os
+import sys
+import hashlib
+import capa.xqueue_interface as xqueue_interface
+
+from pkg_resources import resource_string
+
+from .capa_module import only_one, ComplexEncoder
+from .editing_module import EditingDescriptor
+from .html_checker import check_html
+from progress import Progress
+from .stringify import stringify_children
+from .xml_module import XmlDescriptor
+from xmodule.modulestore import Location
+from capa.util import *
+import openendedchild
+
+from mitxmako.shortcuts import render_to_string
+from numpy import median
+
+from datetime import datetime
+
+from combined_open_ended_rubric import CombinedOpenEndedRubric
+
+log = logging.getLogger("mitx.courseware")
+
+class OpenEndedModule(openendedchild.OpenEndedChild):
+ """
+ The open ended module supports all external open ended grader problems.
+ Sample XML file:
+
+
+ Enter essay here.
+ This is the answer.
+ {"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}
+
+
+ """
+
+ def setup_response(self, system, location, definition, descriptor):
+ """
+ Sets up the response type.
+ @param system: Modulesystem object
+ @param location: The location of the problem
+ @param definition: The xml definition of the problem
+ @param descriptor: The OpenEndedDescriptor associated with this
+ @return: None
+ """
+ oeparam = definition['oeparam']
+
+ self.url = definition.get('url', None)
+ self.queue_name = definition.get('queuename', self.DEFAULT_QUEUE)
+ self.message_queue_name = definition.get('message-queuename', self.DEFAULT_MESSAGE_QUEUE)
+
+ #This is needed to attach feedback to specific responses later
+ self.submission_id = None
+ self.grader_id = None
+
+ if oeparam is None:
+ raise ValueError("No oeparam found in problem xml.")
+ if self.prompt is None:
+ raise ValueError("No prompt found in problem xml.")
+ if self.rubric is None:
+ raise ValueError("No rubric found in problem xml.")
+
+ self._parse(oeparam, self.prompt, self.rubric, system)
+
+ if self.created == True and self.state == self.ASSESSING:
+ self.created = False
+ self.send_to_grader(self.latest_answer(), system)
+ self.created = False
+
+ def _parse(self, oeparam, prompt, rubric, system):
+ '''
+ Parse OpenEndedResponse XML:
+ self.initial_display
+ self.payload - dict containing keys --
+ 'grader' : path to grader settings file, 'problem_id' : id of the problem
+
+ self.answer - What to display when show answer is clicked
+ '''
+ # Note that OpenEndedResponse is agnostic to the specific contents of grader_payload
+ prompt_string = stringify_children(prompt)
+ rubric_string = stringify_children(rubric)
+ self.prompt = prompt_string
+ self.rubric = rubric_string
+
+ grader_payload = oeparam.find('grader_payload')
+ grader_payload = grader_payload.text if grader_payload is not None else ''
+
+ #Update grader payload with student id. If grader payload not json, error.
+ try:
+ parsed_grader_payload = json.loads(grader_payload)
+ # NOTE: self.system.location is valid because the capa_module
+ # __init__ adds it (easiest way to get problem location into
+ # response types)
+ except TypeError, ValueError:
+ log.exception("Grader payload %r is not a json object!", grader_payload)
+
+ self.initial_display = find_with_default(oeparam, 'initial_display', '')
+ self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.')
+
+ parsed_grader_payload.update({
+ 'location': system.location.url(),
+ 'course_id': system.course_id,
+ 'prompt': prompt_string,
+ 'rubric': rubric_string,
+ 'initial_display': self.initial_display,
+ 'answer': self.answer,
+ })
+ updated_grader_payload = json.dumps(parsed_grader_payload)
+
+ self.payload = {'grader_payload': updated_grader_payload}
+
+ def skip_post_assessment(self, get, system):
+ """
+ Ajax function that allows one to skip the post assessment phase
+ @param get: AJAX dictionary
+ @param system: ModuleSystem
+ @return: Success indicator
+ """
+ self.state = self.DONE
+ return {'success': True}
+
+ def message_post(self, get, system):
+ """
+ Handles a student message post (a reaction to the grade they received from an open ended grader type)
+ Returns a boolean success/fail and an error message
+ """
+
+ event_info = dict()
+ event_info['problem_id'] = system.location.url()
+ event_info['student_id'] = system.anonymous_student_id
+ event_info['survey_responses'] = get
+
+ survey_responses = event_info['survey_responses']
+ for tag in ['feedback', 'submission_id', 'grader_id', 'score']:
+ if tag not in survey_responses:
+ return {'success': False, 'msg': "Could not find needed tag {0}".format(tag)}
+ try:
+ submission_id = int(survey_responses['submission_id'])
+ grader_id = int(survey_responses['grader_id'])
+ feedback = str(survey_responses['feedback'].encode('ascii', 'ignore'))
+ score = int(survey_responses['score'])
+ except:
+ error_message = ("Could not parse submission id, grader id, "
+ "or feedback from message_post ajax call. Here is the message data: {0}".format(
+ survey_responses))
+ log.exception(error_message)
+ return {'success': False, 'msg': "There was an error saving your feedback. Please contact course staff."}
+
+ qinterface = system.xqueue['interface']
+ qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
+ anonymous_student_id = system.anonymous_student_id
+ queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime +
+ anonymous_student_id +
+ str(len(self.history)))
+
+ xheader = xqueue_interface.make_xheader(
+ lms_callback_url=system.xqueue['callback_url'],
+ lms_key=queuekey,
+ queue_name=self.message_queue_name
+ )
+
+ student_info = {'anonymous_student_id': anonymous_student_id,
+ 'submission_time': qtime,
+ }
+ contents = {
+ 'feedback': feedback,
+ 'submission_id': submission_id,
+ 'grader_id': grader_id,
+ 'score': score,
+ 'student_info': json.dumps(student_info),
+ }
+
+ (error, msg) = qinterface.send_to_queue(header=xheader,
+ body=json.dumps(contents))
+
+ #Convert error to a success value
+ success = True
+ if error:
+ success = False
+
+ self.state = self.DONE
+
+ return {'success': success, 'msg': "Successfully submitted your feedback."}
+
+ def send_to_grader(self, submission, system):
+ """
+ Send a given submission to the grader, via the xqueue
+ @param submission: The student submission to send to the grader
+ @param system: Modulesystem
+ @return: Boolean true (not useful right now)
+ """
+
+ # Prepare xqueue request
+ #------------------------------------------------------------
+
+ qinterface = system.xqueue['interface']
+ qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
+
+ anonymous_student_id = system.anonymous_student_id
+
+ # Generate header
+ queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime +
+ anonymous_student_id +
+ str(len(self.history)))
+
+ xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['callback_url'],
+ lms_key=queuekey,
+ queue_name=self.queue_name)
+
+ contents = self.payload.copy()
+
+ # Metadata related to the student submission revealed to the external grader
+ student_info = {'anonymous_student_id': anonymous_student_id,
+ 'submission_time': qtime,
+ }
+
+ #Update contents with student response and student info
+ contents.update({
+ 'student_info': json.dumps(student_info),
+ 'student_response': submission,
+ 'max_score': self.max_score(),
+ })
+
+ # Submit request. When successful, 'msg' is the prior length of the queue
+ (error, msg) = qinterface.send_to_queue(header=xheader,
+ body=json.dumps(contents))
+
+ # State associated with the queueing request
+ queuestate = {'key': queuekey,
+ 'time': qtime, }
+ return True
+
+ def _update_score(self, score_msg, queuekey, system):
+ """
+ Called by xqueue to update the score
+ @param score_msg: The message from xqueue
+ @param queuekey: The key sent by xqueue
+ @param system: Modulesystem
+ @return: Boolean True (not useful currently)
+ """
+ new_score_msg = self._parse_score_msg(score_msg)
+ if not new_score_msg['valid']:
+ score_msg['feedback'] = 'Invalid grader reply. Please contact the course staff.'
+
+ self.record_latest_score(new_score_msg['score'])
+ self.record_latest_post_assessment(score_msg)
+ self.state = self.POST_ASSESSMENT
+
+ return True
+
+
+ def get_answers(self):
+ """
+ Gets and shows the answer for this problem.
+ @return: Answer html
+ """
+ anshtml = '
{0}
'.format(self.answer)
+ return {self.answer_id: anshtml}
+
+ def get_initial_display(self):
+ """
+ Gets and shows the initial display for the input box.
+ @return: Initial display html
+ """
+ return {self.answer_id: self.initial_display}
+
+ def _convert_longform_feedback_to_html(self, response_items):
+ """
+ Take in a dictionary, and return html strings for display to student.
+ Input:
+ response_items: Dictionary with keys success, feedback.
+ if success is True, feedback should be a dictionary, with keys for
+ types of feedback, and the corresponding feedback values.
+ if success is False, feedback is actually an error string.
+
+ NOTE: this will need to change when we integrate peer grading, because
+ that will have more complex feedback.
+
+ Output:
+ String -- html that can be displayincorrect-icon.pnged to the student.
+ """
+
+ # We want to display available feedback in a particular order.
+ # This dictionary specifies which goes first--lower first.
+ priorities = {# These go at the start of the feedback
+ 'spelling': 0,
+ 'grammar': 1,
+ # needs to be after all the other feedback
+ 'markup_text': 3}
+
+ default_priority = 2
+
+ def get_priority(elt):
+ """
+ Args:
+ elt: a tuple of feedback-type, feedback
+ Returns:
+ the priority for this feedback type
+ """
+ return priorities.get(elt[0], default_priority)
+
+ def encode_values(feedback_type, value):
+ feedback_type = str(feedback_type).encode('ascii', 'ignore')
+ if not isinstance(value, basestring):
+ value = str(value)
+ value = value.encode('ascii', 'ignore')
+ return feedback_type, value
+
+ def format_feedback(feedback_type, value):
+ feedback_type, value = encode_values(feedback_type, value)
+ feedback = """
+
+ {value}
+
+ """.format(feedback_type=feedback_type, value=value)
+ return feedback
+
+ def format_feedback_hidden(feedback_type, value):
+ feedback_type, value = encode_values(feedback_type, value)
+ feedback = """
+
+ """.format(feedback_type=feedback_type, value=value)
+ return feedback
+
+ # TODO (vshnayder): design and document the details of this format so
+ # that we can do proper escaping here (e.g. are the graders allowed to
+ # include HTML?)
+
+ for tag in ['success', 'feedback', 'submission_id', 'grader_id']:
+ if tag not in response_items:
+ return format_feedback('errors', 'Error getting feedback')
+
+ feedback_items = response_items['feedback']
+ try:
+ feedback = json.loads(feedback_items)
+ except (TypeError, ValueError):
+ log.exception("feedback_items have invalid json %r", feedback_items)
+ return format_feedback('errors', 'Could not parse feedback')
+
+ if response_items['success']:
+ if len(feedback) == 0:
+ return format_feedback('errors', 'No feedback available')
+
+ feedback_lst = sorted(feedback.items(), key=get_priority)
+ feedback_list_part1 = u"\n".join(format_feedback(k, v) for k, v in feedback_lst)
+ else:
+ feedback_list_part1 = format_feedback('errors', response_items['feedback'])
+
+ feedback_list_part2 = (u"\n".join([format_feedback_hidden(feedback_type, value)
+ for feedback_type, value in response_items.items()
+ if feedback_type in ['submission_id', 'grader_id']]))
+
+ return u"\n".join([feedback_list_part1, feedback_list_part2])
+
+ def _format_feedback(self, response_items):
+ """
+ Input:
+ Dictionary called feedback. Must contain keys seen below.
+ Output:
+ Return error message or feedback template
+ """
+
+ log.debug(response_items)
+ rubric_feedback=""
+ feedback = self._convert_longform_feedback_to_html(response_items)
+ if response_items['rubric_scores_complete']==True:
+ rubric_feedback = CombinedOpenEndedRubric.render_rubric(response_items['rubric_xml'])
+
+ if not response_items['success']:
+ return system.render_template("open_ended_error.html",
+ {'errors': feedback})
+
+ feedback_template = render_to_string("open_ended_feedback.html", {
+ 'grader_type': response_items['grader_type'],
+ 'score': "{0} / {1}".format(response_items['score'], self.max_score()),
+ 'feedback': feedback,
+ 'rubric_feedback' : rubric_feedback
+ })
+
+ return feedback_template
+
+
+ def _parse_score_msg(self, score_msg, join_feedback=True):
+ """
+ Grader reply is a JSON-dump of the following dict
+ { 'correct': True/False,
+ 'score': Numeric value (floating point is okay) to assign to answer
+ 'msg': grader_msg
+ 'feedback' : feedback from grader
+ }
+
+ Returns (valid_score_msg, correct, score, msg):
+ valid_score_msg: Flag indicating valid score_msg format (Boolean)
+ correct: Correctness of submission (Boolean)
+ score: Points to be assigned (numeric, can be float)
+ """
+ fail = {'valid': False, 'score': 0, 'feedback': ''}
+ try:
+ score_result = json.loads(score_msg)
+ except (TypeError, ValueError):
+ error_message = ("External grader message should be a JSON-serialized dict."
+ " Received score_msg = {0}".format(score_msg))
+ log.error(error_message)
+ fail['feedback'] = error_message
+ return fail
+
+ if not isinstance(score_result, dict):
+ error_message = ("External grader message should be a JSON-serialized dict."
+ " Received score_result = {0}".format(score_result))
+ log.error(error_message)
+ fail['feedback'] = error_message
+ return fail
+
+ for tag in ['score', 'feedback', 'grader_type', 'success', 'grader_id', 'submission_id']:
+ if tag not in score_result:
+ error_message = ("External grader message is missing required tag: {0}"
+ .format(tag))
+ log.error(error_message)
+ fail['feedback'] = error_message
+ return fail
+ #This is to support peer grading
+ if isinstance(score_result['score'], list):
+ feedback_items = []
+ for i in xrange(0, len(score_result['score'])):
+ new_score_result = {
+ 'score': score_result['score'][i],
+ 'feedback': score_result['feedback'][i],
+ 'grader_type': score_result['grader_type'],
+ 'success': score_result['success'],
+ 'grader_id': score_result['grader_id'][i],
+ 'submission_id': score_result['submission_id'],
+ 'rubric_scores_complete' : score_result['rubric_scores_complete'],
+ 'rubric_xml' : score_result['rubric_xml'],
+ }
+ feedback_items.append(self._format_feedback(new_score_result))
+ if join_feedback:
+ feedback = "".join(feedback_items)
+ else:
+ feedback = feedback_items
+ score = int(median(score_result['score']))
+ else:
+ #This is for instructor and ML grading
+ feedback = self._format_feedback(score_result)
+ score = score_result['score']
+
+ self.submission_id = score_result['submission_id']
+ self.grader_id = score_result['grader_id']
+
+ return {'valid': True, 'score': score, 'feedback': feedback}
+
+ def latest_post_assessment(self, short_feedback=False, join_feedback=True):
+ """
+ Gets the latest feedback, parses, and returns
+ @param short_feedback: If the long feedback is wanted or not
+ @return: Returns formatted feedback
+ """
+ if not self.history:
+ return ""
+
+ feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), join_feedback=join_feedback)
+ if not short_feedback:
+ return feedback_dict['feedback'] if feedback_dict['valid'] else ''
+ if feedback_dict['valid']:
+ short_feedback = self._convert_longform_feedback_to_html(
+ json.loads(self.history[-1].get('post_assessment', "")))
+ return short_feedback if feedback_dict['valid'] else ''
+
+ def format_feedback_with_evaluation(self, feedback):
+ """
+ Renders a given html feedback into an evaluation template
+ @param feedback: HTML feedback
+ @return: Rendered html
+ """
+ context = {'msg': feedback, 'id': "1", 'rows': 50, 'cols': 50}
+ html = render_to_string('open_ended_evaluation.html', context)
+ return html
+
+ def handle_ajax(self, dispatch, get, system):
+ '''
+ This is called by courseware.module_render, to handle an AJAX call.
+ "get" is request.POST.
+
+ Returns a json dictionary:
+ { 'progress_changed' : True/False,
+ 'progress' : 'none'/'in_progress'/'done',
+ }
+ '''
+ handlers = {
+ 'save_answer': self.save_answer,
+ 'score_update': self.update_score,
+ 'save_post_assessment': self.message_post,
+ 'skip_post_assessment': self.skip_post_assessment,
+ 'check_for_score': self.check_for_score,
+ }
+
+ if dispatch not in handlers:
+ return 'Error'
+
+ before = self.get_progress()
+ d = handlers[dispatch](get, system)
+ after = self.get_progress()
+ d.update({
+ 'progress_changed': after != before,
+ 'progress_status': Progress.to_js_status_str(after),
+ })
+ return json.dumps(d, cls=ComplexEncoder)
+
+ def check_for_score(self, get, system):
+ """
+ Checks to see if a score has been received yet.
+ @param get: AJAX get dictionary
+ @param system: Modulesystem (needed to align with other ajax functions)
+ @return: Returns the current state
+ """
+ state = self.state
+ return {'state': state}
+
+ def save_answer(self, get, system):
+ """
+ Saves a student answer
+ @param get: AJAX get dictionary
+ @param system: modulesystem
+ @return: Success indicator
+ """
+ if self.attempts > self.max_attempts:
+ # If too many attempts, prevent student from saving answer and
+ # seeing rubric. In normal use, students shouldn't see this because
+ # they won't see the reset button once they're out of attempts.
+ return {
+ 'success': False,
+ 'error': 'Too many attempts.'
+ }
+
+ if self.state != self.INITIAL:
+ return self.out_of_sync_error(get)
+
+ # add new history element with answer and empty score and hint.
+ self.new_history_entry(get['student_answer'])
+ self.send_to_grader(get['student_answer'], system)
+ self.change_state(self.ASSESSING)
+
+ return {'success': True, }
+
+ def update_score(self, get, system):
+ """
+ Updates the current score via ajax. Called by xqueue.
+ Input: AJAX get dictionary, modulesystem
+ Output: None
+ """
+ queuekey = get['queuekey']
+ score_msg = get['xqueue_body']
+ #TODO: Remove need for cmap
+ self._update_score(score_msg, queuekey, system)
+
+ return dict() # No AJAX return is needed
+
+ def get_html(self, system):
+ """
+ Gets the HTML for this problem and renders it
+ Input: Modulesystem object
+ Output: Rendered HTML
+ """
+ #set context variables and render template
+ if self.state != self.INITIAL:
+ latest = self.latest_answer()
+ previous_answer = latest if latest is not None else self.initial_display
+ post_assessment = self.latest_post_assessment()
+ score = self.latest_score()
+ correct = 'correct' if self.is_submission_correct(score) else 'incorrect'
+ else:
+ post_assessment = ""
+ correct = ""
+ previous_answer = self.initial_display
+
+ context = {
+ 'prompt': self.prompt,
+ 'previous_answer': previous_answer,
+ 'state': self.state,
+ 'allow_reset': self._allow_reset(),
+ 'rows': 30,
+ 'cols': 80,
+ 'id': 'open_ended',
+ 'msg': post_assessment,
+ 'child_type': 'openended',
+ 'correct': correct,
+ }
+ log.debug(context)
+ html = system.render_template('open_ended.html', context)
+ return html
+
+
+class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
+ """
+ Module for adding open ended response questions to courses
+ """
+ mako_template = "widgets/html-edit.html"
+ module_class = OpenEndedModule
+ filename_extension = "xml"
+
+ stores_state = True
+ has_score = True
+ template_dir_name = "openended"
+
+ js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
+ js_module_name = "HTMLEditingDescriptor"
+
+ @classmethod
+ def definition_from_xml(cls, xml_object, system):
+ """
+ Pull out the open ended parameters into a dictionary.
+
+ Returns:
+ {
+ 'oeparam': 'some-html'
+ }
+ """
+ for child in ['openendedparam']:
+ if len(xml_object.xpath(child)) != 1:
+ raise ValueError("Open Ended definition must include exactly one '{0}' tag".format(child))
+
+ def parse(k):
+ """Assumes that xml_object has child k"""
+ return xml_object.xpath(k)[0]
+
+ return {'oeparam': parse('openendedparam'), }
+
+
+ def definition_to_xml(self, resource_fs):
+ '''Return an xml element representing this definition.'''
+ elt = etree.Element('openended')
+
+ def add_child(k):
+ child_str = '<{tag}>{body}{tag}>'.format(tag=k, body=self.definition[k])
+ child_node = etree.fromstring(child_str)
+ elt.append(child_node)
+
+ for child in ['openendedparam']:
+ add_child(child)
+
+ return elt
+
+
diff --git a/common/lib/xmodule/xmodule/openendedchild.py b/common/lib/xmodule/xmodule/openendedchild.py
new file mode 100644
index 0000000000..2ba9528237
--- /dev/null
+++ b/common/lib/xmodule/xmodule/openendedchild.py
@@ -0,0 +1,263 @@
+import copy
+from fs.errors import ResourceNotFoundError
+import itertools
+import json
+import logging
+from lxml import etree
+from lxml.html import rewrite_links
+from path import path
+import os
+import sys
+import hashlib
+import capa.xqueue_interface as xqueue_interface
+
+from pkg_resources import resource_string
+
+from .capa_module import only_one, ComplexEncoder
+from .editing_module import EditingDescriptor
+from .html_checker import check_html
+from progress import Progress
+from .stringify import stringify_children
+from .xml_module import XmlDescriptor
+from xmodule.modulestore import Location
+from capa.util import *
+
+from datetime import datetime
+
+log = logging.getLogger("mitx.courseware")
+
+# Set the default number of max attempts. Should be 1 for production
+# Set higher for debugging/testing
+# attempts specified in xml definition overrides this.
+MAX_ATTEMPTS = 1
+
+# Set maximum available number of points.
+# Overriden by max_score specified in xml.
+MAX_SCORE = 1
+
+class OpenEndedChild():
+ """
+ States:
+
+ initial (prompt, textbox shown)
+ |
+ assessing (read-only textbox, rubric + assessment input shown for self assessment, response queued for open ended)
+ |
+ post_assessment (read-only textbox, read-only rubric and assessment, hint input box shown)
+ |
+ done (submitted msg, green checkmark, everything else read-only. If attempts < max, shows
+ a reset button that goes back to initial state. Saves previous
+ submissions too.)
+ """
+
+ DEFAULT_QUEUE = 'open-ended'
+ DEFAULT_MESSAGE_QUEUE = 'open-ended-message'
+ max_inputfields = 1
+
+ STATE_VERSION = 1
+
+ # states
+ INITIAL = 'initial'
+ ASSESSING = 'assessing'
+ POST_ASSESSMENT = 'post_assessment'
+ DONE = 'done'
+
+ #This is used to tell students where they are at in the module
+ HUMAN_NAMES = {
+ 'initial': 'Started',
+ 'assessing': 'Being scored',
+ 'post_assessment': 'Scoring finished',
+ 'done': 'Problem complete',
+ }
+
+ def __init__(self, system, location, definition, descriptor, static_data,
+ instance_state=None, shared_state=None, **kwargs):
+ # Load instance state
+ if instance_state is not None:
+ instance_state = json.loads(instance_state)
+ else:
+ instance_state = {}
+
+ # History is a list of tuples of (answer, score, hint), where hint may be
+ # None for any element, and score and hint can be None for the last (current)
+ # element.
+ # Scores are on scale from 0 to max_score
+ self.history = instance_state.get('history', [])
+
+ self.state = instance_state.get('state', self.INITIAL)
+
+ self.created = instance_state.get('created', False)
+
+ self.attempts = instance_state.get('attempts', 0)
+ self.max_attempts = static_data['max_attempts']
+
+ self.prompt = static_data['prompt']
+ self.rubric = static_data['rubric']
+
+ # Used for progress / grading. Currently get credit just for
+ # completion (doesn't matter if you self-assessed correct/incorrect).
+ self._max_score = static_data['max_score']
+
+ self.setup_response(system, location, definition, descriptor)
+
+ def setup_response(self, system, location, definition, descriptor):
+ """
+ Needs to be implemented by the inheritors of this module. Sets up additional fields used by the child modules.
+ @param system: Modulesystem
+ @param location: Module location
+ @param definition: XML definition
+ @param descriptor: Descriptor of the module
+ @return: None
+ """
+ pass
+
+ def latest_answer(self):
+ """None if not available"""
+ if not self.history:
+ return ""
+ return self.history[-1].get('answer', "")
+
+ def latest_score(self):
+ """None if not available"""
+ if not self.history:
+ return None
+ return self.history[-1].get('score')
+
+ def latest_post_assessment(self):
+ """None if not available"""
+ if not self.history:
+ return ""
+ return self.history[-1].get('post_assessment', "")
+
+ def new_history_entry(self, answer):
+ """
+ Adds a new entry to the history dictionary
+ @param answer: The student supplied answer
+ @return: None
+ """
+ self.history.append({'answer': answer})
+
+ def record_latest_score(self, score):
+ """Assumes that state is right, so we're adding a score to the latest
+ history element"""
+ self.history[-1]['score'] = score
+
+ def record_latest_post_assessment(self, post_assessment):
+ """Assumes that state is right, so we're adding a score to the latest
+ history element"""
+ self.history[-1]['post_assessment'] = post_assessment
+
+ def change_state(self, new_state):
+ """
+ A centralized place for state changes--allows for hooks. If the
+ current state matches the old state, don't run any hooks.
+ """
+ if self.state == new_state:
+ return
+
+ self.state = new_state
+
+ if self.state == self.DONE:
+ self.attempts += 1
+
+ def get_instance_state(self):
+ """
+ Get the current score and state
+ """
+
+ state = {
+ 'version': self.STATE_VERSION,
+ 'history': self.history,
+ 'state': self.state,
+ 'max_score': self._max_score,
+ 'attempts': self.attempts,
+ 'created': False,
+ }
+ return json.dumps(state)
+
+ def _allow_reset(self):
+ """Can the module be reset?"""
+ return (self.state == self.DONE and self.attempts < self.max_attempts)
+
+ def max_score(self):
+ """
+ Return max_score
+ """
+ return self._max_score
+
+ def get_score(self):
+ """
+ Returns the last score in the list
+ """
+ score = self.latest_score()
+ return {'score': score if score is not None else 0,
+ 'total': self._max_score}
+
+ def reset(self, system):
+ """
+ If resetting is allowed, reset the state.
+
+ Returns {'success': bool, 'error': msg}
+ (error only present if not success)
+ """
+ self.change_state(self.INITIAL)
+ return {'success': True}
+
+ def get_progress(self):
+ '''
+ For now, just return last score / max_score
+ '''
+ if self._max_score > 0:
+ try:
+ return Progress(self.get_score()['score'], self._max_score)
+ except Exception as err:
+ log.exception("Got bad progress")
+ return None
+ return None
+
+ def out_of_sync_error(self, get, msg=''):
+ """
+ return dict out-of-sync error message, and also log.
+ """
+ log.warning("Assessment module state out sync. state: %r, get: %r. %s",
+ self.state, get, msg)
+ return {'success': False,
+ 'error': 'The problem state got out-of-sync'}
+
+ def get_html(self):
+ """
+ Needs to be implemented by inheritors. Renders the HTML that students see.
+ @return:
+ """
+ pass
+
+ def handle_ajax(self):
+ """
+ Needs to be implemented by child modules. Handles AJAX events.
+ @return:
+ """
+ pass
+
+ def is_submission_correct(self, score):
+ """
+ Checks to see if a given score makes the answer correct. Very naive right now (>66% is correct)
+ @param score: Numeric score.
+ @return: Boolean correct.
+ """
+ correct = False
+ if(isinstance(score, (int, long, float, complex))):
+ score_ratio = int(score) / float(self.max_score())
+ correct = (score_ratio >= 0.66)
+ return correct
+
+ def is_last_response_correct(self):
+ """
+ Checks to see if the last response in the module is correct.
+ @return: 'correct' if correct, otherwise 'incorrect'
+ """
+ score = self.get_score()['score']
+ correctness = 'correct' if self.is_submission_correct(score) else 'incorrect'
+ return correctness
+
+
+
diff --git a/common/lib/xmodule/xmodule/self_assessment_module.py b/common/lib/xmodule/xmodule/self_assessment_module.py
index eb8a275d35..940b61c557 100644
--- a/common/lib/xmodule/xmodule/self_assessment_module.py
+++ b/common/lib/xmodule/xmodule/self_assessment_module.py
@@ -1,10 +1,3 @@
-"""
-A Self Assessment module that allows students to write open-ended responses,
-submit, then see a rubric and rate themselves. Persists student supplied
-hints, answers, and assessment judgment (currently only correct/incorrect).
-Parses xml definition file--see below for exact format.
-"""
-
import copy
from fs.errors import ResourceNotFoundError
import itertools
@@ -26,205 +19,50 @@ from .stringify import stringify_children
from .x_module import XModule
from .xml_module import XmlDescriptor
from xmodule.modulestore import Location
+import openendedchild
+
+from combined_open_ended_rubric import CombinedOpenEndedRubric
log = logging.getLogger("mitx.courseware")
-# Set the default number of max attempts. Should be 1 for production
-# Set higher for debugging/testing
-# attempts specified in xml definition overrides this.
-MAX_ATTEMPTS = 1
-
-# Set maximum available number of points.
-# Overriden by max_score specified in xml.
-MAX_SCORE = 1
-
-class SelfAssessmentModule(XModule):
+class SelfAssessmentModule(openendedchild.OpenEndedChild):
"""
- States:
+ A Self Assessment module that allows students to write open-ended responses,
+ submit, then see a rubric and rate themselves. Persists student supplied
+ hints, answers, and assessment judgment (currently only correct/incorrect).
+ Parses xml definition file--see below for exact format.
- initial (prompt, textbox shown)
- |
- assessing (read-only textbox, rubric + assessment input shown)
- |
- request_hint (read-only textbox, read-only rubric and assessment, hint input box shown)
- |
- done (submitted msg, green checkmark, everything else read-only. If attempts < max, shows
- a reset button that goes back to initial state. Saves previous
- submissions too.)
+ Sample XML format:
+
+
+ What hint about this problem would you give to someone?
+
+
+ Save Succcesful. Thanks for participating!
+
+
"""
- STATE_VERSION = 1
-
- # states
- INITIAL = 'initial'
- ASSESSING = 'assessing'
- REQUEST_HINT = 'request_hint'
- DONE = 'done'
-
- js = {'coffee': [resource_string(__name__, 'js/src/selfassessment/display.coffee')]}
- js_module_name = "SelfAssessment"
-
- def __init__(self, system, location, definition, descriptor,
- instance_state=None, shared_state=None, **kwargs):
- XModule.__init__(self, system, location, definition, descriptor,
- instance_state, shared_state, **kwargs)
-
+ def setup_response(self, system, location, definition, descriptor):
"""
- Definition file should have 4 blocks -- prompt, rubric, submitmessage, hintprompt,
- and two optional attributes:
- attempts, which should be an integer that defaults to 1.
- If it's > 1, the student will be able to re-submit after they see
- the rubric.
- max_score, which should be an integer that defaults to 1.
- It defines the maximum number of points a student can get. Assumed to be integer scale
- from 0 to max_score, with an interval of 1.
-
- Note: all the submissions are stored.
-
- Sample file:
-
-
-
- Insert prompt text here. (arbitrary html)
-
-
- Insert grading rubric here. (arbitrary html)
-
-
- Please enter a hint below: (arbitrary html)
-
-
- Thanks for submitting! (arbitrary html)
-
-
+ Sets up the module
+ @param system: Modulesystem
+ @param location: location, to let the module know where it is.
+ @param definition: XML definition of the module.
+ @param descriptor: SelfAssessmentDescriptor
+ @return: None
"""
-
- # Load instance state
- if instance_state is not None:
- instance_state = json.loads(instance_state)
- else:
- instance_state = {}
-
- instance_state = self.convert_state_to_current_format(instance_state)
-
- # History is a list of tuples of (answer, score, hint), where hint may be
- # None for any element, and score and hint can be None for the last (current)
- # element.
- # Scores are on scale from 0 to max_score
- self.history = instance_state.get('history', [])
-
- self.state = instance_state.get('state', 'initial')
-
- self.attempts = instance_state.get('attempts', 0)
- self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
-
- # Used for progress / grading. Currently get credit just for
- # completion (doesn't matter if you self-assessed correct/incorrect).
- self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
-
- self.rubric = definition['rubric']
- self.prompt = definition['prompt']
self.submit_message = definition['submitmessage']
self.hint_prompt = definition['hintprompt']
+ self.prompt = stringify_children(self.prompt)
+ self.rubric = stringify_children(self.rubric)
-
- def latest_answer(self):
- """None if not available"""
- if not self.history:
- return None
- return self.history[-1].get('answer')
-
- def latest_score(self):
- """None if not available"""
- if not self.history:
- return None
- return self.history[-1].get('score')
-
- def latest_hint(self):
- """None if not available"""
- if not self.history:
- return None
- return self.history[-1].get('hint')
-
- def new_history_entry(self, answer):
- self.history.append({'answer': answer})
-
- def record_latest_score(self, score):
- """Assumes that state is right, so we're adding a score to the latest
- history element"""
- self.history[-1]['score'] = score
-
- def record_latest_hint(self, hint):
- """Assumes that state is right, so we're adding a score to the latest
- history element"""
- self.history[-1]['hint'] = hint
-
-
- def change_state(self, new_state):
+ def get_html(self, system):
"""
- A centralized place for state changes--allows for hooks. If the
- current state matches the old state, don't run any hooks.
+ Gets context and renders HTML that represents the module
+ @param system: Modulesystem
+ @return: Rendered HTML
"""
- if self.state == new_state:
- return
-
- self.state = new_state
-
- if self.state == self.DONE:
- self.attempts += 1
-
- @staticmethod
- def convert_state_to_current_format(old_state):
- """
- This module used to use a problematic state representation. This method
- converts that into the new format.
-
- Args:
- old_state: dict of state, as passed in. May be old.
-
- Returns:
- new_state: dict of new state
- """
- if old_state.get('version', 0) == SelfAssessmentModule.STATE_VERSION:
- # already current
- return old_state
-
- # for now, there's only one older format.
-
- new_state = {'version': SelfAssessmentModule.STATE_VERSION}
-
- def copy_if_present(key):
- if key in old_state:
- new_state[key] = old_state[key]
-
- for to_copy in ['attempts', 'state']:
- copy_if_present(to_copy)
-
- # The answers, scores, and hints need to be kept together to avoid them
- # getting out of sync.
-
- # NOTE: Since there's only one problem with a few hundred submissions
- # in production so far, not trying to be smart about matching up hints
- # and submissions in cases where they got out of sync.
-
- student_answers = old_state.get('student_answers', [])
- scores = old_state.get('scores', [])
- hints = old_state.get('hints', [])
-
- new_state['history'] = [
- {'answer': answer,
- 'score': score,
- 'hint': hint}
- for answer, score, hint in itertools.izip_longest(
- student_answers, scores, hints)]
- return new_state
-
-
- def _allow_reset(self):
- """Can the module be reset?"""
- return self.state == self.DONE and self.attempts < self.max_attempts
-
- def get_html(self):
#set context variables and render template
if self.state != self.INITIAL:
latest = self.latest_answer()
@@ -235,46 +73,20 @@ class SelfAssessmentModule(XModule):
context = {
'prompt': self.prompt,
'previous_answer': previous_answer,
- 'ajax_url': self.system.ajax_url,
- 'initial_rubric': self.get_rubric_html(),
- 'initial_hint': self.get_hint_html(),
+ 'ajax_url': system.ajax_url,
+ 'initial_rubric': self.get_rubric_html(system),
+ 'initial_hint': self.get_hint_html(system),
'initial_message': self.get_message_html(),
'state': self.state,
'allow_reset': self._allow_reset(),
+ 'child_type': 'selfassessment',
}
- html = self.system.render_template('self_assessment_prompt.html', context)
- # cdodge: perform link substitutions for any references to course static content (e.g. images)
- return rewrite_links(html, self.rewrite_content_links)
-
- def max_score(self):
- """
- Return max_score
- """
- return self._max_score
-
- def get_score(self):
- """
- Returns the last score in the list
- """
- score = self.latest_score()
- return {'score': score if score is not None else 0,
- 'total': self._max_score}
-
- def get_progress(self):
- '''
- For now, just return last score / max_score
- '''
- if self._max_score > 0:
- try:
- return Progress(self.get_score()['score'], self._max_score)
- except Exception as err:
- log.exception("Got bad progress")
- return None
- return None
+ html = system.render_template('self_assessment_prompt.html', context)
+ return html
- def handle_ajax(self, dispatch, get):
+ def handle_ajax(self, dispatch, get, system):
"""
This is called by courseware.module_render, to handle an AJAX call.
"get" is request.POST.
@@ -288,15 +100,14 @@ class SelfAssessmentModule(XModule):
handlers = {
'save_answer': self.save_answer,
'save_assessment': self.save_assessment,
- 'save_hint': self.save_hint,
- 'reset': self.reset,
+ 'save_post_assessment': self.save_hint,
}
if dispatch not in handlers:
return 'Error'
before = self.get_progress()
- d = handlers[dispatch](get)
+ d = handlers[dispatch](get, system)
after = self.get_progress()
d.update({
'progress_changed': after != before,
@@ -304,37 +115,30 @@ class SelfAssessmentModule(XModule):
})
return json.dumps(d, cls=ComplexEncoder)
- def out_of_sync_error(self, get, msg=''):
- """
- return dict out-of-sync error message, and also log.
- """
- log.warning("Assessment module state out sync. state: %r, get: %r. %s",
- self.state, get, msg)
- return {'success': False,
- 'error': 'The problem state got out-of-sync'}
-
- def get_rubric_html(self):
+ def get_rubric_html(self, system):
"""
Return the appropriate version of the rubric, based on the state.
"""
if self.state == self.INITIAL:
return ''
+ rubric_html = CombinedOpenEndedRubric.render_rubric(self.rubric)
+
# we'll render it
- context = {'rubric': self.rubric,
- 'max_score' : self._max_score,
- }
+ context = {'rubric': rubric_html,
+ 'max_score': self._max_score,
+ }
if self.state == self.ASSESSING:
context['read_only'] = False
- elif self.state in (self.REQUEST_HINT, self.DONE):
+ elif self.state in (self.POST_ASSESSMENT, self.DONE):
context['read_only'] = True
else:
raise ValueError("Illegal state '%r'" % self.state)
- return self.system.render_template('self_assessment_rubric.html', context)
+ return system.render_template('self_assessment_rubric.html', context)
- def get_hint_html(self):
+ def get_hint_html(self, system):
"""
Return the appropriate version of the hint view, based on state.
"""
@@ -343,7 +147,7 @@ class SelfAssessmentModule(XModule):
if self.state == self.DONE:
# display the previous hint
- latest = self.latest_hint()
+ latest = self.latest_post_assessment()
hint = latest if latest is not None else ''
else:
hint = ''
@@ -351,14 +155,14 @@ class SelfAssessmentModule(XModule):
context = {'hint_prompt': self.hint_prompt,
'hint': hint}
- if self.state == self.REQUEST_HINT:
+ if self.state == self.POST_ASSESSMENT:
context['read_only'] = False
elif self.state == self.DONE:
context['read_only'] = True
else:
raise ValueError("Illegal state '%r'" % self.state)
- return self.system.render_template('self_assessment_hint.html', context)
+ return system.render_template('self_assessment_hint.html', context)
def get_message_html(self):
"""
@@ -370,7 +174,7 @@ class SelfAssessmentModule(XModule):
return """
{0}
""".format(self.submit_message)
- def save_answer(self, get):
+ def save_answer(self, get, system):
"""
After the answer is submitted, show the rubric.
@@ -401,10 +205,10 @@ class SelfAssessmentModule(XModule):
return {
'success': True,
- 'rubric_html': self.get_rubric_html()
- }
+ 'rubric_html': self.get_rubric_html(system)
+ }
- def save_assessment(self, get):
+ def save_assessment(self, get, system):
"""
Save the assessment. If the student said they're right, don't ask for a
hint, and go straight to the done state. Otherwise, do ask for a hint.
@@ -429,21 +233,20 @@ class SelfAssessmentModule(XModule):
self.record_latest_score(score)
- d = {'success': True,}
+ d = {'success': True, }
if score == self.max_score():
self.change_state(self.DONE)
d['message_html'] = self.get_message_html()
d['allow_reset'] = self._allow_reset()
else:
- self.change_state(self.REQUEST_HINT)
- d['hint_html'] = self.get_hint_html()
+ self.change_state(self.POST_ASSESSMENT)
+ d['hint_html'] = self.get_hint_html(system)
d['state'] = self.state
return d
-
- def save_hint(self, get):
+ def save_hint(self, get, system):
'''
Save the hint.
Returns a dict { 'success': bool,
@@ -453,63 +256,19 @@ class SelfAssessmentModule(XModule):
with the error key only present if success is False and message_html
only if True.
'''
- if self.state != self.REQUEST_HINT:
+ if self.state != self.POST_ASSESSMENT:
# Note: because we only ask for hints on wrong answers, may not have
# the same number of hints and answers.
return self.out_of_sync_error(get)
- self.record_latest_hint(get['hint'])
+ self.record_latest_post_assessment(get['hint'])
self.change_state(self.DONE)
- # To the tracking logs!
- event_info = {
- 'selfassessment_id': self.location.url(),
- 'state': {
- 'version': self.STATE_VERSION,
- 'history': self.history,
- }
- }
- self.system.track_function('save_hint', event_info)
-
return {'success': True,
'message_html': self.get_message_html(),
'allow_reset': self._allow_reset()}
- def reset(self, get):
- """
- If resetting is allowed, reset the state.
-
- Returns {'success': bool, 'error': msg}
- (error only present if not success)
- """
- if self.state != self.DONE:
- return self.out_of_sync_error(get)
-
- if self.attempts > self.max_attempts:
- return {
- 'success': False,
- 'error': 'Too many attempts.'
- }
- self.change_state(self.INITIAL)
- return {'success': True}
-
-
- def get_instance_state(self):
- """
- Get the current score and state
- """
-
- state = {
- 'version': self.STATE_VERSION,
- 'history': self.history,
- 'state': self.state,
- 'max_score': self._max_score,
- 'attempts': self.attempts,
- }
- return json.dumps(state)
-
-
class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
"""
Module for adding self assessment questions to courses
@@ -532,13 +291,11 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
Returns:
{
- 'rubric': 'some-html',
- 'prompt': 'some-html',
'submitmessage': 'some-html'
'hintprompt': 'some-html'
}
"""
- expected_children = ['rubric', 'prompt', 'submitmessage', 'hintprompt']
+ expected_children = ['submitmessage', 'hintprompt']
for child in expected_children:
if len(xml_object.xpath(child)) != 1:
raise ValueError("Self assessment definition must include exactly one '{0}' tag".format(child))
@@ -547,12 +304,9 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
"""Assumes that xml_object has child k"""
return stringify_children(xml_object.xpath(k)[0])
- return {'rubric': parse('rubric'),
- 'prompt': parse('prompt'),
- 'submitmessage': parse('submitmessage'),
+ return {'submitmessage': parse('submitmessage'),
'hintprompt': parse('hintprompt'),
- }
-
+ }
def definition_to_xml(self, resource_fs):
'''Return an xml element representing this definition.'''
@@ -563,7 +317,7 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
child_node = etree.fromstring(child_str)
elt.append(child_node)
- for child in ['rubric', 'prompt', 'submitmessage', 'hintprompt']:
+ for child in ['submitmessage', 'hintprompt']:
add_child(child)
return elt
diff --git a/jenkins/quality.sh b/jenkins/quality.sh
index 4cf26d76bf..56217af874 100755
--- a/jenkins/quality.sh
+++ b/jenkins/quality.sh
@@ -3,6 +3,8 @@
set -e
set -x
+git remote prune origin
+
# Reset the submodule, in case it changed
git submodule foreach 'git reset --hard HEAD'
diff --git a/jenkins/test.sh b/jenkins/test.sh
index 8a96024785..7a61e914b7 100755
--- a/jenkins/test.sh
+++ b/jenkins/test.sh
@@ -15,6 +15,8 @@ function github_mark_failed_on_exit {
trap '[ $? == "0" ] || github_status state:failure "failed"' EXIT
}
+git remote prune origin
+
github_mark_failed_on_exit
github_status state:pending "is running"
diff --git a/lms/djangoapps/open_ended_grading/grading_service.py b/lms/djangoapps/open_ended_grading/grading_service.py
index 96bd931448..7362411daa 100644
--- a/lms/djangoapps/open_ended_grading/grading_service.py
+++ b/lms/djangoapps/open_ended_grading/grading_service.py
@@ -62,6 +62,7 @@ class GradingService(object):
"""
Make a get request to the grading controller
"""
+ log.debug(params)
op = lambda: self.session.get(url,
allow_redirects=allow_redirects,
params=params)
diff --git a/lms/djangoapps/open_ended_grading/peer_grading_service.py b/lms/djangoapps/open_ended_grading/peer_grading_service.py
index 859499ff7e..9ef0383fb5 100644
--- a/lms/djangoapps/open_ended_grading/peer_grading_service.py
+++ b/lms/djangoapps/open_ended_grading/peer_grading_service.py
@@ -81,7 +81,7 @@ class PeerGradingService(GradingService):
self.get_problem_list_url = self.url + '/get_problem_list/'
def get_next_submission(self, problem_location, grader_id):
- response = self.get(self.get_next_submission_url, False,
+ response = self.get(self.get_next_submission_url,
{'location': problem_location, 'grader_id': grader_id})
return response
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 0516bddc56..7b8c48f4af 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -76,8 +76,8 @@ DATABASES = AUTH_TOKENS['DATABASES']
XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE']
-STAFF_GRADING_INTERFACE = AUTH_TOKENS.get('STAFF_GRADING_INTERFACE')
-
+STAFF_GRADING_INTERFACE = AUTH_TOKENS.get('STAFF_GRADING_INTERFACE', STAFF_GRADING_INTERFACE)
+PEER_GRADING_INTERFACE = AUTH_TOKENS.get('PEER_GRADING_INTERFACE', PEER_GRADING_INTERFACE)
PEARSON_TEST_USER = "pearsontest"
PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD")
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 88cf09502d..0364a8b6f8 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -329,12 +329,28 @@ WIKI_LINK_DEFAULT_LEVEL = 2
################################# Staff grading config #####################
-STAFF_GRADING_INTERFACE = None
+#By setting up the default settings with an incorrect user name and password,
+# will get an error when attempting to connect
+STAFF_GRADING_INTERFACE = {
+ 'url': 'http://sandbox-grader-001.m.edx.org/staff_grading',
+ 'username': 'incorrect_user',
+ 'password': 'incorrect_pass',
+ }
+
# Used for testing, debugging
MOCK_STAFF_GRADING = False
################################# Peer grading config #####################
-PEER_GRADING_INTERFACE = None
+
+#By setting up the default settings with an incorrect user name and password,
+# will get an error when attempting to connect
+PEER_GRADING_INTERFACE = {
+ 'url': 'http://sandbox-grader-001.m.edx.org/peer_grading',
+ 'username': 'incorrect_user',
+ 'password': 'incorrect_pass',
+ }
+
+# Used for testing, debugging
MOCK_PEER_GRADING = False
################################# Jasmine ###################################
diff --git a/lms/templates/combined_open_ended.html b/lms/templates/combined_open_ended.html
new file mode 100644
index 0000000000..71c22085e3
--- /dev/null
+++ b/lms/templates/combined_open_ended.html
@@ -0,0 +1,22 @@
+
+
+
\ No newline at end of file
diff --git a/lms/templates/combined_open_ended_status.html b/lms/templates/combined_open_ended_status.html
new file mode 100644
index 0000000000..34a5dd0d79
--- /dev/null
+++ b/lms/templates/combined_open_ended_status.html
@@ -0,0 +1,28 @@
+
+ %for i in xrange(0,len(status_list)):
+ <%status=status_list[i]%>
+ %if i==len(status_list)-1:
+
+ %else:
+
+ %endif
+
+ Step ${status['task_number']} (${status['human_state']}) : ${status['score']} / ${status['max_score']}
+ % if status['state'] == 'initial':
+
+ % elif status['state'] in ['done', 'post_assessment'] and status['correct'] == 'correct':
+
+ % elif status['state'] in ['done', 'post_assessment'] and status['correct'] == 'incorrect':
+
+ % elif status['state'] == 'assessing':
+
+ % endif
+
+ %if status['type']=="openended" and status['state'] in ['done', 'post_assessment']:
+
\ No newline at end of file
diff --git a/lms/templates/open_ended_feedback.html b/lms/templates/open_ended_feedback.html
index cb90006456..d8aa3d1a9e 100644
--- a/lms/templates/open_ended_feedback.html
+++ b/lms/templates/open_ended_feedback.html
@@ -12,5 +12,6 @@
${ feedback | n}
+ ${rubric_feedback | n}
\ No newline at end of file
diff --git a/lms/templates/open_ended_rubric.html b/lms/templates/open_ended_rubric.html
new file mode 100644
index 0000000000..9f8a2ece4e
--- /dev/null
+++ b/lms/templates/open_ended_rubric.html
@@ -0,0 +1,30 @@
+
+ % for i in range(len(rubric_categories)):
+ <% category = rubric_categories[i] %>
+
+ % for j in range(len(category['options'])):
+ <% option = category['options'][j] %>
+
+
+ ${option['text']}
+ % if option.has_key('selected'):
+ % if option['selected'] == True:
+
[${option['points']} points]
+ %else:
+
[${option['points']} points]
+ % endif
+ % else:
+
[${option['points']} points]
+ %endif
+
+
+ % endfor
+
+ % endfor
+
\ No newline at end of file
diff --git a/lms/templates/self_assessment_hint.html b/lms/templates/self_assessment_hint.html
index 64c45b809e..1adfc69e39 100644
--- a/lms/templates/self_assessment_hint.html
+++ b/lms/templates/self_assessment_hint.html
@@ -2,6 +2,6 @@