Merge pull request #340 from MITx/kimth/lms-coderesponse
Kimth/lms coderesponse
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -299,6 +300,18 @@ def textline_dynamath(element, value, status, render_template, msg=''):
|
||||
return etree.XML(html)
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
@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
|
||||
|
||||
@@ -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}
|
||||
self.context.update({'submission': unicode(submission)})
|
||||
|
||||
r, queuekey = self._send_to_queue(extra_payload) # TODO: Perform checks on the xqueue response
|
||||
# Prepare xqueue request
|
||||
#------------------------------------------------------------
|
||||
|
||||
# Non-null CorrectMap['queuekey'] indicates that the problem has been submitted
|
||||
cmap = CorrectMap()
|
||||
cmap.set(self.answer_id, queuekey=queuekey, msg='Submitted to queue')
|
||||
# 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))
|
||||
|
||||
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(' ', ' ')
|
||||
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
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
16
common/lib/capa/capa/templates/filesubmission.html
Normal file
16
common/lib/capa/capa/templates/filesubmission.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<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
|
||||
|
||||
114
common/lib/capa/capa/xqueue_interface.py
Normal file
114
common/lib/capa/capa/xqueue_interface.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user