diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index cb3c19487d..ba99ee681e 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -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)) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 8b3867be5b..583d29b82e 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -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 @register_render_function diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 75ed92ab3e..12f619a881 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -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 = '
%s

' % 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 #----------------------------------------------------------------------------- diff --git a/common/lib/capa/capa/templates/filesubmission.html b/common/lib/capa/capa/templates/filesubmission.html new file mode 100644 index 0000000000..ff9fc992fd --- /dev/null +++ b/common/lib/capa/capa/templates/filesubmission.html @@ -0,0 +1,16 @@ +
+
+ % if state == 'unsubmitted': + + % elif state == 'correct': + + % elif state == 'incorrect': + + % elif state == 'incomplete': + + % endif + (${state}) +
+ ${msg|n} +
+
diff --git a/common/lib/capa/capa/util.py b/common/lib/capa/capa/util.py index 63a5f43c03..1dc113cd20 100644 --- a/common/lib/capa/capa/util.py +++ b/common/lib/capa/capa/util.py @@ -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 diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py new file mode 100644 index 0000000000..deb068adf6 --- /dev/null +++ b/common/lib/capa/capa/xqueue_interface.py @@ -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) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index eed2cf3ac7..7ce76def32 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -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) diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 4ee8257e36..0e760e98ff 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -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) => diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index b6ba381a26..abab4df149 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -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