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 ...@@ -31,7 +31,7 @@ import calc
from correctmap import CorrectMap from correctmap import CorrectMap
import eia import eia
import inputtypes import inputtypes
from util import contextualize_text from util import contextualize_text, convert_files_to_filenames
# to be replaced with auto-registering # to be replaced with auto-registering
import responsetypes import responsetypes
...@@ -39,7 +39,7 @@ import responsetypes ...@@ -39,7 +39,7 @@ import responsetypes
# dict of tagname, Response Class -- this should come from auto-registering # dict of tagname, Response Class -- this should come from auto-registering
response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__]) 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 solution_types = ['solution'] # extra things displayed after "show answers" is pressed
response_properties = ["responseparam", "answer"] # these get captured as student responses response_properties = ["responseparam", "answer"] # these get captured as student responses
...@@ -228,12 +228,18 @@ class LoncapaProblem(object): ...@@ -228,12 +228,18 @@ class LoncapaProblem(object):
Calls the Response for each question in this problem, to do the actual grading. 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 oldcmap = self.correct_map # old CorrectMap
newcmap = CorrectMap() # start new with empty CorrectMap newcmap = CorrectMap() # start new with empty CorrectMap
# log.debug('Responders: %s' % self.responders) # log.debug('Responders: %s' % self.responders)
for responder in self.responders.values(): for responder in self.responders.values(): # Call each responsetype instance to do actual grading
results = responder.evaluate_answers(answers, oldcmap) # call the responsetype instance to do the 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) newcmap.update(results)
self.correct_map = newcmap self.correct_map = newcmap
# log.debug('%s: in grade_answers, answers=%s, cmap=%s' % (self,answers,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 ...@@ -13,6 +13,7 @@ Module containing the problem elements which render into input objects
- checkboxgroup - checkboxgroup
- imageinput (for clickable image) - imageinput (for clickable image)
- optioninput (for option list) - optioninput (for option list)
- filesubmission (upload a file)
These are matched by *.html files templates/*.html which are mako templates with the actual html. 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=''): ...@@ -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> ## TODO: Make a wrapper for <codeinput>
@register_render_function @register_render_function
def textbox(element, value, status, render_template, msg=''): def textbox(element, value, status, render_template, msg=''):
......
...@@ -8,7 +8,6 @@ Used by capa_problem.py ...@@ -8,7 +8,6 @@ Used by capa_problem.py
''' '''
# standard library imports # standard library imports
import hashlib
import inspect import inspect
import json import json
import logging import logging
...@@ -17,7 +16,6 @@ import numpy ...@@ -17,7 +16,6 @@ import numpy
import random import random
import re import re
import requests import requests
import time
import traceback import traceback
import abc import abc
...@@ -27,9 +25,12 @@ from correctmap import CorrectMap ...@@ -27,9 +25,12 @@ from correctmap import CorrectMap
from util import * from util import *
from lxml import etree from lxml import etree
from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME?
import xqueue_interface
log = logging.getLogger('mitx.' + __name__) log = logging.getLogger('mitx.' + __name__)
qinterface = xqueue_interface.XqueueInterface()
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
# Exceptions # Exceptions
...@@ -162,7 +163,7 @@ class LoncapaResponse(object): ...@@ -162,7 +163,7 @@ class LoncapaResponse(object):
Returns the new CorrectMap, with (correctness,msg,hint,hintmode) for each answer_id. Returns the new CorrectMap, with (correctness,msg,hint,hintmode) for each answer_id.
''' '''
new_cmap = self.get_score(student_answers) 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) # log.debug('new_cmap = %s' % new_cmap)
return new_cmap return new_cmap
...@@ -798,19 +799,18 @@ class SymbolicResponse(CustomResponse): ...@@ -798,19 +799,18 @@ class SymbolicResponse(CustomResponse):
class CodeResponse(LoncapaResponse): class CodeResponse(LoncapaResponse):
''' '''
Grade student code using an external server, called 'xqueue' Grade student code using an external queueing server, called 'xqueue'
In contrast to ExternalResponse, CodeResponse has following behavior:
1) Goes through a queueing system External requests are only submitted for student submission grading
2) Does not do external request for 'get_answers' (i.e. and not for getting reference answers)
''' '''
response_tag = 'coderesponse' response_tag = 'coderesponse'
allowed_inputfields = ['textline', 'textbox'] allowed_inputfields = ['textbox', 'filesubmission']
max_inputfields = 1 max_inputfields = 1
def setup_response(self): def setup_response(self):
xml = self.xml 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) self.queue_name = xml.get('queuename', self.system.xqueue_default_queuename)
answer = xml.find('answer') answer = xml.find('answer')
...@@ -849,19 +849,49 @@ class CodeResponse(LoncapaResponse): ...@@ -849,19 +849,49 @@ class CodeResponse(LoncapaResponse):
def get_score(self, student_answers): def get_score(self, student_answers):
try: try:
submission = student_answers[self.answer_id] submission = student_answers[self.answer_id] # Note that submission can be a file
except Exception as err: 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) raise Exception(err)
self.context.update({'submission': submission}) self.context.update({'submission': unicode(submission)})
extra_payload = {'edX_student_response': submission}
# Prepare xqueue request
r, queuekey = self._send_to_queue(extra_payload) # TODO: Perform checks on the xqueue response #------------------------------------------------------------
# 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 = CorrectMap() if error:
cmap.set(self.answer_id, queuekey=queuekey, msg='Submitted to queue') 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 return cmap
...@@ -883,17 +913,15 @@ class CodeResponse(LoncapaResponse): ...@@ -883,17 +913,15 @@ class CodeResponse(LoncapaResponse):
self.context['correct'][0] = admap[ad] self.context['correct'][0] = admap[ad]
# Replace 'oldcmap' with new grading results if queuekey matches. # 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): if oldcmap.is_right_queuekey(self.answer_id, queuekey):
msg = rxml.find('message').text.replace('&nbsp;', '&#160;') 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 oldcmap.set(self.answer_id, correctness=self.context['correct'][0], msg=msg, queuekey=None) # Queuekey is consumed
else: 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 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): def get_answers(self):
anshtml = '<font color="blue"><span class="code-answer"><br/><pre>%s</pre><br/></span></font>' % self.answer anshtml = '<font color="blue"><span class="code-answer"><br/><pre>%s</pre><br/></span></font>' % self.answer
return {self.answer_id: anshtml} return {self.answer_id: anshtml}
...@@ -901,41 +929,6 @@ class CodeResponse(LoncapaResponse): ...@@ -901,41 +929,6 @@ class CodeResponse(LoncapaResponse):
def get_initial_display(self): def get_initial_display(self):
return {self.answer_id: self.initial_display} 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 ...@@ -30,3 +30,14 @@ def contextualize_text(text, context): # private
for key in sorted(context, lambda x, y: cmp(len(y), len(x))): for key in sorted(context, lambda x, y: cmp(len(y), len(x))):
text = text.replace('$' + key, str(context[key])) text = text.replace('$' + key, str(context[key]))
return text 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 ...@@ -17,6 +17,7 @@ from xmodule.exceptions import NotFoundError
from progress import Progress from progress import Progress
from capa.capa_problem import LoncapaProblem from capa.capa_problem import LoncapaProblem
from capa.responsetypes import StudentInputError from capa.responsetypes import StudentInputError
from capa.util import convert_files_to_filenames
log = logging.getLogger("mitx.courseware") log = logging.getLogger("mitx.courseware")
...@@ -425,10 +426,9 @@ class CapaModule(XModule): ...@@ -425,10 +426,9 @@ class CapaModule(XModule):
event_info = dict() event_info = dict()
event_info['state'] = self.lcp.get_state() event_info['state'] = self.lcp.get_state()
event_info['problem_id'] = self.location.url() event_info['problem_id'] = self.location.url()
answers = self.make_dict_of_responses(get) answers = self.make_dict_of_responses(get)
event_info['answers'] = convert_files_to_filenames(answers)
event_info['answers'] = answers
# Too late. Cannot submit # Too late. Cannot submit
if self.closed(): if self.closed():
...@@ -436,8 +436,7 @@ class CapaModule(XModule): ...@@ -436,8 +436,7 @@ class CapaModule(XModule):
self.system.track_function('save_problem_check_fail', event_info) self.system.track_function('save_problem_check_fail', event_info)
raise NotFoundError('Problem is closed') raise NotFoundError('Problem is closed')
# Problem submitted. Student should reset before checking # Problem submitted. Student should reset before checking again
# again.
if self.lcp.done and self.rerandomize == "always": if self.lcp.done and self.rerandomize == "always":
event_info['failure'] = 'unreset' event_info['failure'] = 'unreset'
self.system.track_function('save_problem_check_fail', event_info) self.system.track_function('save_problem_check_fail', event_info)
......
...@@ -13,7 +13,8 @@ class @Problem ...@@ -13,7 +13,8 @@ class @Problem
MathJax.Hub.Queue ["Typeset", MathJax.Hub] MathJax.Hub.Queue ["Typeset", MathJax.Hub]
window.update_schematics() window.update_schematics()
@$('section.action input:button').click @refreshAnswers @$('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.reset').click @reset
@$('section.action input.show').click @show @$('section.action input.show').click @show
@$('section.action input.save').click @save @$('section.action input.save').click @save
...@@ -45,6 +46,51 @@ class @Problem ...@@ -45,6 +46,51 @@ class @Problem
$('head')[0].appendChild(s[0]) $('head')[0].appendChild(s[0])
$(placeholder).remove() $(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: => check: =>
Logger.log 'problem_check', @answers Logger.log 'problem_check', @answers
$.postWithPrefix "#{@url}/problem_check", @answers, (response) => $.postWithPrefix "#{@url}/problem_check", @answers, (response) =>
......
...@@ -279,6 +279,12 @@ def modx_dispatch(request, dispatch=None, id=None): ...@@ -279,6 +279,12 @@ def modx_dispatch(request, dispatch=None, id=None):
''' '''
# ''' (fix emacs broken parsing) # ''' (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)) 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) 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): ...@@ -290,7 +296,7 @@ def modx_dispatch(request, dispatch=None, id=None):
# Let the module handle the AJAX # Let the module handle the AJAX
try: try:
ajax_return = instance.handle_ajax(dispatch, request.POST) ajax_return = instance.handle_ajax(dispatch, p)
except NotFoundError: except NotFoundError:
log.exception("Module indicating to user that request doesn't exist") log.exception("Module indicating to user that request doesn't exist")
raise Http404 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