perform a new merge from master, resolve conflicts

This commit is contained in:
Chris Dodge
2013-01-03 10:43:03 -05:00
parent 0e3a022086
commit 7229a9cdaf
90 changed files with 3529 additions and 491 deletions

View File

@@ -7,6 +7,7 @@ source = common/lib/capa
ignore_errors = True
[html]
title = Capa Python Test Coverage Report
directory = reports/common/lib/capa/cover
[xml]

View File

@@ -53,7 +53,7 @@ response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
solution_tags = ['solution']
# these get captured as student responses
response_properties = ["codeparam", "responseparam", "answer"]
response_properties = ["codeparam", "responseparam", "answer", "openendedparam"]
# special problem tags which should be turned into innocuous HTML
html_transforms = {'problem': {'tag': 'div'},
@@ -72,7 +72,7 @@ global_context = {'random': random,
'miller': chem.miller}
# These should be removed from HTML output, including all subelements
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"]
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam","openendedrubric"]
log = logging.getLogger('mitx.' + __name__)

View File

@@ -733,3 +733,53 @@ class ChemicalEquationInput(InputTypeBase):
return {'previewer': '/static/js/capa/chemical_equation_preview.js',}
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)
#-----------------------------------------------------------------------------

View File

@@ -8,21 +8,23 @@ Used by capa_problem.py
'''
# standard library imports
import abc
import cgi
import hashlib
import inspect
import json
import logging
import numbers
import numpy
import os
import random
import re
import requests
import traceback
import hashlib
import abc
import os
import subprocess
import traceback
import xml.sax.saxutils as saxutils
from collections import namedtuple
from shapely.geometry import Point, MultiPoint
# specific library imports
@@ -1105,6 +1107,15 @@ class SymbolicResponse(CustomResponse):
#-----------------------------------------------------------------------------
"""
valid: Flag indicating valid score_msg format (Boolean)
correct: Correctness of submission (Boolean)
score: Points to be assigned (numeric, can be float)
msg: Message from grader to display to student (string)
"""
ScoreMessage = namedtuple('ScoreMessage',
['valid', 'correct', 'points', 'msg'])
class CodeResponse(LoncapaResponse):
"""
@@ -1144,7 +1155,7 @@ class CodeResponse(LoncapaResponse):
else:
self._parse_coderesponse_xml(codeparam)
def _parse_coderesponse_xml(self,codeparam):
def _parse_coderesponse_xml(self, codeparam):
'''
Parse the new CodeResponse XML format. When successful, sets:
self.initial_display
@@ -1156,17 +1167,9 @@ class CodeResponse(LoncapaResponse):
grader_payload = grader_payload.text if grader_payload is not None else ''
self.payload = {'grader_payload': grader_payload}
answer_display = codeparam.find('answer_display')
if answer_display is not None:
self.answer = answer_display.text
else:
self.answer = 'No answer provided.'
initial_display = codeparam.find('initial_display')
if initial_display is not None:
self.initial_display = initial_display.text
else:
self.initial_display = ''
self.initial_display = find_with_default(codeparam, 'initial_display', '')
self.answer = find_with_default(codeparam, 'answer_display',
'No answer provided.')
def _parse_externalresponse_xml(self):
'''
@@ -1732,9 +1735,9 @@ class ImageResponse(LoncapaResponse):
Regions is list of lists [region1, region2, region3, ...] where regionN
is disordered list of points: [[1,1], [100,100], [50,50], [20, 70]].
If there is only one region in the list, simpler notation can be used:
regions="[[10,10], [30,30], [10, 30], [30, 10]]" (without explicitly
regions="[[10,10], [30,30], [10, 30], [30, 10]]" (without explicitly
setting outer list)
Returns:
@@ -1816,6 +1819,347 @@ 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'
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)
# The openendedparam tag encapsulates all grader settings
oeparam = self.xml.find('openendedparam')
prompt = self.xml.find('prompt')
rubric = self.xml.find('openendedrubric')
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)
parsed_grader_payload.update({
'location' : self.system.location,
'course_id' : self.system.course_id,
'prompt' : prompt_string,
'rubric' : rubric_string,
})
updated_grader_payload = json.dumps(parsed_grader_payload)
self.payload = {'grader_payload': updated_grader_payload}
self.initial_display = find_with_default(oeparam, 'initial_display', '')
self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.')
try:
self.max_score = int(find_with_default(oeparam, 'max_score', 1))
except ValueError:
self.max_score = 1
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('&nbsp;', '&#160;'), 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 = '<span class="openended-answer"><pre><code>{0}</code></pre></span>'.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 format_feedback(feedback_type, value):
return """
<div class="{feedback_type}">
{value}
</div>
""".format(feedback_type=feedback_type, value=value)
# 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']:
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)
return u"\n".join(format_feedback(k, v) for k, v in feedback_lst)
else:
return format_feedback('errors', response_items['feedback'])
def _format_feedback(self, response_items):
"""
Input:
Dictionary called feedback. Must contain keys seen below.
Output:
Return error message or feedback template
"""
feedback = self._convert_longform_feedback_to_html(response_items)
if not response_items['success']:
return self.system.render_template("open_ended_error.html",
{'errors' : feedback})
feedback_template = self.system.render_template("open_ended_feedback.html", {
'grader_type': response_items['grader_type'],
'score': response_items['score'],
'feedback': feedback,
})
return feedback_template
def _parse_score_msg(self, score_msg):
"""
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 = ScoreMessage(valid=False, correct=False, points=0, msg='')
try:
score_result = json.loads(score_msg)
except (TypeError, ValueError):
log.error("External grader message should be a JSON-serialized dict."
" Received score_msg = {0}".format(score_msg))
return fail
if not isinstance(score_result, dict):
log.error("External grader message should be a JSON-serialized dict."
" Received score_result = {0}".format(score_result))
return fail
for tag in ['score', 'feedback', 'grader_type', 'success']:
if tag not in score_result:
log.error("External grader message is missing required tag: {0}"
.format(tag))
return fail
feedback = self._format_feedback(score_result)
# HACK: for now, just assume it's correct if you got more than 2/3.
# Also assumes that score_result['score'] is an integer.
score_ratio = int(score_result['score']) / self.max_score
correct = (score_ratio >= 0.66)
#Currently ignore msg and only return feedback (which takes the place of msg)
return ScoreMessage(valid=True, correct=correct,
points=score_result['score'], msg=feedback)
#-----------------------------------------------------------------------------
# TEMPORARY: List of all response subclasses
# FIXME: To be replaced by auto-registration
@@ -1832,4 +2176,5 @@ __all__ = [CodeResponse,
ChoiceResponse,
MultipleChoiceResponse,
TrueFalseResponse,
JavascriptResponse]
JavascriptResponse,
OpenEndedResponse]

View File

@@ -0,0 +1,32 @@
<section id="openended_${id}" class="openended">
<textarea rows="${rows}" cols="${cols}" name="input_${id}" class="short-form-response" id="input_${id}"
% if hidden:
style="display:none;"
% endif
>${value|h}</textarea>
<div class="grader-status">
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
% elif status == 'correct':
<span class="correct" id="status_${id}">Correct</span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}">Incorrect</span>
% elif status == 'queued':
<span class="grading" id="status_${id}">Submitted for grading</span>
% endif
% if hidden:
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
% endif
</div>
<span id="answer_${id}"></span>
% if status == 'queued':
<input name="reload" class="reload" type="button" value="Recheck for Feedback" onclick="document.location.reload(true);" />
% endif
<div class="external-grader-message">
${msg|n}
</div>
</section>

View File

@@ -65,3 +65,25 @@ def is_file(file_to_test):
Duck typing to check if 'file_to_test' is a File object
'''
return all(hasattr(file_to_test, method) for method in ['read', 'name'])
def find_with_default(node, path, default):
"""
Look for a child of node using , and return its text if found.
Otherwise returns default.
Arguments:
node: lxml node
path: xpath search expression
default: value to return if nothing found
Returns:
node.find(path).text if the find succeeds, default otherwise.
"""
v = node.find(path)
if v is not None:
return v.text
else:
return default

View File

@@ -49,6 +49,7 @@ def parse_xreply(xreply):
return_code = xreply['return_code']
content = xreply['content']
return (return_code, content)
@@ -80,7 +81,11 @@ class XQueueInterface(object):
# Log in, then try again
if error and (msg == 'login_required'):
self._login()
(error, content) = self._login()
if error != 0:
# when the login fails
log.debug("Failed to login to queue: %s", content)
return (error, content)
if files_to_upload is not None:
# Need to rewind file pointers
for f in files_to_upload: