fix: align pylint fixes in edx-platform Problem XBlock with extracted xblocks-contrib/problem (#37758)

* fix: pylint issues for problem xblock
This commit is contained in:
Irtaza Akram
2026-01-07 16:39:11 +05:00
committed by GitHub
parent 2f065bbae4
commit d29b0473f4
49 changed files with 2376 additions and 1623 deletions

View File

@@ -28,7 +28,7 @@ from xmodule.capa.tests.response_xml_factory import (
OptionResponseXMLFactory, OptionResponseXMLFactory,
SchematicResponseXMLFactory SchematicResponseXMLFactory
) )
from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.tests.test_util import UseUnsafeCodejail
from xmodule.capa.xqueue_interface import XQueueInterface from xmodule.capa.xqueue_interface import XQueueInterface
from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule
@@ -811,7 +811,7 @@ class ProblemWithUploadedFilesTest(TestSubmittingProblems):
self.assertEqual(list(kwargs['files'].keys()), filenames.split()) self.assertEqual(list(kwargs['files'].keys()), filenames.split())
@use_unsafe_codejail() @UseUnsafeCodejail()
class TestPythonGradedResponse(TestSubmittingProblems): class TestPythonGradedResponse(TestSubmittingProblems):
""" """
Check that we can submit a schematic and custom response, and it answers properly. Check that we can submit a schematic and custom response, and it answers properly.

View File

@@ -22,7 +22,7 @@ from django.urls import reverse
from xmodule.capa.responsetypes import StudentInputError from xmodule.capa.responsetypes import StudentInputError
from xmodule.capa.tests.response_xml_factory import CodeResponseXMLFactory, CustomResponseXMLFactory from xmodule.capa.tests.response_xml_factory import CodeResponseXMLFactory, CustomResponseXMLFactory
from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.tests.test_util import UseUnsafeCodejail
from lms.djangoapps.courseware.model_data import StudentModule from lms.djangoapps.courseware.model_data import StudentModule
from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.grades.api import CourseGradeFactory
from lms.djangoapps.instructor_task.api import ( from lms.djangoapps.instructor_task.api import (
@@ -73,7 +73,7 @@ class TestIntegrationTask(InstructorTaskModuleTestCase):
@ddt.ddt @ddt.ddt
@override_settings(RATELIMIT_ENABLE=False) @override_settings(RATELIMIT_ENABLE=False)
@use_unsafe_codejail() @UseUnsafeCodejail()
class TestRescoringTask(TestIntegrationTask): class TestRescoringTask(TestIntegrationTask):
""" """
Integration-style tests for rescoring problems in a background task. Integration-style tests for rescoring problems in a background task.

View File

@@ -26,11 +26,13 @@ class FormatHtmlTest(unittest.TestCase):
("<a>нтмℓ-єѕ¢αρє∂</a>", "&lt;a&gt;нтмℓ-єѕ¢αρє∂&lt;/a&gt;"), ("<a>нтмℓ-єѕ¢αρє∂</a>", "&lt;a&gt;нтмℓ-єѕ¢αρє∂&lt;/a&gt;"),
) )
def test_simple(self, before_after): def test_simple(self, before_after):
"""Verify that plain text is safely HTML-escaped."""
(before, after) = before_after (before, after) = before_after
assert str(Text(_(before))) == after # pylint: disable=translation-of-non-string assert str(Text(_(before))) == after # pylint: disable=translation-of-non-string
assert str(Text(before)) == after assert str(Text(before)) == after
def test_formatting(self): def test_formatting(self):
"""Ensure Text.format correctly mixes escaped text with raw HTML."""
# The whole point of this function is to make sure this works: # The whole point of this function is to make sure this works:
out = Text(_("Point & click {start}here{end}!")).format( out = Text(_("Point & click {start}here{end}!")).format(
start=HTML("<a href='http://edx.org'>"), start=HTML("<a href='http://edx.org'>"),
@@ -39,6 +41,7 @@ class FormatHtmlTest(unittest.TestCase):
assert str(out) == "Point &amp; click <a href='http://edx.org'>here</a>!" assert str(out) == "Point &amp; click <a href='http://edx.org'>here</a>!"
def test_nested_formatting(self): def test_nested_formatting(self):
"""Validate nested formatting where HTML contains formatted text."""
# Sometimes, you have plain text, with html inserted, and the html has # Sometimes, you have plain text, with html inserted, and the html has
# plain text inserted. It gets twisty... # plain text inserted. It gets twisty...
out = Text(_("Send {start}email{end}")).format( out = Text(_("Send {start}email{end}")).format(
@@ -48,6 +51,7 @@ class FormatHtmlTest(unittest.TestCase):
assert str(out) == "Send <a href='mailto:A&amp;B'>email</a>" assert str(out) == "Send <a href='mailto:A&amp;B'>email</a>"
def test_mako(self): def test_mako(self):
"""Confirm Mako templates format Text/HTML objects with expected filters."""
# The default_filters used here have to match the ones in edxmako. # The default_filters used here have to match the ones in edxmako.
template = Template( template = Template(
""" """
@@ -64,6 +68,7 @@ class FormatHtmlTest(unittest.TestCase):
assert out.strip() == "A &amp; B & C" assert out.strip() == "A &amp; B & C"
def test_ungettext(self): def test_ungettext(self):
"""Check that ngettext output is properly formatted and HTML-escaped."""
for i in [1, 2]: for i in [1, 2]:
out = Text(ngettext("1 & {}", "2 & {}", i)).format(HTML("<>")) out = Text(ngettext("1 & {}", "2 & {}", i)).format(HTML("<>"))
assert out == f"{i} &amp; <>" assert out == f"{i} &amp; <>"

View File

@@ -22,7 +22,8 @@ class RestrictedElement(_etree.ElementBase):
__slots__ = () __slots__ = ()
blacklist = (_etree._Entity, _etree._ProcessingInstruction, _etree._Comment) # pylint: disable=protected-access blacklist = (_etree._Entity, _etree._ProcessingInstruction, _etree._Comment) # pylint: disable=protected-access
def _filter(self, iterator): # pylint: disable=missing-function-docstring def _filter(self, iterator):
"""Yield only elements not in the blacklist from the given iterator."""
blacklist = self.blacklist blacklist = self.blacklist
for child in iterator: for child in iterator:
if isinstance(child, blacklist): if isinstance(child, blacklist):
@@ -30,37 +31,37 @@ class RestrictedElement(_etree.ElementBase):
yield child yield child
def __iter__(self): def __iter__(self):
iterator = super(RestrictedElement, self).__iter__() # pylint: disable=super-with-arguments iterator = super().__iter__()
return self._filter(iterator) return self._filter(iterator)
def iterchildren(self, tag=None, reversed=False): # pylint: disable=redefined-builtin def iterchildren(self, tag=None, reversed=False): # pylint: disable=redefined-builtin
iterator = super(RestrictedElement, self).iterchildren( # pylint: disable=super-with-arguments """Iterate over child elements while excluding blacklisted nodes."""
tag=tag, reversed=reversed iterator = super().iterchildren(tag=tag, reversed=reversed)
)
return self._filter(iterator) return self._filter(iterator)
def iter(self, tag=None, *tags): # pylint: disable=keyword-arg-before-vararg def iter(self, tag=None, *tags): # pylint: disable=keyword-arg-before-vararg
iterator = super(RestrictedElement, self).iter(tag=tag, *tags) # pylint: disable=super-with-arguments """Iterate over the element tree excluding blacklisted nodes."""
iterator = super().iter(tag=tag, *tags)
return self._filter(iterator) return self._filter(iterator)
def iterdescendants(self, tag=None, *tags): # pylint: disable=keyword-arg-before-vararg def iterdescendants(self, tag=None, *tags): # pylint: disable=keyword-arg-before-vararg
iterator = super(RestrictedElement, self).iterdescendants( # pylint: disable=super-with-arguments """Iterate over descendants while filtering out blacklisted nodes."""
tag=tag, *tags iterator = super().iterdescendants(tag=tag, *tags)
)
return self._filter(iterator) return self._filter(iterator)
def itersiblings(self, tag=None, preceding=False): def itersiblings(self, tag=None, preceding=False):
iterator = super(RestrictedElement, self).itersiblings( # pylint: disable=super-with-arguments """Iterate over siblings excluding blacklisted node types."""
tag=tag, preceding=preceding iterator = super().itersiblings(tag=tag, preceding=preceding)
)
return self._filter(iterator) return self._filter(iterator)
def getchildren(self): def getchildren(self):
iterator = super(RestrictedElement, self).__iter__() # pylint: disable=super-with-arguments """Return a list of non-blacklisted child elements."""
iterator = super().__iter__()
return list(self._filter(iterator)) return list(self._filter(iterator))
def getiterator(self, tag=None): def getiterator(self, tag=None):
iterator = super(RestrictedElement, self).getiterator(tag) # pylint: disable=super-with-arguments """Iterate over the tree with blacklisted nodes filtered out."""
iterator = super().getiterator(tag)
return self._filter(iterator) return self._filter(iterator)
@@ -73,7 +74,8 @@ class GlobalParserTLS(threading.local):
element_class = RestrictedElement element_class = RestrictedElement
def createDefaultParser(self): # pylint: disable=missing-function-docstring def create_default_parser(self):
"""Create a secure XMLParser using the restricted element class."""
parser = _etree.XMLParser(**self.parser_config) parser = _etree.XMLParser(**self.parser_config)
element_class = self.element_class element_class = self.element_class
if self.element_class is not None: if self.element_class is not None:
@@ -81,19 +83,21 @@ class GlobalParserTLS(threading.local):
parser.set_element_class_lookup(lookup) parser.set_element_class_lookup(lookup)
return parser return parser
def setDefaultParser(self, parser): def set_default_parser(self, parser):
"""Store a thread-local default XML parser instance."""
self._default_parser = parser # pylint: disable=attribute-defined-outside-init self._default_parser = parser # pylint: disable=attribute-defined-outside-init
def getDefaultParser(self): # pylint: disable=missing-function-docstring def get_default_parser(self):
"""Return the thread-local default parser, creating it if missing."""
parser = getattr(self, "_default_parser", None) parser = getattr(self, "_default_parser", None)
if parser is None: if parser is None:
parser = self.createDefaultParser() parser = self.create_default_parser()
self.setDefaultParser(parser) self.set_default_parser(parser)
return parser return parser
_parser_tls = GlobalParserTLS() _parser_tls = GlobalParserTLS()
getDefaultParser = _parser_tls.getDefaultParser get_default_parser = _parser_tls.get_default_parser
def check_docinfo(elementtree, forbid_dtd=False, forbid_entities=True): def check_docinfo(elementtree, forbid_dtd=False, forbid_entities=True):
@@ -107,9 +111,7 @@ def check_docinfo(elementtree, forbid_dtd=False, forbid_entities=True):
raise DTDForbidden(docinfo.doctype, docinfo.system_url, docinfo.public_id) raise DTDForbidden(docinfo.doctype, docinfo.system_url, docinfo.public_id)
if forbid_entities and not LXML3: if forbid_entities and not LXML3:
# lxml < 3 has no iterentities() # lxml < 3 has no iterentities()
raise NotSupportedError( raise NotSupportedError("Unable to check for entity declarations in lxml 2.x")
"Unable to check for entity declarations in lxml 2.x"
) # pylint: disable=implicit-str-concat
if forbid_entities: if forbid_entities:
for dtd in docinfo.internalDTD, docinfo.externalDTD: for dtd in docinfo.internalDTD, docinfo.externalDTD:
@@ -119,29 +121,28 @@ def check_docinfo(elementtree, forbid_dtd=False, forbid_entities=True):
raise EntitiesForbidden(entity.name, entity.content, None, None, None, None) raise EntitiesForbidden(entity.name, entity.content, None, None, None, None)
def parse( def parse(source, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True):
source, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True """Securely parse XML from a source and enforce DTD/entity restrictions."""
): # pylint: disable=missing-function-docstring
if parser is None: if parser is None:
parser = getDefaultParser() parser = get_default_parser()
elementtree = _etree.parse(source, parser, base_url=base_url) elementtree = _etree.parse(source, parser, base_url=base_url)
check_docinfo(elementtree, forbid_dtd, forbid_entities) check_docinfo(elementtree, forbid_dtd, forbid_entities)
return elementtree return elementtree
def fromstring( def fromstring(text, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True):
text, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True """Securely parse XML from a string and validate docinfo."""
): # pylint: disable=missing-function-docstring
if parser is None: if parser is None:
parser = getDefaultParser() parser = get_default_parser()
rootelement = _etree.fromstring(text, parser, base_url=base_url) rootelement = _etree.fromstring(text, parser, base_url=base_url)
elementtree = rootelement.getroottree() elementtree = rootelement.getroottree()
check_docinfo(elementtree, forbid_dtd, forbid_entities) check_docinfo(elementtree, forbid_dtd, forbid_entities)
return rootelement return rootelement
XML = fromstring XML = fromstring # pylint: disable=invalid-name
def iterparse(*args, **kwargs): def iterparse(*args, **kwargs):
"""Disabled XML iterparse function that always raises NotSupportedError."""
raise NotSupportedError("iterparse not available") raise NotSupportedError("iterparse not available")

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
# #
# File: capa/capa_problem.py # File: capa/capa_problem.py
# #
@@ -27,12 +28,9 @@ from django.conf import settings
from lxml import etree from lxml import etree
from pytz import UTC from pytz import UTC
import xmodule.capa.customrender as customrender
import xmodule.capa.inputtypes as inputtypes
import xmodule.capa.responsetypes as responsetypes
import xmodule.capa.xqueue_interface as xqueue_interface
from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.safe_lxml.xmlparser import XML from openedx.core.lib.safe_lxml.xmlparser import XML
from xmodule.capa import customrender, inputtypes, responsetypes, xqueue_interface
from xmodule.capa.correctmap import CorrectMap from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.safe_exec import safe_exec from xmodule.capa.safe_exec import safe_exec
from xmodule.capa.util import contextualize_text, convert_files_to_filenames, get_course_id_from_capa_block from xmodule.capa.util import contextualize_text, convert_files_to_filenames, get_course_id_from_capa_block
@@ -80,7 +78,7 @@ log = logging.getLogger(__name__)
# main class for this module # main class for this module
class LoncapaSystem(object): class LoncapaSystem: # pylint: disable=too-few-public-methods,too-many-instance-attributes
""" """
An encapsulation of resources needed from the outside. An encapsulation of resources needed from the outside.
@@ -96,14 +94,14 @@ class LoncapaSystem(object):
""" """
def __init__( def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments
self, self,
ajax_url, ajax_url,
anonymous_student_id, anonymous_student_id,
cache, cache,
can_execute_unsafe_code, can_execute_unsafe_code,
get_python_lib_zip, get_python_lib_zip,
DEBUG, DEBUG, # pylint: disable=invalid-name
i18n, i18n,
render_template, render_template,
resources_fs, resources_fs,
@@ -126,12 +124,12 @@ class LoncapaSystem(object):
self.matlab_api_key = matlab_api_key self.matlab_api_key = matlab_api_key
class LoncapaProblem(object): class LoncapaProblem: # pylint: disable=too-many-public-methods,too-many-instance-attributes
""" """
Main class for capa Problems. Main class for capa Problems.
""" """
def __init__( def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments
self, self,
problem_text, problem_text,
id, # pylint: disable=redefined-builtin id, # pylint: disable=redefined-builtin
@@ -165,7 +163,7 @@ class LoncapaProblem(object):
""" """
## Initialize class variables from state # Initialize class variables from state
self.do_reset() self.do_reset()
self.problem_id = id self.problem_id = id
self.capa_system = capa_system self.capa_system = capa_system
@@ -339,7 +337,7 @@ class LoncapaProblem(object):
self.student_answers = initial_answers self.student_answers = initial_answers
def __str__(self): def __str__(self):
return "LoncapaProblem ({0})".format(self.problem_id) return f"LoncapaProblem ({self.problem_id})"
def get_state(self): def get_state(self):
""" """
@@ -439,7 +437,7 @@ class LoncapaProblem(object):
if self.correct_map.is_queued(answer_id) if self.correct_map.is_queued(answer_id)
] ]
queuetimes = [ queuetimes = [
datetime.strptime(qt_str, xqueue_interface.dateformat).replace(tzinfo=UTC) for qt_str in queuetime_strs datetime.strptime(qt_str, xqueue_interface.DATEFORMAT).replace(tzinfo=UTC) for qt_str in queuetime_strs
] ]
return max(queuetimes) return max(queuetimes)
@@ -459,7 +457,7 @@ class LoncapaProblem(object):
# if answers include File objects, convert them to filenames. # if answers include File objects, convert them to filenames.
self.student_answers = convert_files_to_filenames(answers) self.student_answers = convert_files_to_filenames(answers)
new_cmap = self.get_grade_from_current_answers(answers) new_cmap = self.get_grade_from_current_answers(answers)
self.correct_map = new_cmap # lint-amnesty, pylint: disable=attribute-defined-outside-init self.correct_map = new_cmap # pylint: disable=attribute-defined-outside-init
self.correct_map_history.append(deepcopy(new_cmap)) self.correct_map_history.append(deepcopy(new_cmap))
return self.correct_map return self.correct_map
@@ -515,7 +513,9 @@ class LoncapaProblem(object):
# TODO: figure out where to get file submissions when rescoring. # TODO: figure out where to get file submissions when rescoring.
if "filesubmission" in responder.allowed_inputfields and student_answers is None: if "filesubmission" in responder.allowed_inputfields and student_answers is None:
_ = self.capa_system.i18n.gettext _ = self.capa_system.i18n.gettext
raise Exception(_("Cannot rescore problems with possible file submissions")) raise Exception( # pylint: disable=broad-exception-raised
_("Cannot rescore problems with possible file submissions")
)
# use 'student_answers' only if it is provided, and if it might contain a file # use 'student_answers' only if it is provided, and if it might contain a file
# submission that would not exist in the persisted "student_answers". # submission that would not exist in the persisted "student_answers".
@@ -540,7 +540,7 @@ class LoncapaProblem(object):
""" """
# dict of (id, correct_answer) # dict of (id, correct_answer)
answer_map = {} answer_map = {}
for response in self.responders.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary for response in self.responders:
results = self.responder_answers[response] results = self.responder_answers[response]
answer_map.update(results) answer_map.update(results)
@@ -560,7 +560,7 @@ class LoncapaProblem(object):
get_question_answers may only return a subset of these. get_question_answers may only return a subset of these.
""" """
answer_ids = [] answer_ids = []
for response in self.responders.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary for response in self.responders:
results = self.responder_answers[response] results = self.responder_answers[response]
answer_ids.append(list(results.keys())) answer_ids.append(list(results.keys()))
return answer_ids return answer_ids
@@ -577,7 +577,7 @@ class LoncapaProblem(object):
""" """
xml_elements = self.tree.xpath('//*[@id="' + answer_id + '"]') xml_elements = self.tree.xpath('//*[@id="' + answer_id + '"]')
if not xml_elements: if not xml_elements:
return return None
xml_element = xml_elements[0] xml_element = xml_elements[0]
answer_text = xml_element.xpath("@answer") answer_text = xml_element.xpath("@answer")
if answer_text: if answer_text:
@@ -653,12 +653,12 @@ class LoncapaProblem(object):
# then from the first optionresponse we'll end with the <p>. # then from the first optionresponse we'll end with the <p>.
# If we start in the second optionresponse, we'll find another response in the way, # If we start in the second optionresponse, we'll find another response in the way,
# stop early, and instead of a question we'll report "Question 2". # stop early, and instead of a question we'll report "Question 2".
SKIP_ELEMS = ["description"] skip_elems = ["description"]
LABEL_ELEMS = ["p", "label"] label_elems = ["p", "label"]
while questiontext_elem is not None and questiontext_elem.tag in SKIP_ELEMS: while questiontext_elem is not None and questiontext_elem.tag in skip_elems:
questiontext_elem = questiontext_elem.getprevious() questiontext_elem = questiontext_elem.getprevious()
if questiontext_elem is not None and questiontext_elem.tag in LABEL_ELEMS: if questiontext_elem is not None and questiontext_elem.tag in label_elems:
question_text = questiontext_elem.text question_text = questiontext_elem.text
else: else:
question_text = generate_default_question_label() question_text = generate_default_question_label()
@@ -695,11 +695,7 @@ class LoncapaProblem(object):
elif isinstance(current_answer, str) and current_answer.startswith("choice_"): elif isinstance(current_answer, str) and current_answer.startswith("choice_"):
# Many problem (e.g. checkbox) report "choice_0" "choice_1" etc. # Many problem (e.g. checkbox) report "choice_0" "choice_1" etc.
# Here we transform it # Here we transform it
elems = self.tree.xpath( elems = self.tree.xpath(f'//*[@id="{answer_id}"]//*[@name="{current_answer}"]')
'//*[@id="{answer_id}"]//*[@name="{choice_number}"]'.format(
answer_id=answer_id, choice_number=current_answer
)
)
if len(elems) == 0: if len(elems) == 0:
log.warning("Answer Text Missing for answer id: %s and choice number: %s", answer_id, current_answer) log.warning("Answer Text Missing for answer id: %s and choice number: %s", answer_id, current_answer)
answer_text = "Answer Text Missing" answer_text = "Answer Text Missing"
@@ -721,7 +717,7 @@ class LoncapaProblem(object):
return answer_text or "Answer Text Missing" return answer_text or "Answer Text Missing"
def do_targeted_feedback(self, tree): def do_targeted_feedback(self, tree): # pylint: disable=too-many-locals,too-many-branches
""" """
Implements targeted-feedback in-place on <multiplechoiceresponse> -- Implements targeted-feedback in-place on <multiplechoiceresponse> --
choice-level explanations shown to a student after submission. choice-level explanations shown to a student after submission.
@@ -827,9 +823,9 @@ class LoncapaProblem(object):
if self.inputs[input_id]: if self.inputs[input_id]:
dispatch = data["dispatch"] dispatch = data["dispatch"]
return self.inputs[input_id].handle_ajax(dispatch, data) return self.inputs[input_id].handle_ajax(dispatch, data)
else:
log.warning("Could not find matching input for id: %s", input_id) log.warning("Could not find matching input for id: %s", input_id)
return {} return {}
# ======= Private Methods Below ======== # ======= Private Methods Below ========
@@ -845,27 +841,25 @@ class LoncapaProblem(object):
try: try:
# open using LoncapaSystem OSFS filesystem # open using LoncapaSystem OSFS filesystem
ifp = self.capa_system.resources_fs.open(filename) ifp = self.capa_system.resources_fs.open(filename)
except Exception as err: # lint-amnesty, pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
log.warning("Error %s in problem xml include: %s", err, etree.tostring(inc, pretty_print=True)) log.warning("Error %s in problem xml include: %s", err, etree.tostring(inc, pretty_print=True))
log.warning("Cannot find file %s in %s", filename, self.capa_system.resources_fs) log.warning("Cannot find file %s in %s", filename, self.capa_system.resources_fs)
# if debugging, don't fail - just log error # if debugging, don't fail - just log error
# TODO (vshnayder): need real error handling, display to users # TODO (vshnayder): need real error handling, display to users
if not self.capa_system.DEBUG: # lint-amnesty, pylint: disable=no-else-raise if not self.capa_system.DEBUG:
raise raise
else: continue
continue
try: try:
# read in and convert to XML # read in and convert to XML
incxml = etree.XML(ifp.read()) incxml = etree.XML(ifp.read())
except Exception as err: # lint-amnesty, pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
log.warning("Error %s in problem xml include: %s", err, etree.tostring(inc, pretty_print=True)) log.warning("Error %s in problem xml include: %s", err, etree.tostring(inc, pretty_print=True))
log.warning("Cannot parse XML in %s", (filename)) log.warning("Cannot parse XML in %s", (filename))
# if debugging, don't fail - just log error # if debugging, don't fail - just log error
# TODO (vshnayder): same as above # TODO (vshnayder): same as above
if not self.capa_system.DEBUG: # lint-amnesty, pylint: disable=no-else-raise if not self.capa_system.DEBUG:
raise raise
else: continue
continue
# insert new XML into tree in place of include # insert new XML into tree in place of include
parent = inc.getparent() parent = inc.getparent()
@@ -882,15 +876,15 @@ class LoncapaProblem(object):
script : ?? (TODO) script : ?? (TODO)
""" """
DEFAULT_PATH = ["code"] default_path = ["code"]
# Separate paths by :, like the system path. # Separate paths by :, like the system path.
raw_path = script.get("system_path", "").split(":") + DEFAULT_PATH raw_path = script.get("system_path", "").split(":") + default_path
# find additional comma-separated modules search path # find additional comma-separated modules search path
path = [] path = []
for dir in raw_path: # lint-amnesty, pylint: disable=redefined-builtin for dir in raw_path: # pylint: disable=redefined-builtin
if not dir: if not dir:
continue continue
@@ -936,8 +930,8 @@ class LoncapaProblem(object):
if d not in python_path and os.path.exists(d): if d not in python_path and os.path.exists(d):
python_path.append(d) python_path.append(d)
XMLESC = {"&apos;": "'", "&quot;": '"'} xmlesc = {"&apos;": "'", "&quot;": '"'}
code = unescape(script.text, XMLESC) code = unescape(script.text, xmlesc)
all_code += code all_code += code
extra_files = [] extra_files = []
@@ -961,10 +955,8 @@ class LoncapaProblem(object):
unsafely=self.capa_system.can_execute_unsafe_code(), unsafely=self.capa_system.can_execute_unsafe_code(),
) )
except Exception as err: except Exception as err:
log.exception( # lint-amnesty, pylint: disable=logging-not-lazy log.exception("Error while execing script code: %s", all_code)
"Error while execing script code: " + all_code msg = Text(f"Error while executing script code: {err}")
)
msg = Text("Error while executing script code: %s" % str(err))
raise responsetypes.LoncapaProblemError(msg) raise responsetypes.LoncapaProblemError(msg)
# Store code source in context, along with the Python path needed to run it correctly. # Store code source in context, along with the Python path needed to run it correctly.
@@ -973,7 +965,9 @@ class LoncapaProblem(object):
context["extra_files"] = extra_files or None context["extra_files"] = extra_files or None
return context return context
def _extract_html(self, problemtree): # private def _extract_html( # private
self, problemtree
): # pylint: disable=too-many-statements,too-many-locals,too-many-branches,too-many-return-statements
""" """
Main (private) function which converts Problem XML tree to HTML. Main (private) function which converts Problem XML tree to HTML.
Calls itself recursively. Calls itself recursively.
@@ -988,14 +982,14 @@ class LoncapaProblem(object):
# and we're ok leaving those behind. # and we're ok leaving those behind.
# BTW: etree gives us no good way to distinguish these things # BTW: etree gives us no good way to distinguish these things
# other than to examine .tag to see if it's a string. :( # other than to examine .tag to see if it's a string. :(
return return None
if problemtree.tag == "script" and problemtree.get("type") and "javascript" in problemtree.get("type"): if problemtree.tag == "script" and problemtree.get("type") and "javascript" in problemtree.get("type"):
# leave javascript intact. # leave javascript intact.
return deepcopy(problemtree) return deepcopy(problemtree)
if problemtree.tag in html_problem_semantics: if problemtree.tag in html_problem_semantics:
return return None
problemid = problemtree.get("id") # my ID problemid = problemtree.get("id") # my ID
@@ -1116,7 +1110,7 @@ class LoncapaProblem(object):
for entry in inputfields: for entry in inputfields:
entry.attrib["response_id"] = str(response_id) entry.attrib["response_id"] = str(response_id)
entry.attrib["answer_id"] = str(answer_id) entry.attrib["answer_id"] = str(answer_id)
entry.attrib["id"] = "%s_%i_%i" % (self.problem_id, response_id, answer_id) entry.attrib["id"] = f"{self.problem_id}_{response_id}_{answer_id}"
answer_id = answer_id + 1 answer_id = answer_id + 1
self.response_a11y_data(response, inputfields, responsetype_id, problem_data) self.response_a11y_data(response, inputfields, responsetype_id, problem_data)
@@ -1133,13 +1127,11 @@ class LoncapaProblem(object):
# get responder answers (do this only once, since there may be a performance cost, # get responder answers (do this only once, since there may be a performance cost,
# eg with externalresponse) # eg with externalresponse)
self.responder_answers = {} self.responder_answers = {}
for response in self.responders.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary for response, responder in self.responders.items():
try: try:
self.responder_answers[response] = self.responders[response].get_answers() self.responder_answers[response] = responder.get_answers()
except: except Exception:
log.debug( log.debug("responder %s failed to properly return get_answers()", responder) # FIXME
"responder %s failed to properly return get_answers()", self.responders[response]
) # FIXME
raise raise
# <solution>...</solution> may not be associated with any specific response; give # <solution>...</solution> may not be associated with any specific response; give
@@ -1147,12 +1139,14 @@ class LoncapaProblem(object):
# TODO: We should make the namespaces consistent and unique (e.g. %s_problem_%i). # TODO: We should make the namespaces consistent and unique (e.g. %s_problem_%i).
solution_id = 1 solution_id = 1
for solution in tree.findall(".//solution"): for solution in tree.findall(".//solution"):
solution.attrib["id"] = "%s_solution_%i" % (self.problem_id, solution_id) solution.attrib["id"] = f"{self.problem_id}_solution_{solution_id}"
solution_id += 1 solution_id += 1
return problem_data return problem_data
def response_a11y_data(self, response, inputfields, responsetype_id, problem_data): def response_a11y_data( # pylint: disable=too-many-locals,too-many-branches
self, response, inputfields, responsetype_id, problem_data
):
""" """
Construct data to be used for a11y. Construct data to be used for a11y.
@@ -1173,7 +1167,7 @@ class LoncapaProblem(object):
response.set("multiple_inputtypes", "true") response.set("multiple_inputtypes", "true")
group_label_tag = response.find("label") group_label_tag = response.find("label")
group_description_tags = response.findall("description") group_description_tags = response.findall("description")
group_label_tag_id = "multiinput-group-label-{}".format(responsetype_id) group_label_tag_id = f"multiinput-group-label-{responsetype_id}"
group_label_tag_text = "" group_label_tag_text = ""
if group_label_tag is not None: if group_label_tag is not None:
group_label_tag.tag = "p" group_label_tag.tag = "p"
@@ -1184,7 +1178,7 @@ class LoncapaProblem(object):
group_description_ids = [] group_description_ids = []
for index, group_description_tag in enumerate(group_description_tags): for index, group_description_tag in enumerate(group_description_tags):
group_description_tag_id = "multiinput-group-description-{}-{}".format(responsetype_id, index) group_description_tag_id = f"multiinput-group-description-{responsetype_id}-{index}"
group_description_tag.tag = "p" group_description_tag.tag = "p"
group_description_tag.set("id", group_description_tag_id) group_description_tag.set("id", group_description_tag_id)
group_description_tag.set("class", "multi-inputs-group-description question-description") group_description_tag.set("class", "multi-inputs-group-description question-description")
@@ -1235,9 +1229,7 @@ class LoncapaProblem(object):
description_id = 1 description_id = 1
descriptions = OrderedDict() descriptions = OrderedDict()
for description in description_tags: for description in description_tags:
descriptions["description_%s_%i" % (responsetype_id, description_id)] = HTML( descriptions[f"description_{responsetype_id}_{description_id}"] = HTML(stringify_children(description))
stringify_children(description)
)
response.remove(description) response.remove(description)
description_id += 1 description_id += 1

View File

@@ -3,7 +3,6 @@
Commandline tool for doing operations on Problems Commandline tool for doing operations on Problems
""" """
import argparse import argparse
import logging import logging
import sys import sys
@@ -18,9 +17,11 @@ logging.basicConfig(format="%(levelname)s %(message)s")
log = logging.getLogger("capa.checker") log = logging.getLogger("capa.checker")
class DemoSystem(object): # lint-amnesty, pylint: disable=missing-class-docstring class DemoSystem: # pylint: disable=too-few-public-methods
"""Render templates using Django's template engine."""
def __init__(self): def __init__(self):
self.DEBUG = True self.DEBUG = True # pylint: disable=invalid-name
def render_template(self, template_filename, dictionary): def render_template(self, template_filename, dictionary):
""" """
@@ -29,7 +30,9 @@ class DemoSystem(object): # lint-amnesty, pylint: disable=missing-class-docstri
return get_template(template_filename).render(dictionary) return get_template(template_filename).render(dictionary)
def main(): # lint-amnesty, pylint: disable=missing-function-docstring def main():
"""Parse command-line arguments to test or display Loncapa problem files."""
parser = argparse.ArgumentParser(description="Check Problem Files") parser = argparse.ArgumentParser(description="Check Problem Files")
parser.add_argument("command", choices=["test", "show"]) # Watch? Render? Open? parser.add_argument("command", choices=["test", "show"]) # Watch? Render? Open?
parser.add_argument("files", nargs="+", type=argparse.FileType("r")) parser.add_argument("files", nargs="+", type=argparse.FileType("r"))
@@ -47,14 +50,17 @@ def main(): # lint-amnesty, pylint: disable=missing-function-docstring
system = DemoSystem() system = DemoSystem()
for problem_file in args.files: for problem_file in args.files:
log.info("Opening {0}".format(problem_file.name)) log.info("Opening %s", problem_file.name)
try: try:
problem = LoncapaProblem( # lint-amnesty, pylint: disable=no-value-for-parameter, unexpected-keyword-arg problem = LoncapaProblem( # pylint: disable=unexpected-keyword-arg, no-value-for-parameter
problem_file, "fakeid", seed=args.seed, system=system problem_file,
"fakeid",
seed=args.seed,
system=system,
) )
except Exception as ex: # lint-amnesty, pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-exception-caught
log.error("Could not parse file {0}".format(problem_file.name)) log.error("Could not parse file %s", problem_file.name)
log.exception(ex) log.exception(ex)
continue continue
@@ -73,7 +79,9 @@ def command_show(problem):
print(problem.get_html()) print(problem.get_html())
def command_test(problem): # lint-amnesty, pylint: disable=missing-function-docstring def command_test(problem):
"""Run tests on a problem while capturing and logging stdout and stderr output."""
# We're going to trap stdout/stderr from the problems (yes, some print) # We're going to trap stdout/stderr from the problems (yes, some print)
old_stdout, old_stderr = sys.stdout, sys.stderr old_stdout, old_stderr = sys.stdout, sys.stderr
try: try:
@@ -83,9 +91,9 @@ def command_test(problem): # lint-amnesty, pylint: disable=missing-function-doc
check_that_suggested_answers_work(problem) check_that_suggested_answers_work(problem)
check_that_blanks_fail(problem) check_that_blanks_fail(problem)
log_captured_output(sys.stdout, "captured stdout from {0}".format(problem)) log_captured_output(sys.stdout, f"captured stdout from {problem}")
log_captured_output(sys.stderr, "captured stderr from {0}".format(problem)) log_captured_output(sys.stderr, f"captured stderr from {problem}")
except Exception as e: # lint-amnesty, pylint: disable=broad-except except Exception as e: # pylint: disable=broad-exception-caught
log.exception(e) log.exception(e)
finally: finally:
sys.stdout, sys.stderr = old_stdout, old_stderr sys.stdout, sys.stderr = old_stdout, old_stderr
@@ -99,9 +107,9 @@ def check_that_blanks_fail(problem):
assert all(result == "incorrect" for result in grading_results.values()) assert all(result == "incorrect" for result in grading_results.values())
except AssertionError: except AssertionError:
log.error( log.error(
"Blank accepted as correct answer in {0} for {1}".format( "Blank accepted as correct answer in %s for %s",
problem, [answer_id for answer_id, result in sorted(grading_results.items()) if result != "incorrect"] problem,
) [answer_id for answer_id, result in sorted(grading_results.items()) if result != "incorrect"],
) )
@@ -124,7 +132,7 @@ def check_that_suggested_answers_work(problem):
all_answer_ids = problem.get_answer_ids() all_answer_ids = problem.get_answer_ids()
all_answers = dict((answer_id, real_answers.get(answer_id, "")) for answer_id in all_answer_ids) all_answers = dict((answer_id, real_answers.get(answer_id, "")) for answer_id in all_answer_ids)
log.debug("Real answers: {0}".format(real_answers)) log.debug("Real answers: %s", real_answers)
if real_answers: if real_answers:
try: try:
real_results = dict( real_results = dict(
@@ -135,28 +143,28 @@ def check_that_suggested_answers_work(problem):
log.debug(real_results) log.debug(real_results)
assert all(result == "correct" for answer_id, result in real_results.items()) assert all(result == "correct" for answer_id, result in real_results.items())
except UndefinedVariable as uv_exc: except UndefinedVariable as uv_exc:
log.error( # lint-amnesty, pylint: disable=logging-not-lazy log.error(
'The variable "{0}" specified in the '.format(uv_exc) 'The variable "%s" specified in the solution isn\'t recognized (is it a units measure?).',
+ "solution isn't recognized (is it a units measure?)." uv_exc,
) )
except AssertionError: except AssertionError:
log.error("The following generated answers were not accepted for {0}:".format(problem)) log.error("The following generated answers were not accepted for %s:", problem)
for question_id, result in sorted(real_results.items()): for question_id, result in sorted(real_results.items()):
if result != "correct": if result != "correct":
log.error(" {0} = {1}".format(question_id, real_answers[question_id])) log.error(" %s = %s", question_id, real_answers[question_id])
except Exception as ex: # lint-amnesty, pylint: disable=broad-except except Exception as ex: # pylint: disable=broad-exception-caught
log.error("Uncaught error in {0}".format(problem)) log.error("Uncaught error in %s", problem)
log.exception(ex) log.exception(ex)
def log_captured_output(output_stream, stream_name): # lint-amnesty, pylint: disable=missing-function-docstring def log_captured_output(output_stream, stream_name):
"""Log the contents of a captured output stream with header and footer markers."""
output_stream.seek(0) output_stream.seek(0)
output_text = output_stream.read() output_text = output_stream.read()
if output_text: if output_text:
log.info( log.info("##### Begin %s #####\n%s", stream_name, output_text)
"##### Begin {0} #####\n".format(stream_name) + output_text log.info("##### End %s #####", stream_name)
) # lint-amnesty, pylint: disable=logging-not-lazy
log.info("##### End {0} #####".format(stream_name))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,11 +1,15 @@
# lint-amnesty, pylint: disable=missing-module-docstring """
CorrectMap: A utility class to store and manage graded responses to CAPA questions.
Provides methods to track correctness, points, messages, hints, and queue state.
"""
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# class used to store graded responses to CAPA questions # class used to store graded responses to CAPA questions
# #
# Used by responsetypes and capa_problem # Used by responsetypes and capa_problem
class CorrectMap(object): class CorrectMap:
""" """
Stores map between answer_id and response evaluation result for each question Stores map between answer_id and response evaluation result for each question
in a capa problem. The response evaluation result for each answer_id includes in a capa problem. The response evaluation result for each answer_id includes
@@ -39,8 +43,8 @@ class CorrectMap(object):
return self.cmap.__iter__() return self.cmap.__iter__()
# See the documentation for 'set_dict' for the use of kwargs # See the documentation for 'set_dict' for the use of kwargs
def set( # lint-amnesty, pylint: disable=missing-function-docstring def set( # pylint: disable=too-many-positional-arguments,too-many-arguments
self, # lint-amnesty, pylint: disable=unused-argument self,
answer_id=None, answer_id=None,
correctness=None, correctness=None,
npoints=None, npoints=None,
@@ -49,8 +53,12 @@ class CorrectMap(object):
hintmode=None, hintmode=None,
queuestate=None, queuestate=None,
answervariable=None, answervariable=None,
**kwargs **kwargs, # pylint: disable=unused-argument
): ):
"""
Set or update the stored evaluation result for a given answer_id.
Unused kwargs are ignored for compatibility with older formats.
"""
if answer_id is not None: if answer_id is not None:
self.cmap[answer_id] = { self.cmap[answer_id] = {
@@ -124,48 +132,59 @@ class CorrectMap(object):
return None return None
def is_queued(self, answer_id): def is_queued(self, answer_id):
"""Return True if the answer has a non-empty queue state."""
return answer_id in self.cmap and self.cmap[answer_id]["queuestate"] is not None return answer_id in self.cmap and self.cmap[answer_id]["queuestate"] is not None
def is_right_queuekey(self, answer_id, test_key): def is_right_queuekey(self, answer_id, test_key):
"""Return True if the queued answer matches the provided queue key."""
return self.is_queued(answer_id) and self.cmap[answer_id]["queuestate"]["key"] == test_key return self.is_queued(answer_id) and self.cmap[answer_id]["queuestate"]["key"] == test_key
def get_queuetime_str(self, answer_id): def get_queuetime_str(self, answer_id):
"""Return the stored queue timestamp string for the given answer."""
if self.cmap[answer_id]["queuestate"]: if self.cmap[answer_id]["queuestate"]:
return self.cmap[answer_id]["queuestate"]["time"] return self.cmap[answer_id]["queuestate"]["time"]
else:
return None return None
def get_npoints(self, answer_id): def get_npoints(self, answer_id):
"""Return the number of points for an answer, used for partial credit.""" """Return the number of points for an answer, used for partial credit."""
npoints = self.get_property(answer_id, "npoints") npoints = self.get_property(answer_id, "npoints")
if npoints is not None: if npoints is not None:
return npoints return npoints
elif self.is_correct(answer_id):
if self.is_correct(answer_id):
return 1 return 1
# if not correct and no points have been assigned, return 0 # if not correct and no points have been assigned, return 0
return 0 return 0
def set_property(self, answer_id, property, value): # lint-amnesty, pylint: disable=redefined-builtin def set_property(self, answer_id, prop, value):
"""Set a specific property value for the given answer_id."""
if answer_id in self.cmap: if answer_id in self.cmap:
self.cmap[answer_id][property] = value self.cmap[answer_id][prop] = value
else: else:
self.cmap[answer_id] = {property: value} self.cmap[answer_id] = {prop: value}
def get_property(self, answer_id, property, default=None): # lint-amnesty, pylint: disable=redefined-builtin def get_property(self, answer_id, prop, default=None):
"""Return the specified property for an answer, or a default value."""
if answer_id in self.cmap: if answer_id in self.cmap:
return self.cmap[answer_id].get(property, default) return self.cmap[answer_id].get(prop, default)
return default return default
def get_correctness(self, answer_id): def get_correctness(self, answer_id):
"""Return the correctness value for the given answer."""
return self.get_property(answer_id, "correctness") return self.get_property(answer_id, "correctness")
def get_msg(self, answer_id): def get_msg(self, answer_id):
"""Return the feedback message for the given answer."""
return self.get_property(answer_id, "msg", "") return self.get_property(answer_id, "msg", "")
def get_hint(self, answer_id): def get_hint(self, answer_id):
"""Return the hint text associated with the given answer."""
return self.get_property(answer_id, "hint", "") return self.get_property(answer_id, "hint", "")
def get_hintmode(self, answer_id): def get_hintmode(self, answer_id):
"""Return the hint display mode for the given answer."""
return self.get_property(answer_id, "hintmode", None) return self.get_property(answer_id, "hintmode", None)
def set_hint_and_mode(self, answer_id, hint, hintmode): def set_hint_and_mode(self, answer_id, hint, hintmode):
@@ -181,7 +200,9 @@ class CorrectMap(object):
Update this CorrectMap with the contents of another CorrectMap Update this CorrectMap with the contents of another CorrectMap
""" """
if not isinstance(other_cmap, CorrectMap): if not isinstance(other_cmap, CorrectMap):
raise Exception("CorrectMap.update called with invalid argument %s" % other_cmap) raise Exception( # pylint: disable=broad-exception-raised
f"CorrectMap.update called with invalid argument {other_cmap}"
)
self.cmap.update(other_cmap.get_dict()) self.cmap.update(other_cmap.get_dict())
self.set_overall_message(other_cmap.get_overall_message()) self.set_overall_message(other_cmap.get_overall_message())

View File

@@ -8,9 +8,9 @@ and the xml element.
import logging import logging
import re import re
import xml.sax.saxutils as saxutils from xml.sax import saxutils
from django.utils import html from django.utils import html as html_escape
from lxml import etree from lxml import etree
from .registry import TagRegistry from .registry import TagRegistry
@@ -23,7 +23,11 @@ registry = TagRegistry()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class MathRenderer(object): # lint-amnesty, pylint: disable=missing-class-docstring class MathRenderer: # pylint: disable=too-few-public-methods
"""
Renders <math> tags into MathJax-compatible HTML for displaying math expressions.
"""
tags = ["math"] tags = ["math"]
def __init__(self, system, xml): def __init__(self, system, xml):
@@ -48,37 +52,30 @@ class MathRenderer(object): # lint-amnesty, pylint: disable=missing-class-docst
mtag += "inline" mtag += "inline"
else: else:
mathstr = mathstr.replace(r"\displaystyle", "") mathstr = mathstr.replace(r"\displaystyle", "")
self.mathstr = mathstr.replace("mathjaxinline]", "%s]" % mtag) self.mathstr = mathstr.replace("mathjaxinline]", f"{mtag}]")
def get_html(self): def get_html(self):
""" """
Return the contents of this tag, rendered to html, as an etree element. Return the contents of this tag, rendered to html, as an etree element.
""" """
# TODO: why are there nested html tags here?? Why are there html tags at all, in fact? # TODO: why are there nested html tags here?? Why are there html tags at all, in fact?
# xss-lint: disable=python-interpolate-html
html = "<html><html>%s</html><html>%s</html></html>" % ( # lint-amnesty, pylint: disable=redefined-outer-name html = f"<html><html>{self.mathstr}</html><html>{saxutils.escape(self.xml.tail or '')}</html></html>"
self.mathstr,
saxutils.escape(self.xml.tail),
)
try: try:
xhtml = etree.XML(html) xhtml = etree.XML(html)
except Exception as err: # lint-amnesty, pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
if self.system.DEBUG: if self.system.DEBUG:
# xss-lint: disable=python-interpolate-html msg = (
msg = '<html><div class="inline-error"><p>Error %s</p>' % ( f"<html><div class='inline-error'>"
str(err).replace("<", "&lt;") # xss-lint: disable=python-custom-escape f"<p>Error {html_escape.escape(err)}</p>"
f"<p>Failed to construct math expression from <pre>{html_escape.escape(html)}</pre></p>"
f"</div></html>"
) )
# xss-lint: disable=python-interpolate-html
msg += "<p>Failed to construct math expression from <pre>%s</pre></p>" % html.replace(
"<", "&lt;" # xss-lint: disable=python-custom-escape
)
msg += "</div></html>"
log.error(msg) log.error(msg)
return etree.XML(msg) return etree.XML(msg)
else:
raise raise
return xhtml return xhtml
@@ -88,7 +85,7 @@ registry.register(MathRenderer)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class SolutionRenderer(object): class SolutionRenderer: # pylint: disable=too-few-public-methods
""" """
A solution is just a <span>...</span> which is given an ID, that is used for displaying an A solution is just a <span>...</span> which is given an ID, that is used for displaying an
extended answer (a problem "solution") after "show answers" is pressed. extended answer (a problem "solution") after "show answers" is pressed.
@@ -103,11 +100,10 @@ class SolutionRenderer(object):
self.system = system self.system = system
self.id = xml.get("id") self.id = xml.get("id")
def get_html(self): # pylint: disable=missing-function-docstring def get_html(self):
"""Return the solution HTML rendered as an etree element."""
context = {"id": self.id} context = {"id": self.id}
html = self.system.render_template( # lint-amnesty, pylint: disable=redefined-outer-name html = self.system.render_template("solutionspan.html", context)
"solutionspan.html", context
)
return etree.XML(html) return etree.XML(html)
@@ -117,7 +113,7 @@ registry.register(SolutionRenderer)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class TargetedFeedbackRenderer(object): class TargetedFeedbackRenderer: # pylint: disable=too-few-public-methods
""" """
A targeted feedback is just a <span>...</span> that is used for displaying an A targeted feedback is just a <span>...</span> that is used for displaying an
extended piece of feedback to students if they incorrectly answered a question. extended piece of feedback to students if they incorrectly answered a question.
@@ -134,29 +130,30 @@ class TargetedFeedbackRenderer(object):
Return the contents of this tag, rendered to html, as an etree element. Return the contents of this tag, rendered to html, as an etree element.
""" """
# xss-lint: disable=python-wrap-html # xss-lint: disable=python-wrap-html
html_str = '<section class="targeted-feedback-span"><span>{}</span></section>'.format( html_str = (
etree.tostring(self.xml, encoding="unicode") f'<section class="targeted-feedback-span">'
f'<span>{etree.tostring(self.xml, encoding="unicode")}</span>'
f"</section>"
) )
try: try:
xhtml = etree.XML(html_str) xhtml = etree.XML(html_str)
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
if self.system.DEBUG: if self.system.DEBUG:
# xss-lint: disable=python-wrap-html # xss-lint: disable=python-wrap-html
msg = """ msg = f"""
<html> <html>
<div class="inline-error"> <div class="inline-error">
<p>Error {err}</p> <p>Error {html_escape.escape(err)}</p>
<p>Failed to construct targeted feedback from <pre>{html}</pre></p> <p>Failed to construct targeted feedback from <pre>{html_escape.escape(html_str)}</pre></p>
</div> </div>
</html> </html>
""".format( """
err=html.escape(err), html=html.escape(html_str)
)
log.error(msg) log.error(msg)
return etree.XML(msg) return etree.XML(msg)
else:
raise raise
return xhtml return xhtml
@@ -166,7 +163,7 @@ registry.register(TargetedFeedbackRenderer)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
class ClarificationRenderer(object): class ClarificationRenderer: # pylint: disable=too-few-public-methods
""" """
A clarification appears as an inline icon which reveals more information when the user A clarification appears as an inline icon which reveals more information when the user
hovers over it. hovers over it.
@@ -188,9 +185,7 @@ class ClarificationRenderer(object):
Return the contents of this tag, rendered to html, as an etree element. Return the contents of this tag, rendered to html, as an etree element.
""" """
context = {"clarification": self.inner_html} context = {"clarification": self.inner_html}
html = self.system.render_template( # lint-amnesty, pylint: disable=redefined-outer-name html = self.system.render_template("clarification.html", context)
"clarification.html", context
)
xml = etree.XML(html) xml = etree.XML(html)
# We must include any text that was following our original <clarification>...</clarification> XML node.: # We must include any text that was following our original <clarification>...</clarification> XML node.:
xml.tail = self.tail xml.tail = self.tail

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
# #
# File: courseware/capa/inputtypes.py # File: courseware/capa/inputtypes.py
# #
@@ -49,9 +50,9 @@ from datetime import datetime
import html5lib import html5lib
import nh3 import nh3
import pyparsing
import six import six
from calc.preview import latex_preview from calc.preview import latex_preview
import pyparsing
from chem import chemcalc from chem import chemcalc
from lxml import etree from lxml import etree
@@ -65,10 +66,10 @@ from .util import sanitize_html
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
registry = TagRegistry() # pylint: disable=invalid-name registry = TagRegistry()
class Status(object): class Status:
""" """
Problem status Problem status
attributes: classname, display_name, display_tooltip attributes: classname, display_name, display_tooltip
@@ -111,7 +112,7 @@ class Status(object):
return self._status return self._status
def __repr__(self): def __repr__(self):
return "Status(%r)" % self._status return f"Status({self._status!r})"
def __eq__(self, other): def __eq__(self, other):
return self._status == str(other) return self._status == str(other)
@@ -120,7 +121,7 @@ class Status(object):
return hash(str(self)) return hash(str(self))
class Attribute(object): class Attribute: # pylint: disable=too-few-public-methods
""" """
Allows specifying required and optional attributes for input types. Allows specifying required and optional attributes for input types.
""" """
@@ -128,7 +129,9 @@ class Attribute(object):
# want to allow default to be None, but also allow required objects # want to allow default to be None, but also allow required objects
_sentinel = object() _sentinel = object()
def __init__(self, name, default=_sentinel, transform=None, validate=None, render=True): def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments
self, name, default=_sentinel, transform=None, validate=None, render=True
):
""" """
Define an attribute Define an attribute
@@ -160,7 +163,7 @@ class Attribute(object):
""" """
val = element.get(self.name) val = element.get(self.name)
if self.default == self._sentinel and val is None: if self.default == self._sentinel and val is None:
raise ValueError("Missing required attribute {0}.".format(self.name)) raise ValueError(f"Missing required attribute {self.name}.")
if val is None: if val is None:
# not required, so return default # not required, so return default
@@ -175,7 +178,7 @@ class Attribute(object):
return val return val
class InputTypeBase(object): class InputTypeBase: # pylint: disable=too-many-instance-attributes
""" """
Abstract base class for input types. Abstract base class for input types.
""" """
@@ -214,7 +217,7 @@ class InputTypeBase(object):
self.input_id = state.get("id", xml.get("id")) self.input_id = state.get("id", xml.get("id"))
if self.input_id is None: if self.input_id is None:
raise ValueError("input id state is None. xml is {0}".format(etree.tostring(xml))) raise ValueError(f"input id state is None. xml is {etree.tostring(xml)}")
self.value = state.get("value", "") self.value = state.get("value", "")
@@ -242,9 +245,9 @@ class InputTypeBase(object):
# super().__init__, and are isolated from changes to the input # super().__init__, and are isolated from changes to the input
# constructor interface. # constructor interface.
self.setup() self.setup()
except Exception as err: # lint-amnesty, pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
# Something went wrong: add xml to message, but keep the traceback # Something went wrong: add xml to message, but keep the traceback
msg = "Error in xml '{x}': {err} ".format(x=etree.tostring(xml), err=str(err)) msg = f"Error in xml '{etree.tostring(xml)}': {err} "
six.reraise(Exception, Exception(msg), sys.exc_info()[2]) six.reraise(Exception, Exception(msg), sys.exc_info()[2])
@classmethod @classmethod
@@ -285,7 +288,6 @@ class InputTypeBase(object):
If this method raises an exception, it will be wrapped with a message that includes the If this method raises an exception, it will be wrapped with a message that includes the
problem xml. problem xml.
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
def handle_ajax(self, dispatch, data): def handle_ajax(self, dispatch, data):
""" """
@@ -298,7 +300,6 @@ class InputTypeBase(object):
Output: Output:
a dictionary object that can be serialized into JSON. This will be sent back to the Javascript. a dictionary object that can be serialized into JSON. This will be sent back to the Javascript.
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
def _get_render_context(self): def _get_render_context(self):
""" """
@@ -356,7 +357,7 @@ class InputTypeBase(object):
Return the html for this input, as an etree element. Return the html for this input, as an etree element.
""" """
if self.template is None: if self.template is None:
raise NotImplementedError("no rendering template specified for class {0}".format(self.__class__)) raise NotImplementedError(f"no rendering template specified for class {self.__class__}")
context = self._get_render_context() context = self._get_render_context()
@@ -366,11 +367,12 @@ class InputTypeBase(object):
output = etree.XML(html) output = etree.XML(html)
except etree.XMLSyntaxError as ex: except etree.XMLSyntaxError as ex:
# If `html` contains attrs with no values, like `controls` in <audio controls src='smth'/>, # 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. # lint-amnesty, pylint: disable=line-too-long # XML parser will raise exception, so wee fallback to html5parser,
# which will set empty "" values for such attrs.
try: try:
output = html5lib.parseFragment(html, treebuilder="lxml", namespaceHTMLElements=False)[0] output = html5lib.parseFragment(html, treebuilder="lxml", namespaceHTMLElements=False)[0]
except IndexError: except IndexError as exc:
raise ex # lint-amnesty, pylint: disable=raise-missing-from raise ex from exc
return output return output
@@ -489,7 +491,7 @@ class ChoiceGroup(InputTypeBase):
_ = i18n.gettext _ = i18n.gettext
# Translators: 'ChoiceGroup' is an input type and should not be translated. # Translators: 'ChoiceGroup' is an input type and should not be translated.
msg = _("ChoiceGroup: unexpected tag {tag_name}").format(tag_name=self.tag) msg = _("ChoiceGroup: unexpected tag {tag_name}").format(tag_name=self.tag)
raise Exception(msg) raise Exception(msg) # pylint: disable=broad-exception-raised
self.choices = self.extract_choices(self.xml, i18n) self.choices = self.extract_choices(self.xml, i18n)
self._choices_map = dict( self._choices_map = dict(
@@ -500,7 +502,7 @@ class ChoiceGroup(InputTypeBase):
def get_attributes(cls): def get_attributes(cls):
# Make '_' a no-op so we can scrape strings. Using lambda instead of # Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file # `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
_ = lambda text: text _ = lambda text: text # pylint: disable=unnecessary-lambda-assignment
return [Attribute("show_correctness", "always"), Attribute("submitted_message", _("Answer received."))] return [Attribute("show_correctness", "always"), Attribute("submitted_message", _("Answer received."))]
def _extra_context(self): def _extra_context(self):
@@ -538,7 +540,7 @@ class ChoiceGroup(InputTypeBase):
_("Expected a <choice> or <compoundhint> tag; got {given_tag} instead") _("Expected a <choice> or <compoundhint> tag; got {given_tag} instead")
).format(given_tag=choice.tag) ).format(given_tag=choice.tag)
) )
raise Exception(msg) raise Exception(msg) # pylint: disable=broad-exception-raised
return choices return choices
def get_user_visible_answer(self, internal_answer): def get_user_visible_answer(self, internal_answer):
@@ -608,8 +610,8 @@ class JSInput(InputTypeBase):
def _extra_context(self): def _extra_context(self):
context = { context = {
"jschannel_loader": "{static_url}js/capa/src/jschannel.js".format(static_url=self.capa_system.STATIC_URL), "jschannel_loader": f"{self.capa_system.STATIC_URL}js/capa/src/jschannel.js",
"jsinput_loader": "{static_url}js/capa/src/jsinput.js".format(static_url=self.capa_system.STATIC_URL), "jsinput_loader": f"{self.capa_system.STATIC_URL}js/capa/src/jsinput.js",
"saved_state": self.value, "saved_state": self.value,
} }
@@ -785,12 +787,12 @@ class CodeInput(InputTypeBase):
self.value = self.xml.text.strip() self.value = self.xml.text.strip()
# Check if problem has been queued # Check if problem has been queued
self.queue_len = 0 # lint-amnesty, pylint: disable=attribute-defined-outside-init self.queue_len = 0 # pylint: disable=attribute-defined-outside-init
# Flag indicating that the problem has been queued, 'msg' is length of # Flag indicating that the problem has been queued, 'msg' is length of
# queue # queue
if self.status == "incomplete": if self.status == "incomplete":
self.status = "queued" self.status = "queued"
self.queue_len = self.msg # lint-amnesty, pylint: disable=attribute-defined-outside-init self.queue_len = self.msg # pylint: disable=attribute-defined-outside-init
self.msg = nh3.clean(self.submitted_msg) self.msg = nh3.clean(self.submitted_msg)
def setup(self): def setup(self):
@@ -919,8 +921,8 @@ class MatlabInput(CodeInput):
""" """
if self.status in ["correct", "incorrect", "partially-correct"]: if self.status in ["correct", "incorrect", "partially-correct"]:
return False return False
else:
return True return True
def _extra_context(self): def _extra_context(self):
"""Set up additional context variables""" """Set up additional context variables"""
@@ -942,9 +944,7 @@ class MatlabInput(CodeInput):
"queue_len": str(self.queue_len), "queue_len": str(self.queue_len),
"queue_msg": queue_msg, "queue_msg": queue_msg,
"button_enabled": self.button_enabled(), "button_enabled": self.button_enabled(),
"matlab_editor_js": "{static_url}js/vendor/CodeMirror/octave.js".format( "matlab_editor_js": f"{self.capa_system.STATIC_URL}js/vendor/CodeMirror/octave.js",
static_url=self.capa_system.STATIC_URL
),
"msg": sanitize_html(self.msg), # sanitize msg before rendering into template "msg": sanitize_html(self.msg), # sanitize msg before rendering into template
} }
return extra_context return extra_context
@@ -984,7 +984,7 @@ class MatlabInput(CodeInput):
# construct xqueue headers # construct xqueue headers
qinterface = self.capa_system.xqueue.interface qinterface = self.capa_system.xqueue.interface
qtime = datetime.utcnow().strftime(xqueue_interface.dateformat) qtime = datetime.utcnow().strftime(xqueue_interface.DATEFORMAT)
callback_url = self.capa_system.xqueue.construct_callback("ungraded_response") callback_url = self.capa_system.xqueue.construct_callback("ungraded_response")
anonymous_student_id = self.capa_system.anonymous_student_id anonymous_student_id = self.capa_system.anonymous_student_id
# TODO: Why is this using self.capa_system.seed when we have self.seed??? # TODO: Why is this using self.capa_system.seed when we have self.seed???
@@ -1044,7 +1044,7 @@ class Schematic(InputTypeBase):
def _extra_context(self): def _extra_context(self):
context = { context = {
"setup_script": "{static_url}js/capa/schematicinput.js".format(static_url=self.capa_system.STATIC_URL), "setup_script": f"{self.capa_system.STATIC_URL}js/capa/schematicinput.js",
} }
return context return context
@@ -1179,9 +1179,7 @@ class ChemicalEquationInput(InputTypeBase):
TODO (vshnayder): Get rid of this once we have a standard way of requiring js to be loaded. TODO (vshnayder): Get rid of this once we have a standard way of requiring js to be loaded.
""" """
return { return {
"previewer": "{static_url}js/capa/chemical_equation_preview.js".format( "previewer": f"{self.capa_system.STATIC_URL}js/capa/chemical_equation_preview.js",
static_url=self.capa_system.STATIC_URL
),
} }
def handle_ajax(self, dispatch, data): def handle_ajax(self, dispatch, data):
@@ -1217,7 +1215,7 @@ class ChemicalEquationInput(InputTypeBase):
result["preview"] = chemcalc.render_to_html(formula) result["preview"] = chemcalc.render_to_html(formula)
except pyparsing.ParseException as err: except pyparsing.ParseException as err:
result["error"] = _("Couldn't parse formula: {error_msg}").format(error_msg=err.msg) result["error"] = _("Couldn't parse formula: {error_msg}").format(error_msg=err.msg)
except Exception: # lint-amnesty, pylint: disable=broad-except except Exception: # pylint: disable=broad-exception-caught
# this is unexpected, so log # this is unexpected, so log
log.warning("Error while previewing chemical formula", exc_info=True) log.warning("Error while previewing chemical formula", exc_info=True)
result["error"] = _("Error while rendering preview") result["error"] = _("Error while rendering preview")
@@ -1263,18 +1261,16 @@ class FormulaEquationInput(InputTypeBase):
# `reported_status` is basically `status`, except we say 'unanswered' # `reported_status` is basically `status`, except we say 'unanswered'
return { return {
"previewer": "{static_url}js/capa/src/formula_equation_preview.js".format( "previewer": f"{self.capa_system.STATIC_URL}js/capa/src/formula_equation_preview.js",
static_url=self.capa_system.STATIC_URL
),
} }
def handle_ajax(self, dispatch, get): # lint-amnesty, pylint: disable=arguments-differ def handle_ajax(self, dispatch, data):
""" """
Since we only have formcalc preview this input, check to see if it Since we only have formcalc preview this input, check to see if it
matches the corresponding dispatch and send it through if it does matches the corresponding dispatch and send it through if it does
""" """
if dispatch == "preview_formcalc": if dispatch == "preview_formcalc":
return self.preview_formcalc(get) return self.preview_formcalc(data)
return {} return {}
def preview_formcalc(self, get): def preview_formcalc(self, get):
@@ -1313,9 +1309,9 @@ class FormulaEquationInput(InputTypeBase):
if not numeric_result["is_valid"]: if not numeric_result["is_valid"]:
result["formula"] = formula result["formula"] = formula
except pyparsing.ParseException: except pyparsing.ParseException:
result['error'] = _("Sorry, couldn't parse formula") result["error"] = _("Sorry, couldn't parse formula")
result['formula'] = formula result["formula"] = formula
except Exception: # lint-amnesty, pylint: disable=broad-except except Exception: # pylint: disable=broad-exception-caught
log.warning("Error while previewing formula", exc_info=True) log.warning("Error while previewing formula", exc_info=True)
result["error"] = _("Error while rendering preview") result["error"] = _("Error while rendering preview")
return result return result
@@ -1327,17 +1323,17 @@ def preview_numeric_input(formula):
""" """
Handles numeric validations, validates that the formula provided is a valid formula. Handles numeric validations, validates that the formula provided is a valid formula.
""" """
result = {'preview': '', 'is_valid': True, 'error': ''} result = {"preview": "", "is_valid": True, "error": ""}
try: try:
result['preview'] = latex_preview(formula) result["preview"] = latex_preview(formula)
except pyparsing.ParseException: except pyparsing.ParseException:
result["error"] = "Sorry, couldn't parse formula" result["error"] = "Sorry, couldn't parse formula"
result['is_valid'] = False result["is_valid"] = False
return result return result
except Exception: # pylint: disable=broad-exception-caught except Exception: # pylint: disable=broad-exception-caught
log.warning("Error while previewing formula", exc_info=True) log.warning("Error while previewing formula", exc_info=True)
result['error'] = "Error while rendering preview" result["error"] = "Error while rendering preview"
result['is_valid'] = False result["is_valid"] = False
return result return result
@@ -1380,18 +1376,18 @@ class DragAndDropInput(InputTypeBase):
""" """
tag_attrs = {} tag_attrs = {}
tag_attrs["draggable"] = { tag_attrs["draggable"] = {
"id": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access "id": Attribute._sentinel, # pylint: disable=protected-access
"label": "", "label": "",
"icon": "", "icon": "",
"can_reuse": "", "can_reuse": "",
} }
tag_attrs["target"] = { tag_attrs["target"] = {
"id": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access "id": Attribute._sentinel, # pylint: disable=protected-access
"x": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access "x": Attribute._sentinel, # pylint: disable=protected-access
"y": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access "y": Attribute._sentinel, # pylint: disable=protected-access
"w": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access "w": Attribute._sentinel, # pylint: disable=protected-access
"h": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access "h": Attribute._sentinel, # pylint: disable=protected-access
} }
dic = {} dic = {}
@@ -1458,7 +1454,7 @@ class DesignProtein2dInput(InputTypeBase):
def _extra_context(self): def _extra_context(self):
context = { context = {
"applet_loader": "{static_url}js/capa/design-protein-2d.js".format(static_url=self.capa_system.STATIC_URL), "applet_loader": f"{self.capa_system.STATIC_URL}js/capa/design-protein-2d.js",
} }
return context return context
@@ -1490,7 +1486,7 @@ class EditAGeneInput(InputTypeBase):
def _extra_context(self): def _extra_context(self):
context = { context = {
"applet_loader": "{static_url}js/capa/edit-a-gene.js".format(static_url=self.capa_system.STATIC_URL), "applet_loader": f"{self.capa_system.STATIC_URL}js/capa/edit-a-gene.js",
} }
return context return context
@@ -1500,7 +1496,7 @@ class EditAGeneInput(InputTypeBase):
@registry.register @registry.register
class AnnotationInput(InputTypeBase): class AnnotationInput(InputTypeBase): # pylint: disable=too-many-instance-attributes
""" """
Input type for annotations: students can enter some notes or other text Input type for annotations: students can enter some notes or other text
(currently ungraded), and then choose from a set of tags/optoins, which are graded. (currently ungraded), and then choose from a set of tags/optoins, which are graded.
@@ -1562,12 +1558,10 @@ class AnnotationInput(InputTypeBase):
valid_choices = ("correct", "partially-correct", "incorrect") valid_choices = ("correct", "partially-correct", "incorrect")
for option in self.options: for option in self.options:
choice = option["choice"] choice = option["choice"]
if choice is None: # lint-amnesty, pylint: disable=no-else-raise if choice is None:
raise ValueError("Missing required choice attribute.") raise ValueError("Missing required choice attribute.")
elif choice not in valid_choices: if choice not in valid_choices:
raise ValueError( raise ValueError(f"Invalid choice attribute: {choice}. Must be one of: {', '.join(valid_choices)}")
"Invalid choice attribute: {0}. Must be one of: {1}".format(choice, ", ".join(valid_choices))
)
def _unpack(self, json_value): def _unpack(self, json_value):
"""Unpacks the json input state into a dict.""" """Unpacks the json input state into a dict."""
@@ -1688,7 +1682,7 @@ class ChoiceTextGroup(InputTypeBase):
else: else:
_ = self.capa_system.i18n.gettext _ = self.capa_system.i18n.gettext
msg = _("{input_type}: unexpected tag {tag_name}").format(input_type="ChoiceTextGroup", tag_name=self.tag) msg = _("{input_type}: unexpected tag {tag_name}").format(input_type="ChoiceTextGroup", tag_name=self.tag)
raise Exception(msg) raise Exception(msg) # pylint: disable=broad-exception-raised
if self.value == "": if self.value == "":
# Make `value` an empty dictionary, if it currently has an empty # Make `value` an empty dictionary, if it currently has an empty
@@ -1704,7 +1698,7 @@ class ChoiceTextGroup(InputTypeBase):
""" """
# Make '_' a no-op so we can scrape strings. Using lambda instead of # Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file # `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
_ = lambda text: text _ = lambda text: text # pylint: disable=unnecessary-lambda-assignment
return [ return [
Attribute("show_correctness", "always"), Attribute("show_correctness", "always"),
Attribute("submitted_message", _("Answer received.")), Attribute("submitted_message", _("Answer received.")),
@@ -1773,7 +1767,7 @@ class ChoiceTextGroup(InputTypeBase):
given_tag=choice.tag, given_tag=choice.tag,
) )
) )
raise Exception(msg) raise Exception(msg) # pylint: disable=broad-exception-raised
components = [] components = []
choice_text = "" choice_text = ""

View File

@@ -1,7 +1,7 @@
"""A registry for finding classes based on tags in the class.""" """A registry for finding classes based on tags in the class."""
class TagRegistry(object): class TagRegistry:
""" """
A registry mapping tags to handlers. A registry mapping tags to handlers.
@@ -23,7 +23,7 @@ class TagRegistry(object):
# Do all checks and complain before changing any state. # Do all checks and complain before changing any state.
if len(cls.tags) == 0: if len(cls.tags) == 0:
raise ValueError("No tags specified for class {0}".format(cls.__name__)) raise ValueError(f"No tags specified for class {cls.__name__}")
for tag in cls.tags: for tag in cls.tags:
if tag in self._mapping: if tag in self._mapping:
@@ -32,12 +32,8 @@ class TagRegistry(object):
# registering the same class multiple times seems silly, but ok # registering the same class multiple times seems silly, but ok
continue continue
raise ValueError( raise ValueError(
"Tag {0} already registered by class {1}." f"Tag {tag} already registered by class {other_cls.__name__}. "
" Can't register for class {2}".format( f"Can't register for class {cls.__name__}"
tag,
other_cls.__name__,
cls.__name__,
)
) )
# Ok, should be good to change state now. # Ok, should be good to change state now.

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
""" """
Problem response evaluation. Handles checking of student responses, Problem response evaluation. Handles checking of student responses,
of a variety of types. of a variety of types.
@@ -35,19 +36,20 @@ from pyparsing import ParseException
from pytz import UTC from pytz import UTC
from shapely.geometry import MultiPoint, Point from shapely.geometry import MultiPoint, Point
from six.moves import map, range, zip from six.moves import map, range, zip
from symmath import symmath_check
import xmodule.capa.safe_exec as safe_exec
import xmodule.capa.xqueue_interface as xqueue_interface
from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.grade_utils import round_away_from_zero from openedx.core.lib.grade_utils import round_away_from_zero
from xmodule.capa.safe_exec import safe_exec
from xmodule.capa.xqueue_interface import DATEFORMAT, make_hashkey, make_xheader
from . import correctmap from . import correctmap
from .registry import TagRegistry from .registry import TagRegistry
from .util import ( from .util import (
DEFAULT_TOLERANCE,
compare_with_tolerance, compare_with_tolerance,
contextualize_text, contextualize_text,
convert_files_to_filenames, convert_files_to_filenames,
default_tolerance,
find_with_default, find_with_default,
get_course_id_from_capa_block, get_course_id_from_capa_block,
get_inner_html_from_xpath, get_inner_html_from_xpath,
@@ -63,7 +65,7 @@ CORRECTMAP_PY = None
# Make '_' a no-op so we can scrape strings. Using lambda instead of # Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file # `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
_ = lambda text: text _ = lambda text: text # pylint: disable=unnecessary-lambda-assignment
QUESTION_HINT_CORRECT_STYLE = "feedback-hint-correct" QUESTION_HINT_CORRECT_STYLE = "feedback-hint-correct"
QUESTION_HINT_INCORRECT_STYLE = "feedback-hint-incorrect" QUESTION_HINT_INCORRECT_STYLE = "feedback-hint-incorrect"
@@ -80,8 +82,6 @@ class LoncapaProblemError(Exception):
Error in specification of a problem Error in specification of a problem
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
class ResponseError(Exception): class ResponseError(Exception):
""" """
@@ -89,8 +89,6 @@ class ResponseError(Exception):
exceptions that occur when executing a custom script. exceptions that occur when executing a custom script.
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
class StudentInputError(Exception): class StudentInputError(Exception):
""" """
@@ -98,15 +96,13 @@ class StudentInputError(Exception):
For example, submitting a string when the problem expects a number For example, submitting a string when the problem expects a number
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# #
# Main base class for CAPA responsetypes # Main base class for CAPA responsetypes
class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)): class LoncapaResponse(six.with_metaclass(abc.ABCMeta)): # pylint: disable=too-many-instance-attributes
""" """
Base class for CAPA responsetypes. Each response type (ie a capa question, Base class for CAPA responsetypes. Each response type (ie a capa question,
which is part of a capa problem) is represented as a subclass, which is part of a capa problem) is represented as a subclass,
@@ -158,7 +154,9 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
# By default, we set this to False, allowing subclasses to override as appropriate. # By default, we set this to False, allowing subclasses to override as appropriate.
multi_device_support = False multi_device_support = False
def __init__(self, xml, inputfields, context, system, capa_block, minimal_init): def __init__( # pylint: disable=too-many-arguments,too-many-branches,too-many-positional-arguments
self, xml, inputfields, context, system, capa_block, minimal_init
):
""" """
Init is passed the following arguments: Init is passed the following arguments:
@@ -180,19 +178,19 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
# only displayed to the user when settings.DEBUG is True # only displayed to the user when settings.DEBUG is True
for abox in inputfields: for abox in inputfields:
if abox.tag not in self.allowed_inputfields: if abox.tag not in self.allowed_inputfields:
msg = "%s: cannot have input field %s" % (str(self), abox.tag) msg = f"{self}: cannot have input field {abox.tag}"
msg += "\nSee XML source line %s" % getattr(xml, "sourceline", "[unavailable]") msg += f"\nSee XML source line {getattr(xml, 'sourceline', '[unavailable]')}"
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
if self.max_inputfields and len(inputfields) > self.max_inputfields: if self.max_inputfields and len(inputfields) > self.max_inputfields:
msg = "%s: cannot have more than %s input fields" % (str(self), self.max_inputfields) msg = f"{self}: cannot have more than {self.max_inputfields} input fields"
msg += "\nSee XML source line %s" % getattr(xml, "sourceline", "[unavailable]") msg += f"\nSee XML source line {getattr(xml, 'sourceline', '[unavailable]')}"
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
for prop in self.required_attributes: for prop in self.required_attributes:
if not xml.get(prop): if not xml.get(prop):
msg = "Error in problem specification: %s missing required attribute %s" % (str(self), prop) msg = f"Error in problem specification: {self} missing required attribute {prop}"
msg += "\nSee XML source line %s" % getattr(xml, "sourceline", "[unavailable]") msg += f"\nSee XML source line {getattr(xml, 'sourceline', '[unavailable]')}"
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
# ordered list of answer_id values for this response # ordered list of answer_id values for this response
@@ -304,7 +302,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
self.get_hints(convert_files_to_filenames(student_answers), new_cmap, old_cmap) self.get_hints(convert_files_to_filenames(student_answers), new_cmap, old_cmap)
return new_cmap return new_cmap
def make_hint_div( def make_hint_div( # pylint: disable=too-many-positional-arguments,too-many-arguments
self, self,
hint_node, hint_node,
correct, correct,
@@ -418,9 +416,8 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
installing it in the new_map for display. installing it in the new_map for display.
Implemented by subclasses that have extended hints. Implemented by subclasses that have extended hints.
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
def get_hints(self, student_answers, new_cmap, old_cmap): def get_hints(self, student_answers, new_cmap, old_cmap): # pylint: disable=too-many-locals
""" """
Generate adaptive hints for this problem based on student answers, the old CorrectMap, Generate adaptive hints for this problem based on student answers, the old CorrectMap,
and the new CorrectMap produced by get_score. and the new CorrectMap produced by get_score.
@@ -449,7 +446,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
# We may extend this in the future to add another argument which provides a # We may extend this in the future to add another argument which provides a
# callback procedure to a social hint generation system. # callback procedure to a social hint generation system.
global CORRECTMAP_PY global CORRECTMAP_PY # pylint: disable=global-statement
if CORRECTMAP_PY is None: if CORRECTMAP_PY is None:
# We need the CorrectMap code for hint functions. No, this is not great. # We need the CorrectMap code for hint functions. No, this is not great.
CORRECTMAP_PY = inspect.getsource(correctmap) CORRECTMAP_PY = inspect.getsource(correctmap)
@@ -479,7 +476,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
} }
try: try:
safe_exec.safe_exec( safe_exec(
code, code,
globals_dict, globals_dict,
python_path=self.context["python_path"], python_path=self.context["python_path"],
@@ -494,7 +491,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
msg = _("Error {err} in evaluating hint function {hintfn}.").format(err=err, hintfn=hintfn) msg = _("Error {err} in evaluating hint function {hintfn}.").format(err=err, hintfn=hintfn)
sourcenum = getattr(self.xml, "sourceline", _("(Source code line unavailable)")) sourcenum = getattr(self.xml, "sourceline", _("(Source code line unavailable)"))
msg += "\n" + _("See XML source line {sourcenum}.").format(sourcenum=sourcenum) msg += "\n" + _("See XML source line {sourcenum}.").format(sourcenum=sourcenum)
raise ResponseError(msg) # lint-amnesty, pylint: disable=raise-missing-from raise ResponseError(msg) from err
new_cmap.set_dict(globals_dict["new_cmap_dict"]) new_cmap.set_dict(globals_dict["new_cmap_dict"])
return return
@@ -524,7 +521,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
): ):
rephints = hintgroup.findall(self.hint_tag) rephints = hintgroup.findall(self.hint_tag)
hints_to_show = self.check_hint_condition( # lint-amnesty, pylint: disable=assignment-from-no-return hints_to_show = self.check_hint_condition( # pylint: disable=assignment-from-no-return
rephints, student_answers rephints, student_answers
) )
# can be 'on_request' or 'always' (default) # can be 'on_request' or 'always' (default)
@@ -551,14 +548,12 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
Arguments: Arguments:
- student_answers : dict of (answer_id, answer) where answer = student input (string) - student_answers : dict of (answer_id, answer) where answer = student input (string)
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
@abc.abstractmethod @abc.abstractmethod
def get_answers(self): def get_answers(self):
""" """
Return a dict of (answer_id, answer_text) for each answer for this question. Return a dict of (answer_id, answer_text) for each answer for this question.
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
def check_hint_condition(self, hxml_set, student_answers): def check_hint_condition(self, hxml_set, student_answers):
""" """
@@ -572,13 +567,12 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
Returns a list of names of hint conditions which were satisfied. Those are used Returns a list of names of hint conditions which were satisfied. Those are used
to determine which hints are displayed. to determine which hints are displayed.
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
def setup_response(self): def setup_response(self):
pass """Check if the given string matches any expected string, optionally case-insensitive."""
def __str__(self): def __str__(self):
return "LoncapaProblem Response %s" % self.xml.tag return f"LoncapaProblem Response {self.xml.tag}"
def _render_response_msg_html(self, response_msg): def _render_response_msg_html(self, response_msg):
"""Render a <div> for a message that applies to the entire response. """Render a <div> for a message that applies to the entire response.
@@ -593,7 +587,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
# If we can't do that, create the <div> and set the message # If we can't do that, create the <div> and set the message
# as the text of the <div> # as the text of the <div>
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-exception-caught
response_msg_div = etree.Element("div") response_msg_div = etree.Element("div")
response_msg_div.text = str(response_msg) response_msg_div.text = str(response_msg)
@@ -733,10 +727,10 @@ class ChoiceResponse(LoncapaResponse):
if edc_current_grade == edc_max_grade: if edc_current_grade == edc_max_grade:
return CorrectMap(self.answer_id, correctness="correct") return CorrectMap(self.answer_id, correctness="correct")
elif edc_current_grade > 0: if edc_current_grade > 0:
return CorrectMap(self.answer_id, correctness="partially-correct", npoints=return_grade) return CorrectMap(self.answer_id, correctness="partially-correct", npoints=return_grade)
else:
return CorrectMap(self.answer_id, correctness="incorrect", npoints=0) return CorrectMap(self.answer_id, correctness="incorrect", npoints=0)
def grade_via_halves(self, **kwargs): def grade_via_halves(self, **kwargs):
""" """
@@ -765,14 +759,16 @@ class ChoiceResponse(LoncapaResponse):
if halves_error_count == 0: if halves_error_count == 0:
return_grade = self.get_max_score() return_grade = self.get_max_score()
return CorrectMap(self.answer_id, correctness="correct", npoints=return_grade) return CorrectMap(self.answer_id, correctness="correct", npoints=return_grade)
elif halves_error_count == 1 and len(all_choices) > 2:
if halves_error_count == 1 and len(all_choices) > 2:
return_grade = round_away_from_zero(self.get_max_score() / 2.0, 2) return_grade = round_away_from_zero(self.get_max_score() / 2.0, 2)
return CorrectMap(self.answer_id, correctness="partially-correct", npoints=return_grade) return CorrectMap(self.answer_id, correctness="partially-correct", npoints=return_grade)
elif halves_error_count == 2 and len(all_choices) > 4:
if halves_error_count == 2 and len(all_choices) > 4:
return_grade = round_away_from_zero(self.get_max_score() / 4.0, 2) return_grade = round_away_from_zero(self.get_max_score() / 4.0, 2)
return CorrectMap(self.answer_id, correctness="partially-correct", npoints=return_grade) return CorrectMap(self.answer_id, correctness="partially-correct", npoints=return_grade)
else:
return CorrectMap(self.answer_id, "incorrect") return CorrectMap(self.answer_id, "incorrect")
def grade_without_partial_credit(self, **kwargs): def grade_without_partial_credit(self, **kwargs):
""" """
@@ -790,8 +786,8 @@ class ChoiceResponse(LoncapaResponse):
if correct: if correct:
return CorrectMap(self.answer_id, "correct") return CorrectMap(self.answer_id, "correct")
else:
return CorrectMap(self.answer_id, "incorrect") return CorrectMap(self.answer_id, "incorrect")
def get_score(self, student_answers): def get_score(self, student_answers):
@@ -855,7 +851,7 @@ class ChoiceResponse(LoncapaResponse):
def get_answers(self): def get_answers(self):
return {self.answer_id: list(self.correct_choices)} return {self.answer_id: list(self.correct_choices)}
def get_extended_hints(self, student_answers, new_cmap): def get_extended_hints(self, student_answers, new_cmap): # pylint: disable=too-many-locals
""" """
Extract compound and extended hint information from the xml based on the student_answers. Extract compound and extended hint information from the xml based on the student_answers.
The hint information goes into the msg= in new_cmap for display. The hint information goes into the msg= in new_cmap for display.
@@ -922,7 +918,7 @@ class ChoiceResponse(LoncapaResponse):
log_extra={"choice_all": choice_all}, # checkbox specific logging log_extra={"choice_all": choice_all}, # checkbox specific logging
) )
def get_compound_hints(self, new_cmap, student_answers): def get_compound_hints(self, new_cmap, student_answers): # pylint: disable=too-many-locals
""" """
Compound hints are a type of extended hint specific to checkboxgroup with the Compound hints are a type of extended hint specific to checkboxgroup with the
<compoundhint value="A C"> meaning choices A and C were selected. <compoundhint value="A C"> meaning choices A and C were selected.
@@ -1036,7 +1032,7 @@ class MultipleChoiceResponse(LoncapaResponse):
if contextualize_text(choice.get("correct"), self.context).lower() == "partial" if contextualize_text(choice.get("correct"), self.context).lower() == "partial"
] ]
def get_extended_hints(self, student_answer_dict, new_cmap): # lint-amnesty, pylint: disable=arguments-differ def get_extended_hints(self, student_answers, new_cmap):
""" """
Extract any hints in a <choicegroup> matching the student's answers Extract any hints in a <choicegroup> matching the student's answers
<choicegroup label="What is your favorite color?" type="MultipleChoice"> <choicegroup label="What is your favorite color?" type="MultipleChoice">
@@ -1046,8 +1042,8 @@ class MultipleChoiceResponse(LoncapaResponse):
... ...
Any hint text is installed in the new_cmap. Any hint text is installed in the new_cmap.
""" """
if self.answer_id in student_answer_dict: if self.answer_id in student_answers:
student_answer = student_answer_dict[self.answer_id] student_answer = student_answers[self.answer_id]
# Warning: mostly student_answer is a string, but sometimes it is a list of strings. # Warning: mostly student_answer is a string, but sometimes it is a list of strings.
if isinstance(student_answer, list): if isinstance(student_answer, list):
@@ -1055,9 +1051,7 @@ class MultipleChoiceResponse(LoncapaResponse):
# Find the named choice used by the student. Silently ignore a non-matching # Find the named choice used by the student. Silently ignore a non-matching
# choice name. # choice name.
choice = self.xml.find( choice = self.xml.find(f'./choicegroup[@id="{self.answer_id}"]/choice[@name="{student_answer}"]')
'./choicegroup[@id="{0}"]/choice[@name="{1}"]'.format(self.answer_id, student_answer)
)
if choice is not None: if choice is not None:
hint_node = choice.find("./choicehint") hint_node = choice.find("./choicehint")
new_cmap[self.answer_id]["msg"] += self.make_hint_div( new_cmap[self.answer_id]["msg"] += self.make_hint_div(
@@ -1119,12 +1113,12 @@ class MultipleChoiceResponse(LoncapaResponse):
if self.answer_id in student_answers and student_answers[self.answer_id] in self.correct_choices: if self.answer_id in student_answers and student_answers[self.answer_id] in self.correct_choices:
return CorrectMap(self.answer_id, correctness="correct") return CorrectMap(self.answer_id, correctness="correct")
elif self.answer_id in student_answers and student_answers[self.answer_id] in self.partial_choices: if self.answer_id in student_answers and student_answers[self.answer_id] in self.partial_choices:
choice_index = self.partial_choices.index(student_answers[self.answer_id]) choice_index = self.partial_choices.index(student_answers[self.answer_id])
credit_amount = self.partial_values[choice_index] credit_amount = self.partial_values[choice_index]
return CorrectMap(self.answer_id, correctness="partially-correct", npoints=credit_amount) return CorrectMap(self.answer_id, correctness="partially-correct", npoints=credit_amount)
else:
return CorrectMap(self.answer_id, "incorrect") return CorrectMap(self.answer_id, "incorrect")
def grade_without_partial_credit(self, **kwargs): def grade_without_partial_credit(self, **kwargs):
""" """
@@ -1138,8 +1132,8 @@ class MultipleChoiceResponse(LoncapaResponse):
if self.answer_id in student_answers and student_answers[self.answer_id] in self.correct_choices: if self.answer_id in student_answers and student_answers[self.answer_id] in self.correct_choices:
return CorrectMap(self.answer_id, correctness="correct") return CorrectMap(self.answer_id, correctness="correct")
else:
return CorrectMap(self.answer_id, "incorrect") return CorrectMap(self.answer_id, "incorrect")
def get_score(self, student_answers): def get_score(self, student_answers):
""" """
@@ -1265,12 +1259,12 @@ class MultipleChoiceResponse(LoncapaResponse):
# defined at the problem level, so the multiple rng's would be seeded the same. # defined at the problem level, so the multiple rng's would be seeded the same.
# The name _shared_rng begins with an _ to suggest that it is not a facility # The name _shared_rng begins with an _ to suggest that it is not a facility
# for general use. # for general use.
# pylint: disable=protected-access
if not hasattr(problem, "_shared_rng"):
problem._shared_rng = random.Random(self.context["seed"])
return problem._shared_rng
def do_answer_pool(self, tree, problem): if not hasattr(problem, "_shared_rng"):
problem._shared_rng = random.Random(self.context["seed"]) # pylint: disable=protected-access
return problem._shared_rng # pylint: disable=protected-access
def do_answer_pool(self, tree, problem): # pylint: disable=too-many-locals
""" """
Implements the answer-pool subsetting operation in-place on the tree. Implements the answer-pool subsetting operation in-place on the tree.
Allows for problem questions with a pool of answers, from which answer options shown to the student Allows for problem questions with a pool of answers, from which answer options shown to the student
@@ -1291,11 +1285,11 @@ class MultipleChoiceResponse(LoncapaResponse):
return return
try: try:
num_choices = int(num_str) num_choices = int(num_str)
except ValueError: except ValueError as exc:
_ = self.capa_system.i18n.gettext _ = self.capa_system.i18n.gettext
# Translators: 'answer-pool' is an attribute name and should not be translated. # Translators: 'answer-pool' is an attribute name and should not be translated.
msg = _("answer-pool value should be an integer") msg = _("answer-pool value should be an integer")
raise LoncapaProblemError(msg) # lint-amnesty, pylint: disable=raise-missing-from raise LoncapaProblemError(msg) from exc
# Note in the response that answerpool is done. # Note in the response that answerpool is done.
# Both to avoid double-processing, and to feed the logs. # Both to avoid double-processing, and to feed the logs.
@@ -1383,14 +1377,16 @@ class MultipleChoiceResponse(LoncapaResponse):
@registry.register @registry.register
class TrueFalseResponse( class TrueFalseResponse(MultipleChoiceResponse):
MultipleChoiceResponse """Response type for True/False multiple choice questions."""
): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring
human_name = _("True/False Choice") human_name = _("True/False Choice")
tags = ["truefalseresponse"] tags = ["truefalseresponse"]
def mc_setup_response(self): def mc_setup_response(self):
"""
Sets up the XML structure for the True/False choices.
"""
i = 0 i = 0
for response in self.xml.xpath("choicegroup"): for response in self.xml.xpath("choicegroup"):
response.set("type", "TrueFalse") response.set("type", "TrueFalse")
@@ -1402,6 +1398,9 @@ class TrueFalseResponse(
choice.set("name", "choice_" + choice.get("name")) choice.set("name", "choice_" + choice.get("name"))
def get_score(self, student_answers): def get_score(self, student_answers):
"""
Grade the student's answer for True/False.
"""
correct = set(self.correct_choices) correct = set(self.correct_choices)
answers = student_answers.get(self.answer_id, []) answers = student_answers.get(self.answer_id, [])
if not isinstance(answers, list): if not isinstance(answers, list):
@@ -1412,6 +1411,10 @@ class TrueFalseResponse(
return CorrectMap(self.answer_id, "incorrect") return CorrectMap(self.answer_id, "incorrect")
def unmask_name(self, name):
"""Return the original choice name (no masking needed for True/False)."""
return name
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -1446,18 +1449,14 @@ class OptionResponse(LoncapaResponse):
return cmap return cmap
def get_answers(self): def get_answers(self):
amap = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension amap = {
[ af.get("id"): contextualize_text(
( af.get("correct"),
af.get("id"), self.context,
contextualize_text( )
af.get("correct"), for af in self.answer_fields
self.context, }
),
)
for af in self.answer_fields
]
)
return amap return amap
def get_student_answer_variable_name(self, student_answers, aid): def get_student_answer_variable_name(self, student_answers, aid):
@@ -1516,10 +1515,10 @@ class NumericalResponse(LoncapaResponse):
self.correct_answer = "" self.correct_answer = ""
self.additional_answers = [] self.additional_answers = []
self.additional_answer_index = -1 self.additional_answer_index = -1
self.tolerance = default_tolerance self.tolerance = DEFAULT_TOLERANCE
self.range_tolerance = False self.range_tolerance = False
self.answer_range = self.inclusion = None self.answer_range = self.inclusion = None
super(NumericalResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments super().__init__(*args, **kwargs)
def setup_response(self): def setup_response(self):
xml = self.xml xml = self.xml
@@ -1531,18 +1530,16 @@ class NumericalResponse(LoncapaResponse):
if answer.startswith(("[", "(")) and answer.endswith(("]", ")")): # range tolerance case if answer.startswith(("[", "(")) and answer.endswith(("]", ")")): # range tolerance case
self.range_tolerance = True self.range_tolerance = True
self.inclusion = ( self.inclusion = (
True if answer.startswith("[") else False, # lint-amnesty, pylint: disable=simplifiable-if-expression answer.startswith("["),
True if answer.endswith("]") else False, # lint-amnesty, pylint: disable=simplifiable-if-expression answer.endswith("]"),
) )
try: try:
self.answer_range = [contextualize_text(x, context) for x in answer[1:-1].split(",")] self.answer_range = [contextualize_text(x, context) for x in answer[1:-1].split(",")]
self.correct_answer = answer[0] + self.answer_range[0] + ", " + self.answer_range[1] + answer[-1] self.correct_answer = answer[0] + self.answer_range[0] + ", " + self.answer_range[1] + answer[-1]
except Exception: except Exception as exc:
log.debug("Content error--answer '%s' is not a valid range tolerance answer", answer) log.debug("Content error--answer '%s' is not a valid range tolerance answer", answer)
_ = self.capa_system.i18n.gettext _ = self.capa_system.i18n.gettext
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(_("There was a problem with the staff answer to this problem.")) from exc
_("There was a problem with the staff answer to this problem.")
)
else: else:
self.correct_answer = contextualize_text(answer, context) self.correct_answer = contextualize_text(answer, context)
@@ -1566,16 +1563,14 @@ class NumericalResponse(LoncapaResponse):
# `complex` seems to only generate `ValueErrors`, only catch these. # `complex` seems to only generate `ValueErrors`, only catch these.
try: try:
correct_ans = evaluator({}, {}, answer) correct_ans = evaluator({}, {}, answer)
except Exception: except Exception as exc:
log.debug("Content error--answer '%s' is not a valid number", answer) log.debug("Content error--answer '%s' is not a valid number", answer)
_ = self.capa_system.i18n.gettext _ = self.capa_system.i18n.gettext
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(_("There was a problem with the staff answer to this problem.")) from exc
_("There was a problem with the staff answer to this problem.")
)
return correct_ans return correct_ans
def get_score(self, student_answers): # lint-amnesty, pylint: disable=too-many-statements def get_score(self, student_answers): # pylint: disable=too-many-statements,too-many-locals,too-many-branches
""" """
Grade a numeric response. Grade a numeric response.
""" """
@@ -1602,28 +1597,27 @@ class NumericalResponse(LoncapaResponse):
try: try:
student_float = evaluator({}, {}, student_answer) student_float = evaluator({}, {}, student_answer)
except UndefinedVariable as err: except UndefinedVariable as err:
raise StudentInputError(err.args[0]) # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(err.args[0]) from err
except UnmatchedParenthesis as err: except UnmatchedParenthesis as err:
raise StudentInputError(err.args[0]) # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(err.args[0]) from err
except ValueError as val_err: except ValueError as val_err:
if "factorial" in str(val_err): # lint-amnesty, pylint: disable=no-else-raise if "factorial" in str(val_err):
# This is thrown when fact() or factorial() is used in an answer # This is thrown when fact() or factorial() is used in an answer
# that evaluates on negative and/or non-integer inputs # that evaluates on negative and/or non-integer inputs
# str(ve) will be: `factorial() only accepts integral values` or # str(ve) will be: `factorial() only accepts integral values` or
# `factorial() not defined for negative values` # `factorial() not defined for negative values`
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(
_("Factorial function evaluated outside its domain:'{student_answer}'").format( _("Factorial function evaluated outside its domain:'{student_answer}'").format(
student_answer=html.escape(student_answer) student_answer=html.escape(student_answer)
) )
) ) from val_err
else: raise general_exception from val_err
raise general_exception # lint-amnesty, pylint: disable=raise-missing-from except ParseException as exc:
except ParseException: raise StudentInputError(
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
_("Invalid math syntax: '{student_answer}'").format(student_answer=html.escape(student_answer)) _("Invalid math syntax: '{student_answer}'").format(student_answer=html.escape(student_answer))
) ) from exc
except Exception: except Exception as exc:
raise general_exception # lint-amnesty, pylint: disable=raise-missing-from raise general_exception from exc
# End `evaluator` block -- we figured out the student's answer! # End `evaluator` block -- we figured out the student's answer!
tree = self.xml tree = self.xml
@@ -1720,11 +1714,11 @@ class NumericalResponse(LoncapaResponse):
if compare_with_tolerance(student_float, value, self.tolerance): if compare_with_tolerance(student_float, value, self.tolerance):
is_correct = "partially-correct" is_correct = "partially-correct"
break break
elif "close" in self.credit_type: if "close" in self.credit_type:
if compare_with_tolerance(student_float, correct_float, expanded_tolerance): if compare_with_tolerance(student_float, correct_float, expanded_tolerance):
is_correct = "partially-correct" is_correct = "partially-correct"
break break
elif compare_with_tolerance(student_float, value, expanded_tolerance): if compare_with_tolerance(student_float, value, expanded_tolerance):
is_correct = "partially-correct" is_correct = "partially-correct"
partial_score = partial_score * partial_score partial_score = partial_score * partial_score
break break
@@ -1748,8 +1742,8 @@ class NumericalResponse(LoncapaResponse):
if is_correct == "partially-correct": if is_correct == "partially-correct":
return CorrectMap(self.answer_id, is_correct, npoints=partial_score) return CorrectMap(self.answer_id, is_correct, npoints=partial_score)
else:
return CorrectMap(self.answer_id, is_correct) return CorrectMap(self.answer_id, is_correct)
def compare_answer(self, ans1, ans2): def compare_answer(self, ans1, ans2):
""" """
@@ -1860,6 +1854,7 @@ class StringResponse(LoncapaResponse):
multi_device_support = True multi_device_support = True
def setup_response_backward(self): def setup_response_backward(self):
"""Prepare the correct answers for backward-compatible string responses."""
self.correct_answer = [ self.correct_answer = [
contextualize_text(answer, self.context).strip() for answer in self.xml.get("answer").split("_or_") contextualize_text(answer, self.context).strip() for answer in self.xml.get("answer").split("_or_")
] ]
@@ -1895,6 +1890,7 @@ class StringResponse(LoncapaResponse):
return CorrectMap(self.answer_id, "correct" if correct else "incorrect") return CorrectMap(self.answer_id, "correct" if correct else "incorrect")
def check_string_backward(self, expected, given): def check_string_backward(self, expected, given):
"""Check if the given string matches any expected string, optionally case-insensitive."""
if self.case_insensitive: if self.case_insensitive:
return given.lower() in [i.lower() for i in expected] return given.lower() in [i.lower() for i in expected]
return given in expected return given in expected
@@ -1974,13 +1970,13 @@ class StringResponse(LoncapaResponse):
# We follow the check_string convention/exception, adding ^ and $ # We follow the check_string convention/exception, adding ^ and $
regex = re.compile("^" + answer + "$", flags=flags | re.UNICODE) regex = re.compile("^" + answer + "$", flags=flags | re.UNICODE)
return re.search(regex, given) return re.search(regex, given)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-exception-caught
return False return False
if ci_mode: if ci_mode:
return answer.lower() == given.lower() return answer.lower() == given.lower()
else:
return answer == given return answer == given
def check_string(self, expected, given): def check_string(self, expected, given):
""" """
@@ -2017,17 +2013,16 @@ class StringResponse(LoncapaResponse):
regexp = re.compile("^" + "|".join(expected) + "$", flags=flags | re.UNICODE) regexp = re.compile("^" + "|".join(expected) + "$", flags=flags | re.UNICODE)
result = re.search(regexp, given) result = re.search(regexp, given)
except Exception as err: except Exception as err:
msg = "[courseware.capa.responsetypes.stringresponse] {error}: {message}".format( msg = f"[courseware.capa.responsetypes.stringresponse] {_('error')}: {err}"
error=_("error"), message=str(err)
)
log.error(msg, exc_info=True) log.error(msg, exc_info=True)
raise ResponseError(msg) # lint-amnesty, pylint: disable=raise-missing-from raise ResponseError(msg) from err
return bool(result) return bool(result)
else: # string match
if self.case_insensitive: # string match
return given.lower() in [i.lower() for i in expected] if self.case_insensitive:
else: return given.lower() in [i.lower() for i in expected]
return given in expected
return given in expected
def check_hint_condition(self, hxml_set, student_answers): def check_hint_condition(self, hxml_set, student_answers):
given = student_answers[self.answer_id].strip() given = student_answers[self.answer_id].strip()
@@ -2114,14 +2109,14 @@ class CustomResponse(LoncapaResponse):
# and invoke the function with the data needed. # and invoke the function with the data needed.
def make_check_function(script_code, cfn): def make_check_function(script_code, cfn):
def check_function(expect, ans, **kwargs): def check_function(expect, ans, **kwargs):
extra_args = "".join(", {0}={0}".format(k) for k in kwargs) extra_args = "".join(f", {k}={k}" for k in kwargs)
code = script_code + "\n" + "cfn_return = %s(expect, ans%s)\n" % (cfn, extra_args) code = f"{script_code}\ncfn_return = {cfn}(expect, ans{extra_args})\n"
globals_dict = { globals_dict = {
"expect": expect, "expect": expect,
"ans": ans, "ans": ans,
} }
globals_dict.update(kwargs) globals_dict.update(kwargs)
safe_exec.safe_exec( safe_exec(
code, code,
globals_dict, globals_dict,
python_path=self.context["python_path"], python_path=self.context["python_path"],
@@ -2149,7 +2144,7 @@ class CustomResponse(LoncapaResponse):
else: else:
self.code = answer.text self.code = answer.text
def get_score(self, student_answers): def get_score(self, student_answers): # pylint: disable=too-many-locals
""" """
student_answers is a dict with everything from request.POST, but with the first part student_answers is a dict with everything from request.POST, but with the first part
of each key removed (the string before the first "_"). of each key removed (the string before the first "_").
@@ -2167,12 +2162,10 @@ class CustomResponse(LoncapaResponse):
# ordered list of answers # ordered list of answers
submission = [student_answers[k] for k in idset] submission = [student_answers[k] for k in idset]
except Exception as err: except Exception as err:
msg = "[courseware.capa.responsetypes.customresponse] {message}\n idset = {idset}, error = {err}".format( msg = (
message=_("error getting student answer from {student_answers}").format( f"[courseware.capa.responsetypes.customresponse] "
student_answers=student_answers, f"{_('error getting student answer from {student_answers}').format(student_answers=student_answers)}\n"
), f"idset = {idset}, error = {err}"
idset=idset,
err=err,
) )
log.error( log.error(
@@ -2183,7 +2176,7 @@ class CustomResponse(LoncapaResponse):
idset, idset,
err, err,
) )
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from raise Exception(msg) from err # pylint: disable=broad-exception-raised
# global variable in context which holds the Presentation MathML from dynamic math input # global variable in context which holds the Presentation MathML from dynamic math input
# ordered list of dynamath responses # ordered list of dynamath responses
@@ -2251,8 +2244,8 @@ class CustomResponse(LoncapaResponse):
correct_map = CorrectMap() correct_map = CorrectMap()
correct_map.set_overall_message(overall_message) correct_map.set_overall_message(overall_message)
for k in range(len(idset)): # lint-amnesty, pylint: disable=consider-using-enumerate for k, item_id in enumerate(idset):
max_points = self.maxpoints[idset[k]] max_points = self.maxpoints[item_id]
if grade_decimals: if grade_decimals:
npoints = max_points * grade_decimals[k] npoints = max_points * grade_decimals[k]
else: else:
@@ -2267,11 +2260,14 @@ class CustomResponse(LoncapaResponse):
def execute_check_function( def execute_check_function(
self, idset, submission self, idset, submission
): # lint-amnesty, pylint: disable=missing-function-docstring, too-many-statements ): # pylint: disable=too-many-statements,too-many-locals,too-many-branches
"""Execute the custom check function for a submission, updating correctness,
messages, and grades in the context."""
# exec the check function # exec the check function
if isinstance(self.code, str): # lint-amnesty, pylint: disable=too-many-nested-blocks if isinstance(self.code, str): # pylint: disable=too-many-nested-blocks
try: try:
safe_exec.safe_exec( safe_exec(
self.code, self.code,
self.context, self.context,
cache=self.capa_system.cache, cache=self.capa_system.cache,
@@ -2282,7 +2278,7 @@ class CustomResponse(LoncapaResponse):
random_seed=self.context["seed"], random_seed=self.context["seed"],
unsafely=self.capa_system.can_execute_unsafe_code(), unsafely=self.capa_system.can_execute_unsafe_code(),
) )
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
self._handle_exec_exception(err) self._handle_exec_exception(err)
else: else:
@@ -2296,7 +2292,7 @@ class CustomResponse(LoncapaResponse):
log.debug(" submission = %s", submission) log.debug(" submission = %s", submission)
try: try:
ret = tutor_cfn(self.expect, answer_given, **kwargs) ret = tutor_cfn(self.expect, answer_given, **kwargs)
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
self._handle_exec_exception(err) self._handle_exec_exception(err)
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s", ret) log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s", ret)
if isinstance(ret, dict): if isinstance(ret, dict):
@@ -2432,7 +2428,9 @@ class CustomResponse(LoncapaResponse):
self.context["correct"] = correct self.context["correct"] = correct
def clean_message_html(self, msg): # lint-amnesty, pylint: disable=missing-function-docstring def clean_message_html(self, msg):
"""Clean and prettify HTML in the given message string,
handling special characters and removing wrapper tags."""
# If *msg* is an empty string, then the code below # If *msg* is an empty string, then the code below
# will return "</html>". To avoid this, we first check # will return "</html>". To avoid this, we first check
@@ -2459,8 +2457,7 @@ class CustomResponse(LoncapaResponse):
return msg.strip() return msg.strip()
# If we start with an empty string, then return an empty string # If we start with an empty string, then return an empty string
else: return ""
return ""
def get_answers(self): def get_answers(self):
""" """
@@ -2515,11 +2512,9 @@ class SymbolicResponse(CustomResponse):
self.xml.set("cfn", "symmath_check") self.xml.set("cfn", "symmath_check")
# Let CustomResponse do its setup # Let CustomResponse do its setup
super(SymbolicResponse, self).setup_response() # lint-amnesty, pylint: disable=super-with-arguments super().setup_response()
def execute_check_function(self, idset, submission): def execute_check_function(self, idset, submission):
from symmath import symmath_check
try: try:
# Since we have limited max_inputfields to 1, # Since we have limited max_inputfields to 1,
# we can assume that there is only one submission # we can assume that there is only one submission
@@ -2540,18 +2535,18 @@ class SymbolicResponse(CustomResponse):
msg = _("An error occurred with SymbolicResponse. The error was: {error_msg}").format( msg = _("An error occurred with SymbolicResponse. The error was: {error_msg}").format(
error_msg=err, error_msg=err,
) )
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from raise Exception(msg) from err # pylint: disable=broad-exception-raised
self.context["messages"][0] = self.clean_message_html(ret["msg"]) self.context["messages"][0] = self.clean_message_html(ret["msg"])
self.context["correct"] = ["correct" if ret["ok"] else "incorrect"] * len(idset) self.context["correct"] = ["correct" if ret["ok"] else "incorrect"] * len(idset)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
## ScoreMessage named tuple ## # ScoreMessage named tuple #
## valid: Flag indicating valid score_msg format (Boolean) # valid: Flag indicating valid score_msg format (Boolean)
## correct: Correctness of submission (Boolean) # correct: Correctness of submission (Boolean)
## score: Points to be assigned (numeric, can be float) # score: Points to be assigned (numeric, can be float)
## msg: Message from grader to display to student (string) # msg: Message from grader to display to student (string)
ScoreMessage = namedtuple("ScoreMessage", ["valid", "correct", "points", "msg"]) ScoreMessage = namedtuple("ScoreMessage", ["valid", "correct", "points", "msg"])
@@ -2634,7 +2629,7 @@ class CodeResponse(LoncapaResponse):
_ = self.capa_system.i18n.gettext _ = self.capa_system.i18n.gettext
self.answer = find_with_default(codeparam, "answer_display", _("No answer provided.")) self.answer = find_with_default(codeparam, "answer_display", _("No answer provided."))
def get_score(self, student_answers): def get_score(self, student_answers): # pylint: disable=too-many-locals
_ = self.capa_system.i18n.gettext _ = self.capa_system.i18n.gettext
try: try:
# Note that submission can be a file # Note that submission can be a file
@@ -2646,7 +2641,7 @@ class CodeResponse(LoncapaResponse):
self.answer_id, self.answer_id,
convert_files_to_filenames(student_answers), convert_files_to_filenames(student_answers),
) )
raise Exception(err) # lint-amnesty, pylint: disable=raise-missing-from raise Exception(err) from err # pylint: disable=broad-exception-raised
# We do not support xqueue within Studio. # We do not support xqueue within Studio.
if self.capa_system.xqueue is None: if self.capa_system.xqueue is None:
@@ -2658,18 +2653,14 @@ class CodeResponse(LoncapaResponse):
# ------------------------------------------------------------ # ------------------------------------------------------------
qinterface = self.capa_system.xqueue.interface qinterface = self.capa_system.xqueue.interface
qtime = datetime.strftime(datetime.now(UTC), xqueue_interface.dateformat) qtime = datetime.strftime(datetime.now(UTC), DATEFORMAT)
anonymous_student_id = self.capa_system.anonymous_student_id anonymous_student_id = self.capa_system.anonymous_student_id
# Generate header # Generate header
queuekey = xqueue_interface.make_hashkey( queuekey = make_hashkey(str(self.capa_system.seed) + qtime + anonymous_student_id + self.answer_id)
str(self.capa_system.seed) + qtime + anonymous_student_id + self.answer_id
)
callback_url = self.capa_system.xqueue.construct_callback() callback_url = self.capa_system.xqueue.construct_callback()
xheader = xqueue_interface.make_xheader( xheader = make_xheader(lms_callback_url=callback_url, lms_key=queuekey, queue_name=self.queue_name)
lms_callback_url=callback_url, lms_key=queuekey, queue_name=self.queue_name
)
# Generate body # Generate body
if is_list_of_files(submission): if is_list_of_files(submission):
@@ -2748,8 +2739,7 @@ class CodeResponse(LoncapaResponse):
# matches # matches
if oldcmap.is_right_queuekey(self.answer_id, queuekey): if oldcmap.is_right_queuekey(self.answer_id, queuekey):
# Sanity check on returned points # Sanity check on returned points
if points < 0: # lint-amnesty, pylint: disable=consider-using-max-builtin points = max(points, 0)
points = 0
# Queuestate is consumed # Queuestate is consumed
oldcmap.set( oldcmap.set(
self.answer_id, self.answer_id,
@@ -2857,7 +2847,7 @@ class ExternalResponse(LoncapaResponse):
self.url = "" self.url = ""
self.tests = [] self.tests = []
self.code = "" self.code = ""
super(ExternalResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments super().__init__(*args, **kwargs)
def setup_response(self): def setup_response(self):
xml = self.xml xml = self.xml
@@ -2876,8 +2866,8 @@ class ExternalResponse(LoncapaResponse):
# no <answer> stanza; get code from <script> # no <answer> stanza; get code from <script>
self.code = self.context["script_code"] self.code = self.context["script_code"]
if not self.code: if not self.code:
msg = "%s: Missing answer script code for externalresponse" % str(self) msg = f"{self}: Missing answer script code for externalresponse"
msg += "\nSee XML source line %s" % getattr(self.xml, "sourceline", "[unavailable]") msg += f"\nSee XML source line {getattr(self.xml, 'sourceline', '[unavailable]')}"
raise LoncapaProblemError(msg) raise LoncapaProblemError(msg)
self.tests = xml.get("tests") self.tests = xml.get("tests")
@@ -2903,25 +2893,27 @@ class ExternalResponse(LoncapaResponse):
try: try:
# call external server. TODO: synchronous call, can block for a # call external server. TODO: synchronous call, can block for a
# long time # long time
req = requests.post(self.url, data=payload) req = requests.post(self.url, data=payload, timeout=10)
except Exception as err: except Exception as err:
msg = "Error {0} - cannot connect to external server url={1}".format(err, self.url) msg = f"Error {err} - cannot connect to external server url={self.url}"
log.error(msg) log.error(msg)
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from raise Exception(msg) from err # pylint: disable=broad-exception-raised
if self.capa_system.DEBUG: if self.capa_system.DEBUG:
log.info("response = %s", req.text) log.info("response = %s", req.text)
if (not req.text) or (not req.text.strip()): if (not req.text) or (not req.text.strip()):
raise Exception("Error: no response from external server url=%s" % self.url) raise Exception( # pylint: disable=broad-exception-raised
f"Error: no response from external server url={self.url}"
)
try: try:
# response is XML; parse it # response is XML; parse it
rxml = etree.fromstring(req.text) rxml = etree.fromstring(req.text)
except Exception as err: except Exception as err:
msg = "Error {0} - cannot parse response from external server req.text={1}".format(err, req.text) msg = f"Error {err} - cannot parse response from external server req.text={req.text}"
log.error(msg) log.error(msg)
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from raise Exception(msg) from err # pylint: disable=broad-exception-raised
return rxml return rxml
@@ -2934,7 +2926,7 @@ class ExternalResponse(LoncapaResponse):
log.error( log.error(
"Error %s: cannot get student answer for %s; student_answers=%s", err, self.answer_ids, student_answers "Error %s: cannot get student answer for %s; student_answers=%s", err, self.answer_ids, student_answers
) )
raise Exception(err) # lint-amnesty, pylint: disable=raise-missing-from raise Exception(err) from err # pylint: disable=broad-exception-raised
self.context.update({"submission": submission}) self.context.update({"submission": submission})
@@ -2942,7 +2934,7 @@ class ExternalResponse(LoncapaResponse):
try: try:
rxml = self.do_external_request("get_score", extra_payload) rxml = self.do_external_request("get_score", extra_payload)
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
log.error("Error %s", err) log.error("Error %s", err)
if self.capa_system.DEBUG: if self.capa_system.DEBUG:
cmap.set_dict(dict(list(zip(sorted(self.answer_ids), ["incorrect"] * len(idset))))) cmap.set_dict(dict(list(zip(sorted(self.answer_ids), ["incorrect"] * len(idset)))))
@@ -2972,7 +2964,7 @@ class ExternalResponse(LoncapaResponse):
try: try:
rxml = self.do_external_request("get_answers", {}) rxml = self.do_external_request("get_answers", {})
exans = json.loads(rxml.find("expected").text) exans = json.loads(rxml.find("expected").text)
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
log.error("Error %s", err) log.error("Error %s", err)
if self.capa_system.DEBUG: if self.capa_system.DEBUG:
msg = HTML('<span class="inline-error">{}</span>').format(err) msg = HTML('<span class="inline-error">{}</span>').format(err)
@@ -2981,7 +2973,7 @@ class ExternalResponse(LoncapaResponse):
if not len(exans) == len(self.answer_ids): if not len(exans) == len(self.answer_ids):
log.error("Expected %s answers from external server, only got %s!", len(self.answer_ids), len(exans)) log.error("Expected %s answers from external server, only got %s!", len(self.answer_ids), len(exans))
raise Exception("Short response from external server") raise Exception("Short response from external server") # pylint: disable=broad-exception-raised
return dict(list(zip(self.answer_ids, exans))) return dict(list(zip(self.answer_ids, exans)))
@@ -3005,9 +2997,9 @@ class FormulaResponse(LoncapaResponse):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.correct_answer = "" self.correct_answer = ""
self.samples = "" self.samples = ""
self.tolerance = default_tolerance self.tolerance = DEFAULT_TOLERANCE
self.case_sensitive = False self.case_sensitive = False
super(FormulaResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments super().__init__(*args, **kwargs)
def setup_response(self): def setup_response(self):
xml = self.xml xml = self.xml
@@ -3061,10 +3053,10 @@ class FormulaResponse(LoncapaResponse):
) )
except UndefinedVariable as err: except UndefinedVariable as err:
log.debug("formularesponse: undefined variable in formula=%s", html.escape(answer)) log.debug("formularesponse: undefined variable in formula=%s", html.escape(answer))
raise StudentInputError(err.args[0]) # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(err.args[0]) from err
except UnmatchedParenthesis as err: except UnmatchedParenthesis as err:
log.debug("formularesponse: unmatched parenthesis in formula=%s", html.escape(answer)) log.debug("formularesponse: unmatched parenthesis in formula=%s", html.escape(answer))
raise StudentInputError(err.args[0]) # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(err.args[0]) from err
except ValueError as err: except ValueError as err:
if "factorial" in str(err): if "factorial" in str(err):
# This is thrown when fact() or factorial() is used in a formularesponse answer # This is thrown when fact() or factorial() is used in a formularesponse answer
@@ -3079,26 +3071,26 @@ class FormulaResponse(LoncapaResponse):
), ),
html.escape(answer), html.escape(answer),
) )
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(
_( _(
"Factorial function not permitted in answer " "Factorial function not permitted in answer "
"for this problem. Provided answer was: " "for this problem. Provided answer was: "
"{bad_input}" "{bad_input}"
).format(bad_input=html.escape(answer)) ).format(bad_input=html.escape(answer))
) ) from err
# If non-factorial related ValueError thrown, handle it the same as any other Exception # If non-factorial related ValueError thrown, handle it the same as any other Exception
log.debug("formularesponse: error %s in formula", err) log.debug("formularesponse: error %s in formula", err)
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(
_("Invalid input: Could not parse '{bad_input}' as a formula.").format( _("Invalid input: Could not parse '{bad_input}' as a formula.").format(
bad_input=html.escape(answer) bad_input=html.escape(answer)
) )
) ) from err
except Exception as err: except Exception as err:
# traceback.print_exc() # traceback.print_exc()
log.debug("formularesponse: error %s in formula", err) log.debug("formularesponse: error %s in formula", err)
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(
_("Invalid input: Could not parse '{bad_input}' as a formula").format(bad_input=html.escape(answer)) _("Invalid input: Could not parse '{bad_input}' as a formula").format(bad_input=html.escape(answer))
) ) from err
return out return out
def randomize_variables(self, samples): def randomize_variables(self, samples):
@@ -3138,8 +3130,8 @@ class FormulaResponse(LoncapaResponse):
) )
if correct: if correct:
return "correct" return "correct"
else:
return "incorrect" return "incorrect"
def compare_answer(self, ans1, ans2): def compare_answer(self, ans1, ans2):
""" """
@@ -3165,13 +3157,12 @@ class FormulaResponse(LoncapaResponse):
keys and all non-numeric values stripped out. All values also keys and all non-numeric values stripped out. All values also
converted to float. Used so we can safely use Python contexts. converted to float. Used so we can safely use Python contexts.
""" """
inp_d = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension inp_d = {
[ k: numpy.complex(inp_d[k])
(k, numpy.complex(inp_d[k])) for k in inp_d
for k in inp_d if isinstance(k, str) and k.isalnum() and isinstance(inp_d[k], numbers.Number)
if isinstance(k, str) and k.isalnum() and isinstance(inp_d[k], numbers.Number) }
]
)
return inp_d return inp_d
def check_hint_condition(self, hxml_set, student_answers): def check_hint_condition(self, hxml_set, student_answers):
@@ -3181,10 +3172,10 @@ class FormulaResponse(LoncapaResponse):
samples = hxml.get("samples") samples = hxml.get("samples")
name = hxml.get("name") name = hxml.get("name")
correct_answer = contextualize_text(hxml.get("answer"), self.context) correct_answer = contextualize_text(hxml.get("answer"), self.context)
# pylint: disable=broad-except
try: try:
correctness = self.check_formula(correct_answer, given, samples) correctness = self.check_formula(correct_answer, given, samples)
except Exception: except Exception: # pylint: disable=broad-exception-caught
correctness = "incorrect" correctness = "incorrect"
if correctness == "correct": if correctness == "correct":
hints_to_show.append(name) hints_to_show.append(name)
@@ -3210,7 +3201,7 @@ class SchematicResponse(LoncapaResponse):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.code = "" self.code = ""
super(SchematicResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments super().__init__(*args, **kwargs)
def setup_response(self): def setup_response(self):
xml = self.xml xml = self.xml
@@ -3226,7 +3217,7 @@ class SchematicResponse(LoncapaResponse):
submission = [json.loads(student_answers[k]) for k in sorted(self.answer_ids)] submission = [json.loads(student_answers[k]) for k in sorted(self.answer_ids)]
self.context.update({"submission": submission}) self.context.update({"submission": submission})
try: try:
safe_exec.safe_exec( safe_exec(
self.code, self.code,
self.context, self.context,
cache=self.capa_system.cache, cache=self.capa_system.cache,
@@ -3241,7 +3232,7 @@ class SchematicResponse(LoncapaResponse):
_ = self.capa_system.i18n.gettext _ = self.capa_system.i18n.gettext
# Translators: 'SchematicResponse' is a problem type and should not be translated. # Translators: 'SchematicResponse' is a problem type and should not be translated.
msg = _("Error in evaluating SchematicResponse. The error was: {error_msg}").format(error_msg=err) msg = _("Error in evaluating SchematicResponse. The error was: {error_msg}").format(error_msg=err)
raise ResponseError(msg) # lint-amnesty, pylint: disable=raise-missing-from raise ResponseError(msg) from err
cmap = CorrectMap() cmap = CorrectMap()
cmap.set_dict(dict(list(zip(sorted(self.answer_ids), self.context["correct"])))) cmap.set_dict(dict(list(zip(sorted(self.answer_ids), self.context["correct"]))))
return cmap return cmap
@@ -3288,13 +3279,13 @@ class ImageResponse(LoncapaResponse):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.ielements = [] self.ielements = []
super(ImageResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments super().__init__(*args, **kwargs)
def setup_response(self): def setup_response(self):
self.ielements = self.inputfields self.ielements = self.inputfields
self.answer_ids = [ie.get("id") for ie in self.ielements] self.answer_ids = [ie.get("id") for ie in self.ielements]
def get_score(self, student_answers): def get_score(self, student_answers): # pylint: disable=too-many-locals
_ = self.capa_system.i18n.gettext _ = self.capa_system.i18n.gettext
correct_map = CorrectMap() correct_map = CorrectMap()
expectedset = self.get_mapped_answers() expectedset = self.get_mapped_answers()
@@ -3310,7 +3301,9 @@ class ImageResponse(LoncapaResponse):
msg = _("error grading {image_input_id} (input={user_input})").format( msg = _("error grading {image_input_id} (input={user_input})").format(
image_input_id=aid, user_input=given image_input_id=aid, user_input=given
) )
raise Exception("[capamodule.capa.responsetypes.imageinput] " + msg) raise Exception( # pylint: disable=broad-exception-raised
"[capamodule.capa.responsetypes.imageinput] " + msg
)
(ans_x, ans_y) = [int(x) for x in acoords.groups()] (ans_x, ans_y) = [int(x) for x in acoords.groups()]
@@ -3331,7 +3324,9 @@ class ImageResponse(LoncapaResponse):
msg = _("Error in problem specification! Cannot parse rectangle in {sr_coords}").format( msg = _("Error in problem specification! Cannot parse rectangle in {sr_coords}").format(
sr_coords=etree.tostring(self.ielements[aid], pretty_print=True) sr_coords=etree.tostring(self.ielements[aid], pretty_print=True)
) )
raise Exception("[capamodule.capa.responsetypes.imageinput] " + msg) raise Exception( # pylint: disable=broad-exception-raised
"[capamodule.capa.responsetypes.imageinput] " + msg
)
(llx, lly, urx, ury) = [int(x) for x in sr_coords.groups()] (llx, lly, urx, ury) = [int(x) for x in sr_coords.groups()]
@@ -3367,19 +3362,10 @@ class ImageResponse(LoncapaResponse):
regions (dict) - a map of inputs to the defined region for that input regions (dict) - a map of inputs to the defined region for that input
""" """
answers = ( answers = (
dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension {ie.get("id"): ie.get("rectangle") for ie in self.ielements},
[ {ie.get("id"): ie.get("regions") for ie in self.ielements},
(
ie.get("id"),
ie.get("rectangle"),
)
for ie in self.ielements
]
),
dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
[(ie.get("id"), ie.get("regions")) for ie in self.ielements]
),
) )
return answers return answers
def get_answers(self): def get_answers(self):
@@ -3421,7 +3407,7 @@ class AnnotationResponse(LoncapaResponse):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.scoring_map = {} self.scoring_map = {}
self.answer_map = {} self.answer_map = {}
super(AnnotationResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments super().__init__(*args, **kwargs)
def setup_response(self): def setup_response(self):
self.scoring_map = self._get_scoring_map() self.scoring_map = self._get_scoring_map()
@@ -3452,21 +3438,17 @@ class AnnotationResponse(LoncapaResponse):
def _get_scoring_map(self): def _get_scoring_map(self):
"""Returns a dict of option->scoring for each input.""" """Returns a dict of option->scoring for each input."""
scoring = self.default_scoring scoring = self.default_scoring
choices = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension choices = {choice: choice for choice in scoring}
[(choice, choice) for choice in scoring]
)
scoring_map = {} scoring_map = {}
for inputfield in self.inputfields: for inputfield in self.inputfields:
option_scoring = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension option_scoring = {
[ option["id"]: {
( "correctness": choices.get(option["choice"]),
option["id"], "points": scoring.get(option["choice"]),
{"correctness": choices.get(option["choice"]), "points": scoring.get(option["choice"])}, }
) for option in self._find_options(inputfield)
for option in self._find_options(inputfield) }
]
)
scoring_map[inputfield.get("id")] = option_scoring scoring_map[inputfield.get("id")] = option_scoring
@@ -3486,9 +3468,7 @@ class AnnotationResponse(LoncapaResponse):
"""Returns a dict of the max points for each input: input id -> maxpoints.""" """Returns a dict of the max points for each input: input id -> maxpoints."""
scoring = self.default_scoring scoring = self.default_scoring
correct_points = scoring.get("correct") correct_points = scoring.get("correct")
return dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension return {inputfield.get("id"): correct_points for inputfield in self.inputfields}
[(inputfield.get("id"), correct_points) for inputfield in self.inputfields]
)
def _find_options(self, inputfield): def _find_options(self, inputfield):
"""Returns an array of dicts where each dict represents an option.""" """Returns an array of dicts where each dict represents an option."""
@@ -3503,6 +3483,7 @@ class AnnotationResponse(LoncapaResponse):
for option in self._find_options(inputfield): for option in self._find_options(inputfield):
if option["choice"] == choice: if option["choice"] == choice:
return option return option
return None
def _unpack(self, json_value): def _unpack(self, json_value):
"""Unpacks a student response value submitted as JSON.""" """Unpacks a student response value submitted as JSON."""
@@ -3550,7 +3531,7 @@ class ChoiceTextResponse(LoncapaResponse):
self.correct_inputs = {} self.correct_inputs = {}
self.answer_values = {} self.answer_values = {}
self.correct_choices = {} self.correct_choices = {}
super(ChoiceTextResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments super().__init__(*args, **kwargs)
def setup_response(self): def setup_response(self):
""" """
@@ -3595,7 +3576,7 @@ class ChoiceTextResponse(LoncapaResponse):
answer = contextualize_text(answer, context) answer = contextualize_text(answer, context)
input_name = child.get("name") input_name = child.get("name")
# Contextualize the tolerance to value. # Contextualize the tolerance to value.
tolerance = contextualize_text(child.get("tolerance", default_tolerance), context) tolerance = contextualize_text(child.get("tolerance", DEFAULT_TOLERANCE), context)
# Add the answer and tolerance information for the current # Add the answer and tolerance information for the current
# numtolerance_input to `correct_inputs` # numtolerance_input to `correct_inputs`
self.correct_inputs[input_name] = {"answer": answer, "tolerance": tolerance} self.correct_inputs[input_name] = {"answer": answer, "tolerance": tolerance}
@@ -3808,28 +3789,26 @@ class ChoiceTextResponse(LoncapaResponse):
correct_ans = params["answer"] correct_ans = params["answer"]
# Set the tolerance to '0' if it was not specified in the xml # Set the tolerance to '0' if it was not specified in the xml
tolerance = params.get("tolerance", default_tolerance) tolerance = params.get("tolerance", DEFAULT_TOLERANCE)
# Make sure that the staff answer is a valid number # Make sure that the staff answer is a valid number
try: try:
correct_ans = complex(correct_ans) correct_ans = complex(correct_ans)
except ValueError: except ValueError as exc:
log.debug("Content error--answer '%s' is not a valid complex number", correct_ans) log.debug("Content error--answer '%s' is not a valid complex number", correct_ans)
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(_("The Staff answer could not be interpreted as a number.")) from exc
_("The Staff answer could not be interpreted as a number.")
)
# Compare the student answer to the staff answer/ or to 0 # Compare the student answer to the staff answer/ or to 0
# if all that is important is verifying numericality # if all that is important is verifying numericality
try: try:
partial_correct = compare_with_tolerance(evaluator({}, {}, answer_value), correct_ans, tolerance) partial_correct = compare_with_tolerance(evaluator({}, {}, answer_value), correct_ans, tolerance)
except: except Exception as exc:
# Use the traceback-preserving version of re-raising with a # Use the traceback-preserving version of re-raising with a
# different type # different type
__, __, trace = sys.exc_info() __, __, trace = sys.exc_info()
msg = _("Could not interpret '{given_answer}' as a number.").format( msg = _( # pylint: disable=translation-of-non-string
given_answer=html.escape(answer_value) f"Could not interpret '{html.escape(answer_value)}' as a number."
) )
msg += " ({0})".format(trace) msg += f" ({trace})"
raise StudentInputError(msg) # lint-amnesty, pylint: disable=raise-missing-from raise StudentInputError(msg) from exc
# Ignore the results of the comparisons which were just for # Ignore the results of the comparisons which were just for
# Numerical Validation. # Numerical Validation.
@@ -3844,22 +3823,20 @@ class ChoiceTextResponse(LoncapaResponse):
# TEMPORARY: List of all response subclasses # TEMPORARY: List of all response subclasses
# FIXME: To be replaced by auto-registration # FIXME: To be replaced by auto-registration
# pylint: disable=invalid-all-object
__all__ = [ __all__ = [
CodeResponse, "CodeResponse",
NumericalResponse, "NumericalResponse",
FormulaResponse, "FormulaResponse",
CustomResponse, "CustomResponse",
SchematicResponse, "SchematicResponse",
ExternalResponse, "ExternalResponse",
ImageResponse, "ImageResponse",
OptionResponse, "OptionResponse",
SymbolicResponse, "SymbolicResponse",
StringResponse, "StringResponse",
ChoiceResponse, "ChoiceResponse",
MultipleChoiceResponse, "MultipleChoiceResponse",
TrueFalseResponse, "TrueFalseResponse",
AnnotationResponse, "AnnotationResponse",
ChoiceTextResponse, "ChoiceTextResponse",
] ]
# pylint: enable=invalid-all-object

View File

@@ -8,7 +8,7 @@ in the public domain.
import sys import sys
class LazyModule(object): class LazyModule: # pylint: disable=too-few-public-methods
"""A lazy module proxy.""" """A lazy module proxy."""
def __init__(self, modname): def __init__(self, modname):
@@ -32,14 +32,12 @@ class LazyModule(object):
if hasattr(mod, name): if hasattr(mod, name):
return getattr(mod, name) return getattr(mod, name)
else:
try: try:
subname = "%s.%s" % (self.__name__, name) subname = f"{self.__name__}.{name}"
__import__(subname) __import__(subname)
submod = getattr(mod, name) # lint-amnesty, pylint: disable=unused-variable submod = getattr(mod, name) # pylint: disable=unused-variable
except ImportError: except ImportError as exc:
raise AttributeError( # lint-amnesty, pylint: disable=raise-missing-from raise AttributeError(f"'module' object has no attribute {name!r}") from exc
"'module' object has no attribute %r" % name self.__dict__[name] = LazyModule(subname)
) return self.__dict__[name]
self.__dict__[name] = LazyModule(subname)
return self.__dict__[name]

View File

@@ -42,6 +42,7 @@ ENABLE_CODEJAIL_DARKLAUNCH = SettingToggle("ENABLE_CODEJAIL_DARKLAUNCH", default
def is_codejail_rest_service_enabled(): def is_codejail_rest_service_enabled():
"""Return whether the codejail REST service is enabled."""
return ENABLE_CODEJAIL_REST_SERVICE.is_enabled() return ENABLE_CODEJAIL_REST_SERVICE.is_enabled()
@@ -69,6 +70,7 @@ def get_remote_exec(*args, **kwargs):
def get_codejail_rest_service_endpoint(): def get_codejail_rest_service_endpoint():
"""Return the endpoint URL for the codejail REST service."""
return f"{settings.CODE_JAIL_REST_SERVICE_HOST}/api/v0/code-exec" return f"{settings.CODE_JAIL_REST_SERVICE_HOST}/api/v0/code-exec"

View File

@@ -69,16 +69,16 @@ ASSUMED_IMPORTS = [
] ]
# We'll need the code from lazymod.py for use in safe_exec, so read it now. # We'll need the code from lazymod.py for use in safe_exec, so read it now.
lazymod_py_file = lazymod.__file__ LAZYMOD_PY_FILE = lazymod.__file__
if lazymod_py_file.endswith("c"): if LAZYMOD_PY_FILE.endswith("c"):
lazymod_py_file = lazymod_py_file[:-1] LAZYMOD_PY_FILE = LAZYMOD_PY_FILE[:-1]
with open(lazymod_py_file) as f: with open(LAZYMOD_PY_FILE, encoding="utf-8") as f:
lazymod_py = f.read() lazymod_py = f.read()
LAZY_IMPORTS = [lazymod_py] LAZY_IMPORTS = [lazymod_py]
for name, modname in ASSUMED_IMPORTS: for name, modname in ASSUMED_IMPORTS:
LAZY_IMPORTS.append("{} = LazyModule('{}')\n".format(name, modname)) LAZY_IMPORTS.append(f"{name} = LazyModule('{modname}')\n")
LAZY_IMPORTS = "".join(LAZY_IMPORTS) LAZY_IMPORTS = "".join(LAZY_IMPORTS)
@@ -107,7 +107,7 @@ def update_hash(hasher, obj):
@function_trace("safe_exec") @function_trace("safe_exec")
def safe_exec( def safe_exec( # pylint: disable=too-many-arguments,too-many-branches,too-many-locals,too-many-positional-arguments,too-many-statements
code, code,
globals_dict, globals_dict,
random_seed=None, random_seed=None,
@@ -117,7 +117,7 @@ def safe_exec(
limit_overrides_context=None, limit_overrides_context=None,
slug=None, slug=None,
unsafely=False, unsafely=False,
): # pylint: disable=too-many-statements ):
""" """
Execute python code safely. Execute python code safely.
@@ -155,7 +155,7 @@ def safe_exec(
md5er = hashlib.md5() md5er = hashlib.md5()
md5er.update(repr(code).encode("utf-8")) md5er.update(repr(code).encode("utf-8"))
update_hash(md5er, safe_globals) update_hash(md5er, safe_globals)
key = "safe_exec.%r.%s" % (random_seed, md5er.hexdigest()) key = f"safe_exec.{random_seed!r}.{md5er.hexdigest()}"
cached = cache.get(key) cached = cache.get(key)
if cached is not None: if cached is not None:
# We have a cached result. The result is a pair: the exception # We have a cached result. The result is a pair: the exception
@@ -210,7 +210,7 @@ def safe_exec(
limit_overrides_context=limit_overrides_context, limit_overrides_context=limit_overrides_context,
slug=slug, slug=slug,
) )
except BaseException as e: except BaseException as e: # pylint: disable=broad-exception-caught
# Saving SafeExecException e in exception to be used later. # Saving SafeExecException e in exception to be used later.
exception = e exception = e
emsg = str(e) emsg = str(e)
@@ -263,7 +263,7 @@ def safe_exec(
# SafeExecException wrapped around emsg (if present). # SafeExecException wrapped around emsg (if present).
remote_emsg, _ = get_remote_exec(data) remote_emsg, _ = get_remote_exec(data)
remote_exception = None remote_exception = None
except BaseException as e: # pragma: no cover # pylint: disable=broad-except except BaseException as e: # pragma: no cover # pylint: disable=broad-exception-caught
# Swallow all exceptions and log it in monitoring so that dark launch doesn't cause issues during # Swallow all exceptions and log it in monitoring so that dark launch doesn't cause issues during
# deploy. # deploy.
remote_emsg = None remote_emsg = None
@@ -282,7 +282,7 @@ def safe_exec(
emsg_remote=remote_emsg, emsg_remote=remote_emsg,
unexpected_exc_remote=remote_exception, unexpected_exc_remote=remote_exception,
) )
except BaseException as e: # pragma: no cover # pylint: disable=broad-except except BaseException: # pragma: no cover # pylint: disable=broad-exception-caught
log.exception("Error occurred while trying to report codejail darklaunch data.") log.exception("Error occurred while trying to report codejail darklaunch data.")
record_exception() record_exception()
@@ -376,7 +376,7 @@ def emsg_normalizers():
custom_setting = getattr(settings, "CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS", []) custom_setting = getattr(settings, "CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS", [])
try: try:
custom_normalizers = _compile_normalizers(custom_setting) custom_normalizers = _compile_normalizers(custom_setting)
except BaseException as e: except BaseException: # pylint: disable=broad-exception-caught
log.error("Could not load custom codejail darklaunch emsg normalizers") log.error("Could not load custom codejail darklaunch emsg normalizers")
record_exception() record_exception()
return default_normalizers return default_normalizers
@@ -390,8 +390,9 @@ def emsg_normalizers():
combine = getattr(settings, "CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE", "append") combine = getattr(settings, "CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE", "append")
if combine == "replace": if combine == "replace":
return custom_normalizers return custom_normalizers
else: # 'append', or unknown
return default_normalizers + custom_normalizers # 'append', or unknown
return default_normalizers + custom_normalizers
def normalize_error_message(emsg): def normalize_error_message(emsg):
@@ -407,7 +408,7 @@ def normalize_error_message(emsg):
return emsg return emsg
def report_darklaunch_results( def report_darklaunch_results( # pylint: disable=too-many-arguments
*, *,
limit_overrides_context, limit_overrides_context,
slug, slug,
@@ -454,22 +455,32 @@ def report_darklaunch_results(
set_custom_attribute("codejail.darklaunch.globals_match", "N/A") set_custom_attribute("codejail.darklaunch.globals_match", "N/A")
set_custom_attribute("codejail.darklaunch.emsg_match", "N/A") set_custom_attribute("codejail.darklaunch.emsg_match", "N/A")
log.info( log.info(
"Codejail darklaunch had unexpected exception for " "Codejail darklaunch had unexpected exception for course=%r, slug=%r:\n"
f"course={limit_overrides_context!r}, slug={slug!r}:\n" "Local exception: %r\nRemote exception: %r",
f"Local exception: {unexpected_exc_local!r}\n" limit_overrides_context,
f"Remote exception: {unexpected_exc_remote!r}" slug,
unexpected_exc_local,
unexpected_exc_remote,
) )
return return None
globals_match = globals_local == globals_remote globals_match = globals_local == globals_remote
emsg_match = normalize_error_message(emsg_local) == normalize_error_message(emsg_remote) emsg_match = normalize_error_message(emsg_local) == normalize_error_message(emsg_remote)
if not globals_match or not emsg_match: if not globals_match or not emsg_match:
log.info( log.info(
f"Codejail darklaunch had mismatch for course={limit_overrides_context!r}, slug={slug!r}:\n" "Codejail darklaunch had mismatch for course=%r, slug=%r:\n"
f"{emsg_match=}, {globals_match=}\n" "emsg_match=%r, globals_match=%r\n"
f"Local: globals={globals_local!r}, emsg={emsg_local!r}\n" "Local: globals=%r, emsg=%r\n"
f"Remote: globals={globals_remote!r}, emsg={emsg_remote!r}" "Remote: globals=%r, emsg=%r",
limit_overrides_context,
slug,
emsg_match,
globals_match,
globals_local,
emsg_local,
globals_remote,
emsg_remote,
) )
# .. custom_attribute_name: codejail.darklaunch.globals_match # .. custom_attribute_name: codejail.darklaunch.globals_match
@@ -484,10 +495,11 @@ def report_darklaunch_results(
# the randomized directory names used for sandboxes. 'N/A' when either # the randomized directory names used for sandboxes. 'N/A' when either
# arm raised an uncaught error. # arm raised an uncaught error.
set_custom_attribute("codejail.darklaunch.emsg_match", emsg_match) set_custom_attribute("codejail.darklaunch.emsg_match", emsg_match)
return None
@receiver(setting_changed) @receiver(setting_changed)
def reset_caches(sender, **kwargs): def reset_caches(sender, **kwargs): # pylint: disable=unused-argument
""" """
Reset cached settings during unit tests. Reset cached settings during unit tests.
""" """

View File

@@ -6,7 +6,7 @@ import unittest
from xmodule.capa.safe_exec.lazymod import LazyModule from xmodule.capa.safe_exec.lazymod import LazyModule
class ModuleIsolation(object): class ModuleIsolation: # pylint: disable=too-few-public-methods
""" """
Manage changes to sys.modules so that we can roll back imported modules. Manage changes to sys.modules so that we can roll back imported modules.
@@ -18,7 +18,9 @@ class ModuleIsolation(object):
# Save all the names of all the imported modules. # Save all the names of all the imported modules.
self.mods = set(sys.modules) self.mods = set(sys.modules)
def clean_up(self): # lint-amnesty, pylint: disable=missing-function-docstring def clean_up(self):
"""Remove any modules imported after initialization to restore state."""
# Get a list of modules that didn't exist when we were created # Get a list of modules that didn't exist when we were created
new_mods = [m for m in sys.modules if m not in self.mods] new_mods = [m for m in sys.modules if m not in self.mods]
# and delete them all so another import will run code for real again. # and delete them all so another import will run code for real again.
@@ -26,14 +28,16 @@ class ModuleIsolation(object):
del sys.modules[m] del sys.modules[m]
class TestLazyMod(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring class TestLazyMod(unittest.TestCase):
"""Unit tests for verifying lazy module importing with LazyModule."""
def setUp(self): def setUp(self):
super(TestLazyMod, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
# Each test will remove modules that it imported. # Each test will remove modules that it imported.
self.addCleanup(ModuleIsolation().clean_up) self.addCleanup(ModuleIsolation().clean_up)
def test_simple(self): def test_simple(self):
"""Test lazy import of a standard module and verify functionality."""
# Import some stdlib module that has not been imported before # Import some stdlib module that has not been imported before
module_name = "colorsys" module_name = "colorsys"
if module_name in sys.modules: if module_name in sys.modules:
@@ -45,6 +49,7 @@ class TestLazyMod(unittest.TestCase): # lint-amnesty, pylint: disable=missing-c
assert hsv[0] == 0.25 assert hsv[0] == 0.25
def test_dotted(self): def test_dotted(self):
"""Test lazy import of a dotted submodule and verify functionality."""
# wsgiref is a module with submodules that is not already imported. # wsgiref is a module with submodules that is not already imported.
# Any similar module would do. This test demonstrates that the module # Any similar module would do. This test demonstrates that the module
# is not already imported # is not already imported

View File

@@ -20,6 +20,7 @@ class TestRemoteExec(TestCase):
) )
@patch("requests.post") @patch("requests.post")
def test_json_encode(self, mock_post): def test_json_encode(self, mock_post):
"""Verify that get_remote_exec correctly JSON-encodes payload with globals."""
get_remote_exec( get_remote_exec(
{ {
"code": "out = 1 + 1", "code": "out = 1 + 1",

View File

@@ -21,31 +21,40 @@ from six.moves import range
from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.capa.safe_exec import safe_exec, update_hash from xmodule.capa.safe_exec import safe_exec, update_hash
from xmodule.capa.safe_exec.remote_exec import is_codejail_in_darklaunch, is_codejail_rest_service_enabled from xmodule.capa.safe_exec.remote_exec import (
is_codejail_in_darklaunch,
is_codejail_rest_service_enabled,
)
from xmodule.capa.safe_exec.safe_exec import emsg_normalizers, normalize_error_message from xmodule.capa.safe_exec.safe_exec import emsg_normalizers, normalize_error_message
from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.tests.test_util import UseUnsafeCodejail
@use_unsafe_codejail() @UseUnsafeCodejail()
class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring class TestSafeExec(unittest.TestCase):
"""Unit tests for verifying functionality and restrictions of safe_exec."""
def test_set_values(self): def test_set_values(self):
"""Verify assignment of values in safe_exec."""
g = {} g = {}
safe_exec("a = 17", g) safe_exec("a = 17", g)
assert g["a"] == 17 assert g["a"] == 17
def test_division(self): def test_division(self):
"""Verify division operation in safe_exec."""
g = {} g = {}
# Future division: 1/2 is 0.5. # Future division: 1/2 is 0.5.
safe_exec("a = 1/2", g) safe_exec("a = 1/2", g)
assert g["a"] == 0.5 assert g["a"] == 0.5
def test_assumed_imports(self): def test_assumed_imports(self):
"""Check assumed standard imports in safe_exec."""
g = {} g = {}
# Math is always available. # Math is always available.
safe_exec("a = int(math.pi)", g) safe_exec("a = int(math.pi)", g)
assert g["a"] == 3 assert g["a"] == 3
def test_random_seeding(self): def test_random_seeding(self):
"""Test predictable random results with seeding in safe_exec."""
g = {} g = {}
r = random.Random(17) r = random.Random(17)
rnums = [r.randint(0, 999) for _ in range(100)] rnums = [r.randint(0, 999) for _ in range(100)]
@@ -59,6 +68,7 @@ class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-
assert g["rnums"] == rnums assert g["rnums"] == rnums
def test_random_is_still_importable(self): def test_random_is_still_importable(self):
"""Ensure random module works with seeding in safe_exec."""
g = {} g = {}
r = random.Random(17) r = random.Random(17)
rnums = [r.randint(0, 999) for _ in range(100)] rnums = [r.randint(0, 999) for _ in range(100)]
@@ -68,18 +78,21 @@ class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-
assert g["rnums"] == rnums assert g["rnums"] == rnums
def test_python_lib(self): def test_python_lib(self):
"""Test importing Python library from custom path in safe_exec."""
pylib = os.path.dirname(__file__) + "/test_files/pylib" pylib = os.path.dirname(__file__) + "/test_files/pylib"
g = {} g = {}
safe_exec("import constant; a = constant.THE_CONST", g, python_path=[pylib]) safe_exec("import constant; a = constant.THE_CONST", g, python_path=[pylib])
def test_raising_exceptions(self): def test_raising_exceptions(self):
"""Ensure exceptions are raised correctly in safe_exec."""
g = {} g = {}
with pytest.raises(SafeExecException) as cm: with pytest.raises(SafeExecException) as cm:
safe_exec("1/0", g) safe_exec("1/0", g)
assert "ZeroDivisionError" in str(cm.value) assert "ZeroDivisionError" in str(cm.value)
class TestSafeOrNot(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring class TestSafeOrNot(unittest.TestCase):
"""Tests to verify safe vs unsafe execution behavior of code jail."""
@skip_unless_lms @skip_unless_lms
def test_cant_do_something_forbidden(self): def test_cant_do_something_forbidden(self):
@@ -131,7 +144,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
@patch("xmodule.capa.safe_exec.safe_exec.get_remote_exec") @patch("xmodule.capa.safe_exec.safe_exec.get_remote_exec")
@patch("xmodule.capa.safe_exec.safe_exec.codejail_safe_exec") @patch("xmodule.capa.safe_exec.safe_exec.codejail_safe_exec")
def test_default_code_execution(self, mock_local_exec, mock_remote_exec): def test_default_code_execution(self, mock_local_exec, mock_remote_exec):
"""Check that default code execution uses local exec only."""
# Test default only runs local exec. # Test default only runs local exec.
g = {} g = {}
safe_exec("a=1", g) safe_exec("a=1", g)
@@ -142,6 +155,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
@patch("xmodule.capa.safe_exec.safe_exec.get_remote_exec") @patch("xmodule.capa.safe_exec.safe_exec.get_remote_exec")
@patch("xmodule.capa.safe_exec.safe_exec.codejail_safe_exec") @patch("xmodule.capa.safe_exec.safe_exec.codejail_safe_exec")
def test_code_execution_only_codejail_service(self, mock_local_exec, mock_remote_exec): def test_code_execution_only_codejail_service(self, mock_local_exec, mock_remote_exec):
"""Check execution via codejail service only."""
# Set return values to empty values to indicate no error. # Set return values to empty values to indicate no error.
mock_remote_exec.return_value = (None, None) mock_remote_exec.return_value = (None, None)
# Test with only the service enabled. # Test with only the service enabled.
@@ -164,7 +178,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
assert mock_remote_exec.called assert mock_remote_exec.called
@override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True) @override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True)
def run_dark_launch( def run_dark_launch( # pylint: disable=too-many-positional-arguments,too-many-arguments
self, self,
globals_dict, globals_dict,
local, local,
@@ -202,7 +216,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
limit_overrides_context="course-v1:org+course+run", limit_overrides_context="course-v1:org+course+run",
slug="hw1", slug="hw1",
) )
except BaseException as e: except BaseException as e: # pylint: disable=broad-exception-caught
safe_exec_e = e safe_exec_e = e
else: else:
safe_exec_e = None safe_exec_e = None
@@ -234,7 +248,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
local_globals = None local_globals = None
remote_globals = None remote_globals = None
def local_exec(code, globals_dict, **kwargs): def local_exec(code, globals_dict, **kwargs): # pylint: disable=unused-argument
# Preserve what local exec saw # Preserve what local exec saw
nonlocal local_globals nonlocal local_globals
local_globals = copy.deepcopy(globals_dict) local_globals = copy.deepcopy(globals_dict)
@@ -264,11 +278,18 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
], ],
expect_log_info_calls=[ expect_log_info_calls=[
call( call(
"Codejail darklaunch had mismatch for " "Codejail darklaunch had mismatch for course=%r, slug=%r:\n"
"course='course-v1:org+course+run', slug='hw1':\n" "emsg_match=%r, globals_match=%r\n"
"emsg_match=True, globals_match=False\n" "Local: globals=%r, emsg=%r\n"
"Local: globals={'overwrite': 'mock local'}, emsg=None\n" "Remote: globals=%r, emsg=%r",
"Remote: globals={'overwrite': 'mock remote'}, emsg=None" "course-v1:org+course+run",
"hw1",
True,
False,
{"overwrite": "mock local"},
None,
{"overwrite": "mock remote"},
None,
), ),
], ],
# Should only see behavior of local exec # Should only see behavior of local exec
@@ -283,12 +304,13 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
def test_remote_runs_even_if_local_raises(self): def test_remote_runs_even_if_local_raises(self):
"""Test that remote exec runs even if local raises.""" """Test that remote exec runs even if local raises."""
expected_error = BaseException("unexpected")
def local_exec(code, globals_dict, **kwargs): def local_exec(code, globals_dict, **kwargs):
# Raise something other than a SafeExecException. # Raise something other than a SafeExecException.
raise BaseException("unexpected") raise expected_error # pylint: disable=broad-exception-raised
def remote_exec(data): def remote_exec(data): # pylint: disable=unused-argument
return (None, None) return (None, None)
results = self.run_dark_launch( results = self.run_dark_launch(
@@ -306,10 +328,12 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
], ],
expect_log_info_calls=[ expect_log_info_calls=[
call( call(
"Codejail darklaunch had unexpected exception " "Codejail darklaunch had unexpected exception for course=%r, slug=%r:\n"
"for course='course-v1:org+course+run', slug='hw1':\n" "Local exception: %r\nRemote exception: %r",
"Local exception: BaseException('unexpected')\n" "course-v1:org+course+run",
"Remote exception: None" "hw1",
expected_error,
None,
), ),
], ],
expect_globals_contains={}, expect_globals_contains={},
@@ -325,7 +349,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
def local_exec(code, globals_dict, **kwargs): def local_exec(code, globals_dict, **kwargs):
raise SafeExecException("oops") raise SafeExecException("oops")
def remote_exec(data): def remote_exec(data): # pylint: disable=unused-argument
return ("OH NO", SafeExecException("OH NO")) return ("OH NO", SafeExecException("OH NO"))
results = self.run_dark_launch( results = self.run_dark_launch(
@@ -343,11 +367,18 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
], ],
expect_log_info_calls=[ expect_log_info_calls=[
call( call(
"Codejail darklaunch had mismatch for " "Codejail darklaunch had mismatch for course=%r, slug=%r:\n"
"course='course-v1:org+course+run', slug='hw1':\n" "emsg_match=%r, globals_match=%r\n"
"emsg_match=False, globals_match=True\n" "Local: globals=%r, emsg=%r\n"
"Local: globals={}, emsg='oops'\n" "Remote: globals=%r, emsg=%r",
"Remote: globals={}, emsg='OH NO'" "course-v1:org+course+run",
"hw1",
False,
True,
{},
"oops",
{},
"OH NO",
), ),
], ],
expect_globals_contains={}, expect_globals_contains={},
@@ -361,7 +392,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
def local_exec(code, globals_dict, **kwargs): def local_exec(code, globals_dict, **kwargs):
raise SafeExecException("stack trace involving /tmp/codejail-1234567/whatever.py") raise SafeExecException("stack trace involving /tmp/codejail-1234567/whatever.py")
def remote_exec(data): def remote_exec(data): # pylint: disable=unused-argument
emsg = "stack trace involving /tmp/codejail-abcd_EFG/whatever.py" emsg = "stack trace involving /tmp/codejail-abcd_EFG/whatever.py"
return (emsg, SafeExecException(emsg)) return (emsg, SafeExecException(emsg))
@@ -449,7 +480,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
@patch("xmodule.capa.safe_exec.safe_exec.log.error") @patch("xmodule.capa.safe_exec.safe_exec.log.error")
def test_normalizers_validate(self, mock_log_error, mock_record_exception): def test_normalizers_validate(self, mock_log_error, mock_record_exception):
"""Normalizers are validated, and fall back to default list on error.""" """Normalizers are validated, and fall back to default list on error."""
assert len(emsg_normalizers()) > 0 # pylint: disable=use-implicit-booleaness-not-comparison assert len(emsg_normalizers()) > 0
mock_log_error.assert_called_once_with("Could not load custom codejail darklaunch emsg normalizers") mock_log_error.assert_called_once_with("Could not load custom codejail darklaunch emsg normalizers")
mock_record_exception.assert_called_once() mock_record_exception.assert_called_once()
@@ -529,28 +560,31 @@ class TestLimitConfiguration(unittest.TestCase):
assert jail_code.get_effective_limits("course-v1:my+special+course")["NPROC"] == 30 assert jail_code.get_effective_limits("course-v1:my+special+course")["NPROC"] == 30
class DictCache(object): class DictCache:
"""A cache implementation over a simple dict, for testing.""" """A cache implementation over a simple dict, for testing."""
def __init__(self, d): def __init__(self, d):
self.cache = d self.cache = d
def get(self, key): def get(self, key):
"""Get value from cache by key with length check."""
# Actual cache implementations have limits on key length # Actual cache implementations have limits on key length
assert len(key) <= 250 assert len(key) <= 250
return self.cache.get(key) return self.cache.get(key)
def set(self, key, value): def set(self, key, value):
"""Set value in cache by key with length check."""
# Actual cache implementations have limits on key length # Actual cache implementations have limits on key length
assert len(key) <= 250 assert len(key) <= 250
self.cache[key] = value self.cache[key] = value
@use_unsafe_codejail() @UseUnsafeCodejail()
class TestSafeExecCaching(unittest.TestCase): class TestSafeExecCaching(unittest.TestCase):
"""Test that caching works on safe_exec.""" """Test that caching works on safe_exec."""
def test_cache_miss_then_hit(self): def test_cache_miss_then_hit(self):
"""Test caching works on miss and hit in safe_exec."""
g = {} g = {}
cache = {} cache = {}
@@ -568,6 +602,7 @@ class TestSafeExecCaching(unittest.TestCase):
assert g["a"] == 17 assert g["a"] == 17
def test_cache_large_code_chunk(self): def test_cache_large_code_chunk(self):
"""Test caching handles large code chunks."""
# Caching used to die on memcache with more than 250 bytes of code. # Caching used to die on memcache with more than 250 bytes of code.
# Check that it doesn't any more. # Check that it doesn't any more.
code = "a = 0\n" + ("a += 1\n" * 12345) code = "a = 0\n" + ("a += 1\n" * 12345)
@@ -578,6 +613,7 @@ class TestSafeExecCaching(unittest.TestCase):
assert g["a"] == 12345 assert g["a"] == 12345
def test_cache_exceptions(self): def test_cache_exceptions(self):
"""Test caching of exceptions in safe_exec."""
# Used to be that running code that raised an exception didn't cache # Used to be that running code that raised an exception didn't cache
# the result. Check that now it does. # the result. Check that now it does.
code = "1/0" code = "1/0"
@@ -588,7 +624,7 @@ class TestSafeExecCaching(unittest.TestCase):
# The exception should be in the cache now. # The exception should be in the cache now.
assert len(cache) == 1 assert len(cache) == 1
cache_exc_msg, cache_globals = list(cache.values())[0] # lint-amnesty, pylint: disable=unused-variable cache_exc_msg, cache_globals = list(cache.values())[0] # pylint: disable=unused-variable
assert "ZeroDivisionError" in cache_exc_msg assert "ZeroDivisionError" in cache_exc_msg
# Change the value stored in the cache, the result should change. # Change the value stored in the cache, the result should change.
@@ -607,6 +643,7 @@ class TestSafeExecCaching(unittest.TestCase):
assert g["a"] == 17 assert g["a"] == 17
def test_unicode_submission(self): def test_unicode_submission(self):
"""Test safe_exec handles non-ASCII unicode."""
# Check that using non-ASCII unicode does not raise an encoding error. # Check that using non-ASCII unicode does not raise an encoding error.
# Try several non-ASCII unicode characters. # Try several non-ASCII unicode characters.
for code in [129, 500, 2**8 - 1, 2**16 - 1]: for code in [129, 500, 2**8 - 1, 2**16 - 1]:
@@ -614,7 +651,7 @@ class TestSafeExecCaching(unittest.TestCase):
try: try:
safe_exec(code_with_unichr, {}, cache=DictCache({})) safe_exec(code_with_unichr, {}, cache=DictCache({}))
except UnicodeEncodeError: except UnicodeEncodeError:
self.fail("Tried executing code with non-ASCII unicode: {0}".format(code)) self.fail(f"Tried executing code with non-ASCII unicode: {code}")
class TestUpdateHash(unittest.TestCase): class TestUpdateHash(unittest.TestCase):
@@ -644,6 +681,7 @@ class TestUpdateHash(unittest.TestCase):
return d1, d2 return d1, d2
def test_simple_cases(self): def test_simple_cases(self):
"""Test hashing of simple objects."""
h1 = self.hash_obj(1) h1 = self.hash_obj(1)
h10 = self.hash_obj(10) h10 = self.hash_obj(10)
hs1 = self.hash_obj("1") hs1 = self.hash_obj("1")
@@ -652,17 +690,20 @@ class TestUpdateHash(unittest.TestCase):
assert h1 != hs1 assert h1 != hs1
def test_list_ordering(self): def test_list_ordering(self):
"""Test that list ordering affects hash."""
h1 = self.hash_obj({"a": [1, 2, 3]}) h1 = self.hash_obj({"a": [1, 2, 3]})
h2 = self.hash_obj({"a": [3, 2, 1]}) h2 = self.hash_obj({"a": [3, 2, 1]})
assert h1 != h2 assert h1 != h2
def test_dict_ordering(self): def test_dict_ordering(self):
"""Test that dict ordering does not affect hash."""
d1, d2 = self.equal_but_different_dicts() d1, d2 = self.equal_but_different_dicts()
h1 = self.hash_obj(d1) h1 = self.hash_obj(d1)
h2 = self.hash_obj(d2) h2 = self.hash_obj(d2)
assert h1 == h2 assert h1 == h2
def test_deep_ordering(self): def test_deep_ordering(self):
"""Test that nested structures are hashed consistently."""
d1, d2 = self.equal_but_different_dicts() d1, d2 = self.equal_but_different_dicts()
o1 = {"a": [1, 2, [d1], 3, 4]} o1 = {"a": [1, 2, [d1], 3, 4]}
o2 = {"a": [1, 2, [d2], 3, 4]} o2 = {"a": [1, 2, [d2], 3, 4]}
@@ -671,9 +712,12 @@ class TestUpdateHash(unittest.TestCase):
assert h1 == h2 assert h1 == h2
@use_unsafe_codejail() @UseUnsafeCodejail()
class TestRealProblems(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring class TestRealProblems(unittest.TestCase):
"""Unit tests for executing real problem code snippets safely."""
def test_802x(self): def test_802x(self):
"Test execution of real problem code snippet safely."
code = textwrap.dedent( code = textwrap.dedent(
"""\ """\
import math import math

View File

@@ -1,6 +1,7 @@
""" """
Score rendering when submission is evaluated for external grader and has been saved successfully Score rendering when submission is evaluated for external grader and has been saved successfully
""" """
import logging import logging
from functools import partial from functools import partial
@@ -20,10 +21,10 @@ log = logging.getLogger(__name__)
def load_xblock_for_external_grader( def load_xblock_for_external_grader(
user_id: str, user_id: str,
course_key: CourseKey, course_key: CourseKey,
usage_key: UsageKey, usage_key: UsageKey,
course=None, course=None,
): ):
""" """
Load a single XBlock for external grading without user access checks. Load a single XBlock for external grading without user access checks.
@@ -35,22 +36,16 @@ def load_xblock_for_external_grader(
try: try:
block = modulestore().get_item(usage_key) block = modulestore().get_item(usage_key)
except Exception as e: except Exception as e:
log.exception(f"Could not find block {usage_key} in modulestore: {e}") log.exception("Could not find block %s in modulestore: %s", usage_key, e)
raise Http404(f"Module {usage_key} was not found") from e raise Http404(f"Module {usage_key} was not found") from e
field_data_cache = FieldDataCache.cache_for_block_descendents( field_data_cache = FieldDataCache.cache_for_block_descendents(course_key, user, block, depth=0)
course_key, user, block, depth=0
)
student_kvs = DjangoKeyValueStore(field_data_cache) student_kvs = DjangoKeyValueStore(field_data_cache)
student_data = KvsFieldData(student_kvs) student_data = KvsFieldData(student_kvs)
instance = get_block_for_descriptor_without_access_check( instance = get_block_for_descriptor_without_access_check(
user=user, user=user, block=block, student_data=student_data, course_key=course_key, course=course
block=block,
student_data=student_data,
course_key=course_key,
course=course
) )
if instance is None: if instance is None:

View File

@@ -4,8 +4,8 @@ import gettext
import io import io
import os import os
import os.path import os.path
import xml.sax.saxutils as saxutils
from unittest.mock import MagicMock, Mock from unittest.mock import MagicMock, Mock
from xml.sax import saxutils
import fs.osfs import fs.osfs
from django.template.loader import get_template as django_get_template from django.template.loader import get_template as django_get_template
@@ -35,15 +35,16 @@ def tst_render_template(template, context): # pylint: disable=unused-argument
A test version of render to template. Renders to the repr of the context, completely ignoring A test version of render to template. Renders to the repr of the context, completely ignoring
the template name. To make the output valid xml, quotes the content, and wraps it in a <div> the template name. To make the output valid xml, quotes the content, and wraps it in a <div>
""" """
return "<div>{0}</div>".format(saxutils.escape(repr(context))) return f"<div>{saxutils.escape(repr(context))}</div>"
class StubXQueueService: class StubXQueueService: # pylint: disable=too-few-public-methods
""" """
Stubs out the XQueueService for Capa problem tests. Stubs out the XQueueService for Capa problem tests.
""" """
def __init__(self): def __init__(self):
"""Initialize the stubbed XQueueService instance."""
self.interface = MagicMock() self.interface = MagicMock()
self.interface.send_to_queue.return_value = (0, "Success!") self.interface.send_to_queue.return_value = (0, "Success!")
self.default_queuename = "testqueue" self.default_queuename = "testqueue"
@@ -82,7 +83,7 @@ def mock_capa_block():
capa response types needs just two things from the capa_block: location and publish. capa response types needs just two things from the capa_block: location and publish.
""" """
def mock_location_text(self): # lint-amnesty, pylint: disable=unused-argument def mock_location_text(self): # pylint: disable=unused-argument
""" """
Mock implementation of __unicode__ or __str__ for the block's location. Mock implementation of __unicode__ or __str__ for the block's location.
""" """

View File

@@ -1,4 +1,4 @@
# lint-amnesty, pylint: disable=missing-module-docstring """Factories to build CAPA response XML."""
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
@@ -28,7 +28,7 @@ class ResponseXMLFactory(six.with_metaclass(ABCMeta, object)):
representing the capa input XML (such as <textline />)""" representing the capa input XML (such as <textline />)"""
return None return None
def build_xml(self, **kwargs): def build_xml(self, **kwargs): # pylint: disable=too-many-locals
"""Construct an XML string for a capa response """Construct an XML string for a capa response
based on **kwargs. based on **kwargs.
@@ -364,13 +364,12 @@ class CodeResponseXMLFactory(ResponseXMLFactory):
"""Factory for creating <coderesponse> XML trees""" """Factory for creating <coderesponse> XML trees"""
def build_xml(self, **kwargs): def build_xml(self, **kwargs):
"""Build a <coderesponse> XML tree."""
# Since we are providing an <answer> tag, # Since we are providing an <answer> tag,
# we should override the default behavior # we should override the default behavior
# of including a <solution> tag as well # of including a <solution> tag as well
kwargs["explanation_text"] = None kwargs["explanation_text"] = None
return super(CodeResponseXMLFactory, self).build_xml( # lint-amnesty, pylint: disable=super-with-arguments return super().build_xml(**kwargs)
**kwargs
)
def create_response_element(self, **kwargs): def create_response_element(self, **kwargs):
""" """
@@ -452,7 +451,7 @@ class ChoiceResponseXMLFactory(ResponseXMLFactory):
class FormulaResponseXMLFactory(ResponseXMLFactory): class FormulaResponseXMLFactory(ResponseXMLFactory):
"""Factory for creating <formularesponse> XML trees""" """Factory for creating <formularesponse> XML trees"""
def create_response_element(self, **kwargs): def create_response_element(self, **kwargs): # pylint: disable=too-many-locals
"""Create a <formularesponse> element. """Create a <formularesponse> element.
*sample_dict*: A dictionary of the form: *sample_dict*: A dictionary of the form:
@@ -534,9 +533,9 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
def create_input_element(self, **kwargs): def create_input_element(self, **kwargs):
return ResponseXMLFactory.textline_input_xml(**kwargs) return ResponseXMLFactory.textline_input_xml(**kwargs)
def _sample_str( def _sample_str(self, sample_dict, num_samples, tolerance): # pylint: disable=unused-argument
self, sample_dict, num_samples, tolerance """Generate a sample string for Loncapa using variable ranges and repetition count."""
): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument
# Loncapa uses a special format for sample strings: # Loncapa uses a special format for sample strings:
# "x,y,z@4,5,3:10,12,8#4" means plug in values for (x,y,z) # "x,y,z@4,5,3:10,12,8#4" means plug in values for (x,y,z)
# from within the box defined by points (4,5,3) and (10,12,8) # from within the box defined by points (4,5,3) and (10,12,8)
@@ -681,8 +680,8 @@ class OptionResponseXMLFactory(ResponseXMLFactory):
# Set the "options" attribute # Set the "options" attribute
# Format: "('first', 'second', 'third')" # Format: "('first', 'second', 'third')"
options_attr_string = ",".join(["'{}'".format(o) for o in options_list]) options_attr_string = ",".join([f"'{o}'" for o in options_list])
options_attr_string = "({})".format(options_attr_string) options_attr_string = f"({options_attr_string})"
optioninput_element.set("options", options_attr_string) optioninput_element.set("options", options_attr_string)
# Set the "correct" attribute # Set the "correct" attribute
@@ -694,7 +693,7 @@ class OptionResponseXMLFactory(ResponseXMLFactory):
class StringResponseXMLFactory(ResponseXMLFactory): class StringResponseXMLFactory(ResponseXMLFactory):
"""Factory for producing <stringresponse> XML""" """Factory for producing <stringresponse> XML"""
def create_response_element(self, **kwargs): def create_response_element(self, **kwargs): # pylint: disable=too-many-locals
"""Create a <stringresponse> XML element. """Create a <stringresponse> XML element.
Uses **kwargs: Uses **kwargs:
@@ -897,7 +896,7 @@ class ChoiceTextResponseXMLFactory(ResponseXMLFactory):
for ind, choice in enumerate(choice_inputs): for ind, choice in enumerate(choice_inputs):
# Give each choice text equal to it's position(0,1,2...) # Give each choice text equal to it's position(0,1,2...)
choice.text = "choice_{0}".format(ind) choice.text = f"choice_{ind}"
input_element.append(choice) input_element.append(choice)
return input_element return input_element

View File

@@ -14,7 +14,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
"""Capa Answer Pool Test""" """Capa Answer Pool Test"""
def setUp(self): def setUp(self):
super(CapaAnswerPoolTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.system = mock_capa_system() self.system = mock_capa_system()
# XML problem setup used by a few tests. # XML problem setup used by a few tests.
@@ -55,6 +55,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
) )
def test_answer_pool_4_choices_1_multiplechoiceresponse_seed1(self): def test_answer_pool_4_choices_1_multiplechoiceresponse_seed1(self):
"""Test answer-pool=4 with one multiplechoiceresponse and a fixed seed."""
problem = new_loncapa_problem(self.common_question_xml, seed=723) problem = new_loncapa_problem(self.common_question_xml, seed=723)
the_html = problem.get_html() the_html = problem.get_html()
# [('choice_3', 'wrong-3'), ('choice_5', 'correct-2'), ('choice_1', 'wrong-2'), ('choice_4', 'wrong-4')] # [('choice_3', 'wrong-3'), ('choice_5', 'correct-2'), ('choice_1', 'wrong-2'), ('choice_4', 'wrong-4')]
@@ -68,6 +69,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert response.unmask_order() == ["choice_3", "choice_5", "choice_1", "choice_4"] assert response.unmask_order() == ["choice_3", "choice_5", "choice_1", "choice_4"]
def test_answer_pool_4_choices_1_multiplechoiceresponse_seed2(self): def test_answer_pool_4_choices_1_multiplechoiceresponse_seed2(self):
"""Test answer-pool=4 with one multiplechoiceresponse and a different seed."""
problem = new_loncapa_problem(self.common_question_xml, seed=9) problem = new_loncapa_problem(self.common_question_xml, seed=9)
the_html = problem.get_html() the_html = problem.get_html()
# [('choice_0', 'wrong-1'), ('choice_4', 'wrong-4'), ('choice_3', 'wrong-3'), ('choice_2', 'correct-1')] # [('choice_0', 'wrong-1'), ('choice_4', 'wrong-4'), ('choice_3', 'wrong-3'), ('choice_2', 'correct-1')]
@@ -80,6 +82,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert response.unmask_order() == ["choice_0", "choice_4", "choice_3", "choice_2"] assert response.unmask_order() == ["choice_0", "choice_4", "choice_3", "choice_2"]
def test_no_answer_pool_4_choices_1_multiplechoiceresponse(self): def test_no_answer_pool_4_choices_1_multiplechoiceresponse(self):
"""Test behavior when no answer-pool attribute is provided."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -121,7 +124,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
the_html = problem.get_html() the_html = problem.get_html()
self.assertRegex( self.assertRegex(
the_html, r"<div>.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*</div>" the_html, r"<div>.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*</div>"
) # lint-amnesty, pylint: disable=line-too-long )
self.assertRegex(the_html, r"<div>\{.*'1_solution_1'.*'1_solution_2'.*\}</div>") self.assertRegex(the_html, r"<div>\{.*'1_solution_1'.*'1_solution_2'.*\}</div>")
assert the_html == problem.get_html(), "should be able to call get_html() twice" assert the_html == problem.get_html(), "should be able to call get_html() twice"
# Check about masking # Check about masking
@@ -130,6 +133,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert not response.has_answerpool() assert not response.has_answerpool()
def test_0_answer_pool_4_choices_1_multiplechoiceresponse(self): def test_0_answer_pool_4_choices_1_multiplechoiceresponse(self):
"""Test behavior when answer-pool is explicitly set to 0."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -171,13 +175,14 @@ class CapaAnswerPoolTest(unittest.TestCase):
the_html = problem.get_html() the_html = problem.get_html()
self.assertRegex( self.assertRegex(
the_html, r"<div>.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*</div>" the_html, r"<div>.*\[.*'wrong-1'.*'wrong-2'.*'correct-1'.*'wrong-3'.*'wrong-4'.*'correct-2'.*\].*</div>"
) # lint-amnesty, pylint: disable=line-too-long )
self.assertRegex(the_html, r"<div>\{.*'1_solution_1'.*'1_solution_2'.*\}</div>") self.assertRegex(the_html, r"<div>\{.*'1_solution_1'.*'1_solution_2'.*\}</div>")
response = list(problem.responders.values())[0] response = list(problem.responders.values())[0]
assert not response.has_mask() assert not response.has_mask()
assert not response.has_answerpool() assert not response.has_answerpool()
def test_invalid_answer_pool_value(self): def test_invalid_answer_pool_value(self):
"""Ensure error is raised for non-integer answer-pool values."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -219,6 +224,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
new_loncapa_problem(xml_str) new_loncapa_problem(xml_str)
def test_invalid_answer_pool_none_correct(self): def test_invalid_answer_pool_none_correct(self):
"""Ensure error is raised if no correct choice exists in answer-pool."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -239,6 +245,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
new_loncapa_problem(xml_str) new_loncapa_problem(xml_str)
def test_invalid_answer_pool_all_correct(self): def test_invalid_answer_pool_all_correct(self):
"""Ensure error is raised if all choices are marked correct in answer-pool."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -258,6 +265,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
new_loncapa_problem(xml_str) new_loncapa_problem(xml_str)
def test_answer_pool_5_choices_1_multiplechoiceresponse_seed1(self): def test_answer_pool_5_choices_1_multiplechoiceresponse_seed1(self):
"""Test answer-pool=5 with one multiplechoiceresponse and fixed seed."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -304,6 +312,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert response.unmask_order() == ["choice_5", "choice_0", "choice_1", "choice_3", "choice_4"] assert response.unmask_order() == ["choice_5", "choice_0", "choice_1", "choice_3", "choice_4"]
def test_answer_pool_2_multiplechoiceresponses_seed1(self): def test_answer_pool_2_multiplechoiceresponses_seed1(self):
"""Test two multiplechoiceresponses with different answer-pools and fixed seed."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -390,6 +399,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
self.assertRegex(without_new_lines, str3 + r".*" + str4) self.assertRegex(without_new_lines, str3 + r".*" + str4)
def test_answer_pool_2_multiplechoiceresponses_seed2(self): def test_answer_pool_2_multiplechoiceresponses_seed2(self):
"""Test two multiplechoiceresponses with different answer-pools and second seed."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -544,6 +554,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
self.assertRegex(without_new_lines, str1) self.assertRegex(without_new_lines, str1)
def test_no_answer_pool(self): def test_no_answer_pool(self):
"""Test multiplechoiceresponse behavior when answer-pool attribute is missing."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -575,6 +586,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
assert not response.has_answerpool() assert not response.has_answerpool()
def test_answer_pool_and_no_answer_pool(self): def test_answer_pool_and_no_answer_pool(self):
"""Test combination of responses with and without answer-pools."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -650,6 +662,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
self.assertRegex(without_new_lines, str3 + r".*" + str4) self.assertRegex(without_new_lines, str3 + r".*" + str4)
def test_answer_pool_without_solutionset(self): def test_answer_pool_without_solutionset(self):
"""Test answer-pool behavior when no solutionset is provided."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>

View File

@@ -17,14 +17,14 @@ from openedx.core.djangolib.markup import HTML
from xmodule.capa.correctmap import CorrectMap from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.responsetypes import LoncapaProblemError from xmodule.capa.responsetypes import LoncapaProblemError
from xmodule.capa.tests.helpers import new_loncapa_problem from xmodule.capa.tests.helpers import new_loncapa_problem
from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.tests.test_util import UseUnsafeCodejail
FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS = settings.FEATURES.copy() FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS = settings.FEATURES.copy()
FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS["ENABLE_GRADING_METHOD_IN_PROBLEMS"] = True FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS["ENABLE_GRADING_METHOD_IN_PROBLEMS"] = True
@ddt.ddt @ddt.ddt
@use_unsafe_codejail() @UseUnsafeCodejail()
class CAPAProblemTest(unittest.TestCase): class CAPAProblemTest(unittest.TestCase):
"""CAPA problem related tests""" """CAPA problem related tests"""
@@ -42,20 +42,18 @@ class CAPAProblemTest(unittest.TestCase):
This is the case when we have a problem with single question or This is the case when we have a problem with single question or
problem with multiple-questions separated as per the new format. problem with multiple-questions separated as per the new format.
""" """
xml = """ xml = f"""
<problem> <problem>
<choiceresponse> <choiceresponse>
<label>{question}</label> <label>{question}</label>
<description>Only the paranoid survive.</description> <description>Only the paranoid survive.</description>
<checkboxgroup> <checkboxgroup>
<choice correct="true">over-suspicious</choice> <choice correct="true">over-suspicious</choice>
<choice correct="false">funny</choice> <choice correct="false">sad</choice>
</checkboxgroup> </checkboxgroup>
</choiceresponse> </choiceresponse>
</problem> </problem>
""".format( """
question=question
)
problem = new_loncapa_problem(xml) problem = new_loncapa_problem(xml)
assert problem.problem_data == { assert problem.problem_data == {
"1_2_1": {"label": question, "descriptions": {"description_1_1_1": "Only the paranoid survive."}} "1_2_1": {"label": question, "descriptions": {"description_1_1_1": "Only the paranoid survive."}}
@@ -77,20 +75,18 @@ class CAPAProblemTest(unittest.TestCase):
""" """
Verify that legacy problem is handled correctly. Verify that legacy problem is handled correctly.
""" """
xml = """ xml = f"""
<problem> <problem>
<p>Be sure to check your spelling.</p> <p>Be sure to check your spelling.</p>
<p>{}</p> <p>{question}</p>
<stringresponse answer="vulnerable" type="ci"> <stringresponse answer="vulnerable" type="ci">
<textline label="{}" size="40"/> <textline label="{label_attr}" size="40"/>
</stringresponse> </stringresponse>
</problem> </problem>
""".format( """
question, label_attr
)
problem = new_loncapa_problem(xml) problem = new_loncapa_problem(xml)
assert problem.problem_data == {"1_2_1": {"label": question, "descriptions": {}}} assert problem.problem_data == {"1_2_1": {"label": question, "descriptions": {}}}
assert len(problem.tree.xpath("//*[normalize-space(text())='{}']".format(question))) == 0 assert len(problem.tree.xpath(f"//*[normalize-space(text())='{question}']")) == 0
@ddt.unpack @ddt.unpack
@ddt.data( @ddt.data(
@@ -112,31 +108,29 @@ class CAPAProblemTest(unittest.TestCase):
tag and label attribute inside responsetype. But we have a label tag tag and label attribute inside responsetype. But we have a label tag
before the responsetype. before the responsetype.
""" """
xml = """ xml = f"""
<problem> <problem>
<p>Be sure to check your spelling.</p> <p>Be sure to check your spelling.</p>
<label>{}</label> <label>{question1}</label>
<stringresponse answer="hide" type="ci"> <stringresponse answer="hide" type="ci">
<textline size="40"/> <textline size="40"/>
</stringresponse> </stringresponse>
<choiceresponse> <choiceresponse>
<label>{}</label> <label>{question2}</label>
<checkboxgroup> <checkboxgroup>
<choice correct="true">over-suspicious</choice> <choice correct="true">over-suspicious</choice>
<choice correct="false">funny</choice> <choice correct="false">shy</choice>
</checkboxgroup> </checkboxgroup>
</choiceresponse> </choiceresponse>
</problem> </problem>
""".format( """
question1, question2
)
problem = new_loncapa_problem(xml) problem = new_loncapa_problem(xml)
assert problem.problem_data == { assert problem.problem_data == {
"1_2_1": {"label": question1, "descriptions": {}}, "1_2_1": {"label": question1, "descriptions": {}},
"1_3_1": {"label": question2, "descriptions": {}}, "1_3_1": {"label": question2, "descriptions": {}},
} }
for question in (question1, question2): for question in (question1, question2):
assert len(problem.tree.xpath('//label[text()="{}"]'.format(question))) == 0 assert len(problem.tree.xpath(f'//label[text()="{question}"]')) == 0
def test_multiple_descriptions(self): def test_multiple_descriptions(self):
""" """
@@ -144,19 +138,17 @@ class CAPAProblemTest(unittest.TestCase):
""" """
desc1 = "The problem with trying to be the <em>bad guy</em>, there's always someone <strong>worse</strong>." desc1 = "The problem with trying to be the <em>bad guy</em>, there's always someone <strong>worse</strong>."
desc2 = "Anyone who looks the world as if it was a game of chess deserves to lose." desc2 = "Anyone who looks the world as if it was a game of chess deserves to lose."
xml = """ xml = f"""
<problem> <problem>
<p>Be sure to check your spelling.</p> <p>Be sure to check your spelling.</p>
<stringresponse answer="War" type="ci"> <stringresponse answer="War" type="ci">
<label>___ requires sacrifices.</label> <label>___ requires sacrifices.</label>
<description>{}</description> <description>{desc1}</description>
<description>{}</description> <description>{desc2}</description>
<textline size="40"/> <textline size="40"/>
</stringresponse> </stringresponse>
</problem> </problem>
""".format( """
desc1, desc2
)
problem = new_loncapa_problem(xml) problem = new_loncapa_problem(xml)
assert problem.problem_data == { assert problem.problem_data == {
"1_2_1": { "1_2_1": {
@@ -187,22 +179,22 @@ class CAPAProblemTest(unittest.TestCase):
Verify that tag with question text is not removed when inputtype is not fully accessible. Verify that tag with question text is not removed when inputtype is not fully accessible.
""" """
question = "Click the country which is home to the Pyramids." question = "Click the country which is home to the Pyramids."
# lint-amnesty, pylint: disable=duplicate-string-formatting-argument
xml = """ xml = """
<problem> <problem>
<p>{}</p> <p>{question}</p>
<imageresponse> <imageresponse>
<imageinput label="{}" <imageinput label="{question}"
src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/> src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/>
</imageresponse> </imageresponse>
</problem> </problem>
""".format( """.format(
question, question question=question
) )
problem = new_loncapa_problem(xml) problem = new_loncapa_problem(xml)
assert problem.problem_data == {"1_2_1": {"label": question, "descriptions": {}}} assert problem.problem_data == {"1_2_1": {"label": question, "descriptions": {}}}
# <p> tag with question text should not be deleted # <p> tag with question text should not be deleted
assert problem.tree.xpath("string(p[text()='{}'])".format(question)) == question assert problem.tree.xpath(f"string(p[text()='{question}'])") == question
def test_label_is_empty_if_no_label_attribute(self): def test_label_is_empty_if_no_label_attribute(self):
""" """
@@ -210,17 +202,15 @@ class CAPAProblemTest(unittest.TestCase):
attribute is missing and responsetype is not fully accessible. attribute is missing and responsetype is not fully accessible.
""" """
question = "Click the country which is home to the Pyramids." question = "Click the country which is home to the Pyramids."
xml = """ xml = f"""
<problem> <problem>
<p>{}</p> <p>{question}</p>
<imageresponse> <imageresponse>
<imageinput <imageinput
src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/> src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/>
</imageresponse> </imageresponse>
</problem> </problem>
""".format( """
question
)
problem = new_loncapa_problem(xml) problem = new_loncapa_problem(xml)
assert problem.problem_data == {"1_2_1": {"label": "", "descriptions": {}}} assert problem.problem_data == {"1_2_1": {"label": "", "descriptions": {}}}
@@ -238,7 +228,7 @@ class CAPAProblemTest(unittest.TestCase):
<description>Only the paranoid survive.</description> <description>Only the paranoid survive.</description>
<checkboxgroup> <checkboxgroup>
<choice correct="true">over-suspicious</choice> <choice correct="true">over-suspicious</choice>
<choice correct="false">funny</choice> <choice correct="false">happy</choice>
</checkboxgroup> </checkboxgroup>
</choiceresponse> </choiceresponse>
<multiplechoiceresponse> <multiplechoiceresponse>
@@ -276,21 +266,19 @@ class CAPAProblemTest(unittest.TestCase):
responsetype to contain other elements. We do not want to delete information in that case. responsetype to contain other elements. We do not want to delete information in that case.
""" """
question = "Is egg plant a fruit?" question = "Is egg plant a fruit?"
xml = """ xml = f"""
<problem> <problem>
<p>Choose wisely.</p> <p>Choose wisely.</p>
<p>Select the correct synonym of paranoid?</p> <p>Select the correct synonym of paranoid?</p>
<p><img src="" /></p> <p><img src="" /></p>
<choiceresponse> <choiceresponse>
<checkboxgroup label="{}"> <checkboxgroup label="{question}">
<choice correct="true">over-suspicious</choice> <choice correct="true">over-suspicious</choice>
<choice correct="false">funny</choice> <choice correct="false">funny</choice>
</checkboxgroup> </checkboxgroup>
</choiceresponse> </choiceresponse>
</problem> </problem>
""".format( """
question
)
problem = new_loncapa_problem(xml) problem = new_loncapa_problem(xml)
assert problem.problem_data == {"1_2_1": {"label": "", "descriptions": {}}} assert problem.problem_data == {"1_2_1": {"label": "", "descriptions": {}}}
assert len(problem.tree.xpath("//p/img")) == 1 assert len(problem.tree.xpath("//p/img")) == 1
@@ -306,17 +294,15 @@ class CAPAProblemTest(unittest.TestCase):
""" """
input1_label = "What color is the sky?" input1_label = "What color is the sky?"
input2_label = "What color are pine needles?" input2_label = "What color are pine needles?"
xml = """ xml = f"""
<problem> <problem>
<optionresponse> <optionresponse>
<label>{}</label> <label>{group_label}</label>
<optioninput options="('yellow','blue','green')" correct="blue" label="{}"/> <optioninput options="('yellow','blue','green')" correct="blue" label="{input1_label}"/>
<optioninput options="('yellow','blue','green')" correct="green" label="{}"/> <optioninput options="('orange','blue','green')" correct="green" label="{input2_label}"/>
</optionresponse> </optionresponse>
</problem> </problem>
""".format( """
group_label, input1_label, input2_label
)
problem = new_loncapa_problem(xml) problem = new_loncapa_problem(xml)
assert problem.problem_data == { assert problem.problem_data == {
@@ -330,20 +316,18 @@ class CAPAProblemTest(unittest.TestCase):
""" """
question = "Enter sum of 1+2" question = "Enter sum of 1+2"
xml = textwrap.dedent( xml = textwrap.dedent(
""" f"""
<problem> <problem>
<customresponse cfn="test_sum" expect="3"> <customresponse cfn="test_sum" expect="3">
<script type="loncapa/python"> <script type="loncapa/python">
def test_sum(expect, ans): def test_sum(expect, ans):
return int(expect) == int(ans) return int(expect) == int(ans)
</script> </script>
<label>{}</label> <label>{question}</label>
<textline size="20" correct_answer="3" /> <textline size="20" correct_answer="3" />
</customresponse> </customresponse>
</problem> </problem>
""".format( """
question
)
) )
problem = new_loncapa_problem(xml, use_capa_render_template=True) problem = new_loncapa_problem(xml, use_capa_render_template=True)
problem_html = etree.XML(problem.get_html()) problem_html = etree.XML(problem.get_html())
@@ -353,18 +337,18 @@ class CAPAProblemTest(unittest.TestCase):
assert len(multi_inputs_group) == 0 assert len(multi_inputs_group) == 0
# verify that question is rendered only once # verify that question is rendered only once
question = problem_html.xpath("//*[normalize-space(text())='{}']".format(question)) question = problem_html.xpath(f"//*[normalize-space(text())='{question}']")
assert len(question) == 1 assert len(question) == 1
def assert_question_tag(self, question1, question2, tag, label_attr=False): def assert_question_tag(self, question1, question2, tag, label_attr=False):
""" """
Verify question tag correctness. Verify question tag correctness.
""" """
question1_tag = "<{tag}>{}</{tag}>".format(question1, tag=tag) if question1 else "" question1_tag = f"<{tag}>{question1}</{tag}>" if question1 else ""
question2_tag = "<{tag}>{}</{tag}>".format(question2, tag=tag) if question2 else "" question2_tag = f"<{tag}>{question2}</{tag}>" if question2 else ""
question1_label_attr = 'label="{}"'.format(question1) if label_attr else "" question1_label_attr = f'label="{question1}"' if label_attr else ""
question2_label_attr = 'label="{}"'.format(question2) if label_attr else "" question2_label_attr = f'label="{question2}"' if label_attr else ""
xml = """ xml = f"""
<problem> <problem>
{question1_tag} {question1_tag}
<choiceresponse> <choiceresponse>
@@ -381,18 +365,13 @@ class CAPAProblemTest(unittest.TestCase):
</choicegroup> </choicegroup>
</multiplechoiceresponse> </multiplechoiceresponse>
</problem> </problem>
""".format( """
question1_tag=question1_tag,
question2_tag=question2_tag,
question1_label_attr=question1_label_attr,
question2_label_attr=question2_label_attr,
)
problem = new_loncapa_problem(xml) problem = new_loncapa_problem(xml)
assert problem.problem_data == { assert problem.problem_data == {
"1_2_1": {"label": question1, "descriptions": {}}, "1_2_1": {"label": question1, "descriptions": {}},
"1_3_1": {"label": question2, "descriptions": {}}, "1_3_1": {"label": question2, "descriptions": {}},
} }
assert len(problem.tree.xpath("//{}".format(tag))) == 0 assert len(problem.tree.xpath(f"//{tag}")) == 0
@ddt.unpack @ddt.unpack
@ddt.data( @ddt.data(
@@ -422,7 +401,9 @@ class CAPAProblemTest(unittest.TestCase):
xml = """ xml = """
<problem> <problem>
<optionresponse> <optionresponse>
<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown problems. Edit this component to replace this template with your own assessment.</p> <p>You can use this template as a guide to the simple editor markdown and
OLX markup to use for dropdown problems. Edit this component to replace
this template with your own assessment.</p>
<label>Add the question text, or prompt, here. This text is required.</label> <label>Add the question text, or prompt, here. This text is required.</label>
<description>You can add an optional tip or note related to the prompt like this. </description> <description>You can add an optional tip or note related to the prompt like this. </description>
<optioninput> <optioninput>
@@ -460,7 +441,7 @@ class CAPAProblemTest(unittest.TestCase):
@ddt.ddt @ddt.ddt
@use_unsafe_codejail() @UseUnsafeCodejail()
class CAPAMultiInputProblemTest(unittest.TestCase): class CAPAMultiInputProblemTest(unittest.TestCase):
"""TestCase for CAPA problems with multiple inputtypes""" """TestCase for CAPA problems with multiple inputtypes"""
@@ -497,14 +478,14 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
# verify that multi input group label <p> tag exists and its # verify that multi input group label <p> tag exists and its
# id matches with correct multi input group aria-labelledby # id matches with correct multi input group aria-labelledby
multi_inputs_group_label_id = multi_inputs_group[0].attrib.get("aria-labelledby") multi_inputs_group_label_id = multi_inputs_group[0].attrib.get("aria-labelledby")
multi_inputs_group_label = html.xpath('//p[@id="{}"]'.format(multi_inputs_group_label_id)) multi_inputs_group_label = html.xpath(f'//p[@id="{multi_inputs_group_label_id}"]')
assert len(multi_inputs_group_label) == 1 assert len(multi_inputs_group_label) == 1
assert multi_inputs_group_label[0].text == group_label assert multi_inputs_group_label[0].text == group_label
# verify that label for each input comes only once # verify that label for each input comes only once
for input_label in input_labels: for input_label in input_labels:
# normalize-space is used to remove whitespace around the text # normalize-space is used to remove whitespace around the text
input_label_element = multi_inputs_group[0].xpath('//*[normalize-space(text())="{}"]'.format(input_label)) input_label_element = multi_inputs_group[0].xpath(f'//*[normalize-space(text())="{input_label}"]')
assert len(input_label_element) == 1 assert len(input_label_element) == 1
@ddt.unpack @ddt.unpack
@@ -518,7 +499,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
""" """
input1_label = "What color is the sky?" input1_label = "What color is the sky?"
input2_label = "What color are pine needles?" input2_label = "What color are pine needles?"
xml = """ xml = f"""
<problem> <problem>
<optionresponse> <optionresponse>
{label_html} {label_html}
@@ -526,9 +507,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
<optioninput options="('yellow','blue','green')" correct="green" label="{input2_label}"/> <optioninput options="('yellow','blue','green')" correct="green" label="{input2_label}"/>
</optionresponse> </optionresponse>
</problem> </problem>
""".format( """
label_html=label_html, input1_label=input1_label, input2_label=input2_label
)
problem = self.capa_problem(xml) problem = self.capa_problem(xml)
self.assert_problem_html(problem.get_html(), group_label, input1_label, input2_label) self.assert_problem_html(problem.get_html(), group_label, input1_label, input2_label)
self.assert_problem_data(problem.problem_data) self.assert_problem_data(problem.problem_data)
@@ -544,21 +523,19 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
input1_label = "Integer 1" input1_label = "Integer 1"
input2_label = "Integer 2" input2_label = "Integer 2"
xml = textwrap.dedent( xml = textwrap.dedent(
""" f"""
<problem> <problem>
<customresponse cfn="test_add_to_ten"> <customresponse cfn="test_add_to_ten">
<script type="loncapa/python"> <script type="loncapa/python">
def test_add_to_ten(expect, ans): def test_add_to_ten(expect, ans):
return test_add(10, ans) return test_add(10, ans)
</script> </script>
<label>{}</label> <label>{group_label}</label>
<{inputtype} size="40" correct_answer="3" label="{}" /><br/> <{inputtype} size="40" correct_answer="3" label="{input1_label}" /><br/>
<{inputtype} size="40" correct_answer="7" label="{}" /> <{inputtype} size="40" correct_answer="7" label="{input2_label}" />
</customresponse> </customresponse>
</problem> </problem>
""".format( """
group_label, input1_label, input2_label, inputtype=inputtype
)
) )
problem = self.capa_problem(xml) problem = self.capa_problem(xml)
self.assert_problem_html(problem.get_html(), group_label, input1_label, input2_label) self.assert_problem_html(problem.get_html(), group_label, input1_label, input2_label)
@@ -576,7 +553,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
""" """
Verify that groups descriptions are rendered correctly. Verify that groups descriptions are rendered correctly.
""" """
xml = """ xml = f"""
<problem> <problem>
<optionresponse> <optionresponse>
<label>group label</label> <label>group label</label>
@@ -585,9 +562,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
<optioninput options="('yellow','blue','green')" correct="green" label="second label"/> <optioninput options="('yellow','blue','green')" correct="green" label="second label"/>
</optionresponse> </optionresponse>
</problem> </problem>
""".format( """
descriptions_html=descriptions_html
)
problem = self.capa_problem(xml) problem = self.capa_problem(xml)
problem_html = etree.XML(problem.get_html()) problem_html = etree.XML(problem.get_html())
@@ -599,7 +574,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
# For each description, check its order and text is correct # For each description, check its order and text is correct
for index, description_id in enumerate(description_ids): for index, description_id in enumerate(description_ids):
description_element = multi_inputs_group.xpath('//p[@id="{}"]'.format(description_id)) description_element = multi_inputs_group.xpath(f'//p[@id="{description_id}"]')
assert len(description_element) == 1 assert len(description_element) == 1
assert description_element[0].text == descriptions[index] assert description_element[0].text == descriptions[index]
@@ -618,13 +593,15 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
) )
@ddt.unpack @ddt.unpack
def test_find_question_label(self, answer_id, label, stripped_label): def test_find_question_label(self, answer_id, label, stripped_label):
problem = new_loncapa_problem('<problem><some-problem id="{}"/></problem>'.format(answer_id)) """Verify that find_question_label returns the correctly stripped question label."""
problem = new_loncapa_problem(f'<problem><some-problem id="{answer_id}"/></problem>')
mock_problem_data = {answer_id: {"label": HTML(label) if label else ""}} mock_problem_data = {answer_id: {"label": HTML(label) if label else ""}}
with patch.object(problem, "problem_data", mock_problem_data): with patch.object(problem, "problem_data", mock_problem_data):
assert problem.find_question_label(answer_id) == stripped_label assert problem.find_question_label(answer_id) == stripped_label
@ddt.data(None, {}, [None]) @ddt.data(None, {}, [None])
def test_find_answer_test_not_implemented(self, current_answer): def test_find_answer_test_not_implemented(self, current_answer):
"""Ensure find_answer_text raises NotImplementedError for unsupported responses."""
problem = new_loncapa_problem("<problem/>") problem = new_loncapa_problem("<problem/>")
self.assertRaises(NotImplementedError, problem.find_answer_text, "", current_answer) self.assertRaises(NotImplementedError, problem.find_answer_text, "", current_answer)
@@ -639,6 +616,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
) )
@ddt.unpack @ddt.unpack
def test_find_answer_text_choices(self, answer_id, choice_id, answer_text): def test_find_answer_text_choices(self, answer_id, choice_id, answer_text):
"""Verify find_answer_text returns correct answer text for choice, multiple-choice, and option responses."""
problem = new_loncapa_problem( problem = new_loncapa_problem(
""" """
<problem> <problem>
@@ -676,6 +654,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
) )
@ddt.unpack @ddt.unpack
def test_find_answer_text_choices_with_missing_text(self, answer_id, choice_id, answer_text): def test_find_answer_text_choices_with_missing_text(self, answer_id, choice_id, answer_text):
"""Ensure find_answer_text handles missing answer text correctly and returns 'Answer Text Missing'."""
problem = new_loncapa_problem( problem = new_loncapa_problem(
""" """
<problem> <problem>
@@ -739,6 +718,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
assert problem.find_correct_answer_text(answer_id) == answer_text assert problem.find_correct_answer_text(answer_id) == answer_text
def test_find_answer_text_textinput(self): def test_find_answer_text_textinput(self):
"""Check that find_answer_text correctly returns the answer for stringresponse/textinput problems."""
problem = new_loncapa_problem( problem = new_loncapa_problem(
""" """
<problem> <problem>
@@ -751,6 +731,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
assert problem.find_answer_text("1_2_1", "hide") == "hide" assert problem.find_answer_text("1_2_1", "hide") == "hide"
def test_get_question_answer(self): def test_get_question_answer(self):
"""Ensure get_question_answers returns answer text as strings suitable for JSON serialization."""
problem = new_loncapa_problem( problem = new_loncapa_problem(
""" """
<problem> <problem>

View File

@@ -16,10 +16,11 @@ class CorrectMapTest(unittest.TestCase):
""" """
def setUp(self): def setUp(self):
super(CorrectMapTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.cmap = CorrectMap() self.cmap = CorrectMap()
def test_set_input_properties(self): def test_set_input_properties(self):
"""Verify setting input properties and correctness-related methods behave correctly."""
# Set the correctmap properties for three inputs # Set the correctmap properties for three inputs
self.cmap.set( self.cmap.set(
answer_id="1_2_1", answer_id="1_2_1",
@@ -96,6 +97,7 @@ class CorrectMapTest(unittest.TestCase):
assert not self.cmap.is_right_queuekey("2_2_1", None) assert not self.cmap.is_right_queuekey("2_2_1", None)
def test_get_npoints(self): def test_get_npoints(self):
"""Ensure get_npoints returns correct values based on correctness and assigned points."""
# Set the correctmap properties for 4 inputs # Set the correctmap properties for 4 inputs
# 1) correct, 5 points # 1) correct, 5 points
# 2) correct, None points # 2) correct, None points
@@ -132,6 +134,7 @@ class CorrectMapTest(unittest.TestCase):
assert self.cmap.get_npoints("7_2_1") == 1 assert self.cmap.get_npoints("7_2_1") == 1
def test_set_overall_message(self): def test_set_overall_message(self):
"""Verify setting and retrieving the overall message works correctly."""
# Default is an empty string string # Default is an empty string string
assert self.cmap.get_overall_message() == "" assert self.cmap.get_overall_message() == ""
@@ -147,6 +150,7 @@ class CorrectMapTest(unittest.TestCase):
assert self.cmap.get_overall_message() == "" assert self.cmap.get_overall_message() == ""
def test_update_from_correctmap(self): def test_update_from_correctmap(self):
"""Test updating one CorrectMap from another preserves all properties."""
# Initialize a CorrectMap with some properties # Initialize a CorrectMap with some properties
self.cmap.set( self.cmap.set(
answer_id="1_2_1", answer_id="1_2_1",
@@ -171,6 +175,7 @@ class CorrectMapTest(unittest.TestCase):
assert other_cmap.get_dict() == self.cmap.get_dict() assert other_cmap.get_dict() == self.cmap.get_dict()
def test_update_from_invalid(self): def test_update_from_invalid(self):
"""Ensure updating CorrectMap with invalid inputs raises exceptions."""
# Should get an exception if we try to update() a CorrectMap # Should get an exception if we try to update() a CorrectMap
# with a non-CorrectMap value # with a non-CorrectMap value
invalid_list = [None, "string", 5, datetime.datetime.today()] invalid_list = [None, "string", 5, datetime.datetime.today()]

View File

@@ -1,7 +1,7 @@
# lint-amnesty, pylint: disable=missing-module-docstring """Unit tests for custom rendering of capa problem elements, including solutions and math expressions."""
import unittest import unittest
import xml.sax.saxutils as saxutils from xml.sax import saxutils
from lxml import etree from lxml import etree
@@ -17,10 +17,11 @@ def extract_context(xml):
Given an xml element corresponding to the output of test_capa_system.render_template, get back the Given an xml element corresponding to the output of test_capa_system.render_template, get back the
original context original context
""" """
return eval(xml.text) # lint-amnesty, pylint: disable=eval-used return eval(xml.text) # pylint: disable=eval-used
def quote_attr(s): def quote_attr(s):
"""Return a string safe for XML attributes without outer quotes."""
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
@@ -30,10 +31,12 @@ class HelperTest(unittest.TestCase):
""" """
def check(self, d): def check(self, d):
"""Check that rendering and extracting context returns the original data."""
xml = etree.XML(mock_capa_system().render_template("blah", d)) xml = etree.XML(mock_capa_system().render_template("blah", d))
assert d == extract_context(xml) assert d == extract_context(xml)
def test_extract_context(self): def test_extract_context(self):
"""Test that the context can be extracted correctly from rendered XML."""
self.check({}) self.check({})
self.check({1, 2}) self.check({1, 2})
self.check({"id", "an id"}) self.check({"id", "an id"})
@@ -46,8 +49,9 @@ class SolutionRenderTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Ensure that <solution> elements are rendered correctly with proper IDs."""
solution = "To compute unicorns, count them." solution = "To compute unicorns, count them."
xml_str = """<solution id="solution_12">{s}</solution>""".format(s=solution) xml_str = f"""<solution id="solution_12">{solution}</solution>"""
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
renderer = lookup_tag("solution")(mock_capa_system(), element) renderer = lookup_tag("solution")(mock_capa_system(), element)
@@ -65,8 +69,9 @@ class MathRenderTest(unittest.TestCase):
Make sure math renders properly. Make sure math renders properly.
""" """
def check_parse(self, latex_in, mathjax_out): # lint-amnesty, pylint: disable=missing-function-docstring def check_parse(self, latex_in, mathjax_out):
xml_str = """<math>{tex}</math>""".format(tex=latex_in) """Check that LaTeX input is correctly converted to MathJax output."""
xml_str = f"""<math>{latex_in}</math>"""
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
renderer = lookup_tag("math")(mock_capa_system(), element) renderer = lookup_tag("math")(mock_capa_system(), element)
@@ -74,6 +79,7 @@ class MathRenderTest(unittest.TestCase):
assert renderer.mathstr == mathjax_out assert renderer.mathstr == mathjax_out
def test_parsing(self): def test_parsing(self):
"""Verify that LaTeX input is correctly converted to MathJax output."""
self.check_parse("$abc$", "[mathjaxinline]abc[/mathjaxinline]") self.check_parse("$abc$", "[mathjaxinline]abc[/mathjaxinline]")
self.check_parse("$abc", "$abc") self.check_parse("$abc", "$abc")
self.check_parse(r"$\displaystyle 2+2$", "[mathjax] 2+2[/mathjax]") self.check_parse(r"$\displaystyle 2+2$", "[mathjax] 2+2[/mathjax]")

View File

@@ -15,30 +15,35 @@ from xmodule.capa.errors import (
def test_json_parsing_error(): def test_json_parsing_error():
"""Verify that JSONParsingError is raised with the correct message."""
with pytest.raises(JSONParsingError) as excinfo: with pytest.raises(JSONParsingError) as excinfo:
raise JSONParsingError("test_name", "test_error") raise JSONParsingError("test_name", "test_error")
assert str(excinfo.value) == "Error parsing test_name: test_error" assert str(excinfo.value) == "Error parsing test_name: test_error"
def test_missing_key_error(): def test_missing_key_error():
"""Ensure MissingKeyError is raised with the correct key in the message."""
with pytest.raises(MissingKeyError) as excinfo: with pytest.raises(MissingKeyError) as excinfo:
raise MissingKeyError("test_key") raise MissingKeyError("test_key")
assert str(excinfo.value) == "Missing key: test_key" assert str(excinfo.value) == "Missing key: test_key"
def test_validation_error(): def test_validation_error():
"""Check that ValidationError is raised with the expected message."""
with pytest.raises(ValidationError) as excinfo: with pytest.raises(ValidationError) as excinfo:
raise ValidationError("test_error") raise ValidationError("test_error")
assert str(excinfo.value) == "Validation error: test_error" assert str(excinfo.value) == "Validation error: test_error"
def test_type_error_submission(): def test_type_error_submission():
"""Confirm TypeErrorSubmission is raised with the appropriate message."""
with pytest.raises(TypeErrorSubmission) as excinfo: with pytest.raises(TypeErrorSubmission) as excinfo:
raise TypeErrorSubmission("test_error") raise TypeErrorSubmission("test_error")
assert str(excinfo.value) == "Type error: test_error" assert str(excinfo.value) == "Type error: test_error"
def test_runtime_error_submission(): def test_runtime_error_submission():
"""Validate that RuntimeErrorSubmission is raised with the correct message."""
with pytest.raises(RuntimeErrorSubmission) as excinfo: with pytest.raises(RuntimeErrorSubmission) as excinfo:
raise RuntimeErrorSubmission("test_error") raise RuntimeErrorSubmission("test_error")
assert str(excinfo.value) == "Runtime error: test_error" assert str(excinfo.value) == "Runtime error: test_error"

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Tests of extended hints Tests of extended hints
@@ -14,7 +15,6 @@ from xmodule.capa.tests.helpers import load_fixture, new_loncapa_problem
# With the use of ddt, some of the data expected_string cases below are naturally long stretches # With the use of ddt, some of the data expected_string cases below are naturally long stretches
# of text text without whitespace. I think it's best to leave such lines intact # of text text without whitespace. I think it's best to leave such lines intact
# in the test code. Therefore: # in the test code. Therefore:
# pylint: disable=line-too-long
# For out many ddt data cases, prefer a compact form of { .. } # For out many ddt data cases, prefer a compact form of { .. }
@@ -34,8 +34,8 @@ class HintTest(unittest.TestCase):
adict = cmap.cmap.get(problem_id) adict = cmap.cmap.get(problem_id)
if adict: if adict:
return adict["msg"] return adict["msg"]
else:
return "" return ""
# It is a little surprising how much more complicated TextInput is than all the other cases. # It is a little surprising how much more complicated TextInput is than all the other cases.
@@ -71,45 +71,74 @@ class TextInputHintsTest(HintTest):
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "GermanyΩ", "choice": "GermanyΩ",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">I do not think so.&#937;</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">I do not think so.&#937;</div></div>'
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "franceΩ", "choice": "franceΩ",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Viva la France!&#937;</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">Viva la France!&#937;</div></div>'
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "FranceΩ", "choice": "FranceΩ",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Viva la France!&#937;</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">Viva la France!&#937;</div></div>'
),
}, },
{"problem_id": "1_2_1", "choice": "Mexico", "expected_string": ""}, {"problem_id": "1_2_1", "choice": "Mexico", "expected_string": ""},
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "USAΩ", "choice": "USAΩ",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Less well known, but yes, there is a Paris, Texas.&#937;</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
"Less well known, but yes, there is a Paris, Texas.&#937;</div></div>"
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "usaΩ", "choice": "usaΩ",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Less well known, but yes, there is a Paris, Texas.&#937;</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
"Less well known, but yes, there is a Paris, Texas.&#937;</div></div>"
),
}, },
{"problem_id": "1_2_1", "choice": "uSAxΩ", "expected_string": ""}, {"problem_id": "1_2_1", "choice": "uSAxΩ", "expected_string": ""},
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "NICKLANDΩ", "choice": "NICKLANDΩ",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">The country name does not end in LAND&#937;</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">The country name does not end in LAND&#937;</div></div>'
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": "Blue", "choice": "Blue",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">The red light is scattered by water molecules leaving only blue light.</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
"The red light is scattered by water molecules leaving only blue light.</div></div>"
),
}, },
{"problem_id": "1_3_1", "choice": "blue", "expected_string": ""}, {"problem_id": "1_3_1", "choice": "blue", "expected_string": ""},
{"problem_id": "1_3_1", "choice": "b", "expected_string": ""}, {"problem_id": "1_3_1", "choice": "b", "expected_string": ""},
) )
@unpack @unpack
def test_text_input_hints(self, problem_id, choice, expected_string): def test_text_input_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML is returned for each text input answer."""
hint = self.get_hint(problem_id, choice) hint = self.get_hint(problem_id, choice)
assert hint == expected_string assert hint == expected_string
@@ -126,47 +155,72 @@ class TextInputExtendedHintsCaseInsensitive(HintTest):
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": "A", "choice": "A",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Woo Hoo </span><div class="hint-text">hint1</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Woo Hoo </span><div class="hint-text">hint1</div></div>'
),
}, },
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": "a", "choice": "a",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Woo Hoo </span><div class="hint-text">hint1</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Woo Hoo </span><div class="hint-text">hint1</div></div>'
),
}, },
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": "B", "choice": "B",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><div class="hint-text">hint2</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint2</div></div>'
),
}, },
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": "b", "choice": "b",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><div class="hint-text">hint2</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint2</div></div>'
),
}, },
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": "C", "choice": "C",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">hint4</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint4</div></div>'
),
}, },
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": "c", "choice": "c",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">hint4</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint4</div></div>'
),
}, },
# regexp cases # regexp cases
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": "FGGG", "choice": "FGGG",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">hint6</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint6</div></div>'
),
}, },
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": "fgG", "choice": "fgG",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">hint6</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">hint6</div></div>'
),
}, },
) )
@unpack @unpack
def test_text_input_hints(self, problem_id, choice, expected_string): def test_text_input_hints(self, problem_id, choice, expected_string):
"""Ensure that text input hints match case-insensitively when expected."""
hint = self.get_hint(problem_id, choice) hint = self.get_hint(problem_id, choice)
assert hint == expected_string assert hint == expected_string
@@ -183,31 +237,46 @@ class TextInputExtendedHintsCaseSensitive(HintTest):
{ {
"problem_id": "1_6_1", "problem_id": "1_6_1",
"choice": "A", "choice": "A",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>'
),
}, },
{"problem_id": "1_6_1", "choice": "a", "expected_string": ""}, {"problem_id": "1_6_1", "choice": "a", "expected_string": ""},
{ {
"problem_id": "1_6_1", "problem_id": "1_6_1",
"choice": "B", "choice": "B",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>'
),
}, },
{"problem_id": "1_6_1", "choice": "b", "expected_string": ""}, {"problem_id": "1_6_1", "choice": "b", "expected_string": ""},
{ {
"problem_id": "1_6_1", "problem_id": "1_6_1",
"choice": "C", "choice": "C",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint4</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint4</div></div>'
),
}, },
{"problem_id": "1_6_1", "choice": "c", "expected_string": ""}, {"problem_id": "1_6_1", "choice": "c", "expected_string": ""},
# regexp cases # regexp cases
{ {
"problem_id": "1_6_1", "problem_id": "1_6_1",
"choice": "FGG", "choice": "FGG",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint6</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint6</div></div>'
),
}, },
{"problem_id": "1_6_1", "choice": "fgG", "expected_string": ""}, {"problem_id": "1_6_1", "choice": "fgG", "expected_string": ""},
) )
@unpack @unpack
def test_text_input_hints(self, problem_id, choice, expected_string): def test_text_input_hints(self, problem_id, choice, expected_string):
"""Ensure that text input hints match case-sensitively when required."""
message_text = self.get_hint(problem_id, choice) message_text = self.get_hint(problem_id, choice)
assert message_text == expected_string assert message_text == expected_string
@@ -226,14 +295,22 @@ class TextInputExtendedHintsCompatible(HintTest):
"problem_id": "1_7_1", "problem_id": "1_7_1",
"choice": "A", "choice": "A",
"correct": "correct", "correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint1</div></div>'
),
}, },
{"problem_id": "1_7_1", "choice": "B", "correct": "correct", "expected_string": ""}, {"problem_id": "1_7_1", "choice": "B", "correct": "correct", "expected_string": ""},
{ {
"problem_id": "1_7_1", "problem_id": "1_7_1",
"choice": "C", "choice": "C",
"correct": "correct", "correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint2</div></div>'
),
}, },
{"problem_id": "1_7_1", "choice": "D", "correct": "incorrect", "expected_string": ""}, {"problem_id": "1_7_1", "choice": "D", "correct": "incorrect", "expected_string": ""},
# check going through conversion with difficult chars # check going through conversion with difficult chars
@@ -241,6 +318,7 @@ class TextInputExtendedHintsCompatible(HintTest):
) )
@unpack @unpack
def test_text_input_hints(self, problem_id, choice, correct, expected_string): def test_text_input_hints(self, problem_id, choice, correct, expected_string):
"""Test compatibility between old and new style additional_answer hints."""
message_text = self.get_hint(problem_id, choice) message_text = self.get_hint(problem_id, choice)
assert message_text == expected_string assert message_text == expected_string
assert self.correctness(problem_id, choice) == correct assert self.correctness(problem_id, choice) == correct
@@ -261,59 +339,96 @@ class TextInputExtendedHintsRegex(HintTest):
"problem_id": "1_8_1", "problem_id": "1_8_1",
"choice": "ABC", "choice": "ABC",
"correct": "correct", "correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint1</div></div>'
),
}, },
{ {
"problem_id": "1_8_1", "problem_id": "1_8_1",
"choice": "ABBBBC", "choice": "ABBBBC",
"correct": "correct", "correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint1</div></div>'
),
}, },
{ {
"problem_id": "1_8_1", "problem_id": "1_8_1",
"choice": "aBc", "choice": "aBc",
"correct": "correct", "correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint1</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint1</div></div>'
),
}, },
{ {
"problem_id": "1_8_1", "problem_id": "1_8_1",
"choice": "BBBB", "choice": "BBBB",
"correct": "correct", "correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint2</div></div>'
),
}, },
{ {
"problem_id": "1_8_1", "problem_id": "1_8_1",
"choice": "bbb", "choice": "bbb",
"correct": "correct", "correct": "correct",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">hint2</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">hint2</div></div>'
),
}, },
{ {
"problem_id": "1_8_1", "problem_id": "1_8_1",
"choice": "C", "choice": "C",
"correct": "incorrect", "correct": "incorrect",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint4</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint4</div></div>'
),
}, },
{ {
"problem_id": "1_8_1", "problem_id": "1_8_1",
"choice": "c", "choice": "c",
"correct": "incorrect", "correct": "incorrect",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint4</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint4</div></div>'
),
}, },
{ {
"problem_id": "1_8_1", "problem_id": "1_8_1",
"choice": "D", "choice": "D",
"correct": "incorrect", "correct": "incorrect",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint6</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint6</div></div>'
),
}, },
{ {
"problem_id": "1_8_1", "problem_id": "1_8_1",
"choice": "d", "choice": "d",
"correct": "incorrect", "correct": "incorrect",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">hint6</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">hint6</div></div>'
),
}, },
) )
@unpack @unpack
def test_text_input_hints(self, problem_id, choice, correct, expected_string): def test_text_input_hints(self, problem_id, choice, correct, expected_string):
"""Validate text input hints where answers are defined with regex matching."""
message_text = self.get_hint(problem_id, choice) message_text = self.get_hint(problem_id, choice)
assert message_text == expected_string assert message_text == expected_string
assert self.correctness(problem_id, choice) == correct assert self.correctness(problem_id, choice) == correct
@@ -329,6 +444,7 @@ class NumericInputHintsTest(HintTest):
problem = new_loncapa_problem(xml) # this problem is properly constructed problem = new_loncapa_problem(xml) # this problem is properly constructed
def test_tracking_log(self): def test_tracking_log(self):
"""Verify that the tracking log is published correctly for numeric input hints."""
self.get_hint("1_2_1", "1.141") self.get_hint("1_2_1", "1.141")
self.problem.capa_block.runtime.publish.assert_called_with( self.problem.capa_block.runtime.publish.assert_called_with(
self.problem.capa_block, self.problem.capa_block,
@@ -349,30 +465,47 @@ class NumericInputHintsTest(HintTest):
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "1.141", "choice": "1.141",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Nice </span><div class="hint-text">The square root of two turns up in the strangest places.</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Nice </span><div class="hint-text">'
"The square root of two turns up in the strangest places.</div></div>"
),
}, },
# additional answer # additional answer
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "10", "choice": "10",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">This is an additional hint.</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">This is an additional hint.</div></div>'
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": "4", "choice": "4",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Pretty easy, uh?.</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">Pretty easy, uh?.</div></div>'
),
}, },
# should get hint, when correct via numeric-tolerance # should get hint, when correct via numeric-tolerance
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "1.15", "choice": "1.15",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Nice </span><div class="hint-text">The square root of two turns up in the strangest places.</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Nice </span><div class="hint-text">'
"The square root of two turns up in the strangest places.</div></div>"
),
}, },
# when they answer wrong, nothing # when they answer wrong, nothing
{"problem_id": "1_2_1", "choice": "2", "expected_string": ""}, {"problem_id": "1_2_1", "choice": "2", "expected_string": ""},
) )
@unpack @unpack
def test_numeric_input_hints(self, problem_id, choice, expected_string): def test_numeric_input_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML is returned for numeric input answers."""
hint = self.get_hint(problem_id, choice) hint = self.get_hint(problem_id, choice)
assert hint == expected_string assert hint == expected_string
@@ -390,122 +523,248 @@ class CheckboxHintsTest(HintTest):
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": ["choice_0"], "choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">You are right that apple is a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">What is a camero anyway?</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">You are right that apple is a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">Remember that grape is also a fruit.</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": ["choice_1"], "choice": ["choice_1"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">Mushroom is a fungus, not a fruit.</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">What is a camero anyway?</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">Remember that apple is also a fruit.</div>'
'<div class="hint-text">Mushroom is a fungus, not a fruit.</div>'
'<div class="hint-text">Remember that grape is also a fruit.</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": ["choice_2"], "choice": ["choice_2"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">You are right that grape is a fruit</div><div class="hint-text">What is a camero anyway?</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">Remember that apple is also a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">You are right that grape is a fruit</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": ["choice_3"], "choice": ["choice_3"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">What is a camero anyway?</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">Remember that apple is also a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">Remember that grape is also a fruit.</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": ["choice_4"], "choice": ["choice_4"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">Remember that apple is also a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">Remember that grape is also a fruit.</div><div class="hint-text">I do not know what a Camero is but it is not a fruit.</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">Remember that apple is also a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">Remember that grape is also a fruit.</div>'
'<div class="hint-text">I do not know what a Camero is but it is not a fruit.'
"</div></div></div>"
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": ["choice_0", "choice_1"], # compound "choice": ["choice_0", "choice_1"], # compound
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Almost right </span><div class="hint-text">You are right that apple is a fruit, but there is one you are missing. Also, mushroom is not a fruit.</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Almost right </span><div class="hint-text">'
"You are right that apple is a fruit, but there is one you are missing. "
"Also, mushroom is not a fruit.</div></div>"
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": ["choice_1", "choice_2"], # compound "choice": ["choice_1", "choice_2"], # compound
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">You are right that grape is a fruit, but there is one you are missing. Also, mushroom is not a fruit.</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
"You are right that grape is a fruit, but there is one you are missing. "
"Also, mushroom is not a fruit.</div></div>"
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": ["choice_0", "choice_2"], "choice": ["choice_0", "choice_2"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="feedback-hint-multi"><div class="hint-text">You are right that apple is a fruit.</div><div class="hint-text">You are right that mushrooms are not fruit</div><div class="hint-text">You are right that grape is a fruit</div><div class="hint-text">What is a camero anyway?</div></div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="feedback-hint-multi">'
'<div class="hint-text">You are right that apple is a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not fruit</div>'
'<div class="hint-text">You are right that grape is a fruit</div>'
'<div class="hint-text">What is a camero anyway?</div></div></div>'
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": ["choice_0"], "choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">No, sorry, a banana is a fruit.</div><div class="hint-text">You are right that mushrooms are not vegatbles</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">No, sorry, a banana is a fruit.</div>'
'<div class="hint-text">You are right that mushrooms are not vegatbles</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": ["choice_1"], "choice": ["choice_1"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">You are right that mushrooms are not vegatbles</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">poor banana.</div>'
'<div class="hint-text">You are right that mushrooms are not vegatbles</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": ["choice_2"], "choice": ["choice_2"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">Mushroom is a fungus, not a vegetable.</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">poor banana.</div>'
'<div class="hint-text">Mushroom is a fungus, not a vegetable.</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": ["choice_3"], "choice": ["choice_3"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">You are right that mushrooms are not vegatbles</div><div class="hint-text">Brussel sprouts are vegetables.</div></div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="feedback-hint-multi">'
'<div class="hint-text">poor banana.</div>'
'<div class="hint-text">You are right that mushrooms are not vegatbles</div>'
'<div class="hint-text">Brussel sprouts are vegetables.</div></div></div>'
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": ["choice_0", "choice_1"], # compound "choice": ["choice_0", "choice_1"], # compound
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Very funny </span><div class="hint-text">Making a banana split?</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Very funny </span>'
'<div class="hint-text">Making a banana split?</div></div>'
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": ["choice_1", "choice_2"], "choice": ["choice_1", "choice_2"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">poor banana.</div><div class="hint-text">Mushroom is a fungus, not a vegetable.</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">poor banana.</div>'
'<div class="hint-text">Mushroom is a fungus, not a vegetable.</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": ["choice_0", "choice_2"], "choice": ["choice_0", "choice_2"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">No, sorry, a banana is a fruit.</div><div class="hint-text">Mushroom is a fungus, not a vegetable.</div><div class="hint-text">Brussel sprout is the only vegetable in this list.</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">No, sorry, a banana is a fruit.</div>'
'<div class="hint-text">Mushroom is a fungus, not a vegetable.</div>'
'<div class="hint-text">Brussel sprout is the only vegetable in this list.'
"</div></div></div>"
),
}, },
# check for interaction between compoundhint and correct/incorrect # check for interaction between compoundhint and correct/incorrect
{ {
"problem_id": "1_4_1", "problem_id": "1_4_1",
"choice": ["choice_0", "choice_1"], # compound "choice": ["choice_0", "choice_1"], # compound
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">AB</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">AB</div></div>'
),
}, },
{ {
"problem_id": "1_4_1", "problem_id": "1_4_1",
"choice": ["choice_0", "choice_2"], # compound "choice": ["choice_0", "choice_2"], # compound
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">AC</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">AC</div></div>'
),
}, },
# check for labeling where multiple child hints have labels # check for labeling where multiple child hints have labels
# These are some tricky cases # These are some tricky cases
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": ["choice_0", "choice_1"], "choice": ["choice_0", "choice_1"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">AA </span><div class="feedback-hint-multi"><div class="hint-text">aa</div></div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">AA </span><div class="feedback-hint-multi">'
'<div class="hint-text">aa</div></div></div>'
),
}, },
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": ["choice_0"], "choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>'
),
}, },
{"problem_id": "1_5_1", "choice": ["choice_1"], "expected_string": ""}, {"problem_id": "1_5_1", "choice": ["choice_1"], "expected_string": ""},
{ {
"problem_id": "1_5_1", "problem_id": "1_5_1",
"choice": [], "choice": [],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">BB </span><div class="feedback-hint-multi"><div class="hint-text">bb</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">BB </span><div class="feedback-hint-multi">'
'<div class="hint-text">bb</div></div></div>'
),
}, },
{ {
"problem_id": "1_6_1", "problem_id": "1_6_1",
"choice": ["choice_0"], "choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="feedback-hint-multi"><div class="hint-text">aa</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="feedback-hint-multi"><div class="hint-text">aa</div></div></div>'
),
}, },
{ {
"problem_id": "1_6_1", "problem_id": "1_6_1",
"choice": ["choice_0", "choice_1"], "choice": ["choice_0", "choice_1"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><div class="hint-text">compoundo</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<div class="hint-text">compoundo</div></div>'
),
}, },
# The user selects *nothing*, but can still get "unselected" feedback # The user selects *nothing*, but can still get "unselected" feedback
{ {
"problem_id": "1_7_1", "problem_id": "1_7_1",
"choice": [], "choice": [],
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="feedback-hint-multi"><div class="hint-text">bb</div></div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="feedback-hint-multi">'
'<div class="hint-text">bb</div></div></div>'
),
}, },
# 100% not match of sel/unsel feedback # 100% not match of sel/unsel feedback
{"problem_id": "1_7_1", "choice": ["choice_1"], "expected_string": ""}, {"problem_id": "1_7_1", "choice": ["choice_1"], "expected_string": ""},
@@ -513,11 +772,18 @@ class CheckboxHintsTest(HintTest):
{ {
"problem_id": "1_7_1", "problem_id": "1_7_1",
"choice": ["choice_0"], "choice": ["choice_0"],
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="feedback-hint-multi"><div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="feedback-hint-multi">'
'<div class="hint-text">aa</div><div class="hint-text">bb</div></div></div>'
),
}, },
) )
@unpack @unpack
def test_checkbox_hints(self, problem_id, choice, expected_string): def test_checkbox_hints(self, problem_id, choice, expected_string):
"""
Check that the correct hint HTML is returned for selected checkboxes, including compound and multi-child hints.
"""
self.maxDiff = None # pylint: disable=invalid-name self.maxDiff = None # pylint: disable=invalid-name
hint = self.get_hint(problem_id, choice) hint = self.get_hint(problem_id, choice)
assert hint == expected_string assert hint == expected_string
@@ -648,28 +914,44 @@ class MultpleChoiceHintsTest(HintTest):
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "choice_0", "choice": "choice_0",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><div class="hint-text">Mushroom is a fungus, not a fruit.</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<div class="hint-text">Mushroom is a fungus, not a fruit.</div></div>'
),
}, },
{"problem_id": "1_2_1", "choice": "choice_1", "expected_string": ""}, {"problem_id": "1_2_1", "choice": "choice_1", "expected_string": ""},
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": "choice_1", "choice": "choice_1",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">Potato is a root vegetable.</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">Potato is a root vegetable.</div></div>'
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "choice_2", "choice": "choice_2",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">OUTSTANDING </span><div class="hint-text">Apple is indeed a fruit.</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">OUTSTANDING </span>'
'<div class="hint-text">Apple is indeed a fruit.</div></div>'
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": "choice_2", "choice": "choice_2",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">OOPS </span><div class="hint-text">Apple is a fruit.</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">OOPS </span>'
'<div class="hint-text">Apple is a fruit.</div></div>'
),
}, },
{"problem_id": "1_3_1", "choice": "choice_9", "expected_string": ""}, {"problem_id": "1_3_1", "choice": "choice_9", "expected_string": ""},
) )
@unpack @unpack
def test_multiplechoice_hints(self, problem_id, choice, expected_string): def test_multiplechoice_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML is returned for each choice in multiple choice problems."""
hint = self.get_hint(problem_id, choice) hint = self.get_hint(problem_id, choice)
assert hint == expected_string assert hint == expected_string
@@ -707,21 +989,34 @@ class MultpleChoiceHintsWithHtmlTest(HintTest):
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "choice_0", "choice": "choice_0",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">Mushroom <img src="#" ale="#"/>is a fungus, not a fruit.</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
'Mushroom <img src="#" ale="#"/>is a fungus, not a fruit.</div></div>'
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "choice_1", "choice": "choice_1",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">Potato is <img src="#" ale="#"/> not a fruit.</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
'Potato is <img src="#" ale="#"/> not a fruit.</div></div>'
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "choice_2", "choice": "choice_2",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text"><a href="#">Apple</a> is a fruit.</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
'<a href="#">Apple</a> is a fruit.</div></div>'
),
}, },
) )
@unpack @unpack
def test_multiplechoice_hints(self, problem_id, choice, expected_string): def test_multiplechoice_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML, including HTML content, is returned for each choice."""
hint = self.get_hint(problem_id, choice) hint = self.get_hint(problem_id, choice)
assert hint == expected_string assert hint == expected_string
@@ -758,44 +1053,73 @@ class DropdownHintsTest(HintTest):
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "Multiple Choice", "choice": "Multiple Choice",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Good Job </span><div class="hint-text">Yes, multiple choice is the right answer.</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Good Job </span><div class="hint-text">'
"Yes, multiple choice is the right answer.</div></div>"
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "Text Input", "choice": "Text Input",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">No, text input problems do not present options.</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
"No, text input problems do not present options.</div></div>"
),
}, },
{ {
"problem_id": "1_2_1", "problem_id": "1_2_1",
"choice": "Numerical Input", "choice": "Numerical Input",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">No, numerical input problems do not present options.</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span><div class="hint-text">'
"No, numerical input problems do not present options.</div></div>"
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": "FACES", "choice": "FACES",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">With lots of makeup, doncha know?</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span><div class="hint-text">'
"With lots of makeup, doncha know?</div></div>"
),
}, },
{ {
"problem_id": "1_3_1", "problem_id": "1_3_1",
"choice": "dogs", "choice": "dogs",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">NOPE </span><div class="hint-text">Not dogs, not cats, not toads</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">NOPE </span><div class="hint-text">'
"Not dogs, not cats, not toads</div></div>"
),
}, },
{"problem_id": "1_3_1", "choice": "wrongo", "expected_string": ""}, {"problem_id": "1_3_1", "choice": "wrongo", "expected_string": ""},
# Regression case where feedback includes answer substring # Regression case where feedback includes answer substring
{ {
"problem_id": "1_4_1", "problem_id": "1_4_1",
"choice": "AAA", "choice": "AAA",
"expected_string": '<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div><span class="hint-label">Incorrect: </span><div class="hint-text">AAABBB1</div></div>', "expected_string": (
'<div class="feedback-hint-incorrect"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Incorrect: </span>'
'<div class="hint-text">AAABBB1</div></div>'
),
}, },
{ {
"problem_id": "1_4_1", "problem_id": "1_4_1",
"choice": "BBB", "choice": "BBB",
"expected_string": '<div class="feedback-hint-correct"><div class="explanation-title">Answer</div><span class="hint-label">Correct: </span><div class="hint-text">AAABBB2</div></div>', "expected_string": (
'<div class="feedback-hint-correct"><div class="explanation-title">Answer</div>'
'<span class="hint-label">Correct: </span>'
'<div class="hint-text">AAABBB2</div></div>'
),
}, },
{"problem_id": "1_4_1", "choice": "not going to match", "expected_string": ""}, {"problem_id": "1_4_1", "choice": "not going to match", "expected_string": ""},
) )
@unpack @unpack
def test_dropdown_hints(self, problem_id, choice, expected_string): def test_dropdown_hints(self, problem_id, choice, expected_string):
"""Check that the correct hint HTML is returned for each choice in dropdown problems."""
hint = self.get_hint(problem_id, choice) hint = self.get_hint(problem_id, choice)
assert hint == expected_string assert hint == expected_string
@@ -806,6 +1130,7 @@ class ErrorConditionsTest(HintTest):
""" """
def test_error_conditions_illegal_element(self): def test_error_conditions_illegal_element(self):
"""Ensure that malformed XML raises an exception when creating a problem."""
xml_with_errors = load_fixture("extended_hints_with_errors.xml") xml_with_errors = load_fixture("extended_hints_with_errors.xml")
with pytest.raises(Exception): with pytest.raises(Exception):
new_loncapa_problem(xml_with_errors) # this problem is improperly constructed new_loncapa_problem(xml_with_errors) # this problem is improperly constructed

View File

@@ -12,20 +12,20 @@ from lxml import etree
from openedx.core.djangolib.markup import HTML from openedx.core.djangolib.markup import HTML
from xmodule.capa.tests.helpers import mock_capa_system, new_loncapa_problem from xmodule.capa.tests.helpers import mock_capa_system, new_loncapa_problem
from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.tests.test_util import UseUnsafeCodejail
from .response_xml_factory import CustomResponseXMLFactory, StringResponseXMLFactory from .response_xml_factory import CustomResponseXMLFactory, StringResponseXMLFactory
@ddt.ddt @ddt.ddt
@use_unsafe_codejail() @UseUnsafeCodejail()
class CapaHtmlRenderTest(unittest.TestCase): class CapaHtmlRenderTest(unittest.TestCase):
""" """
CAPA HTML rendering tests class. CAPA HTML rendering tests class.
""" """
def setUp(self): def setUp(self):
super(CapaHtmlRenderTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.capa_system = mock_capa_system() self.capa_system = mock_capa_system()
def test_blank_problem(self): def test_blank_problem(self):
@@ -43,6 +43,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
# TODO: This test should inspect the rendered html and assert one or more things about it # TODO: This test should inspect the rendered html and assert one or more things about it
def test_include_html(self): def test_include_html(self):
"""Verify that <include> files are embedded correctly in rendered HTML."""
# Create a test file to include # Create a test file to include
self._create_test_file("test_include.xml", "<test>Test include</test>") self._create_test_file("test_include.xml", "<test>Test include</test>")
@@ -67,6 +68,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert test_element.text == "Test include" assert test_element.text == "Test include"
def test_process_outtext(self): def test_process_outtext(self):
"""Ensure <startouttext/> and <endouttext/> are converted to <span> tags."""
# Generate some XML with <startouttext /> and <endouttext /> # Generate some XML with <startouttext /> and <endouttext />
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
@@ -88,6 +90,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert span_element.text == "Test text" assert span_element.text == "Test text"
def test_anonymous_student_id(self): def test_anonymous_student_id(self):
"""Check that $anonymous_student_id is rendered as 'student'."""
# make sure anonymous_student_id is rendered properly as a context variable # make sure anonymous_student_id is rendered properly as a context variable
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
@@ -108,6 +111,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert span_element.text == "Welcome student" assert span_element.text == "Welcome student"
def test_render_script(self): def test_render_script(self):
"""Ensure <script> tags are removed from rendered HTML."""
# Generate some XML with a <script> tag # Generate some XML with a <script> tag
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
@@ -128,6 +132,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert script_element is None assert script_element is None
def test_render_javascript(self): def test_render_javascript(self):
"""Verify JavaScript in <script> tags remains in rendered HTML."""
# Generate some XML with a <script> tag # Generate some XML with a <script> tag
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
@@ -147,6 +152,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert '<script type="text/javascript">function(){}</script>' in etree.tostring(rendered_html).decode("utf-8") assert '<script type="text/javascript">function(){}</script>' in etree.tostring(rendered_html).decode("utf-8")
def test_render_response_xml(self): def test_render_response_xml(self):
"""Test that response XML is rendered correctly into HTML with template calls."""
# Generate some XML for a string response # Generate some XML for a string response
kwargs = { kwargs = {
"question_text": "Test question", "question_text": "Test question",
@@ -214,6 +220,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert the_system.render_template.call_args_list == expected_calls assert the_system.render_template.call_args_list == expected_calls
def test_correct_aria_label(self): def test_correct_aria_label(self):
"""Check that rendered responses have correct aria-label attributes."""
xml = """ xml = """
<problem> <problem>
<choiceresponse> <choiceresponse>
@@ -237,6 +244,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert response_elements[1].attrib["aria-label"] == "Question 2" assert response_elements[1].attrib["aria-label"] == "Question 2"
def test_render_response_with_overall_msg(self): def test_render_response_with_overall_msg(self):
"""Verify that CustomResponse overall messages are rendered correctly."""
# CustomResponse script that sets an overall_message # CustomResponse script that sets an overall_message
script = textwrap.dedent( script = textwrap.dedent(
""" """
@@ -275,6 +283,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert msg_p_elements[1].text == "Test message 2" assert msg_p_elements[1].text == "Test message 2"
def test_substitute_python_vars(self): def test_substitute_python_vars(self):
"""Ensure Python variables in XML scripts are substituted correctly in attributes."""
# Generate some XML with Python variables defined in a script # Generate some XML with Python variables defined in a script
# and used later as attributes # and used later as attributes
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
@@ -295,6 +304,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
assert span_element.get("attr") == "TEST" assert span_element.get("attr") == "TEST"
def test_xml_comments_and_other_odd_things(self): def test_xml_comments_and_other_odd_things(self):
"""Ensure XML comments and processing instructions are skipped in rendering."""
# Comments and processing instructions should be skipped. # Comments and processing instructions should be skipped.
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
"""\ """\
@@ -314,7 +324,9 @@ class CapaHtmlRenderTest(unittest.TestCase):
the_html = problem.get_html() the_html = problem.get_html()
self.assertRegex(the_html, r"<div>\s*</div>") self.assertRegex(the_html, r"<div>\s*</div>")
def _create_test_file(self, path, content_str): # lint-amnesty, pylint: disable=missing-function-docstring def _create_test_file(self, path, content_str):
"""Create a temporary test file with the given content and schedule cleanup."""
test_fp = self.capa_system.resources_fs.open(path, "w") test_fp = self.capa_system.resources_fs.open(path, "w")
test_fp.write(content_str) test_fp.write(content_str)
test_fp.close() test_fp.close()

View File

@@ -1,5 +1,6 @@
# pylint: disable=too-many-lines
""" """
~Tests for the logic in input type Django templates. Tests for the logic in input type Django templates.
""" """
import json import json
@@ -21,8 +22,6 @@ class TemplateError(Exception):
Error occurred while rendering a Django template. Error occurred while rendering a Django template.
""" """
pass # lint-amnesty, pylint: disable=unnecessary-pass
class TemplateTestCase(unittest.TestCase): class TemplateTestCase(unittest.TestCase):
""" """
@@ -45,7 +44,7 @@ class TemplateTestCase(unittest.TestCase):
""" """
Initialize the context. Initialize the context.
""" """
super(TemplateTestCase, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = {} self.context = {}
def render_to_xml(self, context_dict): def render_to_xml(self, context_dict):
@@ -68,9 +67,7 @@ class TemplateTestCase(unittest.TestCase):
try: try:
xml = etree.fromstring("<test>" + xml_str + "</test>") xml = etree.fromstring("<test>" + xml_str + "</test>")
except Exception as exc: except Exception as exc:
raise TemplateError( # lint-amnesty, pylint: disable=raise-missing-from raise TemplateError(f"Could not parse XML from '{xml_str}': {exc}") from exc
"Could not parse XML from '{0}': {1}".format(xml_str, str(exc))
)
return xml return xml
def assert_has_xpath(self, xml_root, xpath, context_dict, exact_num=1): def assert_has_xpath(self, xml_root, xpath, context_dict, exact_num=1):
@@ -82,11 +79,9 @@ class TemplateTestCase(unittest.TestCase):
`context` is used to print a debugging message `context` is used to print a debugging message
`exact_num` is the exact number of matches to expect. `exact_num` is the exact number of matches to expect.
""" """
message = "XML does not have %d match(es) for xpath '%s'\nXML: %s\nContext: %s" % ( message = (
exact_num, f"XML does not have {exact_num} match(es) for xpath '{xpath}'\n"
str(xpath), f"XML: {etree.tostring(xml_root)}\nContext: {context_dict}"
etree.tostring(xml_root),
str(context_dict),
) )
assert len(xml_root.xpath(xpath)) == exact_num, message assert len(xml_root.xpath(xpath)) == exact_num, message
@@ -115,7 +110,7 @@ class TemplateTestCase(unittest.TestCase):
If no elements are found, the assertion fails. If no elements are found, the assertion fails.
""" """
element_list = xml_root.xpath(xpath) element_list = xml_root.xpath(xpath)
assert len(element_list) > 0, "Could not find element at '%s'\n%s" % (str(xpath), etree.tostring(xml_root)) assert len(element_list) > 0, f"Could not find element at '{xpath}'\n{etree.tostring(xml_root)}"
if exact: if exact:
assert text == element_list[0].text.strip() assert text == element_list[0].text.strip()
else: else:
@@ -183,14 +178,14 @@ class TemplateTestCase(unittest.TestCase):
# Expect that we get a <div> with correct class # Expect that we get a <div> with correct class
if status_div: if status_div:
xpath = "//div[normalize-space(@class)='%s']" % div_class xpath = f"//div[normalize-space(@class)='{div_class}']"
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
# Expect that we get a <span> with class="status" # Expect that we get a <span> with class="status"
# (used to by CSS to draw the green check / red x) # (used to by CSS to draw the green check / red x)
self.assert_has_text( self.assert_has_text(
xml, xml,
"//span[@class='status {}']/span[@class='sr']".format(div_class if status_class else ""), f"//span[@class='status {div_class if status_class else ''}']/span[@class='sr']",
self.context["status"].display_name, self.context["status"].display_name,
) )
@@ -221,7 +216,7 @@ class TemplateTestCase(unittest.TestCase):
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
if aria_label: if aria_label:
self.assert_has_xpath(xml, "//*[@aria-label='%s']" % label["expected"], self.context) self.assert_has_xpath(xml, f"//*[@aria-label='{label['expected']}']", self.context)
else: else:
element_list = xml.xpath(xpath) element_list = xml.xpath(xpath)
assert len(element_list) == 1 assert len(element_list) == 1
@@ -236,7 +231,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "choicegroup.html" TEMPLATE_NAME = "choicegroup.html"
def setUp(self): def setUp(self):
super(ChoiceGroupTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
choices = [("1", "choice 1"), ("2", "choice 2"), ("3", "choice 3")] choices = [("1", "choice 1"), ("2", "choice 2"), ("3", "choice 3")]
self.context = { self.context = {
"id": "1", "id": "1",
@@ -466,7 +461,7 @@ class TextlineTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "textline.html" TEMPLATE_NAME = "textline.html"
def setUp(self): def setUp(self):
super(TextlineTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = { self.context = {
"id": "1", "id": "1",
"status": Status("correct"), "status": Status("correct"),
@@ -478,6 +473,7 @@ class TextlineTemplateTest(TemplateTestCase):
} }
def test_section_class(self): def test_section_class(self):
"""Verify CSS classes for <textline> input under different context combinations."""
cases = [ cases = [
({}, " capa_inputtype textline"), ({}, " capa_inputtype textline"),
({"do_math": True}, "text-input-dynamath capa_inputtype textline"), ({"do_math": True}, "text-input-dynamath capa_inputtype textline"),
@@ -489,7 +485,7 @@ class TextlineTemplateTest(TemplateTestCase):
base_context = self.context.copy() base_context = self.context.copy()
base_context.update(context) base_context.update(context)
xml = self.render_to_xml(base_context) xml = self.render_to_xml(base_context)
xpath = "//div[@class='%s']" % css_class xpath = f"//div[@class='{css_class}']"
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
def test_status(self): def test_status(self):
@@ -505,6 +501,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_label(xpath="//label[@class='problem-group-label']") self.assert_label(xpath="//label[@class='problem-group-label']")
def test_hidden(self): def test_hidden(self):
"""Ensure that hidden inputs and containers are rendered with display:none style."""
self.context["hidden"] = True self.context["hidden"] = True
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
@@ -515,6 +512,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
def test_do_math(self): def test_do_math(self):
"""Verify that math-related elements and classes are rendered for do_math context."""
self.context["do_math"] = True self.context["do_math"] = True
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
@@ -528,6 +526,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
def test_size(self): def test_size(self):
"""Ensure that the size attribute is correctly applied to input elements."""
self.context["size"] = "20" self.context["size"] = "20"
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
@@ -535,6 +534,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
def test_preprocessor(self): def test_preprocessor(self):
"""Verify that preprocessor-related data attributes are rendered correctly."""
self.context["preprocessor"] = {"class_name": "test_class", "script_src": "test_script"} self.context["preprocessor"] = {"class_name": "test_class", "script_src": "test_script"}
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
@@ -545,6 +545,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
def test_do_inline_and_preprocessor(self): def test_do_inline_and_preprocessor(self):
"""Ensure inline and preprocessor settings are applied together correctly."""
self.context["preprocessor"] = {"class_name": "test_class", "script_src": "test_script"} self.context["preprocessor"] = {"class_name": "test_class", "script_src": "test_script"}
self.context["inline"] = True self.context["inline"] = True
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
@@ -553,6 +554,7 @@ class TextlineTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
def test_do_inline(self): def test_do_inline(self):
"""Verify inline class is applied correctly based on status context."""
cases = [ cases = [
("correct", "correct"), ("correct", "correct"),
("unsubmitted", "unanswered"), ("unsubmitted", "unanswered"),
@@ -567,10 +569,11 @@ class TextlineTemplateTest(TemplateTestCase):
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
# Expect that we get a <div> with correct class # Expect that we get a <div> with correct class
xpath = "//div[@class='%s inline']" % div_class xpath = f"//div[@class='{div_class} inline']"
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
def test_message(self): def test_message(self):
"""Check that message text is rendered inside the proper span element."""
self.context["msg"] = "Test message" self.context["msg"] = "Test message"
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
@@ -587,14 +590,12 @@ class TextlineTemplateTest(TemplateTestCase):
class FormulaEquationInputTemplateTest(TemplateTestCase): class FormulaEquationInputTemplateTest(TemplateTestCase):
""" """Test django template for `<formulaequationinput>` input."""
Test make template for `<formulaequationinput>`s.
"""
TEMPLATE_NAME = "formulaequationinput.html" TEMPLATE_NAME = "formulaequationinput.html"
def setUp(self): def setUp(self):
super(FormulaEquationInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = { self.context = {
"id": 2, "id": 2,
"value": "PREFILLED_VALUE", "value": "PREFILLED_VALUE",
@@ -607,10 +608,12 @@ class FormulaEquationInputTemplateTest(TemplateTestCase):
} }
def test_no_size(self): def test_no_size(self):
"""Ensure no size attribute is present when not specified in context."""
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
self.assert_no_xpath(xml, "//input[@size]", self.context) self.assert_no_xpath(xml, "//input[@size]", self.context)
def test_size(self): def test_size(self):
"""Verify that size attribute is correctly applied to formula equation input."""
self.context["size"] = "40" self.context["size"] = "40"
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
@@ -645,7 +648,7 @@ class AnnotationInputTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "annotationinput.html" TEMPLATE_NAME = "annotationinput.html"
def setUp(self): def setUp(self):
super(AnnotationInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = { self.context = {
"id": 2, "id": 2,
"value": "<p>Test value</p>", "value": "<p>Test value</p>",
@@ -689,7 +692,7 @@ class AnnotationInputTemplateTest(TemplateTestCase):
# Create options 0-4 and select option 2 # Create options 0-4 and select option 2
self.context["options_value"] = [2] self.context["options_value"] = [2]
self.context["options"] = [ self.context["options"] = [
{"id": id_num, "choice": "correct", "description": "<p>Unescaped <b>HTML {0}</b></p>".format(id_num)} {"id": id_num, "choice": "correct", "description": f"<p>Unescaped <b>HTML {id_num}</b></p>"}
for id_num in range(5) for id_num in range(5)
] ]
@@ -699,8 +702,8 @@ class AnnotationInputTemplateTest(TemplateTestCase):
# with unescaped HTML. # with unescaped HTML.
# Since the HTML is unescaped, we can traverse the XML tree # Since the HTML is unescaped, we can traverse the XML tree
for id_num in range(5): for id_num in range(5):
xpath = "//span[@data-id='{0}']/p/b".format(id_num) xpath = f"//span[@data-id='{id_num}']/p/b"
self.assert_has_text(xml, xpath, "HTML {0}".format(id_num), exact=False) self.assert_has_text(xml, xpath, f"HTML {id_num}", exact=False)
# Expect that the correct option is selected # Expect that the correct option is selected
xpath = "//span[contains(@class,'selected')]/p/b" xpath = "//span[contains(@class,'selected')]/p/b"
@@ -718,7 +721,7 @@ class AnnotationInputTemplateTest(TemplateTestCase):
self.context["status"] = Status(input_status) self.context["status"] = Status(input_status)
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
xpath = "//span[@class='status {0}']".format(expected_css_class) xpath = f"//span[@class='status {expected_css_class}']"
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
# If individual options are being marked, then expect # If individual options are being marked, then expect
@@ -770,10 +773,11 @@ class MathStringTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "mathstring.html" TEMPLATE_NAME = "mathstring.html"
def setUp(self): def setUp(self):
super(MathStringTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = {"isinline": False, "mathstr": "", "tail": ""} self.context = {"isinline": False, "mathstr": "", "tail": ""}
def test_math_string_inline(self): def test_math_string_inline(self):
"""Verify that math string is rendered inline correctly with MathJax tags."""
self.context["isinline"] = True self.context["isinline"] = True
self.context["mathstr"] = "y = ax^2 + bx + c" self.context["mathstr"] = "y = ax^2 + bx + c"
@@ -782,6 +786,7 @@ class MathStringTemplateTest(TemplateTestCase):
self.assert_has_text(xml, xpath, "[mathjaxinline]y = ax^2 + bx + c[/mathjaxinline]") self.assert_has_text(xml, xpath, "[mathjaxinline]y = ax^2 + bx + c[/mathjaxinline]")
def test_math_string_not_inline(self): def test_math_string_not_inline(self):
"""Verify that math string is rendered as block (not inline) correctly with MathJax tags."""
self.context["isinline"] = False self.context["isinline"] = False
self.context["mathstr"] = "y = ax^2 + bx + c" self.context["mathstr"] = "y = ax^2 + bx + c"
@@ -790,6 +795,7 @@ class MathStringTemplateTest(TemplateTestCase):
self.assert_has_text(xml, xpath, "[mathjax]y = ax^2 + bx + c[/mathjax]") self.assert_has_text(xml, xpath, "[mathjax]y = ax^2 + bx + c[/mathjax]")
def test_tail_html(self): def test_tail_html(self):
"""Ensure that tail HTML is rendered correctly and unescaped."""
self.context["tail"] = "<p>This is some <b>tail</b> <em>HTML</em></p>" self.context["tail"] = "<p>This is some <b>tail</b> <em>HTML</em></p>"
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
@@ -810,7 +816,7 @@ class OptionInputTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "optioninput.html" TEMPLATE_NAME = "optioninput.html"
def setUp(self): def setUp(self):
super(OptionInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = { self.context = {
"id": 2, "id": 2,
"options": [], "options": [],
@@ -822,9 +828,9 @@ class OptionInputTemplateTest(TemplateTestCase):
} }
def test_select_options(self): def test_select_options(self):
"""Verify that option elements are rendered correctly and the selected option is marked."""
# Create options 0-4, and select option 2 # Create options 0-4, and select option 2
self.context["options"] = [(id_num, "Option {0}".format(id_num)) for id_num in range(5)] self.context["options"] = [(id_num, f"Option {id_num}") for id_num in range(5)]
self.context["value"] = 2 self.context["value"] = 2
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
@@ -834,8 +840,8 @@ class OptionInputTemplateTest(TemplateTestCase):
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
for id_num in range(5): for id_num in range(5):
xpath = "//option[@value='{0}']".format(id_num) xpath = f"//option[@value='{id_num}']"
self.assert_has_text(xml, xpath, "Option {0}".format(id_num)) self.assert_has_text(xml, xpath, f"Option {id_num}")
# Should have the correct option selected # Should have the correct option selected
xpath = "//option[@selected='true']" xpath = "//option[@selected='true']"
@@ -870,11 +876,11 @@ class DragAndDropTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "drag_and_drop_input.html" TEMPLATE_NAME = "drag_and_drop_input.html"
def setUp(self): def setUp(self):
super(DragAndDropTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = {"id": 2, "drag_and_drop_json": "", "value": 0, "status": Status("unsubmitted"), "msg": ""} self.context = {"id": 2, "drag_and_drop_json": "", "value": 0, "status": Status("unsubmitted"), "msg": ""}
def test_status(self): def test_status(self):
"""Verify that drag-and-drop input renders correct status CSS classes and text."""
# Test cases, where each tuple represents # Test cases, where each tuple represents
# `(input_status, expected_css_class, expected_text)` # `(input_status, expected_css_class, expected_text)`
test_cases = [ test_cases = [
@@ -889,14 +895,15 @@ class DragAndDropTemplateTest(TemplateTestCase):
xml = self.render_to_xml(self.context) xml = self.render_to_xml(self.context)
# Expect a <div> with the status # Expect a <div> with the status
xpath = "//div[@class='{0}']".format(expected_css_class) xpath = f"//div[@class='{expected_css_class}']"
self.assert_has_xpath(xml, xpath, self.context) self.assert_has_xpath(xml, xpath, self.context)
# Expect a <span> with the status # Expect a <span> with the status
xpath = "//span[@class='status {0}']/span[@class='sr']".format(expected_css_class) xpath = f"//span[@class='status {expected_css_class}']/span[@class='sr']"
self.assert_has_text(xml, xpath, expected_text, exact=False) self.assert_has_text(xml, xpath, expected_text, exact=False)
def test_drag_and_drop_json_html(self): def test_drag_and_drop_json_html(self):
"""Ensure that drag-and-drop JSON with HTML is rendered without escaping HTML."""
json_with_html = json.dumps({"test": "<p>Unescaped <b>HTML</b></p>"}) json_with_html = json.dumps({"test": "<p>Unescaped <b>HTML</b></p>"})
self.context["drag_and_drop_json"] = json_with_html self.context["drag_and_drop_json"] = json_with_html
@@ -931,7 +938,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
} }
def setUp(self): def setUp(self):
super(ChoiceTextGroupTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
choices = [ choices = [
( (
"1_choiceinput_0bc", "1_choiceinput_0bc",
@@ -1013,9 +1020,9 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
# Should NOT mark individual options # Should NOT mark individual options
grouping_tag = grouping_tags[test_conditions["input_type"]] grouping_tag = grouping_tags[test_conditions["input_type"]]
self.assert_no_xpath(xml, "//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag), self.context) self.assert_no_xpath(xml, f"//{grouping_tag}[@class='choicetextgroup_incorrect']", self.context)
self.assert_no_xpath(xml, "//{0}[@class='choicetextgroup_correct']".format(grouping_tag), self.context) self.assert_no_xpath(xml, f"//{grouping_tag}[@class='choicetextgroup_correct']", self.context)
def test_problem_marked_unsubmitted(self): def test_problem_marked_unsubmitted(self):
"""Test all conditions under which the entire problem """Test all conditions under which the entire problem
@@ -1041,9 +1048,9 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
# Should NOT mark individual options # Should NOT mark individual options
grouping_tag = grouping_tags[test_conditions["input_type"]] grouping_tag = grouping_tags[test_conditions["input_type"]]
self.assert_no_xpath(xml, "//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag), self.context) self.assert_no_xpath(xml, f"//{grouping_tag}[@class='choicetextgroup_incorrect']", self.context)
self.assert_no_xpath(xml, "//{0}[@class='choicetextgroup_correct']".format(grouping_tag), self.context) self.assert_no_xpath(xml, f"//{grouping_tag}[@class='choicetextgroup_correct']", self.context)
def test_option_marked_correct(self): def test_option_marked_correct(self):
"""Test conditions under which a particular option """Test conditions under which a particular option
@@ -1096,7 +1103,7 @@ class ChemicalEquationTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "chemicalequationinput.html" TEMPLATE_NAME = "chemicalequationinput.html"
def setUp(self): def setUp(self):
super(ChemicalEquationTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = { self.context = {
"id": "1", "id": "1",
"status": Status("correct"), "status": Status("correct"),
@@ -1117,7 +1124,7 @@ class SchematicInputTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "schematicinput.html" TEMPLATE_NAME = "schematicinput.html"
def setUp(self): def setUp(self):
super(SchematicInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = { self.context = {
"id": "1", "id": "1",
"status": Status("correct"), "status": Status("correct"),
@@ -1149,7 +1156,7 @@ class CodeinputTemplateTest(TemplateTestCase):
TEMPLATE_NAME = "codeinput.html" TEMPLATE_NAME = "codeinput.html"
def setUp(self): def setUp(self):
super(CodeinputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.context = { self.context = {
"id": "1", "id": "1",
"status": Status("correct"), "status": Status("correct"),

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Tests of input types. Tests of input types.
@@ -21,9 +22,9 @@ TODO:
import json import json
import textwrap import textwrap
import unittest import unittest
import xml.sax.saxutils as saxutils
from collections import OrderedDict from collections import OrderedDict
from unittest.mock import ANY, patch from unittest.mock import ANY, patch
from xml.sax import saxutils
import pytest import pytest
import six import six
@@ -50,6 +51,7 @@ RESPONSE_DATA = {"label": "question text 101", "descriptions": DESCRIPTIONS}
def quote_attr(s): def quote_attr(s):
"""Return a string with XML attribute-escaped content, without outer quotes."""
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
@@ -59,6 +61,7 @@ class OptionInputTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Test that OptionInput renders correctly with given state and XML element."""
xml_str = """<optioninput options="('Up','Down','Don't know')" id="sky_input" correct="Up"/>""" xml_str = """<optioninput options="('Up','Down','Don't know')" id="sky_input" correct="Up"/>"""
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -89,9 +92,10 @@ class OptionInputTest(unittest.TestCase):
assert context == expected assert context == expected
def test_option_parsing(self): def test_option_parsing(self):
"""Test that OptionInput.parse_options correctly parses various option strings."""
f = inputtypes.OptionInput.parse_options f = inputtypes.OptionInput.parse_options
def check(input, options): # lint-amnesty, pylint: disable=redefined-builtin def check(input, options): # pylint: disable=redefined-builtin
""" """
Take list of options, confirm that output is in the silly doubled format Take list of options, confirm that output is in the silly doubled format
""" """
@@ -117,9 +121,8 @@ class ChoiceGroupTest(unittest.TestCase):
Test choice groups, radio groups, and checkbox groups Test choice groups, radio groups, and checkbox groups
""" """
def check_group( def check_group(self, tag, expected_input_type, expected_suffix):
self, tag, expected_input_type, expected_suffix """Test that choice group inputs render expected context."""
): # lint-amnesty, pylint: disable=missing-function-docstring
xml_str = """ xml_str = """
<{tag}> <{tag}>
<choice correct="false" name="foil1"><text>This is foil One.</text></choice> <choice correct="false" name="foil1"><text>This is foil One.</text></choice>
@@ -161,12 +164,15 @@ class ChoiceGroupTest(unittest.TestCase):
assert context == expected assert context == expected
def test_choicegroup(self): def test_choicegroup(self):
"""Test that a <choicegroup> renders correctly as radio inputs."""
self.check_group("choicegroup", "radio", "") self.check_group("choicegroup", "radio", "")
def test_radiogroup(self): def test_radiogroup(self):
"""Test that a <radiogroup> renders correctly as radio inputs with name suffix."""
self.check_group("radiogroup", "radio", "[]") self.check_group("radiogroup", "radio", "[]")
def test_checkboxgroup(self): def test_checkboxgroup(self):
"""Test that a <checkboxgroup> renders correctly as checkbox inputs with name suffix."""
self.check_group("checkboxgroup", "checkbox", "[]") self.check_group("checkboxgroup", "checkbox", "[]")
@@ -256,8 +262,9 @@ class TextLineTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Test that a standard textline input renders correctly."""
size = "42" size = "42"
xml_str = """<textline id="prob_1_2" size="{size}"/>""".format(size=size) xml_str = f"""<textline id="prob_1_2" size="{size}"/>"""
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -284,15 +291,14 @@ class TextLineTest(unittest.TestCase):
assert context == expected assert context == expected
def test_math_rendering(self): def test_math_rendering(self):
"""Test that a math-enabled textline input renders correctly with preprocessor."""
size = "42" size = "42"
preprocessorClass = "preParty" preprocessor_class = "preParty"
script = "foo/party.js" script = "foo/party.js"
xml_str = """<textline math="True" id="prob_1_2" size="{size}" xml_str = f"""<textline math="True" id="prob_1_2" size="{size}"
preprocessorClassName="{pp}" preprocessorClassName="{preprocessor_class}"
preprocessorSrc="{sc}"/>""".format( preprocessorSrc="{script}"/>"""
size=size, pp=preprocessorClass, sc=script
)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -313,7 +319,7 @@ class TextLineTest(unittest.TestCase):
"trailing_text": "", "trailing_text": "",
"do_math": True, "do_math": True,
"preprocessor": { "preprocessor": {
"class_name": preprocessorClass, "class_name": preprocessor_class,
"script_src": script, "script_src": script,
}, },
"response_data": RESPONSE_DATA, "response_data": RESPONSE_DATA,
@@ -322,6 +328,7 @@ class TextLineTest(unittest.TestCase):
assert context == expected assert context == expected
def test_trailing_text_rendering(self): def test_trailing_text_rendering(self):
"""Test that trailing text in textline inputs is correctly rendered and escaped."""
size = "42" size = "42"
# store (xml_text, expected) # store (xml_text, expected)
trailing_text = [] trailing_text = []
@@ -334,12 +341,10 @@ class TextLineTest(unittest.TestCase):
trailing_text.append(("a &lt; b", "a < b")) trailing_text.append(("a &lt; b", "a < b"))
for xml_text, expected_text in trailing_text: for xml_text, expected_text in trailing_text:
xml_str = """<textline id="prob_1_2" xml_str = f"""<textline id="prob_1_2"
size="{size}" size="{size}"
trailing_text="{tt}" trailing_text="{xml_text}"
/>""".format( />"""
size=size, tt=xml_text
)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -372,16 +377,14 @@ class FileSubmissionTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Test that a filesubmission input renders correctly with allowed and required files."""
allowed_files = "runme.py nooooo.rb ohai.java" allowed_files = "runme.py nooooo.rb ohai.java"
required_files = "cookies.py" required_files = "cookies.py"
xml_str = """<filesubmission id="prob_1_2" xml_str = f"""<filesubmission id="prob_1_2"
allowed_files="{af}" allowed_files="{allowed_files}"
required_files="{rf}" required_files="{required_files}"
/>""".format( />"""
af=allowed_files,
rf=required_files,
)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -418,21 +421,20 @@ class CodeInputTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Test that a codeinput input renders correctly with specified editor settings."""
mode = "parrot" mode = "parrot"
linenumbers = "false" linenumbers = "false"
rows = "37" rows = "37"
cols = "11" cols = "11"
tabsize = "7" tabsize = "7"
xml_str = """<codeinput id="prob_1_2" xml_str = f"""<codeinput id="prob_1_2"
mode="{m}" mode="{mode}"
cols="{c}" cols="{cols}"
rows="{r}" rows="{rows}"
linenumbers="{ln}" linenumbers="{linenumbers}"
tabsize="{ts}" tabsize="{tabsize}"
/>""".format( />"""
m=mode, c=cols, r=rows, ln=linenumbers, ts=tabsize
)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -461,7 +463,7 @@ class CodeInputTest(unittest.TestCase):
"hidden": "", "hidden": "",
"tabsize": int(tabsize), "tabsize": int(tabsize),
"queue_len": "3", "queue_len": "3",
"aria_label": "{mode} editor".format(mode=mode), "aria_label": f"{mode} editor",
"code_mirror_exit_message": "Press ESC then TAB or click outside of the code editor to exit", "code_mirror_exit_message": "Press ESC then TAB or click outside of the code editor to exit",
"response_data": RESPONSE_DATA, "response_data": RESPONSE_DATA,
"describedby_html": DESCRIBEDBY.format(status_id=prob_id), "describedby_html": DESCRIBEDBY.format(status_id=prob_id),
@@ -470,29 +472,27 @@ class CodeInputTest(unittest.TestCase):
assert context == expected assert context == expected
class MatlabTest(unittest.TestCase): class MatlabTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes
""" """
Test Matlab input types Test Matlab input types
""" """
def setUp(self): def setUp(self):
super(MatlabTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.rows = "10" self.rows = "10"
self.cols = "80" self.cols = "80"
self.tabsize = "4" self.tabsize = "4"
self.mode = "" self.mode = ""
self.payload = "payload" self.payload = "payload"
self.linenumbers = "true" self.linenumbers = "true"
self.xml = """<matlabinput id="prob_1_2" self.xml = f"""<matlabinput id="prob_1_2"
rows="{r}" cols="{c}" rows="{self.rows}" cols="{self.cols}"
tabsize="{tabsize}" mode="{m}" tabsize="{self.tabsize}" mode="{self.mode}"
linenumbers="{ln}"> linenumbers="{self.linenumbers}">
<plot_payload> <plot_payload>
{payload} {self.payload}
</plot_payload> </plot_payload>
</matlabinput>""".format( </matlabinput>"""
r=self.rows, c=self.cols, tabsize=self.tabsize, m=self.mode, payload=self.payload, ln=self.linenumbers
)
elt = etree.fromstring(self.xml) elt = etree.fromstring(self.xml)
state = { state = {
"value": 'print "good evening"', "value": 'print "good evening"',
@@ -505,6 +505,7 @@ class MatlabTest(unittest.TestCase):
self.the_input = self.input_class(mock_capa_system(), elt, state) self.the_input = self.input_class(mock_capa_system(), elt, state)
def test_rendering(self): def test_rendering(self):
"""Check that Matlab input renders with default context."""
context = self.the_input._get_render_context() # pylint: disable=protected-access context = self.the_input._get_render_context() # pylint: disable=protected-access
expected = { expected = {
@@ -530,6 +531,7 @@ class MatlabTest(unittest.TestCase):
assert context == expected assert context == expected
def test_rendering_with_state(self): def test_rendering_with_state(self):
"""Verify rendering when Matlab input has a pre-existing state."""
state = { state = {
"value": 'print "good evening"', "value": 'print "good evening"',
"status": "incomplete", "status": "incomplete",
@@ -565,6 +567,7 @@ class MatlabTest(unittest.TestCase):
assert context == expected assert context == expected
def test_rendering_when_completed(self): def test_rendering_when_completed(self):
"""Ensure rendering is correct when Matlab input status is completed."""
for status in ["correct", "incorrect"]: for status in ["correct", "incorrect"]:
state = { state = {
"value": 'print "good evening"', "value": 'print "good evening"',
@@ -599,7 +602,8 @@ class MatlabTest(unittest.TestCase):
assert context == expected assert context == expected
@patch("xmodule.capa.inputtypes.time.time", return_value=10) @patch("xmodule.capa.inputtypes.time.time", return_value=10)
def test_rendering_while_queued(self, time): # lint-amnesty, pylint: disable=unused-argument def test_rendering_while_queued(self, time): # pylint: disable=unused-argument
"""Test rendering of Matlab input while a queue is in progress."""
state = { state = {
"value": 'print "good evening"', "value": 'print "good evening"',
"status": "incomplete", "status": "incomplete",
@@ -633,6 +637,7 @@ class MatlabTest(unittest.TestCase):
assert context == expected assert context == expected
def test_plot_data(self): def test_plot_data(self):
"""Verify that plot submission sends data to Xqueue successfully."""
data = {"submission": "x = 1234;"} data = {"submission": "x = 1234;"}
response = self.the_input.handle_ajax("plot", data) response = self.the_input.handle_ajax("plot", data)
self.the_input.capa_system.xqueue.interface.send_to_queue.assert_called_with(header=ANY, body=ANY) self.the_input.capa_system.xqueue.interface.send_to_queue.assert_called_with(header=ANY, body=ANY)
@@ -641,6 +646,7 @@ class MatlabTest(unittest.TestCase):
assert self.the_input.input_state["queuestate"] == "queued" assert self.the_input.input_state["queuestate"] == "queued"
def test_plot_data_failure(self): def test_plot_data_failure(self):
"""Check behavior when plot submission to Xqueue fails."""
data = {"submission": "x = 1234;"} data = {"submission": "x = 1234;"}
error_message = "Error message!" error_message = "Error message!"
self.the_input.capa_system.xqueue.interface.send_to_queue.return_value = (1, error_message) self.the_input.capa_system.xqueue.interface.send_to_queue.return_value = (1, error_message)
@@ -651,7 +657,8 @@ class MatlabTest(unittest.TestCase):
assert "queuestate" not in self.the_input.input_state assert "queuestate" not in self.the_input.input_state
@patch("xmodule.capa.inputtypes.time.time", return_value=10) @patch("xmodule.capa.inputtypes.time.time", return_value=10)
def test_ungraded_response_success(self, time): # lint-amnesty, pylint: disable=unused-argument def test_ungraded_response_success(self, time): # pylint: disable=unused-argument
"""Test successful handling of ungraded responses from Xqueue."""
queuekey = "abcd" queuekey = "abcd"
input_state = {"queuekey": queuekey, "queuestate": "queued", "queuetime": 5} input_state = {"queuekey": queuekey, "queuestate": "queued", "queuetime": 5}
state = { state = {
@@ -672,7 +679,8 @@ class MatlabTest(unittest.TestCase):
assert input_state["queue_msg"] == inner_msg assert input_state["queue_msg"] == inner_msg
@patch("xmodule.capa.inputtypes.time.time", return_value=10) @patch("xmodule.capa.inputtypes.time.time", return_value=10)
def test_ungraded_response_key_mismatch(self, time): # lint-amnesty, pylint: disable=unused-argument def test_ungraded_response_key_mismatch(self, time): # pylint: disable=unused-argument
"""Ensure ungraded responses with mismatched keys are ignored."""
queuekey = "abcd" queuekey = "abcd"
input_state = {"queuekey": queuekey, "queuestate": "queued", "queuetime": 5} input_state = {"queuekey": queuekey, "queuestate": "queued", "queuetime": 5}
state = { state = {
@@ -693,7 +701,8 @@ class MatlabTest(unittest.TestCase):
assert "queue_msg" not in input_state assert "queue_msg" not in input_state
@patch("xmodule.capa.inputtypes.time.time", return_value=20) @patch("xmodule.capa.inputtypes.time.time", return_value=20)
def test_matlab_response_timeout_not_exceeded(self, time): # lint-amnesty, pylint: disable=unused-argument def test_matlab_response_timeout_not_exceeded(self, time): # pylint: disable=unused-argument
"""Check status when Matlab response timeout has not been exceeded."""
state = {"input_state": {"queuestate": "queued", "queuetime": 5}} state = {"input_state": {"queuestate": "queued", "queuetime": 5}}
elt = etree.fromstring(self.xml) elt = etree.fromstring(self.xml)
@@ -702,17 +711,18 @@ class MatlabTest(unittest.TestCase):
assert the_input.status == "queued" assert the_input.status == "queued"
@patch("xmodule.capa.inputtypes.time.time", return_value=45) @patch("xmodule.capa.inputtypes.time.time", return_value=45)
def test_matlab_response_timeout_exceeded(self, time): # lint-amnesty, pylint: disable=unused-argument def test_matlab_response_timeout_exceeded(self, time): # pylint: disable=unused-argument
"""Verify behavior when Matlab response timeout is exceeded."""
state = {"input_state": {"queuestate": "queued", "queuetime": 5}} state = {"input_state": {"queuestate": "queued", "queuetime": 5}}
elt = etree.fromstring(self.xml) elt = etree.fromstring(self.xml)
the_input = self.input_class(mock_capa_system(), elt, state) the_input = self.input_class(mock_capa_system(), elt, state)
assert the_input.status == "unsubmitted" assert the_input.status == "unsubmitted"
assert the_input.msg == "No response from Xqueue within {} seconds. Aborted.".format(XQUEUE_TIMEOUT) assert the_input.msg == f"No response from Xqueue within {XQUEUE_TIMEOUT} seconds. Aborted."
@patch("xmodule.capa.inputtypes.time.time", return_value=20) @patch("xmodule.capa.inputtypes.time.time", return_value=20)
def test_matlab_response_migration_of_queuetime(self, time): # lint-amnesty, pylint: disable=unused-argument def test_matlab_response_migration_of_queuetime(self, time): # pylint: disable=unused-argument
""" """
Test if problem was saved before queuetime was introduced. Test if problem was saved before queuetime was introduced.
""" """
@@ -732,14 +742,15 @@ class MatlabTest(unittest.TestCase):
the_input = lookup_tag("matlabinput")(system, elt, {}) the_input = lookup_tag("matlabinput")(system, elt, {})
data = {"submission": "x = 1234;"} data = {"submission": "x = 1234;"}
response = the_input.handle_ajax("plot", data) # lint-amnesty, pylint: disable=unused-variable response = the_input.handle_ajax("plot", data) # pylint: disable=unused-variable
body = system.xqueue.interface.send_to_queue.call_args[1]["body"] body = system.xqueue.interface.send_to_queue.call_args[1]["body"]
payload = json.loads(body) payload = json.loads(body)
assert "test_api_key" == payload["token"] assert "test_api_key" == payload["token"]
assert "2" == payload["endpoint_version"] assert "2" == payload["endpoint_version"]
def test_get_html(self): def test_get_html(self): # pylint: disable=too-many-locals
"""Test that Matlab input generates correct HTML representation."""
# usual output # usual output
output = self.the_input.get_html() output = self.the_input.get_html()
output_string = etree.tostring(output).decode("utf-8") output_string = etree.tostring(output).decode("utf-8")
@@ -807,6 +818,7 @@ class MatlabTest(unittest.TestCase):
self.the_input.capa_system.render_template = old_render_template self.the_input.capa_system.render_template = old_render_template
def test_malformed_queue_msg(self): def test_malformed_queue_msg(self):
"""Verify handling of malformed queue messages in Matlab input."""
# an actual malformed response # an actual malformed response
queue_msg = textwrap.dedent( queue_msg = textwrap.dedent(
""" """
@@ -867,16 +879,34 @@ class MatlabTest(unittest.TestCase):
the_input = self.input_class(mock_capa_system(), elt, state) the_input = self.input_class(mock_capa_system(), elt, state)
context = the_input._get_render_context() # pylint: disable=protected-access context = the_input._get_render_context() # pylint: disable=protected-access
self.maxDiff = None self.maxDiff = None # pylint: disable=invalid-name
expected = fromstring( expected = fromstring(
'\n<div class="matlabResponse"><div class="commandWindowOutput" style="white-space: pre;"> <strong>if</strong> Conditionally execute statements.\nThe general form of the <strong>if</strong> statement is\n\n <strong>if</strong> expression\n statements\n ELSEIF expression\n statements\n ELSE\n statements\n END\n\nThe statements are executed if the real part of the expression \nhas all non-zero elements. The ELSE and ELSEIF parts are optional.\nZero or more ELSEIF parts can be used as well as nested <strong>if</strong>\'s.\nThe expression is usually of the form expr rop expr where \nrop is ==, &lt;, &gt;, &lt;=, &gt;=, or ~=.\n<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAjAAAAGkCAIAAACgj==">\n\nExample\n if I == J\n A(I,J) = 2;\n elseif abs(I-J) == 1\n A(I,J) = -1;\n else\n A(I,J) = 0;\n end\n\nSee also <a>relop</a>, <a>else</a>, <a>elseif</a>, <a>end</a>, <a>for</a>, <a>while</a>, <a>switch</a>.\n\nReference page in Help browser\n <a>doc if</a>\n\n</div><ul></ul></div>\n' # lint-amnesty, pylint: disable=line-too-long (
'\n<div class="matlabResponse"><div class="commandWindowOutput" '
'style="white-space: pre;"> <strong>if</strong> Conditionally execute '
"statements.\nThe general form of the <strong>if</strong> statement is\n\n"
" <strong>if</strong> expression\n statements\n ELSEIF expression\n"
" statements\n ELSE\n statements\n END\n\nThe statements are "
"executed if the real part of the expression \nhas all non-zero elements. "
"The ELSE and ELSEIF parts are optional.\nZero or more ELSEIF parts can be "
"used as well as nested <strong>if</strong>'s.\nThe expression is usually "
"of the form expr rop expr where \nrop is ==, &lt;, &gt;, &lt;=, &gt;=, or "
'~=.\n<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAjAAAAGkCAIA'
'AACgj==">\n\nExample\n if I == J\n A(I,J) = 2;\n elseif abs(I-J) '
"== 1\n A(I,J) = -1;\n else\n A(I,J) = 0;\n end\n\nSee also "
"<a>relop</a>, <a>else</a>, <a>elseif</a>, <a>end</a>, <a>for</a>, "
"<a>while</a>, <a>switch</a>.\n\nReference page in Help browser\n "
"<a>doc if</a>\n\n</div><ul></ul></div>\n"
)
) )
received = fromstring(context["queue_msg"]) received = fromstring(context["queue_msg"])
html_tree_equal(received, expected) html_tree_equal(received, expected)
def test_rendering_with_invalid_queue_msg(self): def test_rendering_with_invalid_queue_msg(self):
"""Ensure invalid queue messages are sanitized and handled."""
self.the_input.queue_msg = ( self.the_input.queue_msg = (
"<div class='matlabResponse'><div style='white-space:pre' class='commandWindowOutput'>" # lint-amnesty, pylint: disable=line-too-long "<div class='matlabResponse'><div style='white-space:pre' class='commandWindowOutput'>"
"\nans =\n\n\u0002\n\n</div><ul></ul></div>" "\nans =\n\n\u0002\n\n</div><ul></ul></div>"
) )
context = self.the_input._get_render_context() # pylint: disable=protected-access context = self.the_input._get_render_context() # pylint: disable=protected-access
@@ -900,7 +930,7 @@ class MatlabTest(unittest.TestCase):
"queue_len": "3", "queue_len": "3",
"matlab_editor_js": "/dummy-static/js/vendor/CodeMirror/octave.js", "matlab_editor_js": "/dummy-static/js/vendor/CodeMirror/octave.js",
"response_data": {}, "response_data": {},
"describedby_html": 'aria-describedby="status_{id}"'.format(id=prob_id), "describedby_html": f'aria-describedby="status_{prob_id}"',
} }
assert context == expected assert context == expected
self.the_input.capa_system.render_template = DemoSystem().render_template self.the_input.capa_system.render_template = DemoSystem().render_template
@@ -912,7 +942,7 @@ class MatlabTest(unittest.TestCase):
""" """
allowed_tags = ["div", "p", "audio", "pre", "span"] allowed_tags = ["div", "p", "audio", "pre", "span"]
for tag in allowed_tags: for tag in allowed_tags:
queue_msg = "<{0}>Test message</{0}>".format(tag) queue_msg = f"<{tag}>Test message</{tag}>"
state = { state = {
"input_state": {"queue_msg": queue_msg}, "input_state": {"queue_msg": queue_msg},
"status": "queued", "status": "queued",
@@ -926,7 +956,7 @@ class MatlabTest(unittest.TestCase):
Test not allowed tag. Test not allowed tag.
""" """
not_allowed_tag = "script" not_allowed_tag = "script"
queue_msg = "<{0}>Test message</{0}>".format(not_allowed_tag) queue_msg = f"<{not_allowed_tag}>Test message</{not_allowed_tag}>"
state = { state = {
"input_state": {"queue_msg": queue_msg}, "input_state": {"queue_msg": queue_msg},
"status": "queued", "status": "queued",
@@ -941,7 +971,7 @@ class MatlabTest(unittest.TestCase):
Check that the_input.msg is sanitized. Check that the_input.msg is sanitized.
""" """
not_allowed_tag = "script" not_allowed_tag = "script"
self.the_input.msg = "<{0}>Test message</{0}>".format(not_allowed_tag) self.the_input.msg = f"<{not_allowed_tag}>Test message</{not_allowed_tag}>"
expected = "" expected = ""
assert self.the_input._get_render_context()["msg"] == expected # pylint: disable=protected-access assert self.the_input._get_render_context()["msg"] == expected # pylint: disable=protected-access
@@ -966,6 +996,7 @@ class SchematicTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Check that schematic input renders with expected context."""
height = "12" height = "12"
width = "33" width = "33"
parts = "resistors, capacitors, and flowers" parts = "resistors, capacitors, and flowers"
@@ -973,16 +1004,14 @@ class SchematicTest(unittest.TestCase):
initial_value = "two large batteries" initial_value = "two large batteries"
submit_analyses = "maybe" submit_analyses = "maybe"
xml_str = """<schematic id="prob_1_2" xml_str = f"""<schematic id="prob_1_2"
height="{h}" height="{height}"
width="{w}" width="{width}"
parts="{p}" parts="{parts}"
analyses="{a}" analyses="{analyses}"
initial_value="{iv}" initial_value="{initial_value}"
submit_analyses="{sa}" submit_analyses="{submit_analyses}"
/>""".format( />"""
h=height, w=width, p=parts, a=analyses, iv=initial_value, sa=submit_analyses
)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -1018,18 +1047,17 @@ class ImageInputTest(unittest.TestCase):
Check that image inputs work Check that image inputs work
""" """
def check(self, value, egx, egy): # lint-amnesty, pylint: disable=missing-function-docstring def check(self, value, egx, egy):
"""Test that imageinput renders with expected context."""
height = "78" height = "78"
width = "427" width = "427"
src = "http://www.edx.org/cowclicker.jpg" src = "http://www.edx.org/cowclicker.jpg"
xml_str = """<imageinput id="prob_1_2" xml_str = f"""<imageinput id="prob_1_2"
src="{s}" src="{src}"
height="{h}" height="{height}"
width="{w}" width="{width}"
/>""".format( />"""
s=src, h=height, w=width
)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -1057,13 +1085,16 @@ class ImageInputTest(unittest.TestCase):
assert context == expected assert context == expected
def test_with_value(self): def test_with_value(self):
"""Test image input rendering when a value is provided."""
# Check that compensating for the dot size works properly. # Check that compensating for the dot size works properly.
self.check("[50,40]", 35, 25) self.check("[50,40]", 35, 25)
def test_without_value(self): def test_without_value(self):
"""Test image input rendering when no value is provided."""
self.check("", 0, 0) self.check("", 0, 0)
def test_corrupt_values(self): def test_corrupt_values(self):
"""Ensure image input handles malformed or corrupt values safely."""
self.check("[12", 0, 0) self.check("[12", 0, 0)
self.check("[12, a]", 0, 0) self.check("[12, a]", 0, 0)
self.check("[12 10]", 0, 0) self.check("[12 10]", 0, 0)
@@ -1077,15 +1108,14 @@ class CrystallographyTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Check that crystallography input renders with expected context."""
height = "12" height = "12"
width = "33" width = "33"
xml_str = """<crystallography id="prob_1_2" xml_str = f"""<crystallography id="prob_1_2"
height="{h}" height="{height}"
width="{w}" width="{width}"
/>""".format( />"""
h=height, w=width
)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -1117,19 +1147,18 @@ class VseprTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Test that a vsepr input renders correctly with molecules and geometries."""
height = "12" height = "12"
width = "33" width = "33"
molecules = "H2O, C2O" molecules = "H2O, C2O"
geometries = "AX12,TK421" geometries = "AX12,TK421"
xml_str = """<vsepr id="prob_1_2" xml_str = f"""<vsepr id="prob_1_2"
height="{h}" height="{height}"
width="{w}" width="{width}"
molecules="{m}" molecules="{molecules}"
geometries="{g}" geometries="{geometries}"
/>""".format( />"""
h=height, w=width, m=molecules, g=geometries
)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -1163,9 +1192,9 @@ class ChemicalEquationTest(unittest.TestCase):
""" """
def setUp(self): def setUp(self):
super(ChemicalEquationTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.size = "42" self.size = "42"
xml_str = """<chemicalequationinput id="prob_1_2" size="{size}"/>""".format(size=self.size) xml_str = f"""<chemicalequationinput id="prob_1_2" size="{self.size}"/>"""
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -1248,9 +1277,9 @@ class FormulaEquationTest(unittest.TestCase):
""" """
def setUp(self): def setUp(self):
super(FormulaEquationTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.size = "42" self.size = "42"
xml_str = """<formulaequationinput id="prob_1_2" size="{size}"/>""".format(size=self.size) xml_str = f"""<formulaequationinput id="prob_1_2" size="{self.size}"/>"""
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -1294,12 +1323,10 @@ class FormulaEquationTest(unittest.TestCase):
trailing_text.append(("a &lt; b", "a < b")) trailing_text.append(("a &lt; b", "a < b"))
for xml_text, expected_text in trailing_text: for xml_text, expected_text in trailing_text:
xml_str = """<formulaequationinput id="prob_1_2" xml_str = f"""<formulaequationinput id="prob_1_2"
size="{size}" size="{size}"
trailing_text="{tt}" trailing_text="{xml_text}"
/>""".format( />"""
size=size, tt=xml_text
)
element = etree.fromstring(xml_str) element = etree.fromstring(xml_str)
@@ -1361,7 +1388,7 @@ class FormulaEquationTest(unittest.TestCase):
With parse errors, FormulaEquationInput should give an error message With parse errors, FormulaEquationInput should give an error message
""" """
# Simulate answering a problem that raises the exception # Simulate answering a problem that raises the exception
with patch('xmodule.capa.inputtypes.preview_numeric_input') as mock_preview: with patch("xmodule.capa.inputtypes.preview_numeric_input") as mock_preview:
mock_preview.side_effect = ParseException("Oopsie") mock_preview.side_effect = ParseException("Oopsie")
response = self.the_input.handle_ajax( response = self.the_input.handle_ajax(
"preview_formcalc", "preview_formcalc",
@@ -1379,7 +1406,7 @@ class FormulaEquationTest(unittest.TestCase):
""" """
With other errors, test that FormulaEquationInput also logs it With other errors, test that FormulaEquationInput also logs it
""" """
with patch('xmodule.capa.inputtypes.preview_numeric_input') as mock_preview: with patch("xmodule.capa.inputtypes.preview_numeric_input") as mock_preview:
mock_preview.side_effect = Exception() mock_preview.side_effect = Exception()
response = self.the_input.handle_ajax( response = self.the_input.handle_ajax(
"preview_formcalc", "preview_formcalc",
@@ -1399,6 +1426,7 @@ class DragAndDropTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Test that a drag-and-drop input renders correctly with draggables and targets."""
path_to_images = "/dummy-static/images/" path_to_images = "/dummy-static/images/"
xml_str = """ xml_str = """
@@ -1436,14 +1464,14 @@ class DragAndDropTest(unittest.TestCase):
"id": "name_with_icon", "id": "name_with_icon",
"icon": "/dummy-static/images/cc.jpg", "icon": "/dummy-static/images/cc.jpg",
"target_fields": [], "target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long },
{ {
"can_reuse": "", "can_reuse": "",
"label": "arrow-left", "label": "arrow-left",
"id": "with_icon", "id": "with_icon",
"icon": "/dummy-static/images/arrow-left.png", "icon": "/dummy-static/images/arrow-left.png",
"target_fields": [], "target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long },
{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "target_fields": []}, {"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "target_fields": []},
{ {
"can_reuse": "", "can_reuse": "",
@@ -1451,21 +1479,21 @@ class DragAndDropTest(unittest.TestCase):
"id": "2", "id": "2",
"icon": "/dummy-static/images/mute.png", "icon": "/dummy-static/images/mute.png",
"target_fields": [], "target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long },
{ {
"can_reuse": "", "can_reuse": "",
"label": "spinner", "label": "spinner",
"id": "name_label_icon3", "id": "name_label_icon3",
"icon": "/dummy-static/images/spinner.gif", "icon": "/dummy-static/images/spinner.gif",
"target_fields": [], "target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long },
{ {
"can_reuse": "", "can_reuse": "",
"label": "Star", "label": "Star",
"id": "name4", "id": "name4",
"icon": "/dummy-static/images/volume.png", "icon": "/dummy-static/images/volume.png",
"target_fields": [], "target_fields": [],
}, # lint-amnesty, pylint: disable=line-too-long },
{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "target_fields": []}, {"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "target_fields": []},
], ],
"one_per_target": "True", "one_per_target": "True",
@@ -1503,6 +1531,7 @@ class AnnotationInputTest(unittest.TestCase):
""" """
def test_rendering(self): def test_rendering(self):
"""Test that an annotationinput renders correctly with comments, options, and state."""
xml_str = """ xml_str = """
<annotationinput> <annotationinput>
<title>foo</title> <title>foo</title>
@@ -1554,7 +1583,7 @@ class AnnotationInputTest(unittest.TestCase):
"describedby_html": DESCRIBEDBY.format(status_id=prob_id), "describedby_html": DESCRIBEDBY.format(status_id=prob_id),
} }
self.maxDiff = None self.maxDiff = None # pylint: disable=invalid-name
self.assertDictEqual(context, expected) self.assertDictEqual(context, expected)
@@ -1575,7 +1604,7 @@ class TestChoiceText(unittest.TestCase):
choice = {"type": node_type, "contents": contents, "tail_text": tail_text, "value": value} choice = {"type": node_type, "contents": contents, "tail_text": tail_text, "value": value}
return choice return choice
def check_group(self, tag, choice_tag, expected_input_type): def check_group(self, tag, choice_tag, expected_input_type): # pylint: disable=too-many-locals
""" """
Build a radio or checkbox group, parse it and check the resuls against the Build a radio or checkbox group, parse it and check the resuls against the
expected output. expected output.
@@ -1699,7 +1728,10 @@ class TestStatus(unittest.TestCase):
""" """
Test that display names are "translated" Test that display names are "translated"
""" """
func = lambda t: t.upper()
def func(t):
return t.upper()
# status is in the mapping # status is in the mapping
statobj = inputtypes.Status("queued", func) statobj = inputtypes.Status("queued", func)
assert statobj.display_name == "PROCESSING" assert statobj.display_name == "PROCESSING"

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Tests of responsetypes Tests of responsetypes
@@ -19,8 +20,16 @@ import requests
from pytz import UTC from pytz import UTC
from xmodule.capa.correctmap import CorrectMap from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError from xmodule.capa.responsetypes import (
from xmodule.capa.tests.helpers import load_fixture, mock_capa_system, new_loncapa_problem LoncapaProblemError,
ResponseError,
StudentInputError,
)
from xmodule.capa.tests.helpers import (
load_fixture,
mock_capa_system,
new_loncapa_problem,
)
from xmodule.capa.tests.response_xml_factory import ( from xmodule.capa.tests.response_xml_factory import (
AnnotationResponseXMLFactory, AnnotationResponseXMLFactory,
ChoiceResponseXMLFactory, ChoiceResponseXMLFactory,
@@ -37,9 +46,9 @@ from xmodule.capa.tests.response_xml_factory import (
SymbolicResponseXMLFactory, SymbolicResponseXMLFactory,
TrueFalseResponseXMLFactory, TrueFalseResponseXMLFactory,
) )
from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.tests.test_util import UseUnsafeCodejail
from xmodule.capa.util import convert_files_to_filenames from xmodule.capa.util import convert_files_to_filenames
from xmodule.capa.xqueue_interface import dateformat from xmodule.capa.xqueue_interface import DATEFORMAT
class ResponseTest(unittest.TestCase): class ResponseTest(unittest.TestCase):
@@ -51,16 +60,18 @@ class ResponseTest(unittest.TestCase):
maxDiff = None maxDiff = None
def setUp(self): def setUp(self):
super(ResponseTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
if self.xml_factory_class: if self.xml_factory_class:
self.xml_factory = self.xml_factory_class() # lint-amnesty, pylint: disable=not-callable self.xml_factory = self.xml_factory_class() # pylint: disable=not-callable
def build_problem(self, capa_system=None, **kwargs): def build_problem(self, capa_system=None, **kwargs):
"""Build a Loncapa problem using the XML factory and provided arguments."""
xml = self.xml_factory.build_xml(**kwargs) xml = self.xml_factory.build_xml(**kwargs)
return new_loncapa_problem(xml, capa_system=capa_system) return new_loncapa_problem(xml, capa_system=capa_system)
# pylint: disable=missing-function-docstring
def assert_grade(self, problem, submission, expected_correctness, msg=None): def assert_grade(self, problem, submission, expected_correctness, msg=None):
"""Assert that a single submission is graded as expected."""
input_dict = {"1_2_1": submission} input_dict = {"1_2_1": submission}
correct_map = problem.grade_answers(input_dict) correct_map = problem.grade_answers(input_dict)
if msg is None: if msg is None:
@@ -69,11 +80,12 @@ class ResponseTest(unittest.TestCase):
assert correct_map.get_correctness("1_2_1") == expected_correctness, msg assert correct_map.get_correctness("1_2_1") == expected_correctness, msg
def assert_answer_format(self, problem): def assert_answer_format(self, problem):
"""Assert that the problem's answers are in a valid format."""
answers = problem.get_question_answers() answers = problem.get_question_answers()
assert answers["1_2_1"] is not None assert answers["1_2_1"] is not None
# pylint: disable=missing-function-docstring
def assert_multiple_grade(self, problem, correct_answers, incorrect_answers): def assert_multiple_grade(self, problem, correct_answers, incorrect_answers):
"""Assert that a set of inputs are graded correctly as either correct or incorrect."""
for input_str in correct_answers: for input_str in correct_answers:
result = problem.grade_answers({"1_2_1": input_str}).get_correctness("1_2_1") result = problem.grade_answers({"1_2_1": input_str}).get_correctness("1_2_1")
assert result == "correct" assert result == "correct"
@@ -109,11 +121,14 @@ class ResponseTest(unittest.TestCase):
return str(rand.randint(0, 1e9)) return str(rand.randint(0, 1e9))
@use_unsafe_codejail() @UseUnsafeCodejail()
class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class MultiChoiceResponseTest(ResponseTest):
"""Unit tests for the MultipleChoiceResponse class."""
xml_factory_class = MultipleChoiceResponseXMLFactory xml_factory_class = MultipleChoiceResponseXMLFactory
def test_multiple_choice_grade(self): def test_multiple_choice_grade(self):
"""Test grading of a standard multiple-choice problem."""
problem = self.build_problem(choices=[False, True, False]) problem = self.build_problem(choices=[False, True, False])
# Ensure that we get the expected grades # Ensure that we get the expected grades
@@ -122,6 +137,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
self.assert_grade(problem, "choice_2", "incorrect") self.assert_grade(problem, "choice_2", "incorrect")
def test_partial_multiple_choice_grade(self): def test_partial_multiple_choice_grade(self):
"""Test grading of multiple-choice problem with partial credit."""
problem = self.build_problem(choices=[False, True, "partial"], credit_type="points") problem = self.build_problem(choices=[False, True, "partial"], credit_type="points")
# Ensure that we get the expected grades # Ensure that we get the expected grades
@@ -130,6 +146,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
self.assert_grade(problem, "choice_2", "partially-correct") self.assert_grade(problem, "choice_2", "partially-correct")
def test_named_multiple_choice_grade(self): def test_named_multiple_choice_grade(self):
"""Test grading of multiple-choice problem with named choices."""
problem = self.build_problem(choices=[False, True, False], choice_names=["foil_1", "foil_2", "foil_3"]) problem = self.build_problem(choices=[False, True, False], choice_names=["foil_1", "foil_2", "foil_3"])
# Ensure that we get the expected grades # Ensure that we get the expected grades
@@ -138,6 +155,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
self.assert_grade(problem, "choice_foil_3", "incorrect") self.assert_grade(problem, "choice_foil_3", "incorrect")
def test_multiple_choice_valid_grading_schemes(self): def test_multiple_choice_valid_grading_schemes(self):
"""Test that invalid multiple-choice grading schemes raise an error."""
# Multiple Choice problems only allow one partial credit scheme. # Multiple Choice problems only allow one partial credit scheme.
# Change this test if that changes. # Change this test if that changes.
problem = self.build_problem(choices=[False, True, "partial"], credit_type="points,points") problem = self.build_problem(choices=[False, True, "partial"], credit_type="points,points")
@@ -152,6 +170,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
problem.grade_answers(input_dict) problem.grade_answers(input_dict)
def test_partial_points_multiple_choice_grade(self): def test_partial_points_multiple_choice_grade(self):
"""Test that multiple-choice choices return the correct partial points."""
problem = self.build_problem( problem = self.build_problem(
choices=["partial", "partial", "partial"], credit_type="points", points=["1", "0.6", "0"] choices=["partial", "partial", "partial"], credit_type="points", points=["1", "0.6", "0"]
) )
@@ -168,6 +187,7 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
assert round(correct_map.get_npoints("1_2_1") - 0, 7) >= 0 assert round(correct_map.get_npoints("1_2_1") - 0, 7) >= 0
def test_contextualized_choices(self): def test_contextualized_choices(self):
"""Test grading for multiple-choice responses with contextualized expressions."""
script = textwrap.dedent( script = textwrap.dedent(
""" """
a = 2 a = 2
@@ -194,10 +214,13 @@ class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-do
self.assert_grade(problem, "choice_infinity may be both ... (should be partial)", "partially-correct") self.assert_grade(problem, "choice_infinity may be both ... (should be partial)", "partially-correct")
class TrueFalseResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class TrueFalseResponseTest(ResponseTest):
"""Unit tests for the TrueFalseResponse class."""
xml_factory_class = TrueFalseResponseXMLFactory xml_factory_class = TrueFalseResponseXMLFactory
def test_true_false_grade(self): def test_true_false_grade(self):
"""Test grading for standard True/False response problems."""
problem = self.build_problem(choices=[False, True, True]) problem = self.build_problem(choices=[False, True, True])
# Check the results # Check the results
@@ -215,6 +238,7 @@ class TrueFalseResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_grade(problem, "not_a_choice", "incorrect") self.assert_grade(problem, "not_a_choice", "incorrect")
def test_named_true_false_grade(self): def test_named_true_false_grade(self):
"""Test grading for True/False problems with named choices."""
problem = self.build_problem(choices=[False, True, True], choice_names=["foil_1", "foil_2", "foil_3"]) problem = self.build_problem(choices=[False, True, True], choice_names=["foil_1", "foil_2", "foil_3"])
# Check the results # Check the results
@@ -232,15 +256,19 @@ class TrueFalseResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_grade(problem, "not_a_choice", "incorrect") self.assert_grade(problem, "not_a_choice", "incorrect")
def test_single_correct_response(self): def test_single_correct_response(self):
"""Test grading when there is a single correct True/False choice."""
problem = self.build_problem(choices=[True, False]) problem = self.build_problem(choices=[True, False])
self.assert_grade(problem, "choice_0", "correct") self.assert_grade(problem, "choice_0", "correct")
self.assert_grade(problem, ["choice_0"], "correct") self.assert_grade(problem, ["choice_0"], "correct")
class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class ImageResponseTest(ResponseTest):
"""Unit tests for the ImageResponse class."""
xml_factory_class = ImageResponseXMLFactory xml_factory_class = ImageResponseXMLFactory
def test_rectangle_grade(self): def test_rectangle_grade(self):
"""Test grading for a single rectangular region in ImageResponse."""
# Define a rectangle with corners (10,10) and (20,20) # Define a rectangle with corners (10,10) and (20,20)
problem = self.build_problem(rectangle="(10,10)-(20,20)") problem = self.build_problem(rectangle="(10,10)-(20,20)")
@@ -251,6 +279,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_multiple_rectangles_grade(self): def test_multiple_rectangles_grade(self):
"""Test grading for multiple rectangles in ImageResponse."""
# Define two rectangles # Define two rectangles
rectangle_str = "(10,10)-(20,20);(100,100)-(200,200)" rectangle_str = "(10,10)-(20,20);(100,100)-(200,200)"
@@ -261,6 +290,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_region_grade(self): def test_region_grade(self):
"""Test grading for a single polygonal region in ImageResponse."""
# Define a triangular region with corners (0,0), (5,10), and (0, 10) # Define a triangular region with corners (0,0), (5,10), and (0, 10)
region_str = "[ [1,1], [5,10], [0,10] ]" region_str = "[ [1,1], [5,10], [0,10] ]"
@@ -271,6 +301,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_multiple_regions_grade(self): def test_multiple_regions_grade(self):
"""Test grading for multiple regions in ImageResponse."""
# Define multiple regions that the user can select # Define multiple regions that the user can select
region_str = "[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]" region_str = "[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"
@@ -281,6 +312,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_region_and_rectangle_grade(self): def test_region_and_rectangle_grade(self):
"""Test grading for combined rectangle and region in ImageResponse."""
rectangle_str = "(100,100)-(200,200)" rectangle_str = "(100,100)-(200,200)"
region_str = "[[10,10], [20,10], [20, 30]]" region_str = "[[10,10], [20,10], [20, 30]]"
@@ -291,6 +323,7 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs) self.assert_multiple_grade(problem, correct_inputs, incorrect_inputs)
def test_show_answer(self): def test_show_answer(self):
"""Test that ImageResponse answers are returned in the correct format."""
rectangle_str = "(100,100)-(200,200)" rectangle_str = "(100,100)-(200,200)"
region_str = "[[10,10], [20,10], [20, 30]]" region_str = "[[10,10], [20,10], [20, 30]]"
@@ -298,10 +331,13 @@ class ImageResponseTest(ResponseTest): # pylint: disable=missing-class-docstrin
self.assert_answer_format(problem) self.assert_answer_format(problem)
class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class SymbolicResponseTest(ResponseTest):
"""Unit tests for the SymbolicResponse class."""
xml_factory_class = SymbolicResponseXMLFactory xml_factory_class = SymbolicResponseXMLFactory
def test_grade_single_input_incorrect(self): def test_grade_single_input_incorrect(self):
"""Test grading of incorrect single symbolic input."""
problem = self.build_problem(math_display=True, expect="2*x+3*y") problem = self.build_problem(math_display=True, expect="2*x+3*y")
# Incorrect answers # Incorrect answers
@@ -323,6 +359,7 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
self._assert_symbolic_grade(problem, input_str, input_mathml, "incorrect") self._assert_symbolic_grade(problem, input_str, input_mathml, "incorrect")
def test_complex_number_grade_incorrect(self): def test_complex_number_grade_incorrect(self):
"""Test grading of incorrect complex number symbolic input."""
problem = self.build_problem( problem = self.build_problem(
math_display=True, math_display=True,
@@ -348,13 +385,16 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
) )
def test_multiple_inputs_exception(self): def test_multiple_inputs_exception(self):
"""Test that specifying multiple inputs when only one is expected raises an exception."""
# Should not allow multiple inputs, since we specify # Should not allow multiple inputs, since we specify
# only one "expect" value # only one "expect" value
with pytest.raises(Exception): with pytest.raises(Exception):
self.build_problem(math_display=True, expect="2*x+3*y", num_inputs=3) self.build_problem(math_display=True, expect="2*x+3*y", num_inputs=3)
def _assert_symbolic_grade(self, problem, student_input, dynamath_input, expected_correctness, snuggletex_resp=""): def _assert_symbolic_grade( # pylint: disable=too-many-arguments,too-many-positional-arguments
self, problem, student_input, dynamath_input, expected_correctness, snuggletex_resp=""
):
""" """
Assert that the symbolic response has a certain grade. Assert that the symbolic response has a certain grade.
@@ -375,11 +415,14 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
assert correct_map.get_correctness("1_2_1") == expected_correctness assert correct_map.get_correctness("1_2_1") == expected_correctness
@use_unsafe_codejail() @UseUnsafeCodejail()
class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class OptionResponseTest(ResponseTest):
"""Unit tests for the OptionResponse class."""
xml_factory_class = OptionResponseXMLFactory xml_factory_class = OptionResponseXMLFactory
def test_grade(self): def test_grade(self):
"""Test grading of OptionResponse problem with multiple options."""
problem = self.build_problem(options=["first", "second", "third"], correct_option="second") problem = self.build_problem(options=["first", "second", "third"], correct_option="second")
# Assert that we get the expected grades # Assert that we get the expected grades
@@ -391,6 +434,7 @@ class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "invalid_option", "incorrect") self.assert_grade(problem, "invalid_option", "incorrect")
def test_quote_option(self): def test_quote_option(self):
"""Test that OptionResponse handles options containing quotes correctly."""
# Test that option response properly escapes quotes inside options strings # Test that option response properly escapes quotes inside options strings
problem = self.build_problem(options=["hasnot", "hasn't", "has'nt"], correct_option="hasn't") problem = self.build_problem(options=["hasnot", "hasn't", "has'nt"], correct_option="hasn't")
@@ -419,7 +463,7 @@ class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_property("1_2_1", "answervariable") == "$a" assert correct_map.get_property("1_2_1", "answervariable") == "$a"
@use_unsafe_codejail() @UseUnsafeCodejail()
class FormulaResponseTest(ResponseTest): class FormulaResponseTest(ResponseTest):
""" """
Test the FormulaResponse class Test the FormulaResponse class
@@ -553,8 +597,10 @@ class FormulaResponseTest(ResponseTest):
assert not list(problem.responders.values())[0].validate_answer("3*y+2*x") assert not list(problem.responders.values())[0].validate_answer("3*y+2*x")
@use_unsafe_codejail() @UseUnsafeCodejail()
class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class StringResponseTest(ResponseTest):
"""Unit and integration tests for the StringResponse class."""
xml_factory_class = StringResponseXMLFactory xml_factory_class = StringResponseXMLFactory
def test_backward_compatibility_for_multiple_answers(self): def test_backward_compatibility_for_multiple_answers(self):
@@ -579,6 +625,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "Other String", "incorrect") self.assert_grade(problem, "Other String", "incorrect")
def test_regexp(self): def test_regexp(self):
"""Test grading with various regular expression patterns and options."""
problem = self.build_problem(answer="Second", case_sensitive=False, regexp=True) problem = self.build_problem(answer="Second", case_sensitive=False, regexp=True)
self.assert_grade(problem, "Second", "correct") self.assert_grade(problem, "Second", "correct")
@@ -662,7 +709,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
Test some special cases of [unicode] regexps. Test some special cases of [unicode] regexps.
One needs to use either r'' strings or write real `repr` of unicode strings, because of the following One needs to use either r'' strings or write real `repr` of unicode strings, because of the following
(from python docs, http://docs.python.org/2/library/re.html): (from python docs, https://docs.python.org/3/library/re.html):
'for example, to match a literal backslash, one might have to write '\\\\' as the pattern string, 'for example, to match a literal backslash, one might have to write '\\\\' as the pattern string,
because the regular expression must be \\, because the regular expression must be \\,
@@ -680,14 +727,17 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "5\\æ", "correct") self.assert_grade(problem, "5\\æ", "correct")
def test_backslash(self): def test_backslash(self):
"""Test grading of answers containing literal backslashes."""
problem = self.build_problem(answer="a\\\\c1", case_sensitive=False, regexp=True) problem = self.build_problem(answer="a\\\\c1", case_sensitive=False, regexp=True)
self.assert_grade(problem, "a\\c1", "correct") self.assert_grade(problem, "a\\c1", "correct")
def test_special_chars(self): def test_special_chars(self):
"""Test grading of answers containing special characters like whitespace."""
problem = self.build_problem(answer="a \\s1", case_sensitive=False, regexp=True) problem = self.build_problem(answer="a \\s1", case_sensitive=False, regexp=True)
self.assert_grade(problem, "a 1", "correct") self.assert_grade(problem, "a 1", "correct")
def test_case_sensitive(self): def test_case_sensitive(self):
"""Test that case-sensitive answers are graded correctly."""
# Test single answer # Test single answer
problem_specified = self.build_problem(answer="Second", case_sensitive=True) problem_specified = self.build_problem(answer="Second", case_sensitive=True)
@@ -732,6 +782,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "\\", "correct") self.assert_grade(problem, "\\", "correct")
def test_case_insensitive(self): def test_case_insensitive(self):
"""Test that case-insensitive answers are graded correctly."""
# Test single answer # Test single answer
problem = self.build_problem(answer="Second", case_sensitive=False) problem = self.build_problem(answer="Second", case_sensitive=False)
@@ -756,17 +807,20 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "Other String", "incorrect") self.assert_grade(problem, "Other String", "incorrect")
def test_compatible_non_attribute_additional_answer_xml(self): def test_compatible_non_attribute_additional_answer_xml(self):
"""Test grading with non-attribute additional answers in XML."""
problem = self.build_problem(answer="Donut", non_attribute_answers=["Sprinkles"]) problem = self.build_problem(answer="Donut", non_attribute_answers=["Sprinkles"])
self.assert_grade(problem, "Donut", "correct") self.assert_grade(problem, "Donut", "correct")
self.assert_grade(problem, "Sprinkles", "correct") self.assert_grade(problem, "Sprinkles", "correct")
self.assert_grade(problem, "Meh", "incorrect") self.assert_grade(problem, "Meh", "incorrect")
def test_partial_matching(self): def test_partial_matching(self):
"""Test grading of answers using partial regex matching."""
problem = self.build_problem(answer="a2", case_sensitive=False, regexp=True, additional_answers=[".?\\d.?"]) problem = self.build_problem(answer="a2", case_sensitive=False, regexp=True, additional_answers=[".?\\d.?"])
self.assert_grade(problem, "a3", "correct") self.assert_grade(problem, "a3", "correct")
self.assert_grade(problem, "3a", "correct") self.assert_grade(problem, "3a", "correct")
def test_exception(self): def test_exception(self):
"""Test that invalid regex patterns raise the correct exception."""
problem = self.build_problem(answer="a2", case_sensitive=False, regexp=True, additional_answers=["?\\d?"]) problem = self.build_problem(answer="a2", case_sensitive=False, regexp=True, additional_answers=["?\\d?"])
with pytest.raises(Exception) as cm: with pytest.raises(Exception) as cm:
self.assert_grade(problem, "a3", "correct") self.assert_grade(problem, "a3", "correct")
@@ -774,6 +828,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert "nothing to repeat" in exception_message assert "nothing to repeat" in exception_message
def test_hints(self): def test_hints(self):
"""Test that hints are provided correctly based on student answers."""
hints = [ hints = [
("wisconsin", "wisc", "The state capital of Wisconsin is Madison"), ("wisconsin", "wisc", "The state capital of Wisconsin is Madison"),
@@ -805,6 +860,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_hint("1_2_1") == "" assert correct_map.get_hint("1_2_1") == ""
def test_hints_regexp_and_answer_regexp(self): def test_hints_regexp_and_answer_regexp(self):
"""Test that hints work correctly with regex patterns in answers."""
different_student_answers = [ different_student_answers = [
"May be it is Boston", "May be it is Boston",
"Boston, really?", "Boston, really?",
@@ -862,6 +918,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_hint("1_2_1") == "" assert correct_map.get_hint("1_2_1") == ""
def test_computed_hints(self): def test_computed_hints(self):
"""Test that computed hints from a hint function are returned correctly."""
problem = self.build_problem( problem = self.build_problem(
answer="Michigan", answer="Michigan",
hintfn="gimme_a_hint", hintfn="gimme_a_hint",
@@ -880,19 +937,17 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_hint("1_2_1") == "Hello??" assert correct_map.get_hint("1_2_1") == "Hello??"
def test_hint_function_randomization(self): def test_hint_function_randomization(self):
"""Test that hint functions respect the problem's random seed."""
# The hint function should get the seed from the problem. # The hint function should get the seed from the problem.
problem = self.build_problem( problem = self.build_problem(
answer="1", answer="1",
hintfn="gimme_a_random_hint", hintfn="gimme_a_random_hint",
script=textwrap.dedent( script=textwrap.dedent(
""" f"""
def gimme_a_random_hint(answer_ids, student_answers, new_cmap, old_cmap): def gimme_a_random_hint(answer_ids, student_answers, new_cmap, old_cmap):
answer = {code} answer = {self._get_random_number_code()}
new_cmap.set_hint_and_mode(answer_ids[0], answer, "always") new_cmap.set_hint_and_mode(answer_ids[0], answer, "always")
"""
""".format(
code=self._get_random_number_code()
)
), ),
) )
correct_map = problem.grade_answers({"1_2_1": "2"}) correct_map = problem.grade_answers({"1_2_1": "2"})
@@ -907,11 +962,13 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, " ", "incorrect") self.assert_grade(problem, " ", "incorrect")
class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class CodeResponseTest(ResponseTest):
"""Unit and integration tests for the CodeResponse class."""
xml_factory_class = CodeResponseXMLFactory xml_factory_class = CodeResponseXMLFactory
def setUp(self): def setUp(self):
super(CodeResponseTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
grader_payload = json.dumps({"grader": "ps04/grade_square.py"}) grader_payload = json.dumps({"grader": "ps04/grade_square.py"})
self.problem = self.build_problem( self.problem = self.build_problem(
@@ -921,7 +978,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
@staticmethod @staticmethod
def make_queuestate(key, time): def make_queuestate(key, time):
"""Create queuestate dict""" """Create queuestate dict"""
timestr = datetime.strftime(time, dateformat) timestr = datetime.strftime(time, DATEFORMAT)
return {"key": key, "time": timestr} return {"key": key, "time": timestr}
def test_is_queued(self): def test_is_queued(self):
@@ -948,7 +1005,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
assert self.problem.is_queued() is True assert self.problem.is_queued() is True
def test_update_score(self): def test_update_score(self): # pylint: disable=too-many-locals
""" """
Test whether LoncapaProblem.update_score can deliver queued result to the right subproblem Test whether LoncapaProblem.update_score can deliver queued result to the right subproblem
""" """
@@ -982,7 +1039,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
for answer_id in answer_ids: for answer_id in answer_ids:
assert self.problem.correct_map.is_queued(answer_id) assert self.problem.correct_map.is_queued(answer_id)
# Should be still queued, since message undelivered # lint-amnesty, pylint: disable=line-too-long # Should be still queued, since message undelivered
# Correct queuekey, state should be updated # Correct queuekey, state should be updated
for correctness in ["correct", "incorrect"]: for correctness in ["correct", "incorrect"]:
@@ -995,7 +1052,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
npoints = 1 if correctness == "correct" else 0 npoints = 1 if correctness == "correct" else 0
new_cmap.set( new_cmap.set(
answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None answer_id=answer_id, npoints=npoints, correctness=correctness, msg=grader_msg, queuestate=None
) # lint-amnesty, pylint: disable=line-too-long )
self.problem.update_score(xserver_msgs[correctness], queuekey=1000 + i) self.problem.update_score(xserver_msgs[correctness], queuekey=1000 + i)
assert self.problem.correct_map.get_dict() == new_cmap.get_dict() assert self.problem.correct_map.get_dict() == new_cmap.get_dict()
@@ -1003,10 +1060,10 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
for j, test_id in enumerate(answer_ids): for j, test_id in enumerate(answer_ids):
if j == i: if j == i:
assert not self.problem.correct_map.is_queued(test_id) assert not self.problem.correct_map.is_queued(test_id)
# Should be dequeued, message delivered # lint-amnesty, pylint: disable=line-too-long # Should be dequeued, message delivered
else: else:
assert self.problem.correct_map.is_queued(test_id) assert self.problem.correct_map.is_queued(test_id)
# Should be queued, message undelivered # lint-amnesty, pylint: disable=line-too-long # Should be queued, message undelivered
def test_recentmost_queuetime(self): def test_recentmost_queuetime(self):
""" """
@@ -1032,7 +1089,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
self.problem.correct_map.update(cmap) self.problem.correct_map.update(cmap)
# Queue state only tracks up to second # Queue state only tracks up to second
latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat).replace( latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, DATEFORMAT), DATEFORMAT).replace(
tzinfo=UTC tzinfo=UTC
) )
@@ -1043,7 +1100,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
Test whether file objects are converted to filenames without altering other structures Test whether file objects are converted to filenames without altering other structures
""" """
problem_file = os.path.join(os.path.dirname(__file__), "test_files/filename_convert_test.txt") problem_file = os.path.join(os.path.dirname(__file__), "test_files/filename_convert_test.txt")
with open(problem_file) as fp: with open(problem_file, encoding="utf-8") as fp:
answers_with_file = { answers_with_file = {
"1_2_1": "String-based answer", "1_2_1": "String-based answer",
"1_3_1": ["answer1", "answer2", "answer3"], "1_3_1": ["answer1", "answer2", "answer3"],
@@ -1062,10 +1119,22 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
"<span>MESSAGE</span>", # Valid XML "<span>MESSAGE</span>", # Valid XML
textwrap.dedent( textwrap.dedent(
""" """
<div class='matlabResponse'><div id='mwAudioPlaceHolder'> <div class='matlabResponse'>
<audio controls autobuffer autoplay src='data:audio/wav;base64='>Audio is not supported on this browser.</audio> <div id='mwAudioPlaceHolder'>
<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> <audio controls autobuffer autoplay src='data:audio/wav;base64='>
<div style='white-space:pre' class='commandWindowOutput'></div><ul></ul></div> 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( ).replace(
"\n", "" "\n", ""
@@ -1117,11 +1186,14 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
assert output[answer_id]["msg"] == "Invalid grader reply. Please contact the course staff." assert output[answer_id]["msg"] == "Invalid grader reply. Please contact the course staff."
@use_unsafe_codejail() @UseUnsafeCodejail()
class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class ChoiceResponseTest(ResponseTest):
"""Unit and integration tests for the ChoiceResponse class."""
xml_factory_class = ChoiceResponseXMLFactory xml_factory_class = ChoiceResponseXMLFactory
def test_radio_group_grade(self): def test_radio_group_grade(self):
"""Test grading behavior for radio choice groups."""
problem = self.build_problem(choice_type="radio", choices=[False, True, False]) problem = self.build_problem(choice_type="radio", choices=[False, True, False])
# Check that we get the expected results # Check that we get the expected results
@@ -1133,6 +1205,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "choice_3", "incorrect") self.assert_grade(problem, "choice_3", "incorrect")
def test_checkbox_group_grade(self): def test_checkbox_group_grade(self):
"""Test grading behavior for checkbox choice groups."""
problem = self.build_problem(choice_type="checkbox", choices=[False, True, True]) problem = self.build_problem(choice_type="checkbox", choices=[False, True, True])
# Check that we get the expected results # Check that we get the expected results
@@ -1147,6 +1220,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "choice_3", "incorrect") self.assert_grade(problem, "choice_3", "incorrect")
def test_checkbox_group_valid_grading_schemes(self): def test_checkbox_group_valid_grading_schemes(self):
"""Test validation of allowed grading schemes for checkbox groups."""
# Checkbox-type problems only allow one partial credit scheme. # Checkbox-type problems only allow one partial credit scheme.
# Change this test if that changes. # Change this test if that changes.
problem = self.build_problem( problem = self.build_problem(
@@ -1163,6 +1237,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers(input_dict) problem.grade_answers(input_dict)
def test_checkbox_group_partial_credit_grade(self): def test_checkbox_group_partial_credit_grade(self):
"""Test partial credit grading behavior for checkbox groups."""
# First: Every Decision Counts grading style # First: Every Decision Counts grading style
problem = self.build_problem(choice_type="checkbox", choices=[False, False, True, True], credit_type="edc") problem = self.build_problem(choice_type="checkbox", choices=[False, False, True, True], credit_type="edc")
@@ -1204,6 +1279,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, ["choice_0", "choice_1", "choice_2", "choice_3", "choice_4"], "incorrect") self.assert_grade(problem, ["choice_0", "choice_1", "choice_2", "choice_3", "choice_4"], "incorrect")
def test_checkbox_group_partial_points_grade(self): def test_checkbox_group_partial_points_grade(self):
"""Test that partial points are assigned correctly for checkbox groups."""
# Ensure that we get the expected number of points # Ensure that we get the expected number of points
# Using assertAlmostEqual to avoid floating point issues # Using assertAlmostEqual to avoid floating point issues
# First: Every Decision Counts grading style # First: Every Decision Counts grading style
@@ -1236,6 +1312,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_correctness("1_2_1") == "incorrect" assert correct_map.get_correctness("1_2_1") == "incorrect"
def test_contextualized_choices(self): def test_contextualized_choices(self):
"""Test grading of checkbox choices that depend on contextual script variables."""
script = textwrap.dedent( script = textwrap.dedent(
""" """
a = 6 a = 6
@@ -1256,14 +1333,17 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, ["choice_1", "choice_3"], "incorrect") self.assert_grade(problem, ["choice_1", "choice_3"], "incorrect")
@use_unsafe_codejail() @UseUnsafeCodejail()
class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class NumericalResponseTest(ResponseTest): # pylint: disable=too-many-public-methods
"""Unit and integration tests for the NumericalResponse class."""
xml_factory_class = NumericalResponseXMLFactory xml_factory_class = NumericalResponseXMLFactory
# We blend the line between integration (using evaluator) and exclusively # We blend the line between integration (using evaluator) and exclusively
# unit testing the NumericalResponse (mocking out the evaluator) # unit testing the NumericalResponse (mocking out the evaluator)
# For simple things its not worth the effort. # For simple things its not worth the effort.
def test_grade_range_tolerance(self): def test_grade_range_tolerance(self):
"""Test that numerical responses are graded correctly within a range tolerance."""
problem_setup = [ problem_setup = [
# [given_answer, [list of correct responses], [list of incorrect responses]] # [given_answer, [list of correct responses], [list of incorrect responses]]
["[5, 7)", ["5", "6", "6.999"], ["4.999", "7"]], ["[5, 7)", ["5", "6", "6.999"], ["4.999", "7"]],
@@ -1322,6 +1402,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
assert new_cmap.get_correctness("1_2_1") == "incorrect" assert new_cmap.get_correctness("1_2_1") == "incorrect"
def test_grade_range_tolerance_partial_credit(self): def test_grade_range_tolerance_partial_credit(self):
"""Test that partially correct answers are graded properly within range tolerance."""
problem_setup = [ problem_setup = [
# [given_answer, # [given_answer,
# [list of correct responses], # [list of correct responses],
@@ -1337,6 +1418,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses) self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses)
def test_grade_range_tolerance_exceptions(self): def test_grade_range_tolerance_exceptions(self):
"""Test that invalid inputs and complex numbers raise the appropriate exceptions."""
# no complex number in range tolerance staff answer # no complex number in range tolerance staff answer
problem = self.build_problem(answer="[1j, 5]") problem = self.build_problem(answer="[1j, 5]")
input_dict = {"1_2_1": "3"} input_dict = {"1_2_1": "3"}
@@ -1368,12 +1450,14 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
problem.grade_answers(input_dict) problem.grade_answers(input_dict)
def test_grade_exact(self): def test_grade_exact(self):
"""Test that exact numerical answers are graded correctly."""
problem = self.build_problem(answer=4) problem = self.build_problem(answer=4)
correct_responses = ["4", "4.0", "4.00"] correct_responses = ["4", "4.0", "4.00"]
incorrect_responses = ["", "3.9", "4.1", "0"] incorrect_responses = ["", "3.9", "4.1", "0"]
self.assert_multiple_grade(problem, correct_responses, incorrect_responses) self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_partial(self): def test_grade_partial(self):
"""Test grading of partially correct answers for different grading schemes."""
# First: "list"-style grading scheme. # First: "list"-style grading scheme.
problem = self.build_problem(answer=4, credit_type="list", partial_answers="2,8,-4") problem = self.build_problem(answer=4, credit_type="list", partial_answers="2,8,-4")
correct_responses = ["4", "4.0"] correct_responses = ["4", "4.0"]
@@ -1405,6 +1489,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses) self.assert_multiple_partial(problem, correct_responses, incorrect_responses, partial_responses)
def test_numerical_valid_grading_schemes(self): def test_numerical_valid_grading_schemes(self):
"""Test that invalid grading schemes raise an error."""
# 'bongo' is not a valid grading scheme. # 'bongo' is not a valid grading scheme.
problem = self.build_problem(answer=4, tolerance=0.1, credit_type="bongo") problem = self.build_problem(answer=4, tolerance=0.1, credit_type="bongo")
input_dict = {"1_2_1": "4"} input_dict = {"1_2_1": "4"}
@@ -1412,12 +1497,14 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
problem.grade_answers(input_dict) problem.grade_answers(input_dict)
def test_grade_decimal_tolerance(self): def test_grade_decimal_tolerance(self):
"""Test grading of numerical answers with decimal tolerance."""
problem = self.build_problem(answer=4, tolerance=0.1) problem = self.build_problem(answer=4, tolerance=0.1)
correct_responses = ["4.0", "4.00", "4.09", "3.91"] correct_responses = ["4.0", "4.00", "4.09", "3.91"]
incorrect_responses = ["", "4.11", "3.89", "0"] incorrect_responses = ["", "4.11", "3.89", "0"]
self.assert_multiple_grade(problem, correct_responses, incorrect_responses) self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_percent_tolerance(self): def test_grade_percent_tolerance(self):
"""Test grading of numerical answers with percentage-based tolerance."""
# Positive only range # Positive only range
problem = self.build_problem(answer=4, tolerance="10%") problem = self.build_problem(answer=4, tolerance="10%")
correct_responses = ["4.0", "4.00", "4.39", "3.61"] correct_responses = ["4.0", "4.00", "4.39", "3.61"]
@@ -1479,6 +1566,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
self.assert_multiple_grade(problem, correct_responses, incorrect_responses) self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
def test_grade_with_script(self): def test_grade_with_script(self):
"""Test that script-based answers are graded correctly."""
script_text = "computed_response = math.sqrt(4)" script_text = "computed_response = math.sqrt(4)"
problem = self.build_problem(answer="$computed_response", script=script_text) problem = self.build_problem(answer="$computed_response", script=script_text)
correct_responses = ["2", "2.0"] correct_responses = ["2", "2.0"]
@@ -1517,12 +1605,12 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
mock_log.debug.assert_called_once_with("Content error--answer '%s' is not a valid number", staff_ans) mock_log.debug.assert_called_once_with("Content error--answer '%s' is not a valid number", staff_ans)
@mock.patch("xmodule.capa.responsetypes.log") @mock.patch("xmodule.capa.responsetypes.log")
def test_responsetype_i18n(self, mock_log): # lint-amnesty, pylint: disable=unused-argument def test_responsetype_i18n(self, mock_log): # pylint: disable=unused-argument
"""Test that LoncapaSystem has an i18n that works.""" """Test that LoncapaSystem has an i18n that works."""
staff_ans = "clearly bad syntax )[+1e" staff_ans = "clearly bad syntax )[+1e"
problem = self.build_problem(answer=staff_ans, tolerance=1e-3) problem = self.build_problem(answer=staff_ans, tolerance=1e-3)
class FakeTranslations(object): class FakeTranslations: # pylint: disable=too-few-public-methods
"""A fake gettext.Translations object.""" """A fake gettext.Translations object."""
def ugettext(self, text): def ugettext(self, text):
@@ -1584,10 +1672,10 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
with mock.patch("xmodule.capa.responsetypes.evaluator") as mock_eval: with mock.patch("xmodule.capa.responsetypes.evaluator") as mock_eval:
for err, msg_regex in errors: for err, msg_regex in errors:
def evaluator_side_effect(_, __, math_string): def evaluator_side_effect(_, __, math_string, err=err):
"""Raise an error only for the student input.""" """Raise an error only for the student input."""
if math_string != "4": if math_string != "4":
raise err # lint-amnesty, pylint: disable=cell-var-from-loop raise err
mock_eval.side_effect = evaluator_side_effect mock_eval.side_effect = evaluator_side_effect
@@ -1609,11 +1697,14 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
assert not responder.validate_answer("fish") assert not responder.validate_answer("fish")
@use_unsafe_codejail() @UseUnsafeCodejail()
class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstring class CustomResponseTest(ResponseTest): # pylint: disable=too-many-public-methods
"""Unit tests for validating CustomResponse behavior"""
xml_factory_class = CustomResponseXMLFactory xml_factory_class = CustomResponseXMLFactory
def test_inline_code(self): def test_inline_code(self):
"""Test that inline code correctly evaluates and grades a single answer."""
# For inline code, we directly modify global context variables # For inline code, we directly modify global context variables
# 'answers' is a list of answers provided to us # 'answers' is a list of answers provided to us
# 'correct' is a list we fill in with True/False # 'correct' is a list we fill in with True/False
@@ -1626,6 +1717,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
self.assert_grade(problem, "0", "incorrect") self.assert_grade(problem, "0", "incorrect")
def test_inline_message(self): def test_inline_message(self):
"""Verify that inline code can set per-input and overall messages."""
# Inline code can update the global messages list # Inline code can update the global messages list
# to pass messages to the CorrectMap for a particular input # to pass messages to the CorrectMap for a particular input
# The code can also set the global overall_message (str) # The code can also set the global overall_message (str)
@@ -1650,8 +1742,9 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert overall_msg == "Overall message" assert overall_msg == "Overall message"
def test_inline_randomization(self): def test_inline_randomization(self):
"""Ensure inline code respects the problem's random seed."""
# Make sure the seed from the problem gets fed into the script execution. # Make sure the seed from the problem gets fed into the script execution.
inline_script = "messages[0] = {code}".format(code=self._get_random_number_code()) inline_script = f"messages[0] = {self._get_random_number_code()}"
problem = self.build_problem(answer=inline_script) problem = self.build_problem(answer=inline_script)
input_dict = {"1_2_1": "0"} input_dict = {"1_2_1": "0"}
@@ -1661,6 +1754,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert input_msg == self._get_random_number_result(problem.seed) assert input_msg == self._get_random_number_result(problem.seed)
def test_function_code_single_input(self): def test_function_code_single_input(self):
"""Check that function code grades a single input with optional partial credit."""
# For function code, we pass in these arguments: # For function code, we pass in these arguments:
# #
# 'expect' is the expect attribute of the <customresponse> # 'expect' is the expect attribute of the <customresponse>
@@ -1724,6 +1818,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert npoints == 0 assert npoints == 0
def test_function_code_single_input_decimal_score(self): def test_function_code_single_input_decimal_score(self):
"""Verify that function code returns a decimal score for a single input."""
# For function code, we pass in these arguments: # For function code, we pass in these arguments:
# #
# 'expect' is the expect attribute of the <customresponse> # 'expect' is the expect attribute of the <customresponse>
@@ -1776,6 +1871,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_correctness("1_2_1") == "partially-correct" assert correct_map.get_correctness("1_2_1") == "partially-correct"
def test_script_context(self): def test_script_context(self):
"""Ensure script variables can be used in 'expect' and 'answer' fields."""
# Ensure that python script variables can be used in the "expect" and "answer" fields, # Ensure that python script variables can be used in the "expect" and "answer" fields,
script = script = textwrap.dedent( script = script = textwrap.dedent(
@@ -1805,6 +1901,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correctness == "correct" assert correctness == "correct"
def test_function_code_multiple_input_no_msg(self): def test_function_code_multiple_input_no_msg(self):
"""Test multiple-input function grading without returning messages."""
# Check functions also have the option of returning # Check functions also have the option of returning
# a single boolean or string value # a single boolean or string value
@@ -1859,6 +1956,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correctness == "incorrect" assert correctness == "incorrect"
def test_function_code_multiple_inputs(self): def test_function_code_multiple_inputs(self):
"""Verify multiple-input function grading with individual messages."""
# If the <customresponse> has multiple inputs associated with it, # If the <customresponse> has multiple inputs associated with it,
# the check function can return a dict of the form: # the check function can return a dict of the form:
@@ -1915,6 +2013,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_msg("1_2_4") == "Feedback 4" assert correct_map.get_msg("1_2_4") == "Feedback 4"
def test_function_code_multiple_inputs_decimal_score(self): def test_function_code_multiple_inputs_decimal_score(self):
"""Check multiple-input function grading with decimal scores."""
# If the <customresponse> has multiple inputs associated with it, # If the <customresponse> has multiple inputs associated with it,
# the check function can return a dict of the form: # the check function can return a dict of the form:
@@ -1966,6 +2065,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_npoints("1_2_4") == 0.7 assert correct_map.get_npoints("1_2_4") == 0.7
def test_function_code_with_extra_args(self): def test_function_code_with_extra_args(self):
"""Test function code receiving extra arguments from the problem context."""
script = textwrap.dedent( script = textwrap.dedent(
"""\ """\
def check_func(expect, answer_given, options, dynamath): def check_func(expect, answer_given, options, dynamath):
@@ -2016,6 +2116,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert msg == "Message text" assert msg == "Message text"
def test_function_code_with_attempt_number(self): def test_function_code_with_attempt_number(self):
"""Verify that the function code can access and use the attempt number."""
script = textwrap.dedent( script = textwrap.dedent(
"""\ """\
def gradeit(expect, ans, **kwargs): def gradeit(expect, ans, **kwargs):
@@ -2053,6 +2154,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert msg == "This is attempt number 2" assert msg == "This is attempt number 2"
def test_multiple_inputs_return_one_status(self): def test_multiple_inputs_return_one_status(self):
"""Ensure multiple inputs receive the same status when function returns single dict."""
# When given multiple inputs, the 'answer_given' argument # When given multiple inputs, the 'answer_given' argument
# to the check_func() is a list of inputs # to the check_func() is a list of inputs
# #
@@ -2111,6 +2213,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_overall_message() == "Message text" assert correct_map.get_overall_message() == "Message text"
def test_script_exception_function(self): def test_script_exception_function(self):
"""Check that exceptions in function scripts raise a ResponseError."""
# Construct a script that will raise an exception # Construct a script that will raise an exception
script = textwrap.dedent( script = textwrap.dedent(
@@ -2127,6 +2230,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"}) problem.grade_answers({"1_2_1": "42"})
def test_script_exception_inline(self): def test_script_exception_inline(self):
"""Check that exceptions in inline scripts raise a ResponseError."""
# Construct a script that will raise an exception # Construct a script that will raise an exception
script = 'raise Exception("Test")' script = 'raise Exception("Test")'
@@ -2137,6 +2241,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"}) problem.grade_answers({"1_2_1": "42"})
def test_invalid_dict_exception(self): def test_invalid_dict_exception(self):
"""Verify that returning an invalid dictionary format raises ResponseError."""
# Construct a script that passes back an invalid dict format # Construct a script that passes back an invalid dict format
script = textwrap.dedent( script = textwrap.dedent(
@@ -2153,26 +2258,20 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"}) problem.grade_answers({"1_2_1": "42"})
def test_setup_randomization(self): def test_setup_randomization(self):
"""Ensure problem setup script receives the problem's random seed."""
# Ensure that the problem setup script gets the random seed from the problem. # Ensure that the problem setup script gets the random seed from the problem.
script = textwrap.dedent( script = textwrap.dedent(f"num = {self._get_random_number_code()}")
"""
num = {code}
""".format(
code=self._get_random_number_code()
)
)
problem = self.build_problem(script=script) problem = self.build_problem(script=script)
assert problem.context["num"] == self._get_random_number_result(problem.seed) assert problem.context["num"] == self._get_random_number_result(problem.seed)
def test_check_function_randomization(self): def test_check_function_randomization(self):
"""Verify that the check function is seeded with the problem's random seed."""
# The check function should get random-seeded from the problem. # The check function should get random-seeded from the problem.
script = textwrap.dedent( script = textwrap.dedent(
""" f"""
def check_func(expect, answer_given): def check_func(expect, answer_given):
return {{'ok': True, 'msg': {code} }} return {{'ok': True, 'msg': {self._get_random_number_code()} }}
""".format( """
code=self._get_random_number_code()
)
) )
problem = self.build_problem(script=script, cfn="check_func", expect="42") problem = self.build_problem(script=script, cfn="check_func", expect="42")
@@ -2182,6 +2281,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert msg == self._get_random_number_result(problem.seed) assert msg == self._get_random_number_result(problem.seed)
def test_random_isnt_none(self): def test_random_isnt_none(self):
"""Test that random seeding works correctly and does not return None."""
# Bug LMS-500 says random.seed(10) fails with: # Bug LMS-500 says random.seed(10) fails with:
# File "<string>", line 61, in <module> # File "<string>", line 61, in <module>
# File "/usr/lib/python2.7/random.py", line 116, in seed # File "/usr/lib/python2.7/random.py", line 116, in seed
@@ -2224,10 +2324,9 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
# If the name is not defined, then the script # If the name is not defined, then the script
# will raise an exception # will raise an exception
script = textwrap.dedent( script = textwrap.dedent(
""" f"""
correct[0] = 'correct' correct[0] = 'correct'
assert('%s' in globals())""" assert('{module_name}' in globals())"""
% module_name
) )
# Create the problem # Create the problem
@@ -2239,7 +2338,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"}) problem.grade_answers({"1_2_1": "42"})
except ResponseError: except ResponseError:
self.fail("Could not use name '{0}s' in custom response".format(module_name)) self.fail(f"Could not use name '{module_name}s' in custom response")
def test_module_imports_function(self): def test_module_imports_function(self):
""" """
@@ -2264,11 +2363,10 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
# If the name is not defined, then the script # If the name is not defined, then the script
# will raise an exception # will raise an exception
script = textwrap.dedent( script = textwrap.dedent(
""" f"""
def check_func(expect, answer_given): def check_func(expect, answer_given):
assert('%s' in globals()) assert('{module_name}' in globals())
return True""" return True"""
% module_name
) )
# Create the problem # Create the problem
@@ -2280,24 +2378,24 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
problem.grade_answers({"1_2_1": "42"}) problem.grade_answers({"1_2_1": "42"})
except ResponseError: except ResponseError:
self.fail("Could not use name '{0}s' in custom response".format(module_name)) self.fail(f"Could not use name '{module_name}s' in custom response")
def test_python_lib_zip_is_available(self): def test_python_lib_zip_is_available(self):
"""Test importing Python code from a zipfile in custom response scripts."""
# Prove that we can import code from a zipfile passed down to us. # Prove that we can import code from a zipfile passed down to us.
# Make a zipfile with one module in it with one function. # Make a zipfile with one module in it with one function.
zipstring = io.BytesIO() zipstring = io.BytesIO()
zipf = zipfile.ZipFile(zipstring, "w") # lint-amnesty, pylint: disable=consider-using-with with zipfile.ZipFile(zipstring, "w") as zipf:
zipf.writestr( zipf.writestr(
"my_helper.py", "my_helper.py",
textwrap.dedent( textwrap.dedent(
"""\ """\
def seventeen(): def seventeen():
return 17 return 17
""" """
), ),
) )
zipf.close()
# Use that module in our Python script. # Use that module in our Python script.
script = textwrap.dedent( script = textwrap.dedent(
@@ -2307,13 +2405,12 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
""" """
) )
capa_system = mock_capa_system() capa_system = mock_capa_system()
capa_system.get_python_lib_zip = ( capa_system.get_python_lib_zip = zipstring.getvalue
lambda: zipstring.getvalue() # lint-amnesty, pylint: disable=unnecessary-lambda
)
problem = self.build_problem(script=script, capa_system=capa_system) problem = self.build_problem(script=script, capa_system=capa_system)
assert problem.context["num"] == 17 assert problem.context["num"] == 17
def test_function_code_multiple_inputs_order(self): def test_function_code_multiple_inputs_order(self):
"""Verify that multiple-input grading respects the input order for subproblems."""
# Ensure that order must be correct according to sub-problem position # Ensure that order must be correct according to sub-problem position
script = textwrap.dedent( script = textwrap.dedent(
""" """
@@ -2393,7 +2490,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
assert correct_map.get_msg("1_2_11") == "11" assert correct_map.get_msg("1_2_11") == "11"
@use_unsafe_codejail() @UseUnsafeCodejail()
class SchematicResponseTest(ResponseTest): class SchematicResponseTest(ResponseTest):
""" """
Class containing setup and tests for Schematic responsetype. Class containing setup and tests for Schematic responsetype.
@@ -2402,6 +2499,7 @@ class SchematicResponseTest(ResponseTest):
xml_factory_class = SchematicResponseXMLFactory xml_factory_class = SchematicResponseXMLFactory
def test_grade(self): def test_grade(self):
"""Test that SchematicResponse grades answers correctly using a script."""
# Most of the schematic-specific work is handled elsewhere # Most of the schematic-specific work is handled elsewhere
# (in client-side JavaScript) # (in client-side JavaScript)
# The <schematicresponse> is responsible only for executing the # The <schematicresponse> is responsible only for executing the
@@ -2426,10 +2524,9 @@ class SchematicResponseTest(ResponseTest):
assert correct_map.get_correctness("1_2_1") == "correct" assert correct_map.get_correctness("1_2_1") == "correct"
def test_check_function_randomization(self): def test_check_function_randomization(self):
"""Test that the check function correctly uses randomization from problem seed."""
# The check function should get a random seed from the problem. # The check function should get a random seed from the problem.
script = "correct = ['correct' if (submission[0]['num'] == {code}) else 'incorrect']".format( script = f"correct = ['correct' if (submission[0]['num'] == {self._get_random_number_code()}) else 'incorrect']"
code=self._get_random_number_code()
) # lint-amnesty, pylint: disable=line-too-long
problem = self.build_problem(answer=script) problem = self.build_problem(answer=script)
submission_dict = {"num": self._get_random_number_result(problem.seed)} submission_dict = {"num": self._get_random_number_result(problem.seed)}
@@ -2439,6 +2536,7 @@ class SchematicResponseTest(ResponseTest):
assert correct_map.get_correctness("1_2_1") == "correct" assert correct_map.get_correctness("1_2_1") == "correct"
def test_script_exception(self): def test_script_exception(self):
"""Test that exceptions in schematic scripts are properly raised."""
# Construct a script that will raise an exception # Construct a script that will raise an exception
script = "raise Exception('test')" script = "raise Exception('test')"
problem = self.build_problem(answer=script) problem = self.build_problem(answer=script)
@@ -2450,15 +2548,20 @@ class SchematicResponseTest(ResponseTest):
problem.grade_answers(input_dict) problem.grade_answers(input_dict)
class AnnotationResponseTest(ResponseTest): # lint-amnesty, pylint: disable=missing-class-docstring class AnnotationResponseTest(ResponseTest):
"""Unit tests for grading logic of AnnotationResponse"""
xml_factory_class = AnnotationResponseXMLFactory xml_factory_class = AnnotationResponseXMLFactory
def test_grade(self): def test_grade(self): # pylint: disable=too-many-locals
"""Test grading of AnnotationResponse with correct, partial, and incorrect options."""
(correct, partially, incorrect) = ("correct", "partially-correct", "incorrect") (correct, partially, incorrect) = ("correct", "partially-correct", "incorrect")
answer_id = "1_2_1" answer_id = "1_2_1"
options = (("x", correct), ("y", partially), ("z", incorrect)) options = (("x", correct), ("y", partially), ("z", incorrect))
make_answer = lambda option_ids: {answer_id: json.dumps({"options": option_ids})}
def make_answer(option_ids):
return {answer_id: json.dumps({"options": option_ids})}
tests = [ tests = [
{"correctness": correct, "points": 2, "answers": make_answer([0])}, {"correctness": correct, "points": 2, "answers": make_answer([0])},
@@ -2481,14 +2584,11 @@ class AnnotationResponseTest(ResponseTest): # lint-amnesty, pylint: disable=mis
actual_correctness = correct_map.get_correctness(answer_id) actual_correctness = correct_map.get_correctness(answer_id)
actual_points = correct_map.get_npoints(answer_id) actual_points = correct_map.get_npoints(answer_id)
assert expected_correctness == actual_correctness, "%s should be marked %s" % ( assert expected_correctness == actual_correctness, f"{answer_id} should be marked {expected_correctness}"
answer_id, assert expected_points == actual_points, f"{answer_id} should have {expected_points} points"
expected_correctness,
)
assert expected_points == actual_points, "%s should have %d points" % (answer_id, expected_points)
@use_unsafe_codejail() @UseUnsafeCodejail()
class ChoiceTextResponseTest(ResponseTest): class ChoiceTextResponseTest(ResponseTest):
""" """
Class containing setup and tests for ChoiceText responsetype. Class containing setup and tests for ChoiceText responsetype.
@@ -2615,8 +2715,8 @@ class ChoiceTextResponseTest(ResponseTest):
if choice: if choice:
# Radio/Checkbox inputs in choicetext problems follow # Radio/Checkbox inputs in choicetext problems follow
# a naming convention that gives them names ending with "bc" # a naming convention that gives them names ending with "bc"
choice_id = "1_2_1_choiceinput_{index}bc".format(index=index) choice_id = f"1_2_1_choiceinput_{index}bc"
choice_value = "choiceinput_{index}".format(index=index) choice_value = f"choiceinput_{index}"
answer_dict[choice_id] = choice_value answer_dict[choice_id] = choice_value
# Build the names for the numtolerance_inputs and add their answers # Build the names for the numtolerance_inputs and add their answers
# to `answer_dict`. # to `answer_dict`.
@@ -2624,7 +2724,7 @@ class ChoiceTextResponseTest(ResponseTest):
# In `answer_id` `index` represents the ordinality of the # In `answer_id` `index` represents the ordinality of the
# choice and `ind` represents the ordinality of the # choice and `ind` represents the ordinality of the
# numtolerance_input inside the parent choice. # numtolerance_input inside the parent choice.
answer_id = "1_2_1_choiceinput_{index}_numtolerance_input_{ind}".format(index=index, ind=ind) answer_id = f"1_2_1_choiceinput_{index}_numtolerance_input_{ind}"
answer_dict[answer_id] = answer answer_dict[answer_id] = answer
return answer_dict return answer_dict
@@ -2671,6 +2771,7 @@ class ChoiceTextResponseTest(ResponseTest):
) )
def test_staff_answer_error(self): def test_staff_answer_error(self):
"""Test that invalid staff answers raise a StudentInputError."""
broken_problem = self._make_problem( broken_problem = self._make_problem(
[("true", {"answer": "Platypus", "tolerance": "0"}), ("true", {"answer": "edX", "tolerance": "0"})], [("true", {"answer": "Platypus", "tolerance": "0"}), ("true", {"answer": "edX", "tolerance": "0"})],
"checkboxtextgroup", "checkboxtextgroup",
@@ -2697,7 +2798,7 @@ class ChoiceTextResponseTest(ResponseTest):
# Build the actual problem for the test. # Build the actual problem for the test.
test_problem = self._make_problem(test_choices, "radiotextgroup", test_script) test_problem = self._make_problem(test_choices, "radiotextgroup", test_script)
# Make sure the actual grade matches the expected grade. # Make sure the actual grade matches the expected grade.
self.assert_grade(test_problem, submission, correctness, msg="{0} should be {1}".format(name, correctness)) self.assert_grade(test_problem, submission, correctness, msg=f"{name} should be {correctness}")
def test_checkbox_grades(self): def test_checkbox_grades(self):
""" """
@@ -2744,4 +2845,4 @@ class ChoiceTextResponseTest(ResponseTest):
problem = problems[problem_name] problem = problems[problem_name]
# Make sure the actual grade matches the expected grade # Make sure the actual grade matches the expected grade
self.assert_grade(problem, submission, correctness, msg="{0} should be {1}".format(name, correctness)) self.assert_grade(problem, submission, correctness, msg=f"{name} should be {correctness}")

View File

@@ -1,29 +1,27 @@
""" """
Test for xmodule.capa.score_render module Test for xmodule.capa.score_render module
""" """
import json import json
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from django.http import Http404 from django.http import Http404
from opaque_keys.edx.keys import CourseKey, UsageKey
from common.djangoapps.student.models import AnonymousUserId from common.djangoapps.student.models import AnonymousUserId
from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.grades.signals.handlers import handle_external_grader_score from lms.djangoapps.grades.signals.handlers import handle_external_grader_score
from xmodule.capa.score_render import get_block_for_descriptor_without_access_check, load_xblock_for_external_grader
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
from xmodule.capa.score_render import (
load_xblock_for_external_grader,
get_block_for_descriptor_without_access_check
)
from opaque_keys.edx.keys import CourseKey, UsageKey
class ScoreEvent: class ScoreEvent: # pylint: disable=too-few-public-methods
""" """
Mock class to represent an external grader score event. Mock class to represent an external grader score event.
""" """
def __init__( def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
self, self,
score_msg=None, score_msg=None,
course_id=None, course_id=None,
@@ -31,7 +29,7 @@ class ScoreEvent:
module_id=None, module_id=None,
submission_id=None, submission_id=None,
queue_key=None, queue_key=None,
queue_name=None queue_name=None,
): ):
self.score_msg = score_msg self.score_msg = score_msg
self.course_id = course_id self.course_id = course_id
@@ -54,21 +52,15 @@ class TestScoreRender(ModuleStoreTestCase):
super().setUp() super().setUp()
self.course = CourseFactory.create() self.course = CourseFactory.create()
self.user = UserFactory.create() self.user = UserFactory.create()
self.problem = BlockFactory.create( self.problem = BlockFactory.create(category="problem", parent=self.course, display_name="Test Problem")
category='problem', self.anonymous_user_id = "12345"
parent=self.course,
display_name='Test Problem'
)
self.anonymous_user_id = '12345'
# Create AnonymousUserId instance # Create AnonymousUserId instance
AnonymousUserId.objects.create( AnonymousUserId.objects.create(
user=self.user, user=self.user, anonymous_user_id=self.anonymous_user_id, course_id=self.course.id
anonymous_user_id=self.anonymous_user_id,
course_id=self.course.id
) )
@patch('xmodule.capa.score_render.modulestore') @patch("xmodule.capa.score_render.modulestore")
@patch('xmodule.capa.score_render.FieldDataCache') @patch("xmodule.capa.score_render.FieldDataCache")
def test_load_xblock_for_external_grader(self, mock_field_data_cache, mock_modulestore): def test_load_xblock_for_external_grader(self, mock_field_data_cache, mock_modulestore):
""" """
Test loading an XBlock for external grading. Test loading an XBlock for external grading.
@@ -78,15 +70,12 @@ class TestScoreRender(ModuleStoreTestCase):
mock_modulestore.return_value.get_item.return_value = MagicMock() mock_modulestore.return_value.get_item.return_value = MagicMock()
mock_field_data_cache.cache_for_block_descendents.return_value = MagicMock() mock_field_data_cache.cache_for_block_descendents.return_value = MagicMock()
with patch('xmodule.capa.score_render.get_block_for_descriptor_without_access_check') as mock_get_block: with patch("xmodule.capa.score_render.get_block_for_descriptor_without_access_check") as mock_get_block:
mock_get_block.return_value = MagicMock() mock_get_block.return_value = MagicMock()
# Call the function # Call the function
result = load_xblock_for_external_grader( result = load_xblock_for_external_grader(
self.anonymous_user_id, self.anonymous_user_id, str(self.course.id), str(self.problem.location), self.course
str(self.course.id),
str(self.problem.location),
self.course
) )
# Assertions # Assertions
@@ -95,8 +84,8 @@ class TestScoreRender(ModuleStoreTestCase):
mock_field_data_cache.cache_for_block_descendents.assert_called_once() mock_field_data_cache.cache_for_block_descendents.assert_called_once()
mock_get_block.assert_called_once() mock_get_block.assert_called_once()
@patch('xmodule.capa.score_render.modulestore') @patch("xmodule.capa.score_render.modulestore")
@patch('xmodule.capa.score_render.AnonymousUserId.objects.get') @patch("xmodule.capa.score_render.AnonymousUserId.objects.get")
def test_load_xblock_for_external_grader_missing_block(self, mock_anon_user, mock_modulestore): def test_load_xblock_for_external_grader_missing_block(self, mock_anon_user, mock_modulestore):
""" """
Test that Http404 is raised when the block is not found. Test that Http404 is raised when the block is not found.
@@ -109,13 +98,10 @@ class TestScoreRender(ModuleStoreTestCase):
# Test that Http404 is raised # Test that Http404 is raised
with self.assertRaises(Http404): with self.assertRaises(Http404):
load_xblock_for_external_grader( load_xblock_for_external_grader(
self.anonymous_user_id, self.anonymous_user_id, str(self.course.id), str(self.problem.location), self.course
str(self.course.id),
str(self.problem.location),
self.course
) )
@patch('xmodule.capa.score_render.prepare_runtime_for_user') @patch("xmodule.capa.score_render.prepare_runtime_for_user")
def test_get_block_for_descriptor_without_access_check(self, mock_prepare_runtime): def test_get_block_for_descriptor_without_access_check(self, mock_prepare_runtime):
""" """
Test initializing an XBlock instance without access checks. Test initializing an XBlock instance without access checks.
@@ -127,11 +113,7 @@ class TestScoreRender(ModuleStoreTestCase):
# Call the function # Call the function
result = get_block_for_descriptor_without_access_check( result = get_block_for_descriptor_without_access_check(
self.user, self.user, block, student_data, self.course.id, self.course
block,
student_data,
self.course.id,
self.course
) )
# Assertions # Assertions
@@ -139,8 +121,8 @@ class TestScoreRender(ModuleStoreTestCase):
mock_prepare_runtime.assert_called_once() mock_prepare_runtime.assert_called_once()
block.bind_for_student.assert_called_once() block.bind_for_student.assert_called_once()
@patch('xmodule.capa.score_render.modulestore') @patch("xmodule.capa.score_render.modulestore")
@patch('xmodule.capa.score_render.load_xblock_for_external_grader') @patch("xmodule.capa.score_render.load_xblock_for_external_grader")
def test_handle_external_grader_score_json_string(self, mock_load_xblock, mock_modulestore): def test_handle_external_grader_score_json_string(self, mock_load_xblock, mock_modulestore):
""" """
Test handling an external grader score with a JSON string message. Test handling an external grader score with a JSON string message.
@@ -156,9 +138,9 @@ class TestScoreRender(ModuleStoreTestCase):
course_id=str(self.course.id), course_id=str(self.course.id),
user_id=self.anonymous_user_id, user_id=self.anonymous_user_id,
module_id=str(self.problem.location), module_id=str(self.problem.location),
submission_id='sub_123', submission_id="sub_123",
queue_key='key_456', queue_key="key_456",
queue_name='test_queue' queue_name="test_queue",
) )
# Call the handler # Call the handler
@@ -174,18 +156,18 @@ class TestScoreRender(ModuleStoreTestCase):
self.assertIsInstance(call_args[2], UsageKey) self.assertIsInstance(call_args[2], UsageKey)
self.assertEqual(str(call_args[2]), score.module_id) self.assertEqual(str(call_args[2]), score.module_id)
self.assertIn('course', call_kwargs) self.assertIn("course", call_kwargs)
mock_instance.handle_ajax.assert_called_once() mock_instance.handle_ajax.assert_called_once()
ajax_args, _ = mock_instance.handle_ajax.call_args ajax_args, _ = mock_instance.handle_ajax.call_args
self.assertEqual(ajax_args[0], 'score_update') self.assertEqual(ajax_args[0], "score_update")
self.assertIn('xqueue_header', ajax_args[1]) self.assertIn("xqueue_header", ajax_args[1])
self.assertIn('xqueue_body', ajax_args[1]) self.assertIn("xqueue_body", ajax_args[1])
self.assertIn('queuekey', ajax_args[1]) self.assertIn("queuekey", ajax_args[1])
mock_instance.save.assert_called_once() mock_instance.save.assert_called_once()
@patch('xmodule.capa.score_render.modulestore') @patch("xmodule.capa.score_render.modulestore")
@patch('xmodule.capa.score_render.load_xblock_for_external_grader') @patch("xmodule.capa.score_render.load_xblock_for_external_grader")
def test_handle_external_grader_score_plain_text(self, mock_load_xblock, mock_modulestore): def test_handle_external_grader_score_plain_text(self, mock_load_xblock, mock_modulestore):
""" """
Test handling an external grader score with a plain text message. Test handling an external grader score with a plain text message.
@@ -202,9 +184,9 @@ class TestScoreRender(ModuleStoreTestCase):
course_id=str(self.course.id), course_id=str(self.course.id),
user_id=self.anonymous_user_id, user_id=self.anonymous_user_id,
module_id=str(self.problem.location), module_id=str(self.problem.location),
submission_id='sub_123', submission_id="sub_123",
queue_key='key_456', queue_key="key_456",
queue_name='test_queue' queue_name="test_queue",
) )
# json.loads must fail BEFORE anything else runs # json.loads must fail BEFORE anything else runs
@@ -218,8 +200,8 @@ class TestScoreRender(ModuleStoreTestCase):
mock_instance.save.assert_not_called() mock_instance.save.assert_not_called()
@patch('xmodule.capa.score_render.modulestore') @patch("xmodule.capa.score_render.modulestore")
@patch('xmodule.capa.score_render.load_xblock_for_external_grader') @patch("xmodule.capa.score_render.load_xblock_for_external_grader")
def test_handle_external_grader_score_exception(self, mock_load_xblock, mock_modulestore): def test_handle_external_grader_score_exception(self, mock_load_xblock, mock_modulestore):
""" """
Test handling an exception during score processing. Test handling an exception during score processing.
@@ -234,21 +216,22 @@ class TestScoreRender(ModuleStoreTestCase):
course_id=str(self.course.id), course_id=str(self.course.id),
user_id=self.anonymous_user_id, user_id=self.anonymous_user_id,
module_id=str(self.problem.location), module_id=str(self.problem.location),
submission_id='sub_123', submission_id="sub_123",
queue_key='key_456', queue_key="key_456",
queue_name='test_queue' queue_name="test_queue",
) )
# Call the handler and expect exception to be raised # Call the handler and expect exception to be raised
with self.assertRaises(Exception): with self.assertRaises(Exception):
handle_external_grader_score(None, None, score) handle_external_grader_score(None, None, score)
@patch('xmodule.capa.score_render.AnonymousUserId.objects.get') @patch("xmodule.capa.score_render.AnonymousUserId.objects.get")
@patch('xmodule.capa.score_render.modulestore') @patch("xmodule.capa.score_render.modulestore")
@patch('xmodule.capa.score_render.FieldDataCache') @patch("xmodule.capa.score_render.FieldDataCache")
@patch('xmodule.capa.score_render.get_block_for_descriptor_without_access_check') @patch("xmodule.capa.score_render.get_block_for_descriptor_without_access_check")
def test_load_xblock_for_external_grader_none_instance(self, mock_get_block, mock_field_data_cache, def test_load_xblock_for_external_grader_none_instance(
mock_modulestore, mock_anon_user): self, mock_get_block, mock_field_data_cache, mock_modulestore, mock_anon_user
):
""" """
Test that Http404 is raised when get_block_for_descriptor_without_access_check returns None. Test that Http404 is raised when get_block_for_descriptor_without_access_check returns None.
""" """
@@ -262,11 +245,7 @@ class TestScoreRender(ModuleStoreTestCase):
# Test that Http404 is raised # Test that Http404 is raised
with self.assertRaises(Http404) as context: with self.assertRaises(Http404) as context:
load_xblock_for_external_grader( load_xblock_for_external_grader(self.anonymous_user_id, str(self.course.id), str(self.problem.location))
self.anonymous_user_id,
str(self.course.id),
str(self.problem.location)
)
expected_msg = f"Could not bind XBlock instance for usage key: {str(self.problem.location)}" expected_msg = f"Could not bind XBlock instance for usage key: {str(self.problem.location)}"
self.assertEqual(str(context.exception), expected_msg) self.assertEqual(str(context.exception), expected_msg)
@@ -290,26 +269,20 @@ class TestScoreRenderIntegration(ModuleStoreTestCase):
super().setUp() super().setUp()
self.course = CourseFactory.create() self.course = CourseFactory.create()
self.user = UserFactory.create() self.user = UserFactory.create()
self.problem = BlockFactory.create( self.problem = BlockFactory.create(category="problem", parent=self.course, display_name="Test Problem")
category='problem', self.anonymous_user_id = "12345"
parent=self.course,
display_name='Test Problem'
)
self.anonymous_user_id = '12345'
# Create AnonymousUserId instance # Create AnonymousUserId instance
AnonymousUserId.objects.create( AnonymousUserId.objects.create(
user=self.user, user=self.user, anonymous_user_id=self.anonymous_user_id, course_id=self.course.id
anonymous_user_id=self.anonymous_user_id,
course_id=self.course.id
) )
@patch('xmodule.capa.score_render.modulestore') @patch("xmodule.capa.score_render.modulestore")
def test_end_to_end_grading_flow(self, mock_modulestore): def test_end_to_end_grading_flow(self, mock_modulestore): # pylint: disable=unused-argument
""" """
Test the end-to-end flow from receiving a score event to updating the grade. Test the end-to-end flow from receiving a score event to updating the grade.
""" """
# Mock the internal call to load_xblock_for_external_grader # Mock the internal call to load_xblock_for_external_grader
with patch('xmodule.capa.score_render.load_xblock_for_external_grader') as mock_load_xblock: with patch("xmodule.capa.score_render.load_xblock_for_external_grader") as mock_load_xblock:
# Setup the mock XBlock instance # Setup the mock XBlock instance
mock_instance = MagicMock() mock_instance = MagicMock()
mock_load_xblock.return_value = mock_instance mock_load_xblock.return_value = mock_instance
@@ -320,9 +293,9 @@ class TestScoreRenderIntegration(ModuleStoreTestCase):
course_id=str(self.course.id), course_id=str(self.course.id),
user_id=self.anonymous_user_id, user_id=self.anonymous_user_id,
module_id=str(self.problem.location), module_id=str(self.problem.location),
submission_id='sub_123', submission_id="sub_123",
queue_key='key_456', queue_key="key_456",
queue_name='test_queue' queue_name="test_queue",
) )
# Call the handler # Call the handler
@@ -335,19 +308,19 @@ class TestScoreRenderIntegration(ModuleStoreTestCase):
# Verify the data structure passed to handle_ajax # Verify the data structure passed to handle_ajax
handle_ajax_args = mock_instance.handle_ajax.call_args[0] handle_ajax_args = mock_instance.handle_ajax.call_args[0]
self.assertEqual(handle_ajax_args[0], 'score_update') self.assertEqual(handle_ajax_args[0], "score_update")
data = handle_ajax_args[1] data = handle_ajax_args[1]
self.assertIn('xqueue_header', data) self.assertIn("xqueue_header", data)
self.assertIn('xqueue_body', data) self.assertIn("xqueue_body", data)
self.assertIn('queuekey', data) self.assertIn("queuekey", data)
header = json.loads(data['xqueue_header']) header = json.loads(data["xqueue_header"])
self.assertEqual(header['lms_key'], 'sub_123') self.assertEqual(header["lms_key"], "sub_123")
self.assertEqual(header['queue_name'], 'test_queue') self.assertEqual(header["queue_name"], "test_queue")
# Verify the body is the correct JSON # Verify the body is the correct JSON
body = json.loads(data['xqueue_body']) body = json.loads(data["xqueue_body"])
self.assertEqual(body['score'], 1) self.assertEqual(body["score"], 1)
self.assertEqual(body['max_score'], 1) self.assertEqual(body["max_score"], 1)
self.assertTrue(body['correct']) self.assertTrue(body["correct"])

View File

@@ -11,10 +11,11 @@ class CapaShuffleTest(unittest.TestCase):
"""Capa problem tests for shuffling and choice-name masking.""" """Capa problem tests for shuffling and choice-name masking."""
def setUp(self): def setUp(self):
super(CapaShuffleTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.system = mock_capa_system() self.system = mock_capa_system()
def test_shuffle_4_choices(self): def test_shuffle_4_choices(self):
"""Verify shuffling and name-masking for four choices."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -41,6 +42,7 @@ class CapaShuffleTest(unittest.TestCase):
assert the_html == problem.get_html(), "should be able to call get_html() twice" assert the_html == problem.get_html(), "should be able to call get_html() twice"
def test_shuffle_custom_names(self): def test_shuffle_custom_names(self):
"""Verify shuffling preserves custom choice names."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -64,6 +66,7 @@ class CapaShuffleTest(unittest.TestCase):
assert response.unmask_order() == ["choice_0", "choice_aaa", "choice_1", "choice_ddd"] assert response.unmask_order() == ["choice_0", "choice_aaa", "choice_1", "choice_ddd"]
def test_shuffle_different_seed(self): def test_shuffle_different_seed(self):
"""Check that shuffling produces different order with a different seed."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -83,6 +86,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'Donut'.*'Apple'.*'Banana'.*'Chocolate'.*\].*</div>") self.assertRegex(the_html, r"<div>.*\[.*'Donut'.*'Apple'.*'Banana'.*'Chocolate'.*\].*</div>")
def test_shuffle_1_choice(self): def test_shuffle_1_choice(self):
"""Verify shuffling behavior with only one choice."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -103,6 +107,7 @@ class CapaShuffleTest(unittest.TestCase):
assert response.unmask_order() == ["choice_0"] assert response.unmask_order() == ["choice_0"]
def test_shuffle_6_choices(self): def test_shuffle_6_choices(self):
"""Test shuffling with six choices to ensure proper randomization."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -124,9 +129,10 @@ class CapaShuffleTest(unittest.TestCase):
the_html = problem.get_html() the_html = problem.get_html()
self.assertRegex( self.assertRegex(
the_html, r"<div>.*\[.*'Chocolate'.*'Eggplant'.*'Apple'.*'Banana'.*'Zonut'.*'Filet Mignon'.*\].*</div>" the_html, r"<div>.*\[.*'Chocolate'.*'Eggplant'.*'Apple'.*'Banana'.*'Zonut'.*'Filet Mignon'.*\].*</div>"
) # lint-amnesty, pylint: disable=line-too-long )
def test_shuffle_false(self): def test_shuffle_false(self):
"""Verify that shuffle='false' keeps original order."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -149,6 +155,7 @@ class CapaShuffleTest(unittest.TestCase):
assert not response.has_shuffle() assert not response.has_shuffle()
def test_shuffle_fixed_head_end(self): def test_shuffle_fixed_head_end(self):
"""Ensure choices fixed at the head remain in place during shuffle."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -171,6 +178,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*\].*</div>") self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*\].*</div>")
def test_shuffle_fixed_tail_end(self): def test_shuffle_fixed_tail_end(self):
"""Ensure choices fixed at the tail remain in place during shuffle."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -193,6 +201,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'B'.*'A'.*'C'.*'D'.*'Alpha'.*'Beta'.*\].*</div>") self.assertRegex(the_html, r"<div>.*\[.*'B'.*'A'.*'C'.*'D'.*'Alpha'.*'Beta'.*\].*</div>")
def test_shuffle_fixed_both_ends(self): def test_shuffle_fixed_both_ends(self):
"""Ensure choices fixed at both ends remain in place during shuffle."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -217,6 +226,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*'Psi'.*'Omega'.*\].*</div>") self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*'Psi'.*'Omega'.*\].*</div>")
def test_shuffle_fixed_both_ends_thin(self): def test_shuffle_fixed_both_ends_thin(self):
"""Test shuffling with only three choices, two of which are fixed."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -235,6 +245,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'A'.*'Omega'.*\].*</div>") self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'A'.*'Omega'.*\].*</div>")
def test_shuffle_fixed_all(self): def test_shuffle_fixed_all(self):
"""Verify that all fixed choices remain in order with no shuffle."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -274,6 +285,7 @@ class CapaShuffleTest(unittest.TestCase):
self.assertRegex(the_html, r"<div>.*\[.*'A'.*'Mid'.*'Mid'.*'C'.*'D'.*\].*</div>") self.assertRegex(the_html, r"<div>.*\[.*'A'.*'Mid'.*'Mid'.*'C'.*'D'.*\].*</div>")
def test_multiple_shuffle_responses(self): def test_multiple_shuffle_responses(self):
"""Check shuffling for multiple choice groups in the same problem."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -301,7 +313,6 @@ class CapaShuffleTest(unittest.TestCase):
orig_html = problem.get_html() orig_html = problem.get_html()
assert orig_html == problem.get_html(), "should be able to call get_html() twice" assert orig_html == problem.get_html(), "should be able to call get_html() twice"
html = orig_html.replace("\n", " ") # avoid headaches with .* matching html = orig_html.replace("\n", " ") # avoid headaches with .* matching
print(html)
self.assertRegex( self.assertRegex(
html, html,
r"<div>.*\[.*'Banana'.*'Apple'.*'Chocolate'.*'Donut'.*\].*</div>.*" r"<div>.*\[.*'Banana'.*'Apple'.*'Chocolate'.*'Donut'.*\].*</div>.*"

View File

@@ -15,10 +15,11 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
""" """
def setUp(self): def setUp(self):
super(CapaTargetedFeedbackTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.system = mock_capa_system() self.system = mock_capa_system()
def test_no_targeted_feedback(self): def test_no_targeted_feedback(self):
"""Verify that no targeted feedback is shown when not finished."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -84,6 +85,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertRegex(without_new_lines, r"feedback1|feedback2|feedback3|feedbackC") self.assertRegex(without_new_lines, r"feedback1|feedback2|feedback3|feedbackC")
def test_targeted_feedback_not_finished(self): def test_targeted_feedback_not_finished(self):
"""Check HTML output when targeted feedback is incomplete."""
problem = new_loncapa_problem(load_fixture("targeted_feedback.xml")) problem = new_loncapa_problem(load_fixture("targeted_feedback.xml"))
the_html = problem.get_html() the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "") without_new_lines = the_html.replace("\n", "")
@@ -93,16 +95,20 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
assert the_html == problem.get_html(), "Should be able to call get_html() twice" assert the_html == problem.get_html(), "Should be able to call get_html() twice"
def test_targeted_feedback_student_answer1(self): def test_targeted_feedback_student_answer1(self):
"""Test targeted feedback rendering for a specific wrong student answer."""
problem = new_loncapa_problem(load_fixture("targeted_feedback.xml")) problem = new_loncapa_problem(load_fixture("targeted_feedback.xml"))
problem.done = True problem.done = True
problem.student_answers = {"1_2_1": "choice_3"} problem.student_answers = {"1_2_1": "choice_3"}
the_html = problem.get_html() the_html = problem.get_html()
without_new_lines = the_html.replace("\\n", "").replace("\n", "") without_new_lines = the_html.replace("\\n", "").replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex( self.assertRegex(
without_new_lines, without_new_lines,
r"<targetedfeedback explanation-id=\"feedback3\" role=\"group\" aria-describedby=\"1_2_1-legend\">\s*<span class=\"sr\">Incorrect</span>.*3rd WRONG solution", (
r"<targetedfeedback explanation-id=\"feedback3\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">\s*"
r"<span class=\"sr\">Incorrect</span>.*3rd WRONG solution"
),
) )
self.assertNotRegex(without_new_lines, r"feedback1|feedback2|feedbackC") self.assertNotRegex(without_new_lines, r"feedback1|feedback2|feedbackC")
# Check that calling it multiple times yields the same thing # Check that calling it multiple times yields the same thing
@@ -110,16 +116,20 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
assert the_html == the_html2 assert the_html == the_html2
def test_targeted_feedback_student_answer2(self): def test_targeted_feedback_student_answer2(self):
"""Test targeted feedback rendering for another wrong student answer."""
problem = new_loncapa_problem(load_fixture("targeted_feedback.xml")) problem = new_loncapa_problem(load_fixture("targeted_feedback.xml"))
problem.done = True problem.done = True
problem.student_answers = {"1_2_1": "choice_0"} problem.student_answers = {"1_2_1": "choice_0"}
the_html = problem.get_html() the_html = problem.get_html()
without_new_lines = the_html.replace("\\n", "").replace("\n", "") without_new_lines = the_html.replace("\\n", "").replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex( self.assertRegex(
without_new_lines, without_new_lines,
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" aria-describedby=\"1_2_1-legend\">\s*<span class=\"sr\">Incorrect</span>.*1st WRONG solution", (
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">\s*"
r"<span class=\"sr\">Incorrect</span>.*1st WRONG solution"
),
) )
self.assertRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>") self.assertRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegex(without_new_lines, r"feedback2|feedback3|feedbackC") self.assertNotRegex(without_new_lines, r"feedback2|feedback3|feedbackC")
@@ -132,10 +142,13 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
the_html = problem.get_html() the_html = problem.get_html()
without_new_lines = the_html.replace("\\n", "").replace("\n", "") without_new_lines = the_html.replace("\\n", "").replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex( self.assertRegex(
without_new_lines, without_new_lines,
r"<targetedfeedback explanation-id=\"feedbackC\" role=\"group\" aria-describedby=\"1_2_1-legend\">\s*<span class=\"sr\">Correct</span>.*Feedback on your correct solution...", (
r"<targetedfeedback explanation-id=\"feedbackC\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">\s*"
r"<span class=\"sr\">Correct</span>.*Feedback on your correct solution..."
),
) )
self.assertNotRegex(without_new_lines, r"feedback1|feedback2|feedback3") self.assertNotRegex(without_new_lines, r"feedback1|feedback2|feedback3")
@@ -211,6 +224,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertRegex(the_html, r"<targetedfeedbackset>\s*</targetedfeedbackset>") self.assertRegex(the_html, r"<targetedfeedbackset>\s*</targetedfeedbackset>")
def test_targeted_feedback_no_solution_element(self): def test_targeted_feedback_no_solution_element(self):
"""Check behavior when the solution element is missing."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -245,6 +259,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertRegex(without_new_lines, r"<div>.*<targetedfeedbackset>.*</targetedfeedbackset>\s*</div>") self.assertRegex(without_new_lines, r"<div>.*<targetedfeedbackset>.*</targetedfeedbackset>\s*</div>")
def test_targeted_feedback_show_solution_explanation(self): def test_targeted_feedback_show_solution_explanation(self):
"""Verify that solution explanation is shown when configured to always show."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -307,10 +322,12 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
the_html = problem.get_html() the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "") without_new_lines = the_html.replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex( self.assertRegex(
without_new_lines, without_new_lines,
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" aria-describedby=\"1_2_1-legend\">.*1st WRONG solution", (
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">.*1st WRONG solution"
),
) )
self.assertRegex(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation") self.assertRegex(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation")
self.assertNotRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>") self.assertNotRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
@@ -320,6 +337,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
assert the_html == the_html2 assert the_html == the_html2
def test_targeted_feedback_no_show_solution_explanation(self): def test_targeted_feedback_no_show_solution_explanation(self):
"""Verify that solution explanation is hidden when not configured to show."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -382,16 +400,19 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
the_html = problem.get_html() the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "") without_new_lines = the_html.replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex( self.assertRegex(
without_new_lines, without_new_lines,
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" aria-describedby=\"1_2_1-legend\">.*1st WRONG solution", (
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">.*1st WRONG solution"
),
) )
self.assertNotRegex(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation") self.assertNotRegex(without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC\".*solution explanation")
self.assertRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>") self.assertRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
self.assertNotRegex(without_new_lines, r"feedback2|feedback3|feedbackC") self.assertNotRegex(without_new_lines, r"feedback2|feedback3|feedbackC")
def test_targeted_feedback_with_solutionset_explanation(self): def test_targeted_feedback_with_solutionset_explanation(self):
"""Test targeted feedback when multiple correct solutions exist."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -464,10 +485,12 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
the_html = problem.get_html() the_html = problem.get_html()
without_new_lines = the_html.replace("\n", "") without_new_lines = the_html.replace("\n", "")
# pylint: disable=line-too-long
self.assertRegex( self.assertRegex(
without_new_lines, without_new_lines,
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" aria-describedby=\"1_2_1-legend\">.*1st WRONG solution", (
r"<targetedfeedback explanation-id=\"feedback1\" role=\"group\" "
r"aria-describedby=\"1_2_1-legend\">.*1st WRONG solution"
),
) )
self.assertRegex( self.assertRegex(
without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC2\".*other solution explanation" without_new_lines, r"<targetedfeedback explanation-id=\"feedbackC2\".*other solution explanation"
@@ -476,6 +499,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertNotRegex(without_new_lines, r"feedback2|feedback3") self.assertNotRegex(without_new_lines, r"feedback2|feedback3")
def test_targeted_feedback_no_feedback_for_selected_choice1(self): def test_targeted_feedback_no_feedback_for_selected_choice1(self):
"""Check behavior when selected choice has no feedback but correct solution should show."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -541,6 +565,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertNotRegex(without_new_lines, r"feedback1|feedback3") self.assertNotRegex(without_new_lines, r"feedback1|feedback3")
def test_targeted_feedback_no_feedback_for_selected_choice2(self): def test_targeted_feedback_no_feedback_for_selected_choice2(self):
"""Check behavior when selected choice has no feedback and no solution explanation is shown."""
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
""" """
<problem> <problem>
@@ -605,6 +630,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
self.assertNotRegex(without_new_lines, r"feedback1|feedback3|feedbackC") self.assertNotRegex(without_new_lines, r"feedback1|feedback3|feedbackC")
def test_targeted_feedback_multiple_not_answered(self): def test_targeted_feedback_multiple_not_answered(self):
"""Ensure empty targeted feedback is rendered for unanswered multiple questions."""
# Not answered -> empty targeted feedback # Not answered -> empty targeted feedback
problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml")) problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml"))
the_html = problem.get_html() the_html = problem.get_html()
@@ -616,6 +642,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
) )
def test_targeted_feedback_multiple_answer_1(self): def test_targeted_feedback_multiple_answer_1(self):
"""Test feedback rendering for the first question answered in a multi-question problem."""
problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml")) problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml"))
problem.done = True problem.done = True
problem.student_answers = {"1_2_1": "choice_0"} # feedback1 problem.student_answers = {"1_2_1": "choice_0"} # feedback1
@@ -629,6 +656,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
) )
def test_targeted_feedback_multiple_answer_2(self): def test_targeted_feedback_multiple_answer_2(self):
"""Test feedback rendering for multiple answered questions in a multi-question problem."""
problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml")) problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml"))
problem.done = True problem.done = True
problem.student_answers = {"1_2_1": "choice_0", "1_3_1": "choice_2"} # Q1 wrong, Q2 correct problem.student_answers = {"1_2_1": "choice_0", "1_3_1": "choice_2"} # Q1 wrong, Q2 correct

View File

@@ -26,10 +26,11 @@ class UtilTest(unittest.TestCase):
"""Tests for util""" """Tests for util"""
def setUp(self): def setUp(self):
super(UtilTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments super().setUp()
self.system = mock_capa_system() self.system = mock_capa_system()
def test_compare_with_tolerance(self): # lint-amnesty, pylint: disable=too-many-statements def test_compare_with_tolerance(self): # pylint: disable=too-many-statements
"""Test numeric comparison with relative and absolute tolerances."""
# Test default tolerance '0.001%' (it is relative) # Test default tolerance '0.001%' (it is relative)
result = compare_with_tolerance(100.0, 100.0) result = compare_with_tolerance(100.0, 100.0)
assert result assert result
@@ -67,7 +68,7 @@ class UtilTest(unittest.TestCase):
assert result assert result
result = compare_with_tolerance(112.0, 100.0, 0.1, True) result = compare_with_tolerance(112.0, 100.0, 0.1, True)
assert not result assert not result
##### Infinite values ##### # Infinite values #
infinity = float("Inf") infinity = float("Inf")
# Test relative tolerance (float) # Test relative tolerance (float)
result = compare_with_tolerance(infinity, 100.0, 1.0, True) result = compare_with_tolerance(infinity, 100.0, 1.0, True)
@@ -127,11 +128,11 @@ class UtilTest(unittest.TestCase):
""" """
allowed_tags = ["div", "p", "audio", "pre", "span"] allowed_tags = ["div", "p", "audio", "pre", "span"]
for tag in allowed_tags: for tag in allowed_tags:
queue_msg = "<{0}>Test message</{0}>".format(tag) queue_msg = f"<{tag}>Test message</{tag}>"
assert sanitize_html(queue_msg) == queue_msg assert sanitize_html(queue_msg) == queue_msg
not_allowed_tag = "script" not_allowed_tag = "script"
queue_msg = "<{0}>Test message</{0}>".format(not_allowed_tag) queue_msg = f"<{not_allowed_tag}>Test message</{not_allowed_tag}>"
expected = "" expected = ""
assert sanitize_html(queue_msg) == expected assert sanitize_html(queue_msg) == expected
@@ -170,7 +171,7 @@ class UtilTest(unittest.TestCase):
assert expected_text == contextual_text assert expected_text == contextual_text
class use_unsafe_codejail(TestContextDecorator): class UseUnsafeCodejail(TestContextDecorator):
""" """
Tell codejail to run in unsafe mode for the scope of the decorator. Tell codejail to run in unsafe mode for the scope of the decorator.
Use this as a decorator on Django TestCase classes or methods. Use this as a decorator on Django TestCase classes or methods.
@@ -188,8 +189,10 @@ class use_unsafe_codejail(TestContextDecorator):
super().__init__() super().__init__()
def enable(self): def enable(self):
"""Enable unsafe mode for codejail within the test scope."""
self.old_be_unsafe = codejail.safe_exec.ALWAYS_BE_UNSAFE self.old_be_unsafe = codejail.safe_exec.ALWAYS_BE_UNSAFE
codejail.safe_exec.ALWAYS_BE_UNSAFE = True codejail.safe_exec.ALWAYS_BE_UNSAFE = True
def disable(self): def disable(self):
"""Restore the previous codejail unsafe mode state."""
codejail.safe_exec.ALWAYS_BE_UNSAFE = self.old_be_unsafe codejail.safe_exec.ALWAYS_BE_UNSAFE = self.old_be_unsafe

View File

@@ -40,7 +40,7 @@ class XQueueServiceTest(TestCase):
assert self.service.interface.session.auth.password == "agarwal" assert self.service.interface.session.auth.password == "agarwal"
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True) @patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True)
def test_construct_callback_with_flag_enabled(self, mock_flag): def test_construct_callback_with_flag_enabled(self, mock_flag): # pylint: disable=unused-argument
"""Test construct_callback when the waffle flag is enabled.""" """Test construct_callback when the waffle flag is enabled."""
usage_id = self.block.scope_ids.usage_id usage_id = self.block.scope_ids.usage_id
course_id = str(usage_id.course_key) course_id = str(usage_id.course_key)
@@ -56,7 +56,7 @@ class XQueueServiceTest(TestCase):
assert self.service.construct_callback() == f"{custom_callback_url}/{callback_url}/score_update" assert self.service.construct_callback() == f"{custom_callback_url}/{callback_url}/score_update"
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False) @patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False)
def test_construct_callback_with_flag_disabled(self, mock_flag): def test_construct_callback_with_flag_disabled(self, mock_flag): # pylint: disable=unused-argument
"""Test construct_callback when the waffle flag is disabled.""" """Test construct_callback when the waffle flag is disabled."""
usage_id = self.block.scope_ids.usage_id usage_id = self.block.scope_ids.usage_id
callback_url = f"courses/{usage_id.context_key}/xqueue/user1/{usage_id}" callback_url = f"courses/{usage_id.context_key}/xqueue/user1/{usage_id}"
@@ -83,28 +83,32 @@ class XQueueServiceTest(TestCase):
@pytest.mark.django_db @pytest.mark.django_db
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True) @patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True)
@patch("xmodule.capa.xqueue_submission.XQueueInterfaceSubmission.send_to_submission") @patch("xmodule.capa.xqueue_submission.XQueueInterfaceSubmission.send_to_submission")
def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag): def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag): # pylint: disable=unused-argument
"""Test send_to_queue when the waffle flag is enabled.""" """Test send_to_queue when the waffle flag is enabled."""
url = "http://example.com/xqueue" url = "http://example.com/xqueue"
django_auth = {"username": "user", "password": "pass"} django_auth = {"username": "user", "password": "pass"}
block = Mock() # Mock block for the constructor block = Mock() # Mock block for the constructor
xqueue_interface = XQueueInterface(url, django_auth, block=block) xqueue_interface = XQueueInterface(url, django_auth, block=block)
header = json.dumps({ header = json.dumps(
"lms_callback_url": ( {
"http://example.com/courses/course-v1:test_org+test_course+test_run/" "lms_callback_url": (
"xqueue/block@item_id/type@problem" "http://example.com/courses/course-v1:test_org+test_course+test_run/"
), "xqueue/block@item_id/type@problem"
"lms_key": "default" ),
}) "lms_key": "default",
body = json.dumps({ }
"student_info": json.dumps({"anonymous_student_id": "student_id"}), )
"student_response": "student_answer", body = json.dumps(
}) {
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
"student_response": "student_answer",
}
)
files_to_upload = None files_to_upload = None
mock_send_to_submission.return_value = {"submission": "mock_submission"} mock_send_to_submission.return_value = {"submission": "mock_submission"}
error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload) error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload) # pylint: disable=unused-variable
mock_send_to_submission.assert_called_once_with(header, body, "default", {}) mock_send_to_submission.assert_called_once_with(header, body, "default", {})
@@ -112,28 +116,32 @@ def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag):
@pytest.mark.django_db @pytest.mark.django_db
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False) @patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False)
@patch("xmodule.capa.xqueue_interface.XQueueInterface._http_post") @patch("xmodule.capa.xqueue_interface.XQueueInterface._http_post")
def test_send_to_queue_with_flag_disabled(mock_http_post, mock_flag): def test_send_to_queue_with_flag_disabled(mock_http_post, mock_flag): # pylint: disable=unused-argument
"""Test send_to_queue when the waffle flag is disabled.""" """Test send_to_queue when the waffle flag is disabled."""
url = "http://example.com/xqueue" url = "http://example.com/xqueue"
django_auth = {"username": "user", "password": "pass"} django_auth = {"username": "user", "password": "pass"}
block = Mock() # Mock block for the constructor block = Mock() # Mock block for the constructor
xqueue_interface = XQueueInterface(url, django_auth, block=block) xqueue_interface = XQueueInterface(url, django_auth, block=block)
header = json.dumps({ header = json.dumps(
"lms_callback_url": ( {
"http://example.com/courses/course-v1:test_org+test_course+test_run/" "lms_callback_url": (
"xqueue/block@item_id/type@problem" "http://example.com/courses/course-v1:test_org+test_course+test_run/"
), "xqueue/block@item_id/type@problem"
"lms_key": "default" ),
}) "lms_key": "default",
body = json.dumps({ }
"student_info": json.dumps({"anonymous_student_id": "student_id"}), )
"student_response": "student_answer", body = json.dumps(
}) {
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
"student_response": "student_answer",
}
)
files_to_upload = None files_to_upload = None
mock_http_post.return_value = (0, "Submission sent successfully") mock_http_post.return_value = (0, "Submission sent successfully")
error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload) error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload) # pylint: disable=unused-variable
mock_http_post.assert_called_once_with( mock_http_post.assert_called_once_with(
"http://example.com/xqueue/xqueue/submit/", "http://example.com/xqueue/xqueue/submit/",

View File

@@ -23,7 +23,7 @@ def xqueue_service():
return XQueueInterfaceSubmission(block) return XQueueInterfaceSubmission(block)
def test_get_submission_params(xqueue_service): def test_get_submission_params(xqueue_service): # pylint: disable=redefined-outer-name
""" """
Test extracting item data from an xqueue submission. Test extracting item data from an xqueue submission.
""" """
@@ -54,7 +54,7 @@ def test_get_submission_params(xqueue_service):
@pytest.mark.django_db @pytest.mark.django_db
@patch("submissions.api.create_external_grader_detail") @patch("submissions.api.create_external_grader_detail")
def test_send_to_submission(mock_create_external_grader_detail, xqueue_service): def test_send_to_submission(mock_create_external_grader_detail, xqueue_service): # pylint: disable=redefined-outer-name
""" """
Test sending a submission to the grading system. Test sending a submission to the grading system.
""" """
@@ -87,10 +87,10 @@ def test_send_to_submission(mock_create_external_grader_detail, xqueue_service):
"course_id": "course-v1:test_org+test_course+test_run", "course_id": "course-v1:test_org+test_course+test_run",
"student_id": "student_id", "student_id": "student_id",
}, },
'student_answer', "student_answer",
queue_name='default', queue_name="default",
queue_key='default', queue_key="default",
grader_file_name='test.py', grader_file_name="test.py",
points_possible=10, points_possible=10,
files=None, files=None,
) )
@@ -98,7 +98,9 @@ def test_send_to_submission(mock_create_external_grader_detail, xqueue_service):
@pytest.mark.django_db @pytest.mark.django_db
@patch("submissions.api.create_external_grader_detail") @patch("submissions.api.create_external_grader_detail")
def test_send_to_submission_with_missing_fields(mock_create_external_grader_detail, xqueue_service): def test_send_to_submission_with_missing_fields(
mock_create_external_grader_detail, xqueue_service
): # pylint: disable=redefined-outer-name
""" """
Test send_to_submission with missing required fields. Test send_to_submission with missing required fields.
""" """

View File

@@ -16,11 +16,11 @@ from openedx.core.djangolib.markup import HTML
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# #
# Utility functions used in CAPA responsetypes # Utility functions used in CAPA responsetypes
default_tolerance = "0.001%" DEFAULT_TOLERANCE = "0.001%"
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def compare_with_tolerance(student_complex, instructor_complex, tolerance=default_tolerance, relative_tolerance=False): def compare_with_tolerance(student_complex, instructor_complex, tolerance=DEFAULT_TOLERANCE, relative_tolerance=False):
""" """
Compare student_complex to instructor_complex with maximum tolerance tolerance. Compare student_complex to instructor_complex with maximum tolerance tolerance.
@@ -42,7 +42,7 @@ def compare_with_tolerance(student_complex, instructor_complex, tolerance=defaul
instructor_complex = 10, student_complex = 20, tolerance = '10%' will give instructor_complex = 10, student_complex = 20, tolerance = '10%' will give
[8.0, 12.0]. [8.0, 12.0].
This is typically used internally to compare float, with a This is typically used internally to compare float, with a
default_tolerance = '0.001%'. DEFAULT_TOLERANCE = '0.001%'.
Default tolerance of 1e-3% is added to compare two floats for Default tolerance of 1e-3% is added to compare two floats for
near-equality (to handle machine representation errors). near-equality (to handle machine representation errors).
@@ -56,7 +56,7 @@ def compare_with_tolerance(student_complex, instructor_complex, tolerance=defaul
Out[212]: 268435456.0 Out[212]: 268435456.0
""" """
if isinstance(tolerance, str): if isinstance(tolerance, str):
if tolerance == default_tolerance: if tolerance == DEFAULT_TOLERANCE:
relative_tolerance = True relative_tolerance = True
if tolerance.endswith("%"): if tolerance.endswith("%"):
tolerance = evaluator({}, {}, tolerance[:-1]) * 0.01 tolerance = evaluator({}, {}, tolerance[:-1]) * 0.01
@@ -90,10 +90,9 @@ def compare_with_tolerance(student_complex, instructor_complex, tolerance=defaul
tolerance_decimal = Decimal(str(tolerance)) tolerance_decimal = Decimal(str(tolerance))
return abs(student_decimal - instructor_decimal) <= tolerance_decimal return abs(student_decimal - instructor_decimal) <= tolerance_decimal
else: # v1 and v2 are, in general, complex numbers:
# v1 and v2 are, in general, complex numbers: # there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()).
# there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()). return abs(student_complex - instructor_complex) <= tolerance
return abs(student_complex - instructor_complex) <= tolerance
def contextualize_text(text, context): # private def contextualize_text(text, context): # private
@@ -144,6 +143,7 @@ def convert_files_to_filenames(answers):
def is_list_of_files(files): def is_list_of_files(files):
"""Return True if the input is a list of valid files."""
return isinstance(files, list) and all(is_file(f) for f in files) return isinstance(files, list) and all(is_file(f) for f in files)
@@ -171,8 +171,8 @@ def find_with_default(node, path, default):
v = node.find(path) v = node.find(path)
if v is not None: if v is not None:
return v.text return v.text
else:
return default return default
def sanitize_html(html_code): def sanitize_html(html_code):
@@ -204,7 +204,7 @@ def get_inner_html_from_xpath(xpath_node):
html = etree.tostring(xpath_node).strip().decode("utf-8") html = etree.tostring(xpath_node).strip().decode("utf-8")
# strips outer tag from html string # strips outer tag from html string
# xss-lint: disable=python-interpolate-html # xss-lint: disable=python-interpolate-html
inner_html = re.sub("(?ms)<%s[^>]*>(.*)</%s>" % (xpath_node.tag, xpath_node.tag), "\\1", html) inner_html = re.sub(f"(?ms)<{xpath_node.tag}[^>]*>(.*)</{xpath_node.tag}>", "\\1", html)
return inner_html.strip() return inner_html.strip()

View File

@@ -20,7 +20,7 @@ if TYPE_CHECKING:
from xmodule.capa_block import ProblemBlock from xmodule.capa_block import ProblemBlock
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
dateformat = "%Y%m%d%H%M%S" DATEFORMAT = "%Y%m%d%H%M%S"
XQUEUE_METRIC_NAME = "edxapp.xqueue" XQUEUE_METRIC_NAME = "edxapp.xqueue"
@@ -99,7 +99,7 @@ def parse_xreply(xreply):
return (return_code, content) return (return_code, content)
class XQueueInterface: class XQueueInterface: # pylint: disable=too-few-public-methods
"""Initializes the XQueue interface.""" """Initializes the XQueue interface."""
def __init__( def __init__(
@@ -143,7 +143,7 @@ class XQueueInterface:
# log the send to xqueue # log the send to xqueue
header_info = json.loads(header) header_info = json.loads(header)
queue_name = header_info.get("queue_name", "") # lint-amnesty, pylint: disable=unused-variable queue_name = header_info.get("queue_name", "") # pylint: disable=unused-variable
# Attempt to send to queue # Attempt to send to queue
(error, msg) = self._send_to_queue(header, body, files_to_upload) (error, msg) = self._send_to_queue(header, body, files_to_upload)
@@ -163,11 +163,13 @@ class XQueueInterface:
return error, msg return error, msg
def _login(self): # lint-amnesty, pylint: disable=missing-function-docstring def _login(self):
payload = {"username": self.auth["username"], "password": self.auth["password"]} payload = {"username": self.auth["username"], "password": self.auth["password"]}
return self._http_post(self.url + "/xqueue/login/", payload) return self._http_post(self.url + "/xqueue/login/", payload)
def _send_to_queue(self, header, body, files_to_upload): # lint-amnesty, pylint: disable=missing-function-docstring def _send_to_queue(self, header, body, files_to_upload):
"""Send the problem submission to XQueue, handling legacy fallback and edX submission logic."""
payload = {"xqueue_header": header, "xqueue_body": body} payload = {"xqueue_header": header, "xqueue_body": body}
files = {} files = {}
if files_to_upload is not None: if files_to_upload is not None:
@@ -184,15 +186,19 @@ class XQueueInterface:
course_key = self.block.scope_ids.usage_id.context_key course_key = self.block.scope_ids.usage_id.context_key
header_info = json.loads(header) header_info = json.loads(header)
queue_key = header_info['lms_key'] queue_key = header_info["lms_key"] # pylint: disable=unused-variable
if use_edx_submissions_for_xqueue(course_key): if use_edx_submissions_for_xqueue(course_key):
submission = self.submission.send_to_submission(header, body, queue_key, files) submission = self.submission.send_to_submission( # pylint: disable=unused-variable
return None, '' header, body, queue_key, files
)
return None, ""
return self._http_post(self.url + "/xqueue/submit/", payload, files=files) return self._http_post(self.url + "/xqueue/submit/", payload, files=files)
def _http_post(self, url, data, files=None): # lint-amnesty, pylint: disable=missing-function-docstring def _http_post(self, url, data, files=None):
"""Send an HTTP POST request and handle connection errors, timeouts, and unexpected status codes."""
try: try:
response = self.session.post(url, data=data, files=files, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT)) response = self.session.post(url, data=data, files=files, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT))
except requests.exceptions.ConnectionError as err: except requests.exceptions.ConnectionError as err:
@@ -204,7 +210,7 @@ class XQueueInterface:
return 1, "failed to read from the server" return 1, "failed to read from the server"
if response.status_code not in [200]: if response.status_code not in [200]:
return 1, "unexpected HTTP status code [%d]" % response.status_code return 1, f"unexpected HTTP status code [{response.status_code}]"
return parse_xreply(response.text) return parse_xreply(response.text)

View File

@@ -86,7 +86,7 @@ class XQueueInterfaceSubmission:
Submits the extracted student data to the edx-submissions system. Submits the extracted student data to the edx-submissions system.
""" """
try: try:
from submissions.api import create_external_grader_detail from submissions.api import create_external_grader_detail # pylint: disable=import-outside-toplevel
student_item, answer, queue_name, grader_file_name, points_possible = self.get_submission_params( student_item, answer, queue_name, grader_file_name, points_possible = self.get_submission_params(
header, body header, body

View File

@@ -1,3 +1,4 @@
# pylint: disable=too-many-lines
""" """
Implements the Problem XBlock, which is built on top of the CAPA subsystem. Implements the Problem XBlock, which is built on top of the CAPA subsystem.
""" """
@@ -69,7 +70,7 @@ log = logging.getLogger("edx.courseware")
# Make '_' a no-op so we can scrape strings. Using lambda instead of # Make '_' a no-op so we can scrape strings. Using lambda instead of
# `django.utils.translation.gettext_noop` because Django cannot be imported in this file # `django.utils.translation.gettext_noop` because Django cannot be imported in this file
_ = lambda text: text _ = lambda text: text # pylint: disable=unnecessary-lambda-assignment
# Generate this many different variants of problems with rerandomize=per_student # Generate this many different variants of problems with rerandomize=per_student
NUM_RANDOMIZATION_BINS = 20 NUM_RANDOMIZATION_BINS = 20
@@ -83,7 +84,7 @@ except ImproperlyConfigured:
FEATURES = {} FEATURES = {}
class SHOWANSWER: class SHOWANSWER: # pylint: disable=too-few-public-methods
""" """
Constants for when to show answer Constants for when to show answer
""" """
@@ -102,7 +103,7 @@ class SHOWANSWER:
ATTEMPTED_NO_PAST_DUE = "attempted_no_past_due" ATTEMPTED_NO_PAST_DUE = "attempted_no_past_due"
class GRADING_METHOD: class GRADING_METHOD: # pylint: disable=too-few-public-methods,invalid-name
""" """
Constants for grading method options. Constants for grading method options.
""" """
@@ -113,7 +114,7 @@ class GRADING_METHOD:
AVERAGE_SCORE = "average_score" AVERAGE_SCORE = "average_score"
class RANDOMIZATION: class RANDOMIZATION: # pylint: disable=too-few-public-methods
""" """
Constants for problem randomization Constants for problem randomization
""" """
@@ -124,16 +125,19 @@ class RANDOMIZATION:
PER_STUDENT = "per_student" PER_STUDENT = "per_student"
class Randomization(String): class Randomization(String): # pylint: disable=too-few-public-methods
""" """
Define a field to store how to randomize a problem. Define a field to store how to randomize a problem.
""" """
def from_json(self, value): def from_json(self, value):
"""Convert stored randomization flags into their internal enum values."""
if value in ("", "true"): if value in ("", "true"):
return RANDOMIZATION.ALWAYS return RANDOMIZATION.ALWAYS
elif value == "false":
if value == "false":
return RANDOMIZATION.PER_STUDENT return RANDOMIZATION.PER_STUDENT
return value return value
to_json = from_json to_json = from_json
@@ -146,7 +150,7 @@ class Randomization(String):
@XBlock.needs("sandbox") @XBlock.needs("sandbox")
@XBlock.needs("replace_urls") @XBlock.needs("replace_urls")
@XBlock.wants("call_to_action") @XBlock.wants("call_to_action")
class _BuiltInProblemBlock( class _BuiltInProblemBlock( # pylint: disable=too-many-public-methods,too-many-instance-attributes,too-many-ancestors
ScorableXBlockMixin, ScorableXBlockMixin,
RawMixin, RawMixin,
XmlMixin, XmlMixin,
@@ -361,7 +365,7 @@ class _BuiltInProblemBlock(
default=False, default=False,
) )
def bind_for_student(self, *args, **kwargs): # lint-amnesty, pylint: disable=signature-differs def bind_for_student(self, *args, **kwargs):
super().bind_for_student(*args, **kwargs) super().bind_for_student(*args, **kwargs)
# Capa was an XModule. When bind_for_student() was called on it with a new runtime, a new CapaModule object # Capa was an XModule. When bind_for_student() was called on it with a new runtime, a new CapaModule object
@@ -377,7 +381,7 @@ class _BuiltInProblemBlock(
# self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first. # self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first.
try: try:
self.lcp self.lcp
except Exception as err: # lint-amnesty, pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
html = self.handle_fatal_lcp_error(err if show_detailed_errors else None) html = self.handle_fatal_lcp_error(err if show_detailed_errors else None)
else: else:
html = self.get_html() html = self.get_html()
@@ -397,9 +401,9 @@ class _BuiltInProblemBlock(
# normal student_view. To prevent anonymous users from viewing specific problems, adjust course policies # normal student_view. To prevent anonymous users from viewing specific problems, adjust course policies
# and/or content groups. # and/or content groups.
return self.student_view(context) return self.student_view(context)
else:
# Show a message that this content requires users to login/enroll. # Show a message that this content requires users to login/enroll.
return super().public_view(context) return super().public_view(context)
def author_view(self, context): def author_view(self, context):
""" """
@@ -420,7 +424,7 @@ class _BuiltInProblemBlock(
shim_xmodule_js(fragment, "MarkdownEditingDescriptor") shim_xmodule_js(fragment, "MarkdownEditingDescriptor")
return fragment return fragment
def handle_ajax(self, dispatch, data): def handle_ajax(self, dispatch, data): # pylint: disable=too-many-locals
""" """
This is called by courseware.block_render, to handle an AJAX call. This is called by courseware.block_render, to handle an AJAX call.
@@ -432,7 +436,7 @@ class _BuiltInProblemBlock(
<other request-specific values here > } <other request-specific values here > }
""" """
# self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first. # self.score is initialized in self.lcp but in this method is accessed before self.lcp so just call it first.
self.lcp # lint-amnesty, pylint: disable=pointless-statement self.lcp # pylint: disable=pointless-statement
handlers = { handlers = {
"hint_button": self.hint_button, "hint_button": self.hint_button,
"problem_get": self.get_problem, "problem_get": self.get_problem,
@@ -475,7 +479,7 @@ class _BuiltInProblemBlock(
_, _, traceback_obj = sys.exc_info() _, _, traceback_obj = sys.exc_info()
raise ProcessingError(not_found_error_message).with_traceback(traceback_obj) from ex raise ProcessingError(not_found_error_message).with_traceback(traceback_obj) from ex
except Exception as ex: # lint-amnesty, pylint: disable=broad-except except Exception as ex:
log.exception( log.exception(
"Unknown error when dispatching %s to %s for user %s", "Unknown error when dispatching %s to %s for user %s",
dispatch, dispatch,
@@ -574,6 +578,7 @@ class _BuiltInProblemBlock(
# edited in the cms # edited in the cms
@classmethod @classmethod
def backcompat_paths(cls, path): def backcompat_paths(cls, path):
"""Return legacy filesystem paths for backward compatibility."""
return [ return [
"problems/" + path[8:], "problems/" + path[8:],
path[8:], path[8:],
@@ -581,6 +586,7 @@ class _BuiltInProblemBlock(
@property @property
def non_editable_metadata_fields(self): def non_editable_metadata_fields(self):
"""Return metadata fields that cannot be edited in Studio."""
non_editable_fields = super().non_editable_metadata_fields non_editable_fields = super().non_editable_metadata_fields
non_editable_fields.extend( non_editable_fields.extend(
[ [
@@ -606,7 +612,7 @@ class _BuiltInProblemBlock(
try: try:
tree = etree.XML(self.data) tree = etree.XML(self.data)
except etree.XMLSyntaxError: except etree.XMLSyntaxError:
log.error(f"Error parsing problem types from xml for capa block {self.display_name}") log.error("Error parsing problem types from xml for capa block %s", self.display_name)
return None # short-term fix to prevent errors (TNL-5057). Will be more properly addressed in TNL-4525. return None # short-term fix to prevent errors (TNL-5057). Will be more properly addressed in TNL-4525.
registered_tags = responsetypes.registry.registered_tags() registered_tags = responsetypes.registry.registered_tags()
return {node.tag for node in tree.iter() if node.tag in registered_tags} return {node.tag for node in tree.iter() if node.tag in registered_tags}
@@ -660,7 +666,7 @@ class _BuiltInProblemBlock(
xblock_body["problem_types"] = list(self.problem_types) xblock_body["problem_types"] = list(self.problem_types)
return xblock_body return xblock_body
def has_support(self, view, functionality): def has_support(self, view, functionality): # pylint: disable=unused-argument
""" """
Override the XBlock.has_support method to return appropriate Override the XBlock.has_support method to return appropriate
value for the multi-device functionality. value for the multi-device functionality.
@@ -702,7 +708,7 @@ class _BuiltInProblemBlock(
minimal_init=True, minimal_init=True,
) )
except responsetypes.LoncapaProblemError: except responsetypes.LoncapaProblemError:
log.exception(f"LcpFatalError for block {str(self.location)} while getting max score") log.exception("LcpFatalError for block %s while getting max score", str(self.location))
maximum_score = 0 maximum_score = 0
else: else:
maximum_score = lcp.get_max_score() maximum_score = lcp.get_max_score()
@@ -843,8 +849,8 @@ class _BuiltInProblemBlock(
if self.graceperiod is not None and due_date: if self.graceperiod is not None and due_date:
return due_date + self.graceperiod return due_date + self.graceperiod
else:
return due_date return due_date
def get_seed(self): def get_seed(self):
""" """
@@ -855,11 +861,12 @@ class _BuiltInProblemBlock(
return self.seed return self.seed
@cached_property @cached_property
def lcp(self): # lint-amnesty, pylint: disable=method-hidden, missing-function-docstring def lcp(self): # pylint: disable=method-hidden
"""Lazily create and return a LoncapaProblem instance for this block."""
try: try:
lcp = self.new_lcp(self.get_state_for_lcp()) lcp = self.new_lcp(self.get_state_for_lcp())
except Exception as err: # pylint: disable=broad-except except Exception as err:
msg = "cannot create LoncapaProblem {loc}: {err}".format(loc=str(self.location), err=err) msg = f"cannot create LoncapaProblem {str(self.location)}: {err}"
raise LoncapaProblemError(msg).with_traceback(sys.exc_info()[2]) raise LoncapaProblemError(msg).with_traceback(sys.exc_info()[2])
if self.score is None: if self.score is None:
@@ -1019,17 +1026,19 @@ class _BuiltInProblemBlock(
}, },
) )
def handle_fatal_lcp_error(self, error): # lint-amnesty, pylint: disable=missing-function-docstring def handle_fatal_lcp_error(self, error):
log.exception(f"LcpFatalError Encountered for {str(self.location)}") """
Log a fatal LoncapaProblem error and return an HTML message for display to the user.
"""
log.exception("LcpFatalError Encountered for %s", str(self.location))
if error: if error:
return HTML('<p>Error formatting HTML for problem:</p><p><pre style="color:red">{msg}</pre></p>').format( return HTML('<p>Error formatting HTML for problem:</p><p><pre style="color:red">{msg}</pre></p>').format(
msg=str(error) msg=str(error)
) )
else:
return HTML( return HTML(
"<p>Could not format HTML for problem. " "<p>Could not format HTML for problem. Contact course staff in the discussion forum for assistance.</p>"
"Contact course staff in the discussion forum for assistance.</p>" )
)
def submit_button_name(self): def submit_button_name(self):
""" """
@@ -1065,8 +1074,8 @@ class _BuiltInProblemBlock(
# for the user to reset a randomized problem # for the user to reset a randomized problem
if self.closed() or submitted_without_reset: if self.closed() or submitted_without_reset:
return False return False
else:
return True return True
def should_show_reset_button(self): def should_show_reset_button(self):
""" """
@@ -1082,12 +1091,12 @@ class _BuiltInProblemBlock(
# Button only shows up for randomized problems if the question has been submitted # Button only shows up for randomized problems if the question has been submitted
if self.rerandomize in [RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET] and self.is_submitted(): if self.rerandomize in [RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET] and self.is_submitted():
return True return True
else:
# Do NOT show the button if the problem is correct # Do NOT show the button if the problem is correct
if self.is_correct(): if self.is_correct():
return False return False
else:
return self.show_reset_button return self.show_reset_button
def should_show_save_button(self): def should_show_save_button(self):
""" """
@@ -1099,33 +1108,33 @@ class _BuiltInProblemBlock(
# (past due / too many attempts) # (past due / too many attempts)
if self.force_save_button: if self.force_save_button:
return not self.closed() return not self.closed()
else:
is_survey_question = self.max_attempts == 0
needs_reset = self.is_submitted() and self.rerandomize == RANDOMIZATION.ALWAYS
# If the student has unlimited attempts, and their answers is_survey_question = self.max_attempts == 0
# are not randomized, then we do not need a save button needs_reset = self.is_submitted() and self.rerandomize == RANDOMIZATION.ALWAYS
# because they can use the "Check" button without consequences.
#
# The consequences we want to avoid are:
# * Using up an attempt (if max_attempts is set)
# * Changing the current problem, and no longer being
# able to view it (if rerandomize is "always")
#
# In those cases. the if statement below is false,
# and the save button can still be displayed.
#
if self.max_attempts is None and self.rerandomize != RANDOMIZATION.ALWAYS:
return False
# If the problem is closed (and not a survey question with max_attempts==0), # If the student has unlimited attempts, and their answers
# then do NOT show the save button # are not randomized, then we do not need a save button
# If we're waiting for the user to reset a randomized problem # because they can use the "Check" button without consequences.
# then do NOT show the save button #
elif (self.closed() and not is_survey_question) or needs_reset: # The consequences we want to avoid are:
return False # * Using up an attempt (if max_attempts is set)
else: # * Changing the current problem, and no longer being
return True # able to view it (if rerandomize is "always")
#
# In those cases. the if statement below is false,
# and the save button can still be displayed.
#
if self.max_attempts is None and self.rerandomize != RANDOMIZATION.ALWAYS:
return False
# If the problem is closed (and not a survey question with max_attempts==0),
# then do NOT show the save button
# If we're waiting for the user to reset a randomized problem
# then do NOT show the save button
if (self.closed() and not is_survey_question) or needs_reset:
return False
return True
def handle_problem_html_error(self, err): def handle_problem_html_error(self, err):
""" """
@@ -1279,7 +1288,7 @@ class _BuiltInProblemBlock(
"msg": total_text, "msg": total_text,
} }
def get_problem_html(self, encapsulate=True, submit_notification=False): def get_problem_html(self, encapsulate=True, submit_notification=False): # pylint: disable=too-many-locals
""" """
Return html for the problem. Return html for the problem.
@@ -1294,7 +1303,7 @@ class _BuiltInProblemBlock(
# If we cannot construct the problem HTML, # If we cannot construct the problem HTML,
# then generate an error message instead. # then generate an error message instead.
except Exception as err: # pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
html = self.handle_problem_html_error(err) html = self.handle_problem_html_error(err)
html = self.remove_tags_from_html(html) html = self.remove_tags_from_html(html)
@@ -1365,7 +1374,7 @@ class _BuiltInProblemBlock(
return html return html
def _get_answer_notification(self, render_notifications): def _get_answer_notification(self, render_notifications): # pylint: disable=too-many-branches
""" """
Generate the answer notification type and message from the current problem status. Generate the answer notification type and message from the current problem status.
@@ -1449,7 +1458,7 @@ class _BuiltInProblemBlock(
for tag in tags: for tag in tags:
html = re.sub( html = re.sub(
rf"<{tag}.*?>.*?</{tag}>", "", html, flags=re.DOTALL rf"<{tag}.*?>.*?</{tag}>", "", html, flags=re.DOTALL
) # xss-lint: disable=python-interpolate-html # lint-amnesty, pylint: disable=line-too-long ) # xss-lint: disable=python-interpolate-html
# Some of these tags span multiple lines # Some of these tags span multiple lines
# Note: could probably speed this up by calling sub() once with a big regex # Note: could probably speed this up by calling sub() once with a big regex
# vs. simply calling sub() many times as we have here. # vs. simply calling sub() many times as we have here.
@@ -1510,7 +1519,7 @@ class _BuiltInProblemBlock(
self.lcp # pylint: disable=pointless-statement self.lcp # pylint: disable=pointless-statement
return self.score.raw_earned == self.score.raw_possible return self.score.raw_earned == self.score.raw_possible
def answer_available(self): def answer_available(self): # pylint: disable=too-many-branches,too-many-return-statements
""" """
Is the user allowed to see an answer? Is the user allowed to see an answer?
""" """
@@ -1518,41 +1527,41 @@ class _BuiltInProblemBlock(
if not self.correctness_available(): if not self.correctness_available():
# If correctness is being withheld, then don't show answers either. # If correctness is being withheld, then don't show answers either.
return False return False
elif self.showanswer == "": if self.showanswer == "":
return False return False
elif self.showanswer == SHOWANSWER.NEVER: if self.showanswer == SHOWANSWER.NEVER:
return False return False
elif user_is_staff: if user_is_staff:
# This is after the 'never' check because admins can see the answer # This is after the 'never' check because admins can see the answer
# unless the problem explicitly prevents it # unless the problem explicitly prevents it
return True return True
elif self.showanswer == SHOWANSWER.ATTEMPTED: if self.showanswer == SHOWANSWER.ATTEMPTED:
return self.is_attempted() or self.is_past_due() return self.is_attempted() or self.is_past_due()
elif self.showanswer == SHOWANSWER.ANSWERED: if self.showanswer == SHOWANSWER.ANSWERED:
# NOTE: this is slightly different from 'attempted' -- resetting the problems # NOTE: this is slightly different from 'attempted' -- resetting the problems
# makes lcp.done False, but leaves attempts unchanged. # makes lcp.done False, but leaves attempts unchanged.
return self.is_correct() return self.is_correct()
elif self.showanswer == SHOWANSWER.CLOSED: if self.showanswer == SHOWANSWER.CLOSED:
return self.closed() return self.closed()
elif self.showanswer == SHOWANSWER.FINISHED: if self.showanswer == SHOWANSWER.FINISHED:
return self.closed() or self.is_correct() return self.closed() or self.is_correct()
elif self.showanswer == SHOWANSWER.CORRECT_OR_PAST_DUE: if self.showanswer == SHOWANSWER.CORRECT_OR_PAST_DUE:
return self.is_correct() or self.is_past_due() return self.is_correct() or self.is_past_due()
elif self.showanswer == SHOWANSWER.PAST_DUE: if self.showanswer == SHOWANSWER.PAST_DUE:
return self.is_past_due() return self.is_past_due()
elif self.showanswer == SHOWANSWER.AFTER_SOME_NUMBER_OF_ATTEMPTS: if self.showanswer == SHOWANSWER.AFTER_SOME_NUMBER_OF_ATTEMPTS:
required_attempts = self.attempts_before_showanswer_button required_attempts = self.attempts_before_showanswer_button
if self.max_attempts and required_attempts >= self.max_attempts: if self.max_attempts and required_attempts >= self.max_attempts:
required_attempts = self.max_attempts required_attempts = self.max_attempts
return self.attempts >= required_attempts return self.attempts >= required_attempts
elif self.showanswer == SHOWANSWER.ALWAYS: if self.showanswer == SHOWANSWER.ALWAYS:
return True return True
elif self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS: if self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS:
return self.used_all_attempts() return self.used_all_attempts()
elif self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT: if self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS_OR_CORRECT:
return self.used_all_attempts() or self.is_correct() return self.used_all_attempts() or self.is_correct()
elif self.showanswer == SHOWANSWER.ATTEMPTED_NO_PAST_DUE: if self.showanswer == SHOWANSWER.ATTEMPTED_NO_PAST_DUE:
return self.is_attempted() return self.is_attempted()
return False return False
@@ -1640,28 +1649,29 @@ class _BuiltInProblemBlock(
event_info = {} event_info = {}
event_info["problem_id"] = str(self.location) event_info["problem_id"] = str(self.location)
self.publish_unmasked("showanswer", event_info) self.publish_unmasked("showanswer", event_info)
if not self.answer_available(): # lint-amnesty, pylint: disable=no-else-raise if not self.answer_available():
raise NotFoundError("Answer is not available") raise NotFoundError("Answer is not available")
else:
answers = self.lcp.get_question_answers() answers = self.lcp.get_question_answers()
self.set_state_from_lcp() self.set_state_from_lcp()
# answers (eg <solution>) may have embedded images # answers (eg <solution>) may have embedded images
# but be careful, some problems are using non-string answer dicts # but be careful, some problems are using non-string answer dicts
new_answers = {} new_answers = {}
for answer_id in answers: for answer_id, answer_value in answers.items():
try: try:
answer_content = self.runtime.service(self, "replace_urls").replace_urls(answers[answer_id]) answer_content = self.runtime.service(self, "replace_urls").replace_urls(answer_value)
new_answer = {answer_id: answer_content} new_answer = {answer_id: answer_content}
except TypeError: except TypeError:
log.debug("Unable to perform URL substitution on answers[%s]: %s", answer_id, answers[answer_id]) log.debug("Unable to perform URL substitution on answers[%s]: %s", answer_id, answer_value)
new_answer = {answer_id: answers[answer_id]} new_answer = {answer_id: answer_value}
new_answers.update(new_answer) new_answers.update(new_answer)
return { return {
"answers": new_answers, "answers": new_answers,
"correct_status_html": render_to_string( "correct_status_html": render_to_string(
"status_span.html", {"status": Status("correct", self.runtime.service(self, "i18n").gettext)} "status_span.html",
{"status": Status("correct", self.runtime.service(self, "i18n").gettext)},
), ),
} }
@@ -1723,39 +1733,36 @@ class _BuiltInProblemBlock(
# If key has no underscores, then partition # If key has no underscores, then partition
# will return (key, '', '') # will return (key, '', '')
# We detect this and raise an error # We detect this and raise an error
if not name: # lint-amnesty, pylint: disable=no-else-raise if not name:
raise ValueError(f"{key} must contain at least one underscore") raise ValueError(f"{key} must contain at least one underscore")
# This allows for answers which require more than one value for
# the same form input (e.g. checkbox inputs). The convention is that
# if the name ends with '[]' (which looks like an array), then the
# answer will be an array.
# if the name ends with '{}' (Which looks like a dict),
# then the answer will be a dict
is_list_key = name.endswith("[]")
is_dict_key = name.endswith("{}")
name = name[:-2] if is_list_key or is_dict_key else name
if is_list_key:
val = data.getall(key)
elif is_dict_key:
try:
val = json.loads(data[key])
# If the submission wasn't deserializable, raise an error.
except (KeyError, ValueError) as exc:
raise ValueError(f"Invalid submission: {data[key]} for {key}") from exc
else: else:
# This allows for answers which require more than one value for val = data[key]
# the same form input (e.g. checkbox inputs). The convention is that
# if the name ends with '[]' (which looks like an array), then the
# answer will be an array.
# if the name ends with '{}' (Which looks like a dict),
# then the answer will be a dict
is_list_key = name.endswith("[]")
is_dict_key = name.endswith("{}")
name = name[:-2] if is_list_key or is_dict_key else name
if is_list_key: # If the name already exists, then we don't want
val = data.getall(key) # to override it. Raise an error instead
elif is_dict_key: if name in answers:
try: raise ValueError(f"Key {name} already exists in answers dict")
val = json.loads(data[key])
# If the submission wasn't deserializable, raise an error.
except (KeyError, ValueError):
raise ValueError( # lint-amnesty, pylint: disable=raise-missing-from
f"Invalid submission: {data[key]} for {key}"
)
else:
val = data[key]
# If the name already exists, then we don't want answers[name] = val
# to override it. Raise an error instead
if name in answers: # lint-amnesty, pylint: disable=no-else-raise
raise ValueError(f"Key {name} already exists in answers dict")
else:
answers[name] = val
return answers return answers
@@ -1777,8 +1784,9 @@ class _BuiltInProblemBlock(
return {"grade": self.score.raw_earned, "max_grade": self.score.raw_possible} return {"grade": self.score.raw_earned, "max_grade": self.score.raw_possible}
# pylint: disable=too-many-statements def submit_problem( # pylint: disable=too-many-statements,too-many-branches,too-many-locals
def submit_problem(self, data, override_time=False): self, data, override_time=False
):
""" """
Checks whether answers to a problem are correct Checks whether answers to a problem are correct
@@ -1796,7 +1804,6 @@ class _BuiltInProblemBlock(
self.student_answers_history.append(answers_without_files) self.student_answers_history.append(answers_without_files)
event_info["answers"] = answers_without_files event_info["answers"] = answers_without_files
metric_name = "xmodule.capa.check_problem.{}".format # lint-amnesty, pylint: disable=unused-variable
# Can override current time # Can override current time
current_time = datetime.datetime.now(utc) current_time = datetime.datetime.now(utc)
if override_time is not False: if override_time is not False:
@@ -1931,8 +1938,6 @@ class _BuiltInProblemBlock(
return {"success": success, "contents": html} return {"success": success, "contents": html}
# pylint: enable=too-many-statements
def get_score_with_grading_method(self, current_score: Score) -> Score: def get_score_with_grading_method(self, current_score: Score) -> Score:
""" """
Calculate and return the current score based on the grading method. Calculate and return the current score based on the grading method.
@@ -2040,7 +2045,7 @@ class _BuiltInProblemBlock(
""" """
try: try:
return self.get_submission_metadata(answers, correct_map) return self.get_submission_metadata(answers, correct_map)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-exception-caught
# NOTE: The above process requires deep inspection of capa structures that may break for some # NOTE: The above process requires deep inspection of capa structures that may break for some
# uncommon problem types. Ensure that it does not prevent answer submission in those # uncommon problem types. Ensure that it does not prevent answer submission in those
# cases. Any occurrences of errors in this block should be investigated and resolved. # cases. Any occurrences of errors in this block should be investigated and resolved.
@@ -2137,10 +2142,9 @@ class _BuiltInProblemBlock(
self.publish_unmasked("save_problem_fail", event_info) self.publish_unmasked("save_problem_fail", event_info)
return { return {
"success": False, "success": False,
# pylint: disable=line-too-long # Translators: 'closed' means the problem's due date has passed.
# Translators: 'closed' means the problem's due date has passed. You may no longer attempt to solve the problem. # You may no longer attempt to solve the problem.
"msg": _("Problem is closed."), "msg": _("Problem is closed."),
# pylint: enable=line-too-long
} }
# Problem submitted. Student should reset before saving # Problem submitted. Student should reset before saving
@@ -2186,10 +2190,9 @@ class _BuiltInProblemBlock(
self.publish_unmasked("reset_problem_fail", event_info) self.publish_unmasked("reset_problem_fail", event_info)
return { return {
"success": False, "success": False,
# pylint: disable=line-too-long # Translators: 'closed' means the problem's due date has passed.
# Translators: 'closed' means the problem's due date has passed. You may no longer attempt to solve the problem. # You may no longer attempt to solve the problem.
"msg": _("You cannot select Reset for a problem that is closed."), "msg": _("You cannot select Reset for a problem that is closed."),
# pylint: enable=line-too-long
} }
if not self.is_submitted(): if not self.is_submitted():
@@ -2250,10 +2253,10 @@ class _BuiltInProblemBlock(
if not self.lcp.supports_rescoring(): if not self.lcp.supports_rescoring():
event_info["failure"] = "unsupported" event_info["failure"] = "unsupported"
self.publish_unmasked("problem_rescore_fail", event_info) self.publish_unmasked("problem_rescore_fail", event_info)
# pylint: disable=line-too-long
# Translators: 'rescoring' refers to the act of re-submitting a student's solution so it can get a new score. # Translators: 'rescoring' refers to the act of re-submitting a student's
# solution so it can get a new score.
raise NotImplementedError(_("Problem's definition does not support rescoring.")) raise NotImplementedError(_("Problem's definition does not support rescoring."))
# pylint: enable=line-too-long
if not self.done: if not self.done:
event_info["failure"] = "unanswered" event_info["failure"] = "unanswered"
@@ -2270,7 +2273,7 @@ class _BuiltInProblemBlock(
StudentInputError, StudentInputError,
ResponseError, ResponseError,
LoncapaProblemError, LoncapaProblemError,
) as inst: # lint-amnesty, pylint: disable=unused-variable ):
log.warning("Input error in capa_block:problem_rescore", exc_info=True) log.warning("Input error in capa_block:problem_rescore", exc_info=True)
event_info["failure"] = "input_error" event_info["failure"] = "input_error"
self.publish_unmasked("problem_rescore_fail", event_info) self.publish_unmasked("problem_rescore_fail", event_info)
@@ -2325,6 +2328,7 @@ class _BuiltInProblemBlock(
return grading_method_handler.get_score() return grading_method_handler.get_score()
def has_submitted_answer(self): def has_submitted_answer(self):
"""Return True if the learner has already submitted an answer."""
return self.done return self.done
def set_score(self, score): def set_score(self, score):
@@ -2503,13 +2507,13 @@ class ComplexEncoder(json.JSONEncoder):
Extend the JSON encoder to correctly handle complex numbers Extend the JSON encoder to correctly handle complex numbers
""" """
def default(self, obj): # lint-amnesty, pylint: disable=arguments-differ, method-hidden def default(self, o):
""" """
Print a nicely formatted complex number, or default to the JSON encoder Print a nicely formatted complex number, or default to the JSON encoder
""" """
if isinstance(obj, complex): if isinstance(o, complex):
return f"{obj.real:.7g}{obj.imag:+.7g}*j" return f"{o.real:.7g}{o.imag:+.7g}*j"
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, o)
def randomization_bin(seed, problem_id): def randomization_bin(seed, problem_id):

View File

@@ -1,4 +1,4 @@
# lint-amnesty, pylint: disable=missing-module-docstring """Django integration utilities for loading and accessing the content store."""
from importlib import import_module from importlib import import_module
@@ -18,7 +18,8 @@ def load_function(path):
return getattr(import_module(module_path), name) return getattr(import_module(module_path), name)
def contentstore(name="default"): # lint-amnesty, pylint: disable=missing-function-docstring def contentstore(name="default"):
"""Return a contentstore instance by name, creating and caching it if not already initialized."""
if name not in _CONTENTSTORE: if name not in _CONTENTSTORE:
class_ = load_function(settings.CONTENTSTORE["ENGINE"]) class_ = load_function(settings.CONTENTSTORE["ENGINE"])
options = {} options = {}

View File

@@ -1,4 +1,5 @@
# lint-amnesty, pylint: disable=missing-module-docstring """Mixin classes for handling raw XML data in XBlocks."""
import logging import logging
import re import re
@@ -22,12 +23,11 @@ class RawMixin:
data = String(help="XML data for the block", default="", scope=Scope.content) data = String(help="XML data for the block", default="", scope=Scope.content)
@classmethod @classmethod
def definition_from_xml( def definition_from_xml(cls, xml_object, system): # pylint: disable=unused-argument
cls, xml_object, system """Convert XML node into a dictionary with 'data' key for XBlock."""
): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument
return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, [] return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, []
def definition_to_xml(self, resource_fs): # lint-amnesty, pylint: disable=unused-argument def definition_to_xml(self, resource_fs): # pylint: disable=unused-argument
""" """
Return an Element if we've kept the import OLX, or None otherwise. Return an Element if we've kept the import OLX, or None otherwise.
""" """
@@ -54,11 +54,11 @@ class RawMixin:
# re-raise # re-raise
lines = self.data.split("\n") lines = self.data.split("\n")
line, offset = err.position line, offset = err.position
msg = ("Unable to create xml for block {loc}. " "Context: '{context}'").format( msg = (
context=lines[line - 1][offset - 40 : offset + 40], f"Unable to create xml for block {self.location}. "
loc=self.location, f"Context: '{lines[line - 1][offset - 40 : offset + 40]}'"
) )
raise SerializationError(self.location, msg) # lint-amnesty, pylint: disable=raise-missing-from raise SerializationError(self.location, msg) from err
@classmethod @classmethod
def parse_xml_new_runtime(cls, node, runtime, keys): def parse_xml_new_runtime(cls, node, runtime, keys):
@@ -93,12 +93,14 @@ class EmptyDataRawMixin:
data = String(default="", scope=Scope.content) data = String(default="", scope=Scope.content)
@classmethod @classmethod
def definition_from_xml(cls, xml_object, system): # lint-amnesty, pylint: disable=unused-argument def definition_from_xml(cls, xml_object, system): # pylint: disable=unused-argument
"""Convert XML node to dictionary with 'data', handling empty nodes specially."""
if len(xml_object) == 0 and len(list(xml_object.items())) == 0: if len(xml_object) == 0 and len(list(xml_object.items())) == 0:
return {"data": ""}, [] return {"data": ""}, []
return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, [] return {"data": etree.tostring(xml_object, pretty_print=True, encoding="unicode")}, []
def definition_to_xml(self, resource_fs): # lint-amnesty, pylint: disable=unused-argument def definition_to_xml(self, resource_fs): # pylint: disable=unused-argument
"""Return an XML Element from stored data, or an empty element if data is empty."""
if self.data: if self.data:
return etree.fromstring(self.data) return etree.fromstring(self.data)
return etree.Element(self.category) return etree.Element(self.category)

View File

@@ -1,4 +1,4 @@
# lint-amnesty, pylint: disable=missing-module-docstring """Utility for converting XML nodes to their inner string representation."""
from lxml import etree from lxml import etree

View File

@@ -1,10 +1,8 @@
# pylint: disable=too-many-lines
""" """
Tests of the Capa XModule Tests of the Capa XModule
""" """
# pylint: disable=invalid-name
import datetime import datetime
import json import json
import os import os
@@ -34,8 +32,12 @@ from lms.djangoapps.courseware.user_state_client import XBlockUserState
from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.capa import responsetypes from xmodule.capa import responsetypes
from xmodule.capa.correctmap import CorrectMap from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError from xmodule.capa.responsetypes import (
from xmodule.capa.tests.test_util import use_unsafe_codejail LoncapaProblemError,
ResponseError,
StudentInputError,
)
from xmodule.capa.tests.test_util import UseUnsafeCodejail
from xmodule.capa.xqueue_interface import XQueueInterface from xmodule.capa.xqueue_interface import XQueueInterface
from xmodule.capa_block import ComplexEncoder, ProblemBlock from xmodule.capa_block import ComplexEncoder, ProblemBlock
from xmodule.tests import DATA_DIR from xmodule.tests import DATA_DIR
@@ -70,6 +72,7 @@ class CapaFactory:
@classmethod @classmethod
def next_num(cls): def next_num(cls):
"""Increment and return a unique number for naming problems."""
cls.num += 1 cls.num += 1
return cls.num return cls.num
@@ -85,14 +88,12 @@ class CapaFactory:
""" """
Return the key stored in the capa problem answer dict Return the key stored in the capa problem answer dict
""" """
return "%s_%d_%d" % ( return (
"-".join(["i4x", "edX", "capa_test", "problem", "SampleProblem%d" % cls.num]), f"{'-'.join(['i4x', 'edX', 'capa_test', 'problem', f'SampleProblem{cls.num}'])}_{response_num}_{input_num}"
response_num,
input_num,
) )
@classmethod @classmethod
def create( def create( # pylint: disable=too-many-arguments,too-many-positional-arguments
cls, cls,
attempts=None, attempts=None,
problem_state=None, problem_state=None,
@@ -206,7 +207,8 @@ if submission[0] == '':
@ddt.ddt @ddt.ddt
@skip_unless_lms @skip_unless_lms
class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring class ProblemBlockTest(unittest.TestCase): # pylint: disable=too-many-public-methods
"""Tests for various problem types in XBlocks."""
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@@ -221,6 +223,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
self.two_day_delta_str = "2 days" self.two_day_delta_str = "2 days"
def test_import(self): def test_import(self):
"""Verify CapaFactory creates blocks with zero initial score and unique URLs."""
block = CapaFactory.create() block = CapaFactory.create()
assert block.get_score().raw_earned == 0 assert block.get_score().raw_earned == 0
@@ -282,11 +285,11 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
""" """
xqueue_interface = XQueueInterface("http://example.com/xqueue", Mock()) xqueue_interface = XQueueInterface("http://example.com/xqueue", Mock())
with patch.object(xqueue_interface.session, "post", side_effect=exception): with patch.object(xqueue_interface.session, "post", side_effect=exception):
# pylint: disable = protected-access response = xqueue_interface._http_post("http://some/fake/url", {}) # pylint: disable=protected-access
response = xqueue_interface._http_post("http://some/fake/url", {})
assert response == result assert response == result
def test_showanswer_attempted(self): def test_showanswer_attempted(self):
"""Check answer availability changes after attempting the problem."""
problem = CapaFactory.create(showanswer="attempted") problem = CapaFactory.create(showanswer="attempted")
assert not problem.answer_available() assert not problem.answer_available()
problem.attempts = 1 problem.attempts = 1
@@ -351,6 +354,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert problem.answer_available() == answer_available_after_attempt assert problem.answer_available() == answer_available_after_attempt
def test_showanswer_closed(self): def test_showanswer_closed(self):
"""Check show answer visibility with showanswer='closed' and various conditions."""
# can see after attempts used up, even with due date in the future # can see after attempts used up, even with due date in the future
used_all_attempts = CapaFactory.create( used_all_attempts = CapaFactory.create(
@@ -695,6 +699,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert problem.correctness_available() == expected_result assert problem.correctness_available() == expected_result
def test_closed(self): def test_closed(self):
"""Verify problem closed status based on attempts and due date."""
# Attempts < Max attempts --> NOT closed # Attempts < Max attempts --> NOT closed
block = CapaFactory.create(max_attempts="1", attempts="0") block = CapaFactory.create(max_attempts="1", attempts="0")
@@ -722,6 +727,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@patch.object(ProblemBlock, "course_end_date", new_callable=PropertyMock) @patch.object(ProblemBlock, "course_end_date", new_callable=PropertyMock)
def test_closed_for_archive(self, mock_course_end_date): def test_closed_for_archive(self, mock_course_end_date):
"""Check closed status for archived and active courses with/without grace periods."""
# Utility to create a datetime object in the past # Utility to create a datetime object in the past
def past_datetime(days): def past_datetime(days):
@@ -752,6 +758,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert not block.closed() assert not block.closed()
def test_parse_get_params(self): def test_parse_get_params(self):
"""Test parsing of GET parameters into response dictionaries with validation."""
# Valid GET param dict # Valid GET param dict
# 'input_5' intentionally left unset, # 'input_5' intentionally left unset,
@@ -770,9 +777,9 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
# Expect that we get a dict with "input" stripped from key names # Expect that we get a dict with "input" stripped from key names
# and that we get the same values back # and that we get the same values back
for key in result.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary for key in result:
original_key = "input_" + key original_key = "input_" + key
assert original_key in valid_get_dict, "Output dict should have key %s" % original_key assert original_key in valid_get_dict, f"Output dict should have key {original_key}"
assert valid_get_dict[original_key] == result[key] assert valid_get_dict[original_key] == result[key]
# Valid GET param dict with list keys # Valid GET param dict with list keys
@@ -801,6 +808,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
result = ProblemBlock.make_dict_of_responses(invalid_get_dict) result = ProblemBlock.make_dict_of_responses(invalid_get_dict)
def test_submit_problem_correct(self): def test_submit_problem_correct(self):
"""Verify submitting a correct problem updates attempts, grading, and HTML content."""
block = CapaFactory.create(attempts=1) block = CapaFactory.create(attempts=1)
@@ -1172,6 +1180,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.score == Score(raw_earned=0.25, raw_possible=1) assert block.score == Score(raw_earned=0.25, raw_possible=1)
def test_submit_problem_incorrect(self): def test_submit_problem_incorrect(self):
"""Verify submitting an incorrect answer marks failure and increments attempts."""
block = CapaFactory.create(attempts=0) block = CapaFactory.create(attempts=0)
@@ -1192,6 +1201,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1 assert block.lcp.context["attempt"] == 1
def test_submit_problem_closed(self): def test_submit_problem_closed(self):
"""Ensure submitting a closed problem raises NotFoundError and does not increment attempts."""
block = CapaFactory.create(attempts=3) block = CapaFactory.create(attempts=3)
# Problem closed -- cannot submit # Problem closed -- cannot submit
@@ -1207,6 +1217,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.ALWAYS, "true") @ddt.data(RANDOMIZATION.ALWAYS, "true")
def test_submit_problem_resubmitted_with_randomize(self, rerandomize): def test_submit_problem_resubmitted_with_randomize(self, rerandomize):
"""Verify resubmission is blocked when rerandomization is enabled and problem is done."""
# Randomize turned on # Randomize turned on
block = CapaFactory.create(rerandomize=rerandomize, attempts=0) block = CapaFactory.create(rerandomize=rerandomize, attempts=0)
@@ -1223,6 +1234,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.NEVER, "false", RANDOMIZATION.PER_STUDENT) @ddt.data(RANDOMIZATION.NEVER, "false", RANDOMIZATION.PER_STUDENT)
def test_submit_problem_resubmitted_no_randomize(self, rerandomize): def test_submit_problem_resubmitted_no_randomize(self, rerandomize):
"""Verify resubmission succeeds when rerandomization is disabled."""
# Randomize turned off # Randomize turned off
block = CapaFactory.create(rerandomize=rerandomize, attempts=0, done=True) block = CapaFactory.create(rerandomize=rerandomize, attempts=0, done=True)
@@ -1237,6 +1249,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1 assert block.lcp.context["attempt"] == 1
def test_submit_problem_queued(self): def test_submit_problem_queued(self):
"""Ensure queued problems return a wait message and do not increment attempts."""
block = CapaFactory.create(attempts=1) block = CapaFactory.create(attempts=1)
# Simulate that the problem is queued # Simulate that the problem is queued
@@ -1259,13 +1272,13 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@pytest.mark.django_db @pytest.mark.django_db
@patch.object(XQueueInterface, "_http_post") @patch.object(XQueueInterface, "_http_post")
def test_submit_problem_with_files(self, mock_xqueue_post): def test_submit_problem_with_files(self, mock_xqueue_post):
"""Verify file-upload submissions are sent correctly to XQueue via submit_problem."""
# Check a problem with uploaded files, using the submit_problem API. # Check a problem with uploaded files, using the submit_problem API.
# pylint: disable=protected-access
# The files we'll be uploading. # The files we'll be uploading.
fnames = ["prog1.py", "prog2.py", "prog3.py"] fnames = ["prog1.py", "prog2.py", "prog3.py"]
fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames] fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames]
fileobjs = [open(fpath) for fpath in fpaths] fileobjs = [open(fpath, encoding="utf-8") for fpath in fpaths] # pylint: disable=consider-using-with
for fileobj in fileobjs: for fileobj in fileobjs:
self.addCleanup(fileobj.close) self.addCleanup(fileobj.close)
@@ -1282,24 +1295,42 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
block.submit_problem(get_request_dict) block.submit_problem(get_request_dict)
# pylint: disable=line-too-long
# _http_post is called like this: # _http_post is called like this:
# _http_post( # _http_post(
# 'http://example.com/xqueue/xqueue/submit/', # 'http://example.com/xqueue/xqueue/submit/',
# { # {
# 'xqueue_header': '{"lms_key": "df34fb702620d7ae892866ba57572491", "lms_callback_url": "/", "queue_name": "BerkeleyX-cs188x"}', # 'xqueue_header':
# 'xqueue_body': '{"student_info": "{\\"anonymous_student_id\\": \\"student\\", \\"submission_time\\": \\"20131117183318\\"}", "grader_payload": "{\\"project\\": \\"p3\\"}", "student_response": ""}', # '{"lms_key": "df34fb702620d7ae892866ba57572491", '
# '"lms_callback_url": "/", '
# '"queue_name": "BerkeleyX-cs188x"}',
# 'xqueue_body':
# '{"student_info": "{\\"anonymous_student_id\\": '
# '\\"student\\", \\"submission_time\\": '
# '\\"20131117183318\\"}", '
# '"grader_payload": "{\\"project\\": \\"p3\\"}", '
# '"student_response": ""}',
# }, # },
# files={ # files={
# path(u'/home/ned/edx/edx-platform/common/test/data/uploads/asset.html'): # path(
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/asset.html', mode 'r' at 0x49c5f60>, # u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# path(u'/home/ned/edx/edx-platform/common/test/data/uploads/image.jpg'): # 'asset.html'
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/image.jpg', mode 'r' at 0x49c56f0>, # ):
# path(u'/home/ned/edx/edx-platform/common/test/data/uploads/textbook.pdf'): # <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/textbook.pdf', mode 'r' at 0x49c5a50>, # 'asset.html', mode 'r' at 0x49c5f60>,
# path(
# u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'image.jpg'
# ):
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'image.jpg', mode 'r' at 0x49c56f0>,
# path(
# u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'textbook.pdf'
# ):
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/'
# 'textbook.pdf', mode 'r' at 0x49c5a50>,
# }, # },
# ) # )
# pylint: enable=line-too-long
assert mock_xqueue_post.call_count == 1 assert mock_xqueue_post.call_count == 1
_, kwargs = mock_xqueue_post.call_args _, kwargs = mock_xqueue_post.call_args
@@ -1310,15 +1341,16 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@pytest.mark.django_db @pytest.mark.django_db
@patch.object(XQueueInterface, "_http_post") @patch.object(XQueueInterface, "_http_post")
def test_submit_problem_with_files_as_xblock(self, mock_xqueue_post): def test_submit_problem_with_files_as_xblock(self, mock_xqueue_post):
"""Verify file-upload submissions work correctly via the XBlock handler API."""
# Check a problem with uploaded files, using the XBlock API. # Check a problem with uploaded files, using the XBlock API.
# pylint: disable=protected-access
# The files we'll be uploading. # The files we'll be uploading.
fnames = ["prog1.py", "prog2.py", "prog3.py"] fnames = ["prog1.py", "prog2.py", "prog3.py"]
fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames] fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames]
fileobjs = [open(fpath) for fpath in fpaths] fileobjs = []
for fileobj in fileobjs: for fpath in fpaths:
self.addCleanup(fileobj.close) with open(fpath, encoding="utf-8") as f:
fileobjs.append(f.read())
block = CapaFactoryWithFiles.create() block = CapaFactoryWithFiles.create()
@@ -1341,6 +1373,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert fpath == fileobj.name assert fpath == fileobj.name
def test_submit_problem_error(self): def test_submit_problem_error(self):
"""Ensure expected grading errors return messages without incrementing attempts."""
# Try each exception that capa_block should handle # Try each exception that capa_block should handle
exception_classes = [StudentInputError, LoncapaProblemError, ResponseError] exception_classes = [StudentInputError, LoncapaProblemError, ResponseError]
@@ -1366,6 +1399,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 2 assert block.lcp.context["attempt"] == 2
def test_submit_problem_error_with_codejail_exception(self): def test_submit_problem_error_with_codejail_exception(self):
"""Verify codejail execution errors are sanitized and handled correctly."""
# Try each exception that capa_block should handle # Try each exception that capa_block should handle
exception_classes = [StudentInputError, LoncapaProblemError, ResponseError] exception_classes = [StudentInputError, LoncapaProblemError, ResponseError]
@@ -1436,6 +1470,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
block.submit_problem(get_request_dict) block.submit_problem(get_request_dict)
def test_submit_problem_error_nonascii(self): def test_submit_problem_error_nonascii(self):
"""Ensure non-ASCII error messages are preserved and handled correctly."""
# Try each exception that capa_block should handle # Try each exception that capa_block should handle
exception_classes = [StudentInputError, LoncapaProblemError, ResponseError] exception_classes = [StudentInputError, LoncapaProblemError, ResponseError]
@@ -1461,6 +1496,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 2 assert block.lcp.context["attempt"] == 2
def test_submit_problem_error_with_staff_user(self): def test_submit_problem_error_with_staff_user(self):
"""Verify staff users receive full traceback information on errors."""
# Try each exception that capa block should handle # Try each exception that capa block should handle
for exception_class in [StudentInputError, LoncapaProblemError, ResponseError]: for exception_class in [StudentInputError, LoncapaProblemError, ResponseError]:
@@ -1495,6 +1531,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
) )
@ddt.unpack @ddt.unpack
def test_handle_ajax_show_correctness(self, show_correctness, is_correct, expected_score, expected_success): def test_handle_ajax_show_correctness(self, show_correctness, is_correct, expected_score, expected_success):
"""Verify AJAX submission respects show_correctness settings."""
block = CapaFactory.create(show_correctness=show_correctness, due=self.tomorrow_str, correct=is_correct) block = CapaFactory.create(show_correctness=show_correctness, due=self.tomorrow_str, correct=is_correct)
# Simulate marking the input correct/incorrect # Simulate marking the input correct/incorrect
@@ -1515,6 +1552,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1 assert block.lcp.context["attempt"] == 1
def test_reset_problem(self): def test_reset_problem(self):
"""Ensure resetting a completed problem regenerates state and HTML."""
block = CapaFactory.create(done=True) block = CapaFactory.create(done=True)
block.new_lcp = Mock(wraps=block.new_lcp) block.new_lcp = Mock(wraps=block.new_lcp)
block.choose_new_seed = Mock(wraps=block.choose_new_seed) block.choose_new_seed = Mock(wraps=block.choose_new_seed)
@@ -1538,6 +1576,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
block.new_lcp.assert_called_once_with(None) block.new_lcp.assert_called_once_with(None)
def test_reset_problem_closed(self): def test_reset_problem_closed(self):
"""Verify reset is blocked when the problem is closed."""
# pre studio default # pre studio default
block = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS) block = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS)
@@ -1553,6 +1592,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert ("success" in result) and (not result["success"]) assert ("success" in result) and (not result["success"])
def test_reset_problem_not_done(self): def test_reset_problem_not_done(self):
"""Verify reset is blocked when the problem is not yet completed."""
# Simulate that the problem is NOT done # Simulate that the problem is NOT done
block = CapaFactory.create(done=False) block = CapaFactory.create(done=False)
@@ -1564,6 +1604,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert ("success" in result) and (not result["success"]) assert ("success" in result) and (not result["success"])
def test_rescore_problem_correct(self): def test_rescore_problem_correct(self):
"""Ensure rescoring marks the problem correct without incrementing attempts."""
block = CapaFactory.create(attempts=0, done=True) block = CapaFactory.create(attempts=0, done=True)
@@ -1592,6 +1633,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1 assert block.lcp.context["attempt"] == 1
def test_rescore_problem_additional_correct(self): def test_rescore_problem_additional_correct(self):
"""Verify rescoring updates scores correctly when new correct answers are added."""
# make sure it also works when new correct answer has been added # make sure it also works when new correct answer has been added
block = CapaFactory.create(attempts=0) block = CapaFactory.create(attempts=0)
answer_id = CapaFactory.answer_key() answer_id = CapaFactory.answer_key()
@@ -1631,6 +1673,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1 assert block.lcp.context["attempt"] == 1
def test_rescore_problem_incorrect(self): def test_rescore_problem_incorrect(self):
"""Ensure rescoring marks the problem incorrect without changing attempts."""
# make sure it also works when attempts have been reset, # make sure it also works when attempts have been reset,
# so add this to the test: # so add this to the test:
block = CapaFactory.create(attempts=0, done=True) block = CapaFactory.create(attempts=0, done=True)
@@ -1844,6 +1887,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
mock_publish_grade.assert_called_with(score=Score(raw_earned=0.33, raw_possible=1), only_if_higher=False) mock_publish_grade.assert_called_with(score=Score(raw_earned=0.33, raw_possible=1), only_if_higher=False)
def test_rescore_problem_not_done(self): def test_rescore_problem_not_done(self):
"""Ensure rescoring an unfinished problem raises NotFoundError."""
# Simulate that the problem is NOT done # Simulate that the problem is NOT done
block = CapaFactory.create(done=False) block = CapaFactory.create(done=False)
@@ -1852,6 +1896,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
block.rescore(only_if_higher=False) block.rescore(only_if_higher=False)
def test_rescore_problem_not_supported(self): def test_rescore_problem_not_supported(self):
"""Ensure rescoring raises NotImplementedError when unsupported by the problem."""
block = CapaFactory.create(done=True) block = CapaFactory.create(done=True)
# Try to rescore the problem, and get exception # Try to rescore the problem, and get exception
@@ -1998,7 +2043,9 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
current_score.raw_possible, current_score.raw_possible,
) )
def capa_factory_for_problem_xml(self, xml): # lint-amnesty, pylint: disable=missing-function-docstring def capa_factory_for_problem_xml(self, xml):
"""Return a custom CapaFactory configured with the given problem XML."""
class CustomCapaFactory(CapaFactory): class CustomCapaFactory(CapaFactory):
""" """
A factory for creating a Capa problem with arbitrary xml. A factory for creating a Capa problem with arbitrary xml.
@@ -2009,6 +2056,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
return CustomCapaFactory return CustomCapaFactory
def test_codejail_error_upon_problem_creation(self): def test_codejail_error_upon_problem_creation(self):
"""Verify codejail execution errors during problem creation raise LoncapaProblemError."""
# Simulate a codejail safe_exec failure upon problem creation. # Simulate a codejail safe_exec failure upon problem creation.
# Create a problem with some script attached. # Create a problem with some script attached.
xml_str = textwrap.dedent( xml_str = textwrap.dedent(
@@ -2048,15 +2096,19 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.lcp.context["attempt"] == 1 assert block.lcp.context["attempt"] == 1
def test_rescore_problem_student_input_error(self): def test_rescore_problem_student_input_error(self):
"""Ensure StudentInputError during rescore is handled correctly."""
self._rescore_problem_error_helper(StudentInputError) self._rescore_problem_error_helper(StudentInputError)
def test_rescore_problem_problem_error(self): def test_rescore_problem_problem_error(self):
"""Ensure LoncapaProblemError during rescore is handled correctly."""
self._rescore_problem_error_helper(LoncapaProblemError) self._rescore_problem_error_helper(LoncapaProblemError)
def test_rescore_problem_response_error(self): def test_rescore_problem_response_error(self):
"""Ensure ResponseError during rescore is handled correctly."""
self._rescore_problem_error_helper(ResponseError) self._rescore_problem_error_helper(ResponseError)
def test_save_problem(self): def test_save_problem(self):
"""Verify saving a problem persists answers and returns success."""
block = CapaFactory.create(done=False) block = CapaFactory.create(done=False)
# Save the problem # Save the problem
@@ -2071,6 +2123,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert ("success" in result) and result["success"] assert ("success" in result) and result["success"]
def test_save_problem_closed(self): def test_save_problem_closed(self):
"""Ensure saving a closed problem fails."""
block = CapaFactory.create(done=False) block = CapaFactory.create(done=False)
# Simulate that the problem is closed # Simulate that the problem is closed
@@ -2086,6 +2139,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.ALWAYS, "true") @ddt.data(RANDOMIZATION.ALWAYS, "true")
def test_save_problem_submitted_with_randomize(self, rerandomize): def test_save_problem_submitted_with_randomize(self, rerandomize):
"""Verify saving fails when problem is submitted and rerandomization is enabled."""
# Capa XModule treats 'always' and 'true' equivalently # Capa XModule treats 'always' and 'true' equivalently
block = CapaFactory.create(rerandomize=rerandomize, done=True) block = CapaFactory.create(rerandomize=rerandomize, done=True)
@@ -2098,6 +2152,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.NEVER, "false", RANDOMIZATION.PER_STUDENT) @ddt.data(RANDOMIZATION.NEVER, "false", RANDOMIZATION.PER_STUDENT)
def test_save_problem_submitted_no_randomize(self, rerandomize): def test_save_problem_submitted_no_randomize(self, rerandomize):
"""Verify saving succeeds when problem is submitted without rerandomization."""
# Capa XBlock treats 'false' and 'per_student' equivalently # Capa XBlock treats 'false' and 'per_student' equivalently
block = CapaFactory.create(rerandomize=rerandomize, done=True) block = CapaFactory.create(rerandomize=rerandomize, done=True)
@@ -2109,14 +2164,17 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert ("success" in result) and result["success"] assert ("success" in result) and result["success"]
def test_submit_button_name(self): def test_submit_button_name(self):
"""Verify the submit button label is correct."""
block = CapaFactory.create(attempts=0) block = CapaFactory.create(attempts=0)
assert block.submit_button_name() == "Submit" assert block.submit_button_name() == "Submit"
def test_submit_button_submitting_name(self): def test_submit_button_submitting_name(self):
"""Verify the submitting button label is correct."""
block = CapaFactory.create(attempts=1, max_attempts=10) block = CapaFactory.create(attempts=1, max_attempts=10)
assert block.submit_button_submitting_name() == "Submitting" assert block.submit_button_submitting_name() == "Submitting"
def test_should_enable_submit_button(self): def test_should_enable_submit_button(self):
"""Verify submit button enablement logic across deadlines, attempts, and states."""
attempts = random.randint(1, 10) attempts = random.randint(1, 10)
@@ -2159,6 +2217,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.should_enable_submit_button() assert block.should_enable_submit_button()
def test_should_show_reset_button(self): def test_should_show_reset_button(self):
"""Verify reset button visibility logic across problem states and settings."""
attempts = random.randint(1, 10) attempts = random.randint(1, 10)
@@ -2203,6 +2262,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.should_show_reset_button() assert block.should_show_reset_button()
def test_should_show_save_button(self): def test_should_show_save_button(self):
"""Verify save button visibility logic across attempts, deadlines, and randomization."""
attempts = random.randint(1, 10) attempts = random.randint(1, 10)
@@ -2253,6 +2313,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.should_show_save_button() assert block.should_show_save_button()
def test_should_show_save_button_force_save_button(self): def test_should_show_save_button_force_save_button(self):
"""Verify force_save_button overrides normal save button visibility rules."""
# If we're after the deadline, do NOT show the save button # If we're after the deadline, do NOT show the save button
# even though we're forcing a save # even though we're forcing a save
block = CapaFactory.create(due=self.yesterday_str, force_save_button="true", done=True) block = CapaFactory.create(due=self.yesterday_str, force_save_button="true", done=True)
@@ -2273,6 +2334,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
assert block.should_show_save_button() assert block.should_show_save_button()
def test_no_max_attempts(self): def test_no_max_attempts(self):
"""Ensure problems with empty max_attempts render without errors."""
block = CapaFactory.create(max_attempts="") block = CapaFactory.create(max_attempts="")
html = block.get_problem_html() html = block.get_problem_html()
assert html is not None assert html is not None
@@ -2280,6 +2342,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@patch("xmodule.capa_block.render_to_string") @patch("xmodule.capa_block.render_to_string")
def test_get_problem_html(self, render_template): def test_get_problem_html(self, render_template):
"""Verify problem HTML rendering uses correct template context and encapsulation."""
render_template.return_value = "<div>Test Template HTML</div>" render_template.return_value = "<div>Test Template HTML</div>"
block = CapaFactory.create() block = CapaFactory.create()
@@ -2341,6 +2404,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@patch("xmodule.capa_block.render_to_string") @patch("xmodule.capa_block.render_to_string")
def test_demand_hint(self, render_template): def test_demand_hint(self, render_template):
"""Verify image-based demand hints render correctly without static URL issues."""
# HTML generation is mocked out to be meaningless here, so instead we check # HTML generation is mocked out to be meaningless here, so instead we check
# the context dict passed into HTML generation. # the context dict passed into HTML generation.
render_template.return_value = "<div>Test Template HTML</div>" render_template.return_value = "<div>Test Template HTML</div>"
@@ -2442,6 +2506,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
) )
def test_input_state_consistency(self): def test_input_state_consistency(self):
"""Verify input_state keys remain consistent and isolated across block instances."""
block1 = CapaFactory.create() block1 = CapaFactory.create()
block2 = CapaFactory.create() block2 = CapaFactory.create()
@@ -2538,6 +2603,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
"false", "true", RANDOMIZATION.NEVER, RANDOMIZATION.PER_STUDENT, RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET "false", "true", RANDOMIZATION.NEVER, RANDOMIZATION.PER_STUDENT, RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET
) )
def test_random_seed_no_change(self, rerandomize): def test_random_seed_no_change(self, rerandomize):
"""Verify problem seed remains stable when rerandomization does not apply."""
# Run the test for each possible rerandomize value # Run the test for each possible rerandomize value
@@ -2551,7 +2617,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
# If we're not rerandomizing, the seed is always set # If we're not rerandomizing, the seed is always set
# to the same value (1) # to the same value (1)
if rerandomize == RANDOMIZATION.NEVER: if rerandomize == RANDOMIZATION.NEVER:
assert seed == 1, "Seed should always be 1 when rerandomize='%s'" % rerandomize assert seed == 1, f"Seed should always be 1 when rerandomize='{rerandomize}'"
# Check the problem # Check the problem
get_request_dict = {CapaFactory.input_key(): "3.14"} get_request_dict = {CapaFactory.input_key(): "3.14"}
@@ -2665,6 +2731,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.data(RANDOMIZATION.ALWAYS, RANDOMIZATION.PER_STUDENT, "true", RANDOMIZATION.ONRESET) @ddt.data(RANDOMIZATION.ALWAYS, RANDOMIZATION.PER_STUDENT, "true", RANDOMIZATION.ONRESET)
def test_random_seed_bins(self, rerandomize): def test_random_seed_bins(self, rerandomize):
"""Ensure generated random seeds fall within the expected numeric range."""
# Assert that we are limiting the number of possible seeds. # Assert that we are limiting the number of possible seeds.
# Get a bunch of seeds, they should all be in 0-999. # Get a bunch of seeds, they should all be in 0-999.
i = 200 i = 200
@@ -2846,7 +2913,9 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
@ddt.ddt @ddt.ddt
class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring class ProblemBlockXMLTest(unittest.TestCase):
"""Tests XML strings for various problem types in XBlocks."""
sample_checkbox_problem_xml = textwrap.dedent( sample_checkbox_problem_xml = textwrap.dedent(
""" """
<problem> <problem>
@@ -3242,6 +3311,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_response_types_ignores_non_response_tags(self): def test_response_types_ignores_non_response_tags(self):
"""Ensure non-response XML tags are ignored when determining problem response types."""
xml = textwrap.dedent( xml = textwrap.dedent(
""" """
<problem> <problem>
@@ -3268,6 +3338,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_response_types_multiple_tags(self): def test_response_types_multiple_tags(self):
"""Verify indexing behavior when multiple response types are present in a single problem."""
xml = textwrap.dedent( xml = textwrap.dedent(
""" """
<problem> <problem>
@@ -3309,6 +3380,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
) )
def test_solutions_not_indexed(self): def test_solutions_not_indexed(self):
"""Confirm that solutions, scripts, styles, answers, and hints are excluded from indexing."""
xml = textwrap.dedent( xml = textwrap.dedent(
""" """
<problem> <problem>
@@ -3350,6 +3422,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_checkboxes(self): def test_indexing_checkboxes(self):
"""Verify correct indexing of checkbox-based problems and extracted content."""
name = "Checkboxes" name = "Checkboxes"
block = self._create_block(self.sample_checkbox_problem_xml, name=name) block = self._create_block(self.sample_checkbox_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3374,6 +3447,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_dropdown(self): def test_indexing_dropdown(self):
"""Verify correct indexing of dropdown-based problems and extracted content."""
name = "Dropdown" name = "Dropdown"
block = self._create_block(self.sample_dropdown_problem_xml, name=name) block = self._create_block(self.sample_dropdown_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3392,6 +3466,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_multiple_choice(self): def test_indexing_multiple_choice(self):
"""Verify correct indexing of multiple-choice problems and extracted content."""
name = "Multiple Choice" name = "Multiple Choice"
block = self._create_block(self.sample_multichoice_problem_xml, name=name) block = self._create_block(self.sample_multichoice_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3414,6 +3489,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_numerical_input(self): def test_indexing_numerical_input(self):
"""Verify correct indexing of numerical input problems and extracted content."""
name = "Numerical Input" name = "Numerical Input"
block = self._create_block(self.sample_numerical_input_problem_xml, name=name) block = self._create_block(self.sample_numerical_input_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3439,6 +3515,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_text_input(self): def test_indexing_text_input(self):
"""Verify correct indexing of text input problems and extracted content."""
name = "Text Input" name = "Text Input"
block = self._create_block(self.sample_text_input_problem_xml, name=name) block = self._create_block(self.sample_text_input_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3461,6 +3538,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_non_latin_problem(self): def test_indexing_non_latin_problem(self):
"""Ensure non-Latin characters are preserved correctly in indexed problem content."""
sample_text_input_problem_xml = textwrap.dedent( sample_text_input_problem_xml = textwrap.dedent(
""" """
<problem> <problem>
@@ -3477,6 +3555,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
assert block_dict["content"]["capa_content"] == smart_str(capa_content) assert block_dict["content"]["capa_content"] == smart_str(capa_content)
def test_indexing_checkboxes_with_hints_and_feedback(self): def test_indexing_checkboxes_with_hints_and_feedback(self):
"""Verify indexing of checkbox problems containing hints and feedback."""
name = "Checkboxes with Hints and Feedback" name = "Checkboxes with Hints and Feedback"
block = self._create_block(self.sample_checkboxes_with_hints_and_feedback_problem_xml, name=name) block = self._create_block(self.sample_checkboxes_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3504,6 +3583,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_dropdown_with_hints_and_feedback(self): def test_indexing_dropdown_with_hints_and_feedback(self):
"""Verify indexing of dropdown problems containing hints and feedback."""
name = "Dropdown with Hints and Feedback" name = "Dropdown with Hints and Feedback"
block = self._create_block(self.sample_dropdown_with_hints_and_feedback_problem_xml, name=name) block = self._create_block(self.sample_dropdown_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3527,6 +3607,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_multiple_choice_with_hints_and_feedback(self): def test_indexing_multiple_choice_with_hints_and_feedback(self):
"""Verify indexing of multiple-choice problems containing hints and feedback."""
name = "Multiple Choice with Hints and Feedback" name = "Multiple Choice with Hints and Feedback"
block = self._create_block(self.sample_multichoice_with_hints_and_feedback_problem_xml, name=name) block = self._create_block(self.sample_multichoice_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3550,6 +3631,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_numerical_input_with_hints_and_feedback(self): def test_indexing_numerical_input_with_hints_and_feedback(self):
"""Verify indexing of numerical input problems containing hints and feedback."""
name = "Numerical Input with Hints and Feedback" name = "Numerical Input with Hints and Feedback"
block = self._create_block(self.sample_numerical_input_with_hints_and_feedback_problem_xml, name=name) block = self._create_block(self.sample_numerical_input_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3571,6 +3653,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_text_input_with_hints_and_feedback(self): def test_indexing_text_input_with_hints_and_feedback(self):
"""Verify indexing of text input problems containing hints and feedback."""
name = "Text Input with Hints and Feedback" name = "Text Input with Hints and Feedback"
block = self._create_block(self.sample_text_input_with_hints_and_feedback_problem_xml, name=name) block = self._create_block(self.sample_text_input_with_hints_and_feedback_problem_xml, name=name)
capa_content = textwrap.dedent( capa_content = textwrap.dedent(
@@ -3592,6 +3675,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
} }
def test_indexing_problem_with_html_tags(self): def test_indexing_problem_with_html_tags(self):
"""Ensure HTML tags, comments, scripts, and styles are safely ignored during indexing."""
sample_problem_xml = textwrap.dedent( sample_problem_xml = textwrap.dedent(
""" """
<problem> <problem>
@@ -3678,7 +3762,8 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
CapaFactory.create(xml=problem_xml) CapaFactory.create(xml=problem_xml)
class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring class ComplexEncoderTest(unittest.TestCase):
"""Tests JSON encoding of complex numbers."""
def test_default(self): def test_default(self):
""" """
@@ -3692,7 +3777,7 @@ class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=mi
@skip_unless_lms @skip_unless_lms
@use_unsafe_codejail() @UseUnsafeCodejail()
class ProblemCheckTrackingTest(unittest.TestCase): class ProblemCheckTrackingTest(unittest.TestCase):
""" """
Ensure correct tracking information is included in events emitted during problem checks. Ensure correct tracking information is included in events emitted during problem checks.
@@ -3700,9 +3785,10 @@ class ProblemCheckTrackingTest(unittest.TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.maxDiff = None self.maxDiff = None # pylint: disable=invalid-name
def test_choice_answer_text(self): def test_choice_answer_text(self):
"""Verify tracked submission data for multiple choice, option, and checkbox responses."""
xml = """\ xml = """\
<problem display_name="Multiple Choice Questions"> <problem display_name="Multiple Choice Questions">
<optionresponse> <optionresponse>
@@ -3774,7 +3860,9 @@ class ProblemCheckTrackingTest(unittest.TestCase):
}, },
} }
def capa_factory_for_problem_xml(self, xml): # lint-amnesty, pylint: disable=missing-function-docstring def capa_factory_for_problem_xml(self, xml):
"""Create a custom CapaFactory for a given problem XML string."""
class CustomCapaFactory(CapaFactory): class CustomCapaFactory(CapaFactory):
""" """
A factory for creating a Capa problem with arbitrary xml. A factory for creating a Capa problem with arbitrary xml.
@@ -3784,9 +3872,8 @@ class ProblemCheckTrackingTest(unittest.TestCase):
return CustomCapaFactory return CustomCapaFactory
def get_event_for_answers( def get_event_for_answers(self, block, answer_input_dict):
self, block, answer_input_dict """Submit answers and return the emitted tracking event payload."""
): # lint-amnesty, pylint: disable=missing-function-docstring
with patch.object(block.runtime, "publish") as mock_publish: with patch.object(block.runtime, "publish") as mock_publish:
block.submit_problem(answer_input_dict) block.submit_problem(answer_input_dict)
@@ -3798,6 +3885,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
return event return event
def test_numerical_textline(self): def test_numerical_textline(self):
"""Verify tracking data for numerical textline responses."""
factory = CapaFactory factory = CapaFactory
block = factory.create() block = factory.create()
@@ -3817,21 +3905,20 @@ class ProblemCheckTrackingTest(unittest.TestCase):
} }
def test_multiple_inputs(self): def test_multiple_inputs(self):
"""Verify tracking data for multiple inputs within a single response group."""
group_label = "Choose the correct color" group_label = "Choose the correct color"
input1_label = "What color is the sky?" input1_label = "What color is the sky?"
input2_label = "What color are pine needles?" input2_label = "What color are pine needles?"
factory = self.capa_factory_for_problem_xml( factory = self.capa_factory_for_problem_xml(
"""\ f"""\
<problem display_name="Multiple Inputs"> <problem display_name="Multiple Inputs">
<optionresponse> <optionresponse>
<label>{}</label> <label>{group_label}</label>
<optioninput options="('yellow','blue','green')" correct="blue" label="{}"/> <optioninput options="('yellow','blue','green')" correct="blue" label="{input1_label}"/>
<optioninput options="('yellow','blue','green')" correct="green" label="{}"/> <optioninput options="('yellow','blue','green')" correct="green" label="{input2_label}"/>
</optionresponse> </optionresponse>
</problem> </problem>
""".format( """
group_label, input1_label, input2_label
)
) )
block = factory.create() block = factory.create()
answer_input_dict = { answer_input_dict = {
@@ -3867,11 +3954,11 @@ class ProblemCheckTrackingTest(unittest.TestCase):
input1_label = "input 1 label" input1_label = "input 1 label"
input2_label = "input 2 label" input2_label = "input 2 label"
factory = self.capa_factory_for_problem_xml( factory = self.capa_factory_for_problem_xml(
"""\ f"""\
<problem display_name="Woo Hoo"> <problem display_name="Woo Hoo">
<optionresponse> <optionresponse>
<label>{}</label> <label>{group_label}</label>
<optioninput label="{}"> <optioninput label="{input1_label}">
<option correct="True" label="Good Job"> <option correct="True" label="Good Job">
apple apple
<optionhint> <optionhint>
@@ -3886,7 +3973,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
</option> </option>
</optioninput> </optioninput>
<optioninput label="{}"> <optioninput label="{input2_label}">
<option correct="True"> <option correct="True">
apple apple
<optionhint> <optionhint>
@@ -3902,9 +3989,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
</optioninput> </optioninput>
</optionresponse> </optionresponse>
</problem> </problem>
""".format( """
group_label, input1_label, input2_label
)
) )
block = factory.create() block = factory.create()
@@ -3936,6 +4021,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
} }
def test_rerandomized_inputs(self): def test_rerandomized_inputs(self):
"""Ensure variant seed is included in tracking data for rerandomized problems."""
factory = CapaFactory factory = CapaFactory
block = factory.create(rerandomize=RANDOMIZATION.ALWAYS) block = factory.create(rerandomize=RANDOMIZATION.ALWAYS)
@@ -3957,9 +4043,10 @@ class ProblemCheckTrackingTest(unittest.TestCase):
@pytest.mark.django_db @pytest.mark.django_db
@patch.object(XQueueInterface, "_http_post") @patch.object(XQueueInterface, "_http_post")
def test_file_inputs(self, mock_xqueue_post): def test_file_inputs(self, mock_xqueue_post):
"""Verify tracking data for file submission and custom response inputs."""
fnames = ["prog1.py", "prog2.py", "prog3.py"] fnames = ["prog1.py", "prog2.py", "prog3.py"]
fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames] fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames]
fileobjs = [open(fpath) for fpath in fpaths] fileobjs = [open(fpath, encoding="utf-8") for fpath in fpaths] # pylint: disable=consider-using-with
for fileobj in fileobjs: for fileobj in fileobjs:
self.addCleanup(fileobj.close) self.addCleanup(fileobj.close)
@@ -4031,7 +4118,7 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
Ensure that Capa report generation works correctly Ensure that Capa report generation works correctly
""" """
def setUp(self): # lint-amnesty, pylint: disable=super-method-not-called def setUp(self):
self.find_question_label_patcher = patch( self.find_question_label_patcher = patch(
"xmodule.capa.capa_problem.LoncapaProblem.find_question_label", lambda self, answer_id: answer_id "xmodule.capa.capa_problem.LoncapaProblem.find_question_label", lambda self, answer_id: answer_id
) )
@@ -4063,7 +4150,8 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
scope=None, scope=None,
) )
def _get_block(self): # lint-amnesty, pylint: disable=missing-function-docstring def _get_block(self):
"""Create and return a mock ProblemBlock with default test data."""
scope_ids = Mock(block_type="problem") scope_ids = Mock(block_type="problem")
block = ProblemBlock(get_test_system(), scope_ids=scope_ids) block = ProblemBlock(get_test_system(), scope_ids=scope_ids)
block.runtime = Mock() block.runtime = Mock()
@@ -4071,17 +4159,20 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
return block return block
def test_generate_report_data_not_implemented(self): def test_generate_report_data_not_implemented(self):
"""Verify report generation is not supported for non-problem blocks."""
scope_ids = Mock(block_type="noproblem") scope_ids = Mock(block_type="noproblem")
block = ProblemBlock(get_test_system(), scope_ids=scope_ids) block = ProblemBlock(get_test_system(), scope_ids=scope_ids)
with pytest.raises(NotImplementedError): with pytest.raises(NotImplementedError):
next(block.generate_report_data(iter([]))) next(block.generate_report_data(iter([])))
def test_generate_report_data_limit_responses(self): def test_generate_report_data_limit_responses(self):
"""Ensure report generation respects the response limit."""
block = self._get_block() block = self._get_block()
report_data = list(block.generate_report_data(self._mock_user_state_generator(), 2)) report_data = list(block.generate_report_data(self._mock_user_state_generator(), 2))
assert 2 == len(report_data) assert 2 == len(report_data)
def test_generate_report_data_dont_limit_responses(self): def test_generate_report_data_dont_limit_responses(self):
"""Verify all responses are included when no limit is provided."""
block = self._get_block() block = self._get_block()
user_count = 5 user_count = 5
response_count = 10 response_count = 10
@@ -4096,16 +4187,18 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
assert (user_count * response_count) == len(report_data) assert (user_count * response_count) == len(report_data)
def test_generate_report_data_skip_dynamath(self): def test_generate_report_data_skip_dynamath(self):
"""Ensure Dynamath responses are excluded from reports."""
block = self._get_block() block = self._get_block()
iterator = iter([self._user_state(suffix="_dynamath")]) iterator = iter([self._user_state(suffix="_dynamath")])
report_data = list(block.generate_report_data(iterator)) report_data = list(block.generate_report_data(iterator))
assert 0 == len(report_data) assert 0 == len(report_data)
def test_generate_report_data_report_loncapa_error(self): def test_generate_report_data_report_loncapa_error(self):
"""Verify LonCapa errors are captured and reported instead of aborting."""
# Test to make sure reports continue despite loncappa errors, and write them into the report. # Test to make sure reports continue despite loncappa errors, and write them into the report.
block = self._get_block() block = self._get_block()
with patch("xmodule.capa_block.LoncapaProblem") as mock_LoncapaProblem: with patch("xmodule.capa_block.LoncapaProblem") as mock_loncapa_problem:
mock_LoncapaProblem.side_effect = LoncapaProblemError mock_loncapa_problem.side_effect = LoncapaProblemError
report_data = list( report_data = list(
block.generate_report_data( block.generate_report_data(
self._mock_user_state_generator( self._mock_user_state_generator(

View File

@@ -8,6 +8,7 @@ from xmodule.stringify import stringify_children
def test_stringify(): def test_stringify():
"""Test that `stringify_children` correctly concatenates text and child elements."""
text = 'Hi <div x="foo">there <span>Bruce</span><b>!</b></div>' text = 'Hi <div x="foo">there <span>Bruce</span><b>!</b></div>'
html = f"""<html a="b" foo="bar">{text}</html>""" html = f"""<html a="b" foo="bar">{text}</html>"""
xml = etree.fromstring(html) xml = etree.fromstring(html)
@@ -16,6 +17,7 @@ def test_stringify():
def test_stringify_again(): def test_stringify_again():
"""Test that `stringify_children` handles complex HTML without duplicating content."""
html = r"""<html name="Voltage Source Answer" >A voltage source is non-linear! html = r"""<html name="Voltage Source Answer" >A voltage source is non-linear!
<div align="center"> <div align="center">
<img src="/static/images/circuits/voltage-source.png"/> <img src="/static/images/circuits/voltage-source.png"/>

View File

@@ -1,11 +1,10 @@
# disable missing docstring """Unit tests for XBlock field serialization, deserialization, and XML attributes."""
# pylint: disable=missing-docstring
import unittest import unittest
from unittest.mock import Mock from unittest.mock import Mock
import dateutil.parser import dateutil.parser
from lxml import etree
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
from xblock.fields import Any, Boolean, Date, Dict, Float, Integer, List, RelativeTime, Scope, String, Timedelta from xblock.fields import Any, Boolean, Date, Dict, Float, Integer, List, RelativeTime, Scope, String, Timedelta
@@ -22,12 +21,17 @@ from xmodule.x_module import XModuleMixin
from xmodule.xml_block import XmlMixin, deserialize_field, serialize_field from xmodule.xml_block import XmlMixin, deserialize_field, serialize_field
class CrazyJsonString(String): class CrazyJsonString(String): # pylint: disable=too-few-public-methods
"""String field that appends ' JSON' when serialized."""
def to_json(self, value): def to_json(self, value):
"""Return the string value appended with ' JSON'."""
return value + " JSON" return value + " JSON"
class TestFields: class TestFields: # pylint: disable=too-few-public-methods
"""XBlock fields for testing editable and inherited behavior."""
# Will be returned by editable_metadata_fields. # Will be returned by editable_metadata_fields.
max_attempts = Integer(scope=Scope.settings, default=1000, values={"min": 1, "max": 10}) max_attempts = Integer(scope=Scope.settings, default=1000, values={"min": 1, "max": 10})
# Will not be returned by editable_metadata_fields because filtered out by non_editable_metadata_fields. # Will not be returned by editable_metadata_fields because filtered out by non_editable_metadata_fields.
@@ -60,7 +64,7 @@ class InheritingFieldDataTest(unittest.TestCase):
Tests of InheritingFieldData. Tests of InheritingFieldData.
""" """
class TestableInheritingXBlock(XmlMixin): # lint-amnesty, pylint: disable=abstract-method class TestableInheritingXBlock(XmlMixin):
""" """
An XBlock we can use in these tests. An XBlock we can use in these tests.
""" """
@@ -68,6 +72,13 @@ class InheritingFieldDataTest(unittest.TestCase):
inherited = String(scope=Scope.settings, default="the default") inherited = String(scope=Scope.settings, default="the default")
not_inherited = String(scope=Scope.settings, default="nothing") not_inherited = String(scope=Scope.settings, default="nothing")
@classmethod
def definition_from_xml(cls, xml_object, system):
return {}, []
def definition_to_xml(self, resource_fs):
return etree.Element("test_block")
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.dummy_course_key = CourseLocator("test_org", "test_123", "test_run") self.dummy_course_key = CourseLocator("test_org", "test_123", "test_run")
@@ -178,8 +189,8 @@ class InheritingFieldDataTest(unittest.TestCase):
parent_block = self.get_block_using_split_kvs( parent_block = self.get_block_using_split_kvs(
block_type="library_content", block_type="library_content",
block_id="parent", block_id="parent",
fields=dict(inherited="changed!"), fields={"inherited": "changed!"},
defaults=dict(inherited="parent's default"), defaults={"inherited": "parent's default"},
) )
assert parent_block.inherited == "changed!" assert parent_block.inherited == "changed!"
@@ -200,8 +211,8 @@ class InheritingFieldDataTest(unittest.TestCase):
parent_block = self.get_block_using_split_kvs( parent_block = self.get_block_using_split_kvs(
block_type="library_content", block_type="library_content",
block_id="parent", block_id="parent",
fields=dict(inherited="changed!"), fields={"inherited": "changed!"},
defaults=dict(inherited="parent's default"), defaults={"inherited": "parent's default"},
) )
assert parent_block.inherited == "changed!" assert parent_block.inherited == "changed!"
@@ -209,19 +220,29 @@ class InheritingFieldDataTest(unittest.TestCase):
block_type="library_content", block_type="library_content",
block_id="parent", block_id="parent",
fields={}, fields={},
defaults=dict(inherited="child's default"), defaults={"inherited": "child's default"},
) )
child.parent = parent_block.location child.parent = parent_block.location
assert child.inherited == "child's default" assert child.inherited == "child's default"
class EditableMetadataFieldsTest(unittest.TestCase): class EditableMetadataFieldsTest(unittest.TestCase):
class TestableXmlXBlock(XmlMixin, XModuleMixin): # lint-amnesty, pylint: disable=abstract-method """Tests editable metadata fields and their serialization."""
class TestableXmlXBlock(XmlMixin, XModuleMixin):
""" """
This is subclassing `XModuleMixin` to use metadata fields in the unmixed class. This is subclassing `XModuleMixin` to use metadata fields in the unmixed class.
""" """
@classmethod
def definition_from_xml(cls, xml_object, system):
return {}, []
def definition_to_xml(self, resource_fs):
return etree.Element("test_block")
def test_display_name_field(self): def test_display_name_field(self):
"""Test filtering and values of display_name editable metadata field."""
editable_fields = self.get_xml_editable_fields(DictFieldData({})) editable_fields = self.get_xml_editable_fields(DictFieldData({}))
# Tests that the xblock fields (currently tags and name) get filtered out. # Tests that the xblock fields (currently tags and name) get filtered out.
# Also tests that xml_attributes is filtered out of XmlMixin. # Also tests that xml_attributes is filtered out of XmlMixin.
@@ -236,6 +257,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
) )
def test_override_default(self): def test_override_default(self):
"""Test explicitly set values override defaults for editable fields."""
# Tests that explicitly_set is correct when a value overrides the default (not inheritable). # Tests that explicitly_set is correct when a value overrides the default (not inheritable).
editable_fields = self.get_xml_editable_fields(DictFieldData({"display_name": "foo"})) editable_fields = self.get_xml_editable_fields(DictFieldData({"display_name": "foo"}))
self.assert_field_values( self.assert_field_values(
@@ -248,6 +270,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
) )
def test_integer_field(self): def test_integer_field(self):
"""Test serialization and options of Integer metadata fields."""
block = self.get_block(DictFieldData({"max_attempts": "7"})) block = self.get_block(DictFieldData({"max_attempts": "7"}))
editable_fields = block.editable_metadata_fields editable_fields = block.editable_metadata_fields
assert 8 == len(editable_fields) assert 8 == len(editable_fields)
@@ -283,6 +306,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
) )
def test_inherited_field(self): def test_inherited_field(self):
"""Test inheritance behavior of editable metadata fields."""
kvs = InheritanceKeyValueStore(initial_values={}, inherited_settings={"showanswer": "inherited"}) kvs = InheritanceKeyValueStore(initial_values={}, inherited_settings={"showanswer": "inherited"})
model_data = KvsFieldData(kvs) model_data = KvsFieldData(kvs)
block = self.get_block(model_data) block = self.get_block(model_data)
@@ -313,6 +337,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
) )
def test_type_and_options(self): def test_type_and_options(self):
"""Test type and options representation of various editable metadata fields."""
# test_display_name_field verifies that a String field is of type "Generic". # test_display_name_field verifies that a String field is of type "Generic".
# test_integer_field verifies that a Integer field is of type "Integer". # test_integer_field verifies that a Integer field is of type "Integer".
@@ -380,6 +405,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
# Start of helper methods # Start of helper methods
def get_xml_editable_fields(self, field_data): def get_xml_editable_fields(self, field_data):
"""Return editable fields from a test XML XBlock with given field data."""
runtime = get_test_descriptor_system() runtime = get_test_descriptor_system()
return runtime.construct_xblock_from_class( return runtime.construct_xblock_from_class(
self.TestableXmlXBlock, self.TestableXmlXBlock,
@@ -388,7 +414,11 @@ class EditableMetadataFieldsTest(unittest.TestCase):
).editable_metadata_fields ).editable_metadata_fields
def get_block(self, field_data): def get_block(self, field_data):
class TestModuleBlock(TestFields, self.TestableXmlXBlock): # lint-amnesty, pylint: disable=abstract-method """Construct a test XBlock combining test fields and XML behavior."""
class TestModuleBlock(TestFields, self.TestableXmlXBlock): # pylint: disable=too-many-ancestors
"""Test XBlock class combining test fields and XML behavior, overriding non-editable fields."""
@property @property
def non_editable_metadata_fields(self): def non_editable_metadata_fields(self):
non_editable_fields = super().non_editable_metadata_fields non_editable_fields = super().non_editable_metadata_fields
@@ -398,7 +428,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
system = get_test_descriptor_system(render_template=Mock()) system = get_test_descriptor_system(render_template=Mock())
return system.construct_xblock_from_class(TestModuleBlock, field_data=field_data, scope_ids=Mock()) return system.construct_xblock_from_class(TestModuleBlock, field_data=field_data, scope_ids=Mock())
def assert_field_values( # lint-amnesty, pylint: disable=dangerous-default-value def assert_field_values( # pylint: disable=dangerous-default-value,too-many-arguments,too-many-positional-arguments
self, self,
editable_fields, editable_fields,
name, name,
@@ -406,9 +436,10 @@ class EditableMetadataFieldsTest(unittest.TestCase):
explicitly_set, explicitly_set,
value, value,
default_value, default_value,
type="Generic", type="Generic", # pylint: disable=redefined-builtin
options=[], options=[],
): # lint-amnesty, pylint: disable=redefined-builtin ):
"""Assert correctness of field values, type, options, and explicitness."""
test_field = editable_fields[name] test_field = editable_fields[name]
assert field.name == test_field["field_name"] assert field.name == test_field["field_name"]
@@ -428,6 +459,7 @@ class TestSerialize(unittest.TestCase):
"""Tests the serialize, method, which is not dependent on type.""" """Tests the serialize, method, which is not dependent on type."""
def test_serialize(self): def test_serialize(self):
"""Test serialization of various field types to JSON-compatible strings."""
assert serialize_field(None) == "null" assert serialize_field(None) == "null"
assert serialize_field(-2) == "-2" assert serialize_field(-2) == "-2"
assert serialize_field("2") == "2" assert serialize_field("2") == "2"
@@ -438,11 +470,7 @@ class TestSerialize(unittest.TestCase):
assert serialize_field("fAlse") == "fAlse" assert serialize_field("fAlse") == "fAlse"
assert serialize_field("hat box") == "hat box" assert serialize_field("hat box") == "hat box"
serialized_dict = serialize_field({"bar": "hat", "frog": "green"}) serialized_dict = serialize_field({"bar": "hat", "frog": "green"})
assert ( assert serialized_dict in ('{"bar": "hat", "frog": "green"}', '{"frog": "green", "bar": "hat"}')
serialized_dict # lint-amnesty, pylint: disable=consider-using-in, line-too-long
== '{"bar": "hat", "frog": "green"}'
or serialized_dict == '{"frog": "green", "bar": "hat"}'
)
assert serialize_field([3.5, 5.6]) == "[3.5, 5.6]" assert serialize_field([3.5, 5.6]) == "[3.5, 5.6]"
assert serialize_field(["foo", "bar"]) == '["foo", "bar"]' assert serialize_field(["foo", "bar"]) == '["foo", "bar"]'
assert serialize_field("2012-12-31T23:59:59Z") == "2012-12-31T23:59:59Z" assert serialize_field("2012-12-31T23:59:59Z") == "2012-12-31T23:59:59Z"
@@ -451,25 +479,26 @@ class TestSerialize(unittest.TestCase):
class TestDeserialize(unittest.TestCase): class TestDeserialize(unittest.TestCase):
"""Base class for testing deserialization of field values."""
def assertDeserializeEqual(self, expected, arg): def assert_deserialize_equal(self, expected, arg):
""" """
Asserts the result of deserialize_field. Asserts the result of deserialize_field.
""" """
assert deserialize_field(self.field_type(), arg) == expected # lint-amnesty, pylint: disable=no-member assert deserialize_field(self.field_type(), arg) == expected # pylint: disable=no-member
def assertDeserializeNonString(self): def assert_deserialize_non_string(self):
""" """
Asserts input value is returned for None or something that is not a string. Asserts input value is returned for None or something that is not a string.
For all types, 'null' is also always returned as None. For all types, 'null' is also always returned as None.
""" """
self.assertDeserializeEqual(None, None) self.assert_deserialize_equal(None, None)
self.assertDeserializeEqual(3.14, 3.14) self.assert_deserialize_equal(3.14, 3.14)
self.assertDeserializeEqual(True, True) self.assert_deserialize_equal(True, True)
self.assertDeserializeEqual([10], [10]) self.assert_deserialize_equal([10], [10])
self.assertDeserializeEqual({}, {}) self.assert_deserialize_equal({}, {})
self.assertDeserializeEqual([], []) self.assert_deserialize_equal([], [])
self.assertDeserializeEqual(None, "null") self.assert_deserialize_equal(None, "null")
class TestDeserializeInteger(TestDeserialize): class TestDeserializeInteger(TestDeserialize):
@@ -478,23 +507,25 @@ class TestDeserializeInteger(TestDeserialize):
field_type = Integer field_type = Integer
def test_deserialize(self): def test_deserialize(self):
self.assertDeserializeEqual(-2, "-2") """Test deserialization of Integer field values."""
self.assertDeserializeEqual("450", '"450"') self.assert_deserialize_equal(-2, "-2")
self.assert_deserialize_equal("450", '"450"')
# False can be parsed as a int (converts to 0) # False can be parsed as a int (converts to 0)
self.assertDeserializeEqual(False, "false") self.assert_deserialize_equal(False, "false")
# True can be parsed as a int (converts to 1) # True can be parsed as a int (converts to 1)
self.assertDeserializeEqual(True, "true") self.assert_deserialize_equal(True, "true")
# 2.78 can be converted to int, so the string will be deserialized # 2.78 can be converted to int, so the string will be deserialized
self.assertDeserializeEqual(-2.78, "-2.78") self.assert_deserialize_equal(-2.78, "-2.78")
def test_deserialize_unsupported_types(self): def test_deserialize_unsupported_types(self):
self.assertDeserializeEqual("[3]", "[3]") """Test deserialization handles unsupported Integer inputs gracefully."""
self.assert_deserialize_equal("[3]", "[3]")
# '2.78' cannot be converted to int, so input value is returned # '2.78' cannot be converted to int, so input value is returned
self.assertDeserializeEqual('"-2.78"', '"-2.78"') self.assert_deserialize_equal('"-2.78"', '"-2.78"')
# 'false' cannot be converted to int, so input value is returned # 'false' cannot be converted to int, so input value is returned
self.assertDeserializeEqual('"false"', '"false"') self.assert_deserialize_equal('"false"', '"false"')
self.assertDeserializeNonString() self.assert_deserialize_non_string()
class TestDeserializeFloat(TestDeserialize): class TestDeserializeFloat(TestDeserialize):
@@ -503,21 +534,23 @@ class TestDeserializeFloat(TestDeserialize):
field_type = Float field_type = Float
def test_deserialize(self): def test_deserialize(self):
self.assertDeserializeEqual(-2, "-2") """Test deserialization of Float field values."""
self.assertDeserializeEqual("450", '"450"') self.assert_deserialize_equal(-2, "-2")
self.assertDeserializeEqual(-2.78, "-2.78") self.assert_deserialize_equal("450", '"450"')
self.assertDeserializeEqual("0.45", '"0.45"') self.assert_deserialize_equal(-2.78, "-2.78")
self.assert_deserialize_equal("0.45", '"0.45"')
# False can be parsed as a float (converts to 0) # False can be parsed as a float (converts to 0)
self.assertDeserializeEqual(False, "false") self.assert_deserialize_equal(False, "false")
# True can be parsed as a float (converts to 1) # True can be parsed as a float (converts to 1)
self.assertDeserializeEqual(True, "true") self.assert_deserialize_equal(True, "true")
def test_deserialize_unsupported_types(self): def test_deserialize_unsupported_types(self):
self.assertDeserializeEqual("[3]", "[3]") """Test deserialization handles unsupported Float inputs gracefully."""
self.assert_deserialize_equal("[3]", "[3]")
# 'false' cannot be converted to float, so input value is returned # 'false' cannot be converted to float, so input value is returned
self.assertDeserializeEqual('"false"', '"false"') self.assert_deserialize_equal('"false"', '"false"')
self.assertDeserializeNonString() self.assert_deserialize_non_string()
class TestDeserializeBoolean(TestDeserialize): class TestDeserializeBoolean(TestDeserialize):
@@ -526,23 +559,24 @@ class TestDeserializeBoolean(TestDeserialize):
field_type = Boolean field_type = Boolean
def test_deserialize(self): def test_deserialize(self):
"""Test deserialization of Boolean field values."""
# json.loads converts the value to Python bool # json.loads converts the value to Python bool
self.assertDeserializeEqual(False, "false") self.assert_deserialize_equal(False, "false")
self.assertDeserializeEqual(True, "true") self.assert_deserialize_equal(True, "true")
# json.loads fails, string value is returned. # json.loads fails, string value is returned.
self.assertDeserializeEqual("False", "False") self.assert_deserialize_equal("False", "False")
self.assertDeserializeEqual("True", "True") self.assert_deserialize_equal("True", "True")
# json.loads deserializes as a string # json.loads deserializes as a string
self.assertDeserializeEqual("false", '"false"') self.assert_deserialize_equal("false", '"false"')
self.assertDeserializeEqual("fAlse", '"fAlse"') self.assert_deserialize_equal("fAlse", '"fAlse"')
self.assertDeserializeEqual("TruE", '"TruE"') self.assert_deserialize_equal("TruE", '"TruE"')
# 2.78 can be converted to a bool, so the string will be deserialized # 2.78 can be converted to a bool, so the string will be deserialized
self.assertDeserializeEqual(-2.78, "-2.78") self.assert_deserialize_equal(-2.78, "-2.78")
self.assertDeserializeNonString() self.assert_deserialize_non_string()
class TestDeserializeString(TestDeserialize): class TestDeserializeString(TestDeserialize):
@@ -551,16 +585,18 @@ class TestDeserializeString(TestDeserialize):
field_type = String field_type = String
def test_deserialize(self): def test_deserialize(self):
self.assertDeserializeEqual("hAlf", '"hAlf"') """Test deserialization of String field values."""
self.assertDeserializeEqual("false", '"false"') self.assert_deserialize_equal("hAlf", '"hAlf"')
self.assertDeserializeEqual("single quote", "single quote") self.assert_deserialize_equal("false", '"false"')
self.assert_deserialize_equal("single quote", "single quote")
def test_deserialize_unsupported_types(self): def test_deserialize_unsupported_types(self):
self.assertDeserializeEqual("3.4", "3.4") """Test deserialization handles unsupported String inputs gracefully."""
self.assertDeserializeEqual("false", "false") self.assert_deserialize_equal("3.4", "3.4")
self.assertDeserializeEqual("2", "2") self.assert_deserialize_equal("false", "false")
self.assertDeserializeEqual("[3]", "[3]") self.assert_deserialize_equal("2", "2")
self.assertDeserializeNonString() self.assert_deserialize_equal("[3]", "[3]")
self.assert_deserialize_non_string()
class TestDeserializeAny(TestDeserialize): class TestDeserializeAny(TestDeserialize):
@@ -569,14 +605,15 @@ class TestDeserializeAny(TestDeserialize):
field_type = Any field_type = Any
def test_deserialize(self): def test_deserialize(self):
self.assertDeserializeEqual("hAlf", '"hAlf"') """Test deserialization of Any-type field values."""
self.assertDeserializeEqual("false", '"false"') self.assert_deserialize_equal("hAlf", '"hAlf"')
self.assertDeserializeEqual({"bar": "hat", "frog": "green"}, '{"bar": "hat", "frog": "green"}') self.assert_deserialize_equal("false", '"false"')
self.assertDeserializeEqual([3.5, 5.6], "[3.5, 5.6]") self.assert_deserialize_equal({"bar": "hat", "frog": "green"}, '{"bar": "hat", "frog": "green"}')
self.assertDeserializeEqual("[", "[") self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]")
self.assertDeserializeEqual(False, "false") self.assert_deserialize_equal("[", "[")
self.assertDeserializeEqual(3.4, "3.4") self.assert_deserialize_equal(False, "false")
self.assertDeserializeNonString() self.assert_deserialize_equal(3.4, "3.4")
self.assert_deserialize_non_string()
class TestDeserializeList(TestDeserialize): class TestDeserializeList(TestDeserialize):
@@ -585,26 +622,29 @@ class TestDeserializeList(TestDeserialize):
field_type = List field_type = List
def test_deserialize(self): def test_deserialize(self):
self.assertDeserializeEqual(["foo", "bar"], '["foo", "bar"]') """Test deserialization of List field values."""
self.assertDeserializeEqual([3.5, 5.6], "[3.5, 5.6]") self.assert_deserialize_equal(["foo", "bar"], '["foo", "bar"]')
self.assertDeserializeEqual([], "[]") self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]")
self.assert_deserialize_equal([], "[]")
def test_deserialize_unsupported_types(self): def test_deserialize_unsupported_types(self):
self.assertDeserializeEqual("3.4", "3.4") """Test deserialization handles unsupported List inputs gracefully."""
self.assertDeserializeEqual("false", "false") self.assert_deserialize_equal("3.4", "3.4")
self.assertDeserializeEqual("2", "2") self.assert_deserialize_equal("false", "false")
self.assertDeserializeNonString() self.assert_deserialize_equal("2", "2")
self.assert_deserialize_non_string()
class TestDeserializeDate(TestDeserialize): class TestDeserializeDate(TestDeserialize):
"""Tests deserialize as related to Date type.""" """Test deserialization of Date field values."""
field_type = Date field_type = Date
def test_deserialize(self): def test_deserialize(self):
self.assertDeserializeEqual("2012-12-31T23:59:59Z", "2012-12-31T23:59:59Z") """Test deserialization of Timedelta field values."""
self.assertDeserializeEqual("2012-12-31T23:59:59Z", '"2012-12-31T23:59:59Z"') self.assert_deserialize_equal("2012-12-31T23:59:59Z", "2012-12-31T23:59:59Z")
self.assertDeserializeNonString() self.assert_deserialize_equal("2012-12-31T23:59:59Z", '"2012-12-31T23:59:59Z"')
self.assert_deserialize_non_string()
class TestDeserializeTimedelta(TestDeserialize): class TestDeserializeTimedelta(TestDeserialize):
@@ -613,9 +653,10 @@ class TestDeserializeTimedelta(TestDeserialize):
field_type = Timedelta field_type = Timedelta
def test_deserialize(self): def test_deserialize(self):
self.assertDeserializeEqual("1 day 12 hours 59 minutes 59 seconds", "1 day 12 hours 59 minutes 59 seconds") """Test deserialization of RelativeTime field values."""
self.assertDeserializeEqual("1 day 12 hours 59 minutes 59 seconds", '"1 day 12 hours 59 minutes 59 seconds"') self.assert_deserialize_equal("1 day 12 hours 59 minutes 59 seconds", "1 day 12 hours 59 minutes 59 seconds")
self.assertDeserializeNonString() self.assert_deserialize_equal("1 day 12 hours 59 minutes 59 seconds", '"1 day 12 hours 59 minutes 59 seconds"')
self.assert_deserialize_non_string()
class TestDeserializeRelativeTime(TestDeserialize): class TestDeserializeRelativeTime(TestDeserialize):
@@ -627,8 +668,8 @@ class TestDeserializeRelativeTime(TestDeserialize):
""" """
There is no check for There is no check for
self.assertDeserializeEqual('10:20:30', '10:20:30') self.assert_deserialize_equal('10:20:30', '10:20:30')
self.assertDeserializeNonString() self.assert_deserialize_non_string()
because these two tests work only because json.loads fires exception, because these two tests work only because json.loads fires exception,
and xml_module.deserialized_field catches it and returns same value, and xml_module.deserialized_field catches it and returns same value,
@@ -637,24 +678,28 @@ class TestDeserializeRelativeTime(TestDeserialize):
""" """
# test that from_json produces no exceptions # test that from_json produces no exceptions
self.assertDeserializeEqual("10:20:30", '"10:20:30"') self.assert_deserialize_equal("10:20:30", '"10:20:30"')
class TestXmlAttributes(XModuleXmlImportTest): class TestXmlAttributes(XModuleXmlImportTest):
"""Tests XML import/export of XBlock attributes, including known, unknown, and inheritable attributes."""
def test_unknown_attribute(self): def test_unknown_attribute(self):
"""Test processing and retention of unknown XML attributes."""
assert not hasattr(CourseBlock, "unknown_attr") assert not hasattr(CourseBlock, "unknown_attr")
course = self.process_xml(CourseFactory.build(unknown_attr="value")) course = self.process_xml(CourseFactory.build(unknown_attr="value"))
assert not hasattr(course, "unknown_attr") assert not hasattr(course, "unknown_attr")
assert course.xml_attributes["unknown_attr"] == "value" assert course.xml_attributes["unknown_attr"] == "value"
def test_known_attribute(self): def test_known_attribute(self):
"""Test that known XML attributes are correctly assigned to XBlock fields."""
assert hasattr(CourseBlock, "show_calculator") assert hasattr(CourseBlock, "show_calculator")
course = self.process_xml(CourseFactory.build(show_calculator="true")) course = self.process_xml(CourseFactory.build(show_calculator="true"))
assert course.show_calculator assert course.show_calculator
assert "show_calculator" not in course.xml_attributes assert "show_calculator" not in course.xml_attributes
def test_rerandomize_in_policy(self): def test_rerandomize_in_policy(self):
"""Test that rerandomize attribute from policy is correctly processed."""
# Rerandomize isn't a basic attribute of Sequence # Rerandomize isn't a basic attribute of Sequence
assert not hasattr(SequenceBlock, "rerandomize") assert not hasattr(SequenceBlock, "rerandomize")
@@ -671,6 +716,7 @@ class TestXmlAttributes(XModuleXmlImportTest):
assert "rerandomize" not in seq.xml_attributes assert "rerandomize" not in seq.xml_attributes
def test_attempts_in_policy(self): def test_attempts_in_policy(self):
"""Test that attempts attribute from policy is correctly handled in XML import."""
# attempts isn't a basic attribute of Sequence # attempts isn't a basic attribute of Sequence
assert not hasattr(SequenceBlock, "attempts") assert not hasattr(SequenceBlock, "attempts")
@@ -689,6 +735,7 @@ class TestXmlAttributes(XModuleXmlImportTest):
assert "attempts" in seq.xml_attributes assert "attempts" in seq.xml_attributes
def check_inheritable_attribute(self, attribute, value): def check_inheritable_attribute(self, attribute, value):
"""Check that an inheritable attribute is correctly processed and excluded from XML attributes."""
# `attribute` isn't a basic attribute of Sequence # `attribute` isn't a basic attribute of Sequence
assert not hasattr(SequenceBlock, attribute) assert not hasattr(SequenceBlock, attribute)
@@ -715,6 +762,7 @@ class TestXmlAttributes(XModuleXmlImportTest):
assert attribute not in seq.xml_attributes assert attribute not in seq.xml_attributes
def test_inheritable_attributes(self): def test_inheritable_attributes(self):
"""Check multiple inheritable attributes are processed correctly from XML."""
self.check_inheritable_attribute("days_early_for_beta", 2) self.check_inheritable_attribute("days_early_for_beta", 2)
self.check_inheritable_attribute("max_attempts", 5) self.check_inheritable_attribute("max_attempts", 5)
self.check_inheritable_attribute("visible_to_staff_only", True) self.check_inheritable_attribute("visible_to_staff_only", True)

View File

@@ -1,4 +1,4 @@
# lint-amnesty, pylint: disable=missing-module-docstring """Utilities for managing course code libraries and sandbox execution."""
import re import re
@@ -51,8 +51,8 @@ def get_python_lib_zip(contentstore, context_key: LearningContextKey):
zip_lib = contentstore().find(asset_key, throw_on_not_found=False) zip_lib = contentstore().find(asset_key, throw_on_not_found=False)
if zip_lib is not None: if zip_lib is not None:
return zip_lib.data return zip_lib.data
else:
return None return None
class SandboxService: class SandboxService:

View File

@@ -1,12 +1,13 @@
# lint-amnesty, pylint: disable=missing-module-docstring # pylint: disable=too-many-lines
"""Core classes, mixins, and utilities for XModules and XBlock integration."""
import importlib.resources as resources
import logging import logging
import os import os
import time import time
import warnings import warnings
from collections import namedtuple from collections import namedtuple
from functools import partial from functools import partial
from importlib.resources import as_file, files
import yaml import yaml
from django.conf import settings from django.conf import settings
@@ -195,7 +196,7 @@ def shim_xmodule_js(fragment, js_module_name):
""" """
# Delay this import so that it is only used (and django settings are parsed) when # Delay this import so that it is only used (and django settings are parsed) when
# they are required (rather than at startup) # they are required (rather than at startup)
import webpack_loader.utils # lint-amnesty, pylint: disable=unused-import import webpack_loader.utils # pylint: disable=unused-import,import-outside-toplevel
if not fragment.js_init_fn: if not fragment.js_init_fn:
fragment.initialize_js("XBlockToXModuleShim") fragment.initialize_js("XBlockToXModuleShim")
@@ -204,7 +205,7 @@ def shim_xmodule_js(fragment, js_module_name):
add_webpack_js_to_fragment(fragment, "XModuleShim") add_webpack_js_to_fragment(fragment, "XModuleShim")
class XModuleFields: class XModuleFields: # pylint: disable=too-few-public-methods
""" """
Common fields for XModules. Common fields for XModules.
""" """
@@ -220,7 +221,7 @@ class XModuleFields:
@XBlock.needs("i18n") @XBlock.needs("i18n")
class XModuleMixin(XModuleFields, XBlock): class XModuleMixin(XModuleFields, XBlock): # pylint: disable=too-many-public-methods
""" """
Fields and methods used by XModules internally. Fields and methods used by XModules internally.
@@ -273,6 +274,7 @@ class XModuleMixin(XModuleFields, XBlock):
@property @property
def runtime(self): def runtime(self):
"""Return the runtime for this XBlock instance."""
return self._runtime return self._runtime
@runtime.setter @runtime.setter
@@ -309,14 +311,17 @@ class XModuleMixin(XModuleFields, XBlock):
@property @property
def course_id(self): def course_id(self):
"""Return the course key for this block."""
return self.location.course_key return self.location.course_key
@property @property
def category(self): def category(self):
"""Return the block type/category."""
return self.scope_ids.block_type return self.scope_ids.block_type
@property @property
def location(self): def location(self):
"""Return the usage key identifying this block instance."""
return self.scope_ids.usage_id return self.scope_ids.usage_id
@location.setter @location.setter
@@ -329,6 +334,7 @@ class XModuleMixin(XModuleFields, XBlock):
@property @property
def url_name(self): def url_name(self):
"""Return the URL-friendly name for this block."""
return block_metadata_utils.url_name_for_block(self) return block_metadata_utils.url_name_for_block(self)
@property @property
@@ -391,15 +397,13 @@ class XModuleMixin(XModuleFields, XBlock):
any set to None.) any set to None.)
""" """
result = {} result = {}
for field in self.fields.values(): # lint-amnesty, pylint: disable=no-member for field in self.fields.values():
if field.scope == scope and field.is_set_on(self): if field.scope == scope and field.is_set_on(self):
try: try:
result[field.name] = field.read_json(self) result[field.name] = field.read_json(self)
except TypeError as exception: except TypeError as exception:
exception_message = "{message}, Block-location:{location}, Field-name:{field_name}".format( exception_message = f"{exception}, Block-location:{self.location}, Field-name:{field.name}"
message=str(exception), location=str(self.location), field_name=field.name raise TypeError(exception_message) from exception
)
raise TypeError(exception_message) # lint-amnesty, pylint: disable=raise-missing-from
return result return result
def has_children_at_depth(self, depth): def has_children_at_depth(self, depth):
@@ -418,12 +422,13 @@ class XModuleMixin(XModuleFields, XBlock):
So the example above would return True for `has_children_at_depth(2)`, and False So the example above would return True for `has_children_at_depth(2)`, and False
for depth > 2 for depth > 2
""" """
if depth < 0: # lint-amnesty, pylint: disable=no-else-raise if depth < 0:
raise ValueError("negative depth argument is invalid") raise ValueError("negative depth argument is invalid")
elif depth == 0:
if depth == 0:
return bool(self.get_children()) return bool(self.get_children())
else:
return any(child.has_children_at_depth(depth - 1) for child in self.get_children()) return any(child.has_children_at_depth(depth - 1) for child in self.get_children())
def get_content_titles(self): def get_content_titles(self):
r""" r"""
@@ -447,11 +452,11 @@ class XModuleMixin(XModuleFields, XBlock):
""" """
if self.has_children: if self.has_children:
return sum((child.get_content_titles() for child in self.get_children()), []) return sum((child.get_content_titles() for child in self.get_children()), [])
else:
# xss-lint: disable=python-deprecated-display-name
return [self.display_name_with_default_escaped]
def get_children(self, usage_id_filter=None, usage_key_filter=None): # pylint: disable=arguments-differ # xss-lint: disable=python-deprecated-display-name
return [self.display_name_with_default_escaped]
def get_children(self, usage_id_filter=None, usage_key_filter=None):
"""Returns a list of XBlock instances for the children of """Returns a list of XBlock instances for the children of
this module""" this module"""
@@ -580,7 +585,7 @@ class XModuleMixin(XModuleFields, XBlock):
self.clear_child_cache() self.clear_child_cache()
# Clear out any cached field data scoped to the old user. # Clear out any cached field data scoped to the old user.
for field in self.fields.values(): # lint-amnesty, pylint: disable=no-member for field in self.fields.values():
if field.scope in (Scope.parent, Scope.children): if field.scope in (Scope.parent, Scope.children):
continue continue
@@ -659,8 +664,8 @@ class XModuleMixin(XModuleFields, XBlock):
"""Localize a text value that might be None.""" """Localize a text value that might be None."""
if value is None: if value is None:
return None return None
else:
return self.runtime.service(self, "i18n").ugettext(value) return self.runtime.service(self, "i18n").ugettext(value)
# gets the 'default_value' and 'explicitly_set' attrs # gets the 'default_value' and 'explicitly_set' attrs
metadata_field_editor_info = self.runtime.get_field_provenance(self, field) metadata_field_editor_info = self.runtime.get_field_provenance(self, field)
@@ -713,7 +718,7 @@ class XModuleMixin(XModuleFields, XBlock):
"Sign in or register, and enroll in this course to view it." "Sign in or register, and enroll in this course to view it."
).format(display_name=self.display_name) ).format(display_name=self.display_name)
else: else:
display_text = _(DEFAULT_PUBLIC_VIEW_MESSAGE) # lint-amnesty, pylint: disable=translation-of-non-string display_text = _(DEFAULT_PUBLIC_VIEW_MESSAGE) # pylint: disable=translation-of-non-string
return Fragment(alert_html.format(display_text)) return Fragment(alert_html.format(display_text))
@@ -736,7 +741,7 @@ class XModuleToXBlockMixin:
XBlock handler that wraps `handle_ajax` XBlock handler that wraps `handle_ajax`
""" """
class FileObjForWebobFiles: class FileObjForWebobFiles: # pylint: disable=too-few-public-methods
""" """
Turn Webob cgi.FieldStorage uploaded files into pure file objects. Turn Webob cgi.FieldStorage uploaded files into pure file objects.
@@ -801,7 +806,7 @@ class ResourceTemplates:
if not os.path.exists(template_path): if not os.path.exists(template_path):
return None return None
with open(template_path) as file_object: with open(template_path, encoding="utf-8") as file_object:
template = yaml.safe_load(file_object) template = yaml.safe_load(file_object)
template["template_id"] = template_id template["template_id"] = template_id
return template return template
@@ -839,21 +844,21 @@ class ResourceTemplates:
return list(templates.values()) return list(templates.values())
@classmethod @classmethod
def get_template_dir(cls): # lint-amnesty, pylint: disable=missing-function-docstring def get_template_dir(cls):
"""Return the directory name for the classs built-in resource templates."""
if getattr(cls, "template_dir_name", None): if getattr(cls, "template_dir_name", None):
dirname = os.path.join("templates", cls.template_dir_name) # lint-amnesty, pylint: disable=no-member dirname = os.path.join("templates", cls.template_dir_name)
template_path = resources.files(__name__.rsplit(".", 1)[0]) / dirname template_path = files(__name__.rsplit(".", 1)[0]) / dirname
if not template_path.is_dir(): if not template_path.is_dir():
log.warning( log.warning(
"No resource directory {dir} found when loading {cls_name} templates".format( "No resource directory %s found when loading %s templates",
dir=dirname, dirname,
cls_name=cls.__name__, cls.__name__,
)
) )
return return None
return dirname return dirname
return return None
@classmethod @classmethod
def get_template_dirpaths(cls): def get_template_dirpaths(cls):
@@ -863,9 +868,9 @@ class ResourceTemplates:
template_dirpaths = [] template_dirpaths = []
template_dirname = cls.get_template_dir() template_dirname = cls.get_template_dir()
if template_dirname: if template_dirname:
template_path = resources.files(__name__.rsplit(".", 1)[0]) / template_dirname template_path = files(__name__.rsplit(".", 1)[0]) / template_dirname
if template_path.is_dir(): if template_path.is_dir():
with resources.as_file(template_path) as template_real_path: with as_file(template_path) as template_real_path:
template_dirpaths.append(str(template_real_path)) template_dirpaths.append(str(template_real_path))
custom_template_dir = cls.get_custom_template_dir() custom_template_dir = cls.get_custom_template_dir()
@@ -882,7 +887,7 @@ class ResourceTemplates:
template_dir_name = getattr(cls, "template_dir_name", None) template_dir_name = getattr(cls, "template_dir_name", None)
if template_dir_name is None: if template_dir_name is None:
return return None
resource_dir = settings.CUSTOM_RESOURCE_TEMPLATES_DIRECTORY resource_dir = settings.CUSTOM_RESOURCE_TEMPLATES_DIRECTORY
@@ -893,6 +898,7 @@ class ResourceTemplates:
if os.path.exists(template_dir_path): if os.path.exists(template_dir_path):
return template_dir_path return template_dir_path
return None return None
@classmethod @classmethod
@@ -906,6 +912,8 @@ class ResourceTemplates:
if os.path.exists(abs_path): if os.path.exists(abs_path):
return cls._load_template(abs_path, template_id) return cls._load_template(abs_path, template_id)
return None
class _ConfigurableFragmentWrapper: class _ConfigurableFragmentWrapper:
""" """
@@ -939,7 +947,9 @@ class _ConfigurableFragmentWrapper:
return frag return frag
def wrap_aside(self, block, aside, view, frag, context): # pylint: disable=unused-argument def wrap_aside(
self, block, aside, view, frag, context
): # pylint: disable=unused-argument,too-many-arguments,too-many-positional-arguments
""" """
See :func:`Runtime.wrap_child` See :func:`Runtime.wrap_child`
""" """
@@ -973,7 +983,7 @@ def block_global_local_resource_url(block, uri):
""" """
raise NotImplementedError( raise NotImplementedError(
"Applications must monkey-patch this function before using local_resource_url for studio_view" "Applications must monkey-patch this function before using local_resource_url for studio_view"
) # lint-amnesty, pylint: disable=line-too-long )
class _MetricsMixin: class _MetricsMixin:
@@ -981,7 +991,8 @@ class _MetricsMixin:
Mixin for adding metric logging for render and handle methods in the ModuleStoreRuntime. Mixin for adding metric logging for render and handle methods in the ModuleStoreRuntime.
""" """
def render(self, block, view_name, context=None): # lint-amnesty, pylint: disable=missing-function-docstring def render(self, block, view_name, context=None):
"""Render a block view while recording execution time."""
context = context or {} context = context or {}
start_time = time.time() start_time = time.time()
try: try:
@@ -997,9 +1008,8 @@ class _MetricsMixin:
getattr(block, "location", ""), getattr(block, "location", ""),
) )
def handle( def handle(self, block, handler_name, request, suffix=""):
self, block, handler_name, request, suffix="" """Handle a block request while recording execution time."""
): # lint-amnesty, pylint: disable=missing-function-docstring
start_time = time.time() start_time = time.time()
try: try:
return super().handle(block, handler_name, request, suffix=suffix) return super().handle(block, handler_name, request, suffix=suffix)
@@ -1037,7 +1047,7 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=3, stacklevel=3,
) )
user_service = self._services.get("user") user_service = self._services.get("user") # pylint: disable=no-member
if user_service: if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID) return user_service.get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID)
return None return None
@@ -1069,7 +1079,7 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
user_service = self._services.get("user") user_service = self._services.get("user") # pylint: disable=no-member
if user_service: if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_ID) return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_ID)
return None return None
@@ -1086,7 +1096,7 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
user_service = self._services.get("user") user_service = self._services.get("user") # pylint: disable=no-member
if user_service: if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_STAFF) return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_STAFF)
return None return None
@@ -1103,7 +1113,7 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
user_service = self._services.get("user") user_service = self._services.get("user") # pylint: disable=no-member
if user_service: if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_REQUEST_COUNTRY_CODE) return user_service.get_current_user().opt_attrs.get(ATTR_KEY_REQUEST_COUNTRY_CODE)
return None return None
@@ -1124,7 +1134,7 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
user_service = self._services.get("user") user_service = self._services.get("user") # pylint: disable=no-member
if user_service: if user_service:
return user_service.get_user_by_anonymous_id return user_service.get_user_by_anonymous_id
return None return None
@@ -1143,10 +1153,12 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
user_service = self._services.get("user") user_service = self._services.get("user") # pylint: disable=no-member
if user_service: if user_service:
return partial(user_service.get_current_user().opt_attrs.get, ATTR_KEY_USER_ROLE) return partial(user_service.get_current_user().opt_attrs.get, ATTR_KEY_USER_ROLE)
return None
@property @property
def user_is_beta_tester(self): def user_is_beta_tester(self):
""" """
@@ -1159,10 +1171,12 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
user_service = self._services.get("user") user_service = self._services.get("user") # pylint: disable=no-member
if user_service: if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_BETA_TESTER) return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_BETA_TESTER)
return None
@property @property
def user_is_admin(self): def user_is_admin(self):
""" """
@@ -1175,10 +1189,12 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
user_service = self._services.get("user") user_service = self._services.get("user") # pylint: disable=no-member
if user_service: if user_service:
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_GLOBAL_STAFF) return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_GLOBAL_STAFF)
return None
@property @property
def render_template(self): def render_template(self):
""" """
@@ -1194,7 +1210,7 @@ class _ModuleSystemShim:
) )
if hasattr(self, "_deprecated_render_template"): if hasattr(self, "_deprecated_render_template"):
return self._deprecated_render_template return self._deprecated_render_template
render_service = self._services.get("mako") render_service = self._services.get("mako") # pylint: disable=no-member
if render_service: if render_service:
return render_service.render_template return render_service.render_template
return None return None
@@ -1221,7 +1237,7 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
sandbox_service = self._services.get("sandbox") sandbox_service = self._services.get("sandbox") # pylint: disable=no-member
if sandbox_service: if sandbox_service:
return sandbox_service.can_execute_unsafe_code return sandbox_service.can_execute_unsafe_code
# Default to saying "no unsafe code". # Default to saying "no unsafe code".
@@ -1242,7 +1258,7 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
sandbox_service = self._services.get("sandbox") sandbox_service = self._services.get("sandbox") # pylint: disable=no-member
if sandbox_service: if sandbox_service:
return sandbox_service.get_python_lib_zip return sandbox_service.get_python_lib_zip
# Default to saying "no lib data" # Default to saying "no lib data"
@@ -1262,7 +1278,7 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
return self._services.get("cache") or DoNothingCache() return self._services.get("cache") or DoNothingCache() # pylint: disable=no-member
@property @property
def filestore(self): def filestore(self):
@@ -1276,7 +1292,7 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
return self.resources_fs return self.resources_fs # pylint: disable=no-member
@property @property
def node_path(self): def node_path(self):
@@ -1317,10 +1333,12 @@ class _ModuleSystemShim:
DeprecationWarning, DeprecationWarning,
stacklevel=2, stacklevel=2,
) )
rebind_user_service = self._services.get("rebind_user") rebind_user_service = self._services.get("rebind_user") # pylint: disable=no-member
if rebind_user_service: if rebind_user_service:
return partial(rebind_user_service.rebind_noauth_module_to_user) return partial(rebind_user_service.rebind_noauth_module_to_user)
return None
# noinspection PyPep8Naming # noinspection PyPep8Naming
@property @property
def STATIC_URL(self): # pylint: disable=invalid-name def STATIC_URL(self): # pylint: disable=invalid-name
@@ -1350,6 +1368,8 @@ class _ModuleSystemShim:
if hasattr(self, "_deprecated_course_id"): if hasattr(self, "_deprecated_course_id"):
return self._deprecated_course_id.for_branch(None) return self._deprecated_course_id.for_branch(None)
return None
@course_id.setter @course_id.setter
def course_id(self, course_id): def course_id(self, course_id):
""" """
@@ -1363,7 +1383,7 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
Base class for :class:`Runtime`s to be used with :class:`XBlock`s loaded from ModuleStore. Base class for :class:`Runtime`s to be used with :class:`XBlock`s loaded from ModuleStore.
""" """
def __init__( def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
self, self,
load_item, load_item,
resources_fs, resources_fs,
@@ -1416,7 +1436,7 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
# Dashboard bulk emails tab, when rendering the HtmlBlock for its WYSIWYG editor. * during testing, when # Dashboard bulk emails tab, when rendering the HtmlBlock for its WYSIWYG editor. * during testing, when
# fetching factory-created blocks. # fetching factory-created blocks.
if "mako" not in self._services: if "mako" not in self._services:
from common.djangoapps.edxmako.services import MakoService from common.djangoapps.edxmako.services import MakoService # pylint: disable=import-outside-toplevel
self._services["mako"] = MakoService() self._services["mako"] = MakoService()
@@ -1459,20 +1479,23 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
:param xblock: :param xblock:
:param field: :param field:
""" """
# pylint: disable=protected-access
# in runtime b/c runtime contains app-specific xblock behavior. Studio's the only app # in runtime b/c runtime contains app-specific xblock behavior. Studio's the only app
# which needs this level of introspection right now. runtime also is 'allowed' to know # which needs this level of introspection right now. runtime also is 'allowed' to know
# about the kvs, dbmodel, etc. # about the kvs, dbmodel, etc.
result = {} result = {}
result["explicitly_set"] = xblock._field_data.has(xblock, field.name) result["explicitly_set"] = xblock._field_data.has(xblock, field.name) # pylint: disable=protected-access
try: try:
result["default_value"] = xblock._field_data.default(xblock, field.name) result["default_value"] = xblock._field_data.default(xblock, field.name) # pylint: disable=protected-access
except KeyError: except KeyError:
result["default_value"] = field.to_json(field.default) result["default_value"] = field.to_json(field.default)
return result return result
def handler_url(self, block, handler_name, suffix="", query="", thirdparty=False): def handler_url( # pylint: disable=too-many-positional-arguments
self, block, handler_name, suffix="", query="", thirdparty=False
): # pylint: disable=too-many-arguments
"""Return the handler URL for a block, using override if provided."""
# When the Modulestore instantiates ModuleStoreRuntime, we will reference a # When the Modulestore instantiates ModuleStoreRuntime, we will reference a
# global function that the application can override, unless a specific function is # global function that the application can override, unless a specific function is
# defined for LMS/CMS through the handler_url_override property. # defined for LMS/CMS through the handler_url_override property.
@@ -1509,17 +1532,18 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls") raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls")
def add_block_as_child_node(self, block, node): def add_block_as_child_node(self, block, node):
"""Append the blocks XML to the given parent XML node."""
child = etree.SubElement(node, block.category) child = etree.SubElement(node, block.category)
child.set("url_name", block.url_name) child.set("url_name", block.url_name)
block.add_xml_to_node(child) block.add_xml_to_node(child)
def publish(self, block, event_type, event): # lint-amnesty, pylint: disable=arguments-differ def publish(self, block, event_type, event_data):
""" """
Publish events through the `EventPublishingService`. Publish events through the `EventPublishingService`.
This ensures that the correct track method is used for Instructor tasks. This ensures that the correct track method is used for Instructor tasks.
""" """
if publish_service := self._services.get("publish"): if publish_service := self._services.get("publish"):
publish_service.publish(block, event_type, event) publish_service.publish(block, event_type, event_data)
def service(self, block, service_name): def service(self, block, service_name):
""" """
@@ -1543,13 +1567,18 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
return service(block) return service(block)
return service return service
def wrap_aside(self, block, aside, view, frag, context): def wrap_aside(
self, block, aside, view, frag, context
): # pylint: disable=too-many-arguments,too-many-positional-arguments
# LMS/CMS can define custom wrap aside using wrap_asides_override as required. # LMS/CMS can define custom wrap aside using wrap_asides_override as required.
if getattr(self, "wrap_asides_override", None): if getattr(self, "wrap_asides_override", None):
return self.wrap_asides_override(block, aside, view, frag, context, request_token=self.request_token) return self.wrap_asides_override(block, aside, view, frag, context, request_token=self.request_token)
return super().wrap_aside(block, aside, view, frag, context) return super().wrap_aside(block, aside, view, frag, context)
def layout_asides(self, block, context, frag, view_name, aside_frag_fns): def layout_asides(
self, block, context, frag, view_name, aside_frag_fns
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Layout aside fragments for a block, with LMS/CMS override support."""
# LMS/CMS can define custom layout aside using layout_asides_override as required. # LMS/CMS can define custom layout aside using layout_asides_override as required.
if getattr(self, "layout_asides_override", None): if getattr(self, "layout_asides_override", None):
return self.layout_asides_override(block, context, frag, view_name, aside_frag_fns) return self.layout_asides_override(block, context, frag, view_name, aside_frag_fns)
@@ -1560,7 +1589,8 @@ class DoNothingCache:
"""A duck-compatible object to use in ModuleSystemShim when there's no cache.""" """A duck-compatible object to use in ModuleSystemShim when there's no cache."""
def get(self, _key): def get(self, _key):
"""Return None for any requested cache key."""
return None return None
def set(self, key, value, timeout=None): def set(self, key, value, timeout=None):
pass """Ignore cache set calls and store nothing."""

View File

@@ -1,4 +1,5 @@
# lint-amnesty, pylint: disable=missing-module-docstring """Utilities for XML parsing and XBlock/XModuleDescriptor serialization."""
import copy import copy
import datetime import datetime
import json import json
@@ -61,9 +62,11 @@ def serialize_field(value):
""" """
if isinstance(value, str): if isinstance(value, str):
return value return value
elif isinstance(value, datetime.datetime):
if isinstance(value, datetime.datetime):
if value.tzinfo is not None and value.utcoffset() is None: if value.tzinfo is not None and value.utcoffset() is None:
return value.isoformat() + "Z" return value.isoformat() + "Z"
return value.isoformat() return value.isoformat()
return json.dumps(value, cls=EdxJSONEncoder) return json.dumps(value, cls=EdxJSONEncoder)
@@ -170,7 +173,7 @@ class XmlMixin:
xml_object: An etree Element xml_object: An etree Element
""" """
raise NotImplementedError("%s does not implement definition_from_xml" % cls.__name__) raise NotImplementedError(f"{cls.__name__} does not implement definition_from_xml")
@classmethod @classmethod
def clean_metadata_from_xml(cls, xml_object, excluded_fields=()): def clean_metadata_from_xml(cls, xml_object, excluded_fields=()):
@@ -197,7 +200,7 @@ class XmlMixin:
return etree.parse(file_object, parser=EDX_XML_PARSER).getroot() return etree.parse(file_object, parser=EDX_XML_PARSER).getroot()
@classmethod @classmethod
def load_file(cls, filepath, fs, def_id): # pylint: disable=invalid-name def load_file(cls, filepath, fs, def_id):
""" """
Open the specified file in fs, and call cls.file_to_xml on it, Open the specified file in fs, and call cls.file_to_xml on it,
returning the lxml object. returning the lxml object.
@@ -207,9 +210,11 @@ class XmlMixin:
try: try:
with fs.open(filepath) as xml_file: with fs.open(filepath) as xml_file:
return cls.file_to_xml(xml_file) return cls.file_to_xml(xml_file)
except Exception as err: # lint-amnesty, pylint: disable=broad-except except Exception as err:
# Add info about where we are, but keep the traceback # Add info about where we are, but keep the traceback
raise Exception(f"Unable to load file contents at path {filepath} for item {def_id}: {err}") from err raise Exception( # pylint: disable=broad-exception-raised
f"Unable to load file contents at path {filepath} for item {def_id}: {err}"
) from err
@classmethod @classmethod
def load_definition(cls, xml_object, system, def_id, id_generator): def load_definition(cls, xml_object, system, def_id, id_generator):
@@ -301,7 +306,7 @@ class XmlMixin:
metadata[attr] = value metadata[attr] = value
@classmethod @classmethod
def parse_xml(cls, node, runtime, keys): # pylint: disable=too-many-statements def parse_xml(cls, node, runtime, keys): # pylint: disable=too-many-locals,too-many-branches
""" """
Use `node` to construct a new block. Use `node` to construct a new block.
@@ -316,7 +321,10 @@ class XmlMixin:
Returns (XBlock): The newly parsed XBlock Returns (XBlock): The newly parsed XBlock
""" """
from xmodule.modulestore.xml import XMLImportingModuleStoreRuntime # done here to avoid circular import
from xmodule.modulestore.xml import ( # pylint: disable=import-outside-toplevel
XMLImportingModuleStoreRuntime,
)
if keys is None: if keys is None:
# Passing keys=None is against the XBlock API but some platform tests do it. # Passing keys=None is against the XBlock API but some platform tests do it.
@@ -357,7 +365,7 @@ class XmlMixin:
metadata["definition_metadata_raw"] = dmdata metadata["definition_metadata_raw"] = dmdata
try: try:
metadata.update(json.loads(dmdata)) metadata.update(json.loads(dmdata))
except Exception as err: # lint-amnesty, pylint: disable=broad-except except Exception as err: # pylint: disable=broad-exception-caught
log.debug("Error in loading metadata %r", dmdata, exc_info=True) log.debug("Error in loading metadata %r", dmdata, exc_info=True)
metadata["definition_metadata_err"] = str(err) metadata["definition_metadata_err"] = str(err)
@@ -481,9 +489,10 @@ class XmlMixin:
val = serialize_field(self.fields[attr].to_json(getattr(self, attr))) val = serialize_field(self.fields[attr].to_json(getattr(self, attr)))
try: try:
xml_object.set(attr, val) xml_object.set(attr, val)
except Exception: # lint-amnesty, pylint: disable=broad-except except Exception: # pylint: disable=broad-exception-caught
logging.exception( logging.exception(
"Failed to serialize metadata attribute %s with value %s in module %s. This could mean data loss!!!", # lint-amnesty, pylint: disable=line-too-long "Failed to serialize metadata attribute %s with value %s in module %s."
" This could mean data loss!!!",
attr, attr,
val, val,
self.url_name, self.url_name,
@@ -527,7 +536,7 @@ class XmlMixin:
""" """
Return a new etree Element object created from this modules definition. Return a new etree Element object created from this modules definition.
""" """
raise NotImplementedError("%s does not implement definition_to_xml" % self.__class__.__name__) raise NotImplementedError(f"{self.__class__.__name__} does not implement definition_to_xml")
@property @property
def non_editable_metadata_fields(self): def non_editable_metadata_fields(self):