Commit cd42c917 by Han Su Kim

Merge pull request #2818 from edx/alex/allow_html_in_grader_msg

Allow HTML in grader messages.
parents 5e14d045 c7785d8f
...@@ -46,6 +46,7 @@ import re ...@@ -46,6 +46,7 @@ import re
import shlex # for splitting quoted strings import shlex # for splitting quoted strings
import sys import sys
import pyparsing import pyparsing
import html5lib
from .registry import TagRegistry from .registry import TagRegistry
from chem import chemcalc from chem import chemcalc
...@@ -286,7 +287,18 @@ class InputTypeBase(object): ...@@ -286,7 +287,18 @@ class InputTypeBase(object):
context = self._get_render_context() context = self._get_render_context()
html = self.capa_system.render_template(self.template, context) html = self.capa_system.render_template(self.template, context)
return etree.XML(html)
try:
output = etree.XML(html)
except etree.XMLSyntaxError as ex:
# If `html` contains attrs with no values, like `controls` in <audio controls src='smth'/>,
# XML parser will raise exception, so wee fallback to html5parser, which will set empty "" values for such attrs.
try:
output = html5lib.parseFragment(html, treebuilder='lxml', namespaceHTMLElements=False)[0]
except IndexError:
raise ex
return output
def get_user_visible_answer(self, internal_answer): def get_user_visible_answer(self, internal_answer):
""" """
......
...@@ -14,6 +14,7 @@ import cgi ...@@ -14,6 +14,7 @@ import cgi
import inspect import inspect
import json import json
import logging import logging
import html5lib
import numbers import numbers
import numpy import numpy
import os import os
...@@ -1761,14 +1762,19 @@ class CodeResponse(LoncapaResponse): ...@@ -1761,14 +1762,19 @@ class CodeResponse(LoncapaResponse):
" tags: 'correct', 'score', 'msg'") " tags: 'correct', 'score', 'msg'")
return fail return fail
# Next, we need to check that the contents of the external grader message # Next, we need to check that the contents of the external grader message is safe for the LMS.
# is safe for the LMS.
# 1) Make sure that the message is valid XML (proper opening/closing tags) # 1) Make sure that the message is valid XML (proper opening/closing tags)
# 2) TODO: Is the message actually HTML? # 2) If it is not valid XML, make sure it is valid HTML. Note: html5lib parser will try to repair any broken HTML
# For example: <aaa></bbb> will become <aaa/>.
msg = score_result['msg'] msg = score_result['msg']
try: try:
etree.fromstring(msg) etree.fromstring(msg)
except etree.XMLSyntaxError as _err: except etree.XMLSyntaxError as _err:
# If `html` contains attrs with no values, like `controls` in <audio controls src='smth'/>,
# XML parser will raise exception, so wee fallback to html5parser, which will set empty "" values for such attrs.
parsed = html5lib.parseFragment(msg, treebuilder='lxml', namespaceHTMLElements=False)
if not parsed:
log.error("Unable to parse external grader message as valid" log.error("Unable to parse external grader message as valid"
" XML: score_msg['msg']=%s", msg) " XML: score_msg['msg']=%s", msg)
return fail return fail
......
...@@ -20,6 +20,7 @@ TODO: ...@@ -20,6 +20,7 @@ TODO:
import json import json
from lxml import etree from lxml import etree
import unittest import unittest
import textwrap
import xml.sax.saxutils as saxutils import xml.sax.saxutils as saxutils
from . import test_capa_system from . import test_capa_system
...@@ -583,6 +584,44 @@ class MatlabTest(unittest.TestCase): ...@@ -583,6 +584,44 @@ class MatlabTest(unittest.TestCase):
self.assertEqual(input_state['queuestate'], 'queued') self.assertEqual(input_state['queuestate'], 'queued')
self.assertFalse('queue_msg' in input_state) self.assertFalse('queue_msg' in input_state)
def test_get_html(self):
# usual output
output = self.the_input.get_html()
self.assertEqual(
etree.tostring(output),
"""<div>{\'status\': \'queued\', \'button_enabled\': True, \'rows\': \'10\', \'queue_len\': \'3\'\
, \'mode\': \'\', \'cols\': \'80\', \'STATIC_URL\': \'/dummy-static/\', \'linenumbers\': \'true\'\
, \'queue_msg\': \'\', \'value\': \'print "good evening"\', \'msg\': u\'Submitted\
. As soon as a response is returned, this message will be replaced by that feedback.\', \'hidden\': \'\'\
, \'id\': \'prob_1_2\', \'tabsize\': 4}</div>"""
)
# test html, that is correct HTML5 html, but is not parsable by XML parser.
old_render_template = self.the_input.capa_system.render_template
self.the_input.capa_system.render_template = lambda *args: textwrap.dedent("""
<div class='matlabResponse'><div id='mwAudioPlaceHolder'>
<audio controls autobuffer autoplay src='data:audio/wav;base64='>Audio is not supported on this browser.</audio>
<div>Right click <a href=https://endpoint.mss-mathworks.com/media/filename.wav>here</a> and click \"Save As\" to download the file</div></div>
<div style='white-space:pre' class='commandWindowOutput'></div><ul></ul></div>
""").replace('\n', '')
output = self.the_input.get_html()
self.assertEqual(
etree.tostring(output),
textwrap.dedent("""
<div class='matlabResponse'><div id='mwAudioPlaceHolder'>
<audio src='data:audio/wav;base64=' autobuffer="" controls="" autoplay="">Audio is not supported on this browser.</audio>
<div>Right click <a href="https://endpoint.mss-mathworks.com/media/filename.wav">here</a> and click \"Save As\" to download the file</div></div>
<div style='white-space:pre' class='commandWindowOutput'/><ul/></div>
""").replace('\n', '').replace('\'', '\"')
)
# check that exception is raised during parsing for html.
self.the_input.capa_system.render_template = lambda *args: "<aaa"
with self.assertRaises(etree.XMLSyntaxError):
self.the_input.get_html()
self.the_input.capa_system.render_template = old_render_template
class SchematicTest(unittest.TestCase): class SchematicTest(unittest.TestCase):
''' '''
......
...@@ -998,6 +998,59 @@ class CodeResponseTest(ResponseTest): ...@@ -998,6 +998,59 @@ class CodeResponseTest(ResponseTest):
self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3']) self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3'])
self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name]) self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name])
def test_parse_score_msg_of_responder(self):
"""
Test whether LoncapaProblem._parse_score_msg correcly parses valid HTML5 html.
"""
valid_grader_msgs = [
u'<span>MESSAGE</span>', # Valid XML
textwrap.dedent("""
<div class='matlabResponse'><div id='mwAudioPlaceHolder'>
<audio controls autobuffer autoplay src='data:audio/wav;base64='>Audio is not supported on this browser.</audio>
<div>Right click <a href=https://endpoint.mss-mathworks.com/media/filename.wav>here</a> and click \"Save As\" to download the file</div></div>
<div style='white-space:pre' class='commandWindowOutput'></div><ul></ul></div>
""").replace('\n', ''), # Valid HTML5 real case Matlab response, invalid XML
'<aaa></bbb>' # Invalid XML, but will be parsed by html5lib to <aaa/>
]
invalid_grader_msgs = [
'<audio', # invalid XML and HTML5
]
answer_ids = sorted(self.problem.get_question_answers())
# CodeResponse requires internal CorrectMap state. Build it now in the queued state
old_cmap = CorrectMap()
for i, answer_id in enumerate(answer_ids):
queuekey = 1000 + i
queuestate = CodeResponseTest.make_queuestate(queuekey, datetime.now(UTC))
old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
for grader_msg in valid_grader_msgs:
correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg})
incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg})
xserver_msgs = {'correct': correct_score_msg, 'incorrect': incorrect_score_msg, }
for i, answer_id in enumerate(answer_ids):
self.problem.correct_map = CorrectMap()
self.problem.correct_map.update(old_cmap)
output = self.problem.update_score(xserver_msgs['correct'], queuekey=1000 + i)
self.assertEquals(output[answer_id]['msg'], grader_msg)
for grader_msg in invalid_grader_msgs:
correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg})
incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg})
xserver_msgs = {'correct': correct_score_msg, 'incorrect': incorrect_score_msg, }
for i, answer_id in enumerate(answer_ids):
self.problem.correct_map = CorrectMap()
self.problem.correct_map.update(old_cmap)
output = self.problem.update_score(xserver_msgs['correct'], queuekey=1000 + i)
self.assertEquals(output[answer_id]['msg'], u'Invalid grader reply. Please contact the course staff.')
class ChoiceResponseTest(ResponseTest): class ChoiceResponseTest(ResponseTest):
from capa.tests.response_xml_factory import ChoiceResponseXMLFactory from capa.tests.response_xml_factory import ChoiceResponseXMLFactory
......
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