Commit 24f85bf2 by Calen Pennington

Merge pull request #340 from MITx/kimth/lms-coderesponse

Kimth/lms coderesponse
parents 291cbac8 110637c0
......@@ -31,7 +31,7 @@ import calc
from correctmap import CorrectMap
import eia
import inputtypes
from util import contextualize_text
from util import contextualize_text, convert_files_to_filenames
# to be replaced with auto-registering
import responsetypes
......@@ -39,7 +39,7 @@ import responsetypes
# dict of tagname, Response Class -- this should come from auto-registering
response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
entry_types = ['textline', 'schematic', 'textbox', 'imageinput', 'optioninput', 'choicegroup', 'radiogroup', 'checkboxgroup']
entry_types = ['textline', 'schematic', 'textbox', 'imageinput', 'optioninput', 'choicegroup', 'radiogroup', 'checkboxgroup', 'filesubmission']
solution_types = ['solution'] # extra things displayed after "show answers" is pressed
response_properties = ["responseparam", "answer"] # these get captured as student responses
......@@ -228,12 +228,18 @@ class LoncapaProblem(object):
Calls the Response for each question in this problem, to do the actual grading.
'''
self.student_answers = answers
self.student_answers = convert_files_to_filenames(answers)
oldcmap = self.correct_map # old CorrectMap
newcmap = CorrectMap() # start new with empty CorrectMap
# log.debug('Responders: %s' % self.responders)
for responder in self.responders.values():
results = responder.evaluate_answers(answers, oldcmap) # call the responsetype instance to do the actual grading
for responder in self.responders.values(): # Call each responsetype instance to do actual grading
if 'filesubmission' in responder.allowed_inputfields: # File objects are passed only if responsetype
# explicitly allows for file submissions
results = responder.evaluate_answers(answers, oldcmap)
else:
results = responder.evaluate_answers(convert_files_to_filenames(answers), oldcmap)
newcmap.update(results)
self.correct_map = newcmap
# log.debug('%s: in grade_answers, answers=%s, cmap=%s' % (self,answers,newcmap))
......
......@@ -13,6 +13,7 @@ Module containing the problem elements which render into input objects
- checkboxgroup
- imageinput (for clickable image)
- optioninput (for option list)
- filesubmission (upload a file)
These are matched by *.html files templates/*.html which are mako templates with the actual html.
......@@ -300,6 +301,18 @@ def textline_dynamath(element, value, status, render_template, msg=''):
#-----------------------------------------------------------------------------
@register_render_function
def filesubmission(element, value, status, render_template, msg=''):
'''
Upload a single file (e.g. for programming assignments)
'''
eid = element.get('id')
context = { 'id': eid, 'state': status, 'msg': msg, 'value': value, }
html = render_template("filesubmission.html", context)
return etree.XML(html)
#-----------------------------------------------------------------------------
## TODO: Make a wrapper for <codeinput>
@register_render_function
def textbox(element, value, status, render_template, msg=''):
......
......@@ -8,7 +8,6 @@ Used by capa_problem.py
'''
# standard library imports
import hashlib
import inspect
import json
import logging
......@@ -17,7 +16,6 @@ import numpy
import random
import re
import requests
import time
import traceback
import abc
......@@ -27,9 +25,12 @@ from correctmap import CorrectMap
from util import *
from lxml import etree
from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME?
import xqueue_interface
log = logging.getLogger('mitx.' + __name__)
qinterface = xqueue_interface.XqueueInterface()
#-----------------------------------------------------------------------------
# Exceptions
......@@ -162,7 +163,7 @@ class LoncapaResponse(object):
Returns the new CorrectMap, with (correctness,msg,hint,hintmode) for each answer_id.
'''
new_cmap = self.get_score(student_answers)
self.get_hints(student_answers, new_cmap, old_cmap)
self.get_hints(convert_files_to_filenames(student_answers), new_cmap, old_cmap)
# log.debug('new_cmap = %s' % new_cmap)
return new_cmap
......@@ -798,19 +799,18 @@ class SymbolicResponse(CustomResponse):
class CodeResponse(LoncapaResponse):
'''
Grade student code using an external server, called 'xqueue'
In contrast to ExternalResponse, CodeResponse has following behavior:
1) Goes through a queueing system
2) Does not do external request for 'get_answers'
Grade student code using an external queueing server, called 'xqueue'
External requests are only submitted for student submission grading
(i.e. and not for getting reference answers)
'''
response_tag = 'coderesponse'
allowed_inputfields = ['textline', 'textbox']
allowed_inputfields = ['textbox', 'filesubmission']
max_inputfields = 1
def setup_response(self):
xml = self.xml
self.url = xml.get('url', "http://107.20.215.194/xqueue/submit/") # FIXME -- hardcoded url
self.queue_name = xml.get('queuename', self.system.xqueue_default_queuename)
answer = xml.find('answer')
......@@ -849,19 +849,49 @@ class CodeResponse(LoncapaResponse):
def get_score(self, student_answers):
try:
submission = student_answers[self.answer_id]
submission = student_answers[self.answer_id] # Note that submission can be a file
except Exception as err:
log.error('Error in CodeResponse %s: cannot get student answer for %s; student_answers=%s' % (err, self.answer_id, student_answers))
log.error('Error in CodeResponse %s: cannot get student answer for %s; student_answers=%s' %
(err, self.answer_id, convert_files_to_filenames(student_answers)))
raise Exception(err)
self.context.update({'submission': submission})
extra_payload = {'edX_student_response': submission}
r, queuekey = self._send_to_queue(extra_payload) # TODO: Perform checks on the xqueue response
self.context.update({'submission': unicode(submission)})
# Prepare xqueue request
#------------------------------------------------------------
# Generate header
queuekey = xqueue_interface.make_hashkey(self.system.seed)
xheader = xqueue_interface.make_xheader(lms_callback_url=self.system.xqueue_callback_url,
lms_key=queuekey,
queue_name=self.queue_name)
# Generate body
# NOTE: Currently specialized to 6.00x's pyxserver, which follows the ExternalResponse interface
# We should define a common interface for external code graders to CodeResponse
contents = {'xml': etree.tostring(self.xml, pretty_print=True),
'edX_cmd': 'get_score',
'edX_tests': self.tests,
'processor': self.code,
'edX_student_response': unicode(submission), # unicode on File object returns its filename
}
# Submit request
if hasattr(submission, 'read'): # Test for whether submission is a file
(error, msg) = qinterface.send_to_queue(header=xheader,
body=json.dumps(contents),
file_to_upload=submission)
else:
(error, msg) = qinterface.send_to_queue(header=xheader,
body=json.dumps(contents))
# Non-null CorrectMap['queuekey'] indicates that the problem has been submitted
cmap = CorrectMap()
cmap.set(self.answer_id, queuekey=queuekey, msg='Submitted to queue')
cmap = CorrectMap()
if error:
cmap.set(self.answer_id, queuekey=None,
msg='Unable to deliver your submission to grader. (Reason: %s.) Please try again later.' % msg)
else:
# Non-null CorrectMap['queuekey'] indicates that the problem has been queued
cmap.set(self.answer_id, queuekey=queuekey, msg='Submitted to grader')
return cmap
......@@ -883,17 +913,15 @@ class CodeResponse(LoncapaResponse):
self.context['correct'][0] = admap[ad]
# Replace 'oldcmap' with new grading results if queuekey matches.
# If queuekey does not match, we keep waiting for the score_msg that will match
# 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):
msg = rxml.find('message').text.replace('&nbsp;', '&#160;')
oldcmap.set(self.answer_id, correctness=self.context['correct'][0], msg=msg, queuekey=None) # Queuekey is consumed
else:
log.debug('CodeResponse: queuekey %d does not match for answer_id=%s.' % (queuekey, self.answer_id))
log.debug('CodeResponse: queuekey %s does not match for answer_id=%s.' % (queuekey, self.answer_id))
return oldcmap
# CodeResponse differentiates from ExternalResponse in the behavior of 'get_answers'. CodeResponse.get_answers
# does NOT require a queue submission, and the answer is computed (extracted from problem XML) locally.
def get_answers(self):
anshtml = '<font color="blue"><span class="code-answer"><br/><pre>%s</pre><br/></span></font>' % self.answer
return {self.answer_id: anshtml}
......@@ -901,41 +929,6 @@ class CodeResponse(LoncapaResponse):
def get_initial_display(self):
return {self.answer_id: self.initial_display}
# CodeResponse._send_to_queue implements the same interface as defined for ExternalResponse's 'get_score'
def _send_to_queue(self, extra_payload):
# Prepare payload
xmlstr = etree.tostring(self.xml, pretty_print=True)
header = {'lms_callback_url': self.system.xqueue_callback_url,
'queue_name': self.queue_name,
}
# Queuekey generation
h = hashlib.md5()
h.update(str(self.system.seed))
h.update(str(time.time()))
queuekey = int(h.hexdigest(), 16)
header.update({'lms_key': queuekey})
body = {'xml': xmlstr,
'edX_cmd': 'get_score',
'edX_tests': self.tests,
'processor': self.code,
}
body.update(extra_payload)
payload = {'xqueue_header': json.dumps(header),
'xqueue_body' : json.dumps(body),
}
# Contact queue server
try:
r = requests.post(self.url, data=payload)
except Exception as err:
msg = "Error in CodeResponse %s: cannot connect to queue server url=%s" % (err, self.url)
log.error(msg)
raise Exception(msg)
return r, queuekey
#-----------------------------------------------------------------------------
......
<section id="filesubmission_${id}" class="filesubmission">
<input type="file" name="input_${id}" id="input_${id}" value="${value}" /><br />
% if state == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif state == 'correct':
<span class="correct" id="status_${id}"></span>
% elif state == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
% elif state == 'incomplete':
<span class="incorrect" id="status_${id}"></span>
% endif
<span class="debug">(${state})</span>
<br/>
<span class="message">${msg|n}</span>
<br/>
</section>
......@@ -30,3 +30,14 @@ def contextualize_text(text, context): # private
for key in sorted(context, lambda x, y: cmp(len(y), len(x))):
text = text.replace('$' + key, str(context[key]))
return text
def convert_files_to_filenames(answers):
'''
Check for File objects in the dict of submitted answers,
convert File objects to their filename (string)
'''
new_answers = dict()
for answer_id in answers.keys():
new_answers[answer_id] = unicode(answers[answer_id])
return new_answers
#
# LMS Interface to external queueing system (xqueue)
#
import hashlib
import json
import logging
import requests
import time
# TODO: Collection of parameters to be hooked into rest of edX system
XQUEUE_LMS_AUTH = { 'username': 'LMS',
'password': 'PaloAltoCA' }
XQUEUE_URL = 'http://xqueue.edx.org'
log = logging.getLogger('mitx.' + __name__)
def make_hashkey(seed=None):
'''
Generate a string key by hashing
'''
h = hashlib.md5()
if seed is not None:
h.update(str(seed))
h.update(str(time.time()))
return h.hexdigest()
def make_xheader(lms_callback_url, lms_key, queue_name):
'''
Generate header for delivery and reply of queue request.
Xqueue header is a JSON-serialized dict:
{ 'lms_callback_url': url to which xqueue will return the request (string),
'lms_key': secret key used by LMS to protect its state (string),
'queue_name': designate a specific queue within xqueue server, e.g. 'MITx-6.00x' (string)
}
'''
return json.dumps({ 'lms_callback_url': lms_callback_url,
'lms_key': lms_key,
'queue_name': queue_name })
def parse_xreply(xreply):
'''
Parse the reply from xqueue. Messages are JSON-serialized dict:
{ 'return_code': 0 (success), 1 (fail)
'content': Message from xqueue (string)
}
'''
xreply = json.loads(xreply)
return_code = xreply['return_code']
content = xreply['content']
return (return_code, content)
class XqueueInterface:
'''
Interface to the external grading system
'''
def __init__(self, url=XQUEUE_URL, auth=XQUEUE_LMS_AUTH):
self.url = url
self.auth = auth
self.s = requests.session()
self._login()
def send_to_queue(self, header, body, file_to_upload=None):
'''
Submit a request to xqueue.
header: JSON-serialized dict in the format described in 'xqueue_interface.make_xheader'
body: Serialized data for the receipient behind the queueing service. The operation of
xqueue is agnostic to the contents of 'body'
file_to_upload: File object to be uploaded to xqueue along with queue request
Returns (error_code, msg) where error_code != 0 indicates an error
'''
# Attempt to send to queue
(error, msg) = self._send_to_queue(header, body, file_to_upload)
if error and (msg == 'login_required'): # Log in, then try again
self._login()
(error, msg) = self._send_to_queue(header, body, file_to_upload)
return (error, msg)
def _login(self):
try:
r = self.s.post(self.url+'/xqueue/login/', data={ 'username': self.auth['username'],
'password': self.auth['password'] })
except requests.exceptions.ConnectionError, err:
log.error(err)
return (1, 'cannot connect to server')
return parse_xreply(r.text)
def _send_to_queue(self, header, body, file_to_upload=None):
payload = {'xqueue_header': header,
'xqueue_body' : body}
files = None
if file_to_upload is not None:
files = { file_to_upload.name: file_to_upload }
try:
r = self.s.post(self.url+'/xqueue/submit/', data=payload, files=files)
except requests.exceptions.ConnectionError, err:
log.error(err)
return (1, 'cannot connect to server')
return parse_xreply(r.text)
......@@ -17,6 +17,7 @@ from xmodule.exceptions import NotFoundError
from progress import Progress
from capa.capa_problem import LoncapaProblem
from capa.responsetypes import StudentInputError
from capa.util import convert_files_to_filenames
log = logging.getLogger("mitx.courseware")
......@@ -425,10 +426,9 @@ class CapaModule(XModule):
event_info = dict()
event_info['state'] = self.lcp.get_state()
event_info['problem_id'] = self.location.url()
answers = self.make_dict_of_responses(get)
event_info['answers'] = answers
event_info['answers'] = convert_files_to_filenames(answers)
# Too late. Cannot submit
if self.closed():
......@@ -436,8 +436,7 @@ class CapaModule(XModule):
self.system.track_function('save_problem_check_fail', event_info)
raise NotFoundError('Problem is closed')
# Problem submitted. Student should reset before checking
# again.
# Problem submitted. Student should reset before checking again
if self.lcp.done and self.rerandomize == "always":
event_info['failure'] = 'unreset'
self.system.track_function('save_problem_check_fail', event_info)
......
......@@ -13,7 +13,8 @@ class @Problem
MathJax.Hub.Queue ["Typeset", MathJax.Hub]
window.update_schematics()
@$('section.action input:button').click @refreshAnswers
@$('section.action input.check').click @check
@$('section.action input.check').click @check_fd
#@$('section.action input.check').click @check
@$('section.action input.reset').click @reset
@$('section.action input.show').click @show
@$('section.action input.save').click @save
......@@ -45,6 +46,51 @@ class @Problem
$('head')[0].appendChild(s[0])
$(placeholder).remove()
###
# 'check_fd' uses FormData to allow file submissions in the 'problem_check' dispatch,
# in addition to simple querystring-based answers
#
# NOTE: The dispatch 'problem_check' is being singled out for the use of FormData;
# maybe preferable to consolidate all dispatches to use FormData
###
check_fd: =>
Logger.log 'problem_check', @answers
# If there are no file inputs in the problem, we can fall back on @check
if $('input:file').length == 0
@check()
return
if not window.FormData
alert "Sorry, your browser does not support file uploads. Your submit request could not be fulfilled. If you can, please use Chrome or Safari which have been verified to support file uploads."
return
fd = new FormData()
@$("[id^=input_#{@element_id.replace(/problem_/, '')}_]").each (index, element) ->
if element.type is 'file'
if element.files[0] instanceof File
fd.append(element.id, element.files[0])
else
fd.append(element.id, '')
else
fd.append(element.id, element.value)
settings =
type: "POST"
data: fd
processData: false
contentType: false
success: (response) =>
switch response.success
when 'incorrect', 'correct'
@render(response.contents)
@updateProgress response
else
alert(response.success)
$.ajaxWithPrefix("#{@url}/problem_check", settings)
check: =>
Logger.log 'problem_check', @answers
$.postWithPrefix "#{@url}/problem_check", @answers, (response) =>
......
......@@ -279,6 +279,12 @@ def modx_dispatch(request, dispatch=None, id=None):
'''
# ''' (fix emacs broken parsing)
# Check for submitted files
p = request.POST.copy()
if request.FILES:
for inputfile_id in request.FILES.keys():
p[inputfile_id] = request.FILES[inputfile_id]
student_module_cache = StudentModuleCache(request.user, modulestore().get_item(id))
instance, instance_module, shared_module, module_type = get_module(request.user, request, id, student_module_cache)
......@@ -290,7 +296,7 @@ def modx_dispatch(request, dispatch=None, id=None):
# Let the module handle the AJAX
try:
ajax_return = instance.handle_ajax(dispatch, request.POST)
ajax_return = instance.handle_ajax(dispatch, p)
except NotFoundError:
log.exception("Module indicating to user that request doesn't exist")
raise Http404
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment