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:
@@ -28,7 +28,7 @@ from xmodule.capa.tests.response_xml_factory import (
|
||||
OptionResponseXMLFactory,
|
||||
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 common.djangoapps.course_modes.models import CourseMode
|
||||
from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule
|
||||
@@ -811,7 +811,7 @@ class ProblemWithUploadedFilesTest(TestSubmittingProblems):
|
||||
self.assertEqual(list(kwargs['files'].keys()), filenames.split())
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class TestPythonGradedResponse(TestSubmittingProblems):
|
||||
"""
|
||||
Check that we can submit a schematic and custom response, and it answers properly.
|
||||
|
||||
@@ -22,7 +22,7 @@ from django.urls import reverse
|
||||
|
||||
from xmodule.capa.responsetypes import StudentInputError
|
||||
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.grades.api import CourseGradeFactory
|
||||
from lms.djangoapps.instructor_task.api import (
|
||||
@@ -73,7 +73,7 @@ class TestIntegrationTask(InstructorTaskModuleTestCase):
|
||||
|
||||
@ddt.ddt
|
||||
@override_settings(RATELIMIT_ENABLE=False)
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class TestRescoringTask(TestIntegrationTask):
|
||||
"""
|
||||
Integration-style tests for rescoring problems in a background task.
|
||||
|
||||
@@ -26,11 +26,13 @@ class FormatHtmlTest(unittest.TestCase):
|
||||
("<a>нтмℓ-єѕ¢αρє∂</a>", "<a>нтмℓ-єѕ¢αρє∂</a>"),
|
||||
)
|
||||
def test_simple(self, before_after):
|
||||
"""Verify that plain text is safely HTML-escaped."""
|
||||
(before, after) = before_after
|
||||
assert str(Text(_(before))) == after # pylint: disable=translation-of-non-string
|
||||
assert str(Text(before)) == after
|
||||
|
||||
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:
|
||||
out = Text(_("Point & click {start}here{end}!")).format(
|
||||
start=HTML("<a href='http://edx.org'>"),
|
||||
@@ -39,6 +41,7 @@ class FormatHtmlTest(unittest.TestCase):
|
||||
assert str(out) == "Point & click <a href='http://edx.org'>here</a>!"
|
||||
|
||||
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
|
||||
# plain text inserted. It gets twisty...
|
||||
out = Text(_("Send {start}email{end}")).format(
|
||||
@@ -48,6 +51,7 @@ class FormatHtmlTest(unittest.TestCase):
|
||||
assert str(out) == "Send <a href='mailto:A&B'>email</a>"
|
||||
|
||||
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.
|
||||
template = Template(
|
||||
"""
|
||||
@@ -64,6 +68,7 @@ class FormatHtmlTest(unittest.TestCase):
|
||||
assert out.strip() == "A & B & C"
|
||||
|
||||
def test_ungettext(self):
|
||||
"""Check that ngettext output is properly formatted and HTML-escaped."""
|
||||
for i in [1, 2]:
|
||||
out = Text(ngettext("1 & {}", "2 & {}", i)).format(HTML("<>"))
|
||||
assert out == f"{i} & <>"
|
||||
|
||||
@@ -22,7 +22,8 @@ class RestrictedElement(_etree.ElementBase):
|
||||
__slots__ = ()
|
||||
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
|
||||
for child in iterator:
|
||||
if isinstance(child, blacklist):
|
||||
@@ -30,37 +31,37 @@ class RestrictedElement(_etree.ElementBase):
|
||||
yield child
|
||||
|
||||
def __iter__(self):
|
||||
iterator = super(RestrictedElement, self).__iter__() # pylint: disable=super-with-arguments
|
||||
iterator = super().__iter__()
|
||||
return self._filter(iterator)
|
||||
|
||||
def iterchildren(self, tag=None, reversed=False): # pylint: disable=redefined-builtin
|
||||
iterator = super(RestrictedElement, self).iterchildren( # pylint: disable=super-with-arguments
|
||||
tag=tag, reversed=reversed
|
||||
)
|
||||
"""Iterate over child elements while excluding blacklisted nodes."""
|
||||
iterator = super().iterchildren(tag=tag, reversed=reversed)
|
||||
return self._filter(iterator)
|
||||
|
||||
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)
|
||||
|
||||
def iterdescendants(self, tag=None, *tags): # pylint: disable=keyword-arg-before-vararg
|
||||
iterator = super(RestrictedElement, self).iterdescendants( # pylint: disable=super-with-arguments
|
||||
tag=tag, *tags
|
||||
)
|
||||
"""Iterate over descendants while filtering out blacklisted nodes."""
|
||||
iterator = super().iterdescendants(tag=tag, *tags)
|
||||
return self._filter(iterator)
|
||||
|
||||
def itersiblings(self, tag=None, preceding=False):
|
||||
iterator = super(RestrictedElement, self).itersiblings( # pylint: disable=super-with-arguments
|
||||
tag=tag, preceding=preceding
|
||||
)
|
||||
"""Iterate over siblings excluding blacklisted node types."""
|
||||
iterator = super().itersiblings(tag=tag, preceding=preceding)
|
||||
return self._filter(iterator)
|
||||
|
||||
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))
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -73,7 +74,8 @@ class GlobalParserTLS(threading.local):
|
||||
|
||||
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)
|
||||
element_class = self.element_class
|
||||
if self.element_class is not None:
|
||||
@@ -81,19 +83,21 @@ class GlobalParserTLS(threading.local):
|
||||
parser.set_element_class_lookup(lookup)
|
||||
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
|
||||
|
||||
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)
|
||||
if parser is None:
|
||||
parser = self.createDefaultParser()
|
||||
self.setDefaultParser(parser)
|
||||
parser = self.create_default_parser()
|
||||
self.set_default_parser(parser)
|
||||
return parser
|
||||
|
||||
|
||||
_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):
|
||||
@@ -107,9 +111,7 @@ def check_docinfo(elementtree, forbid_dtd=False, forbid_entities=True):
|
||||
raise DTDForbidden(docinfo.doctype, docinfo.system_url, docinfo.public_id)
|
||||
if forbid_entities and not LXML3:
|
||||
# lxml < 3 has no iterentities()
|
||||
raise NotSupportedError(
|
||||
"Unable to check for entity declarations in lxml 2.x"
|
||||
) # pylint: disable=implicit-str-concat
|
||||
raise NotSupportedError("Unable to check for entity declarations in lxml 2.x")
|
||||
|
||||
if forbid_entities:
|
||||
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)
|
||||
|
||||
|
||||
def parse(
|
||||
source, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True
|
||||
): # pylint: disable=missing-function-docstring
|
||||
def parse(source, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True):
|
||||
"""Securely parse XML from a source and enforce DTD/entity restrictions."""
|
||||
if parser is None:
|
||||
parser = getDefaultParser()
|
||||
parser = get_default_parser()
|
||||
elementtree = _etree.parse(source, parser, base_url=base_url)
|
||||
check_docinfo(elementtree, forbid_dtd, forbid_entities)
|
||||
return elementtree
|
||||
|
||||
|
||||
def fromstring(
|
||||
text, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True
|
||||
): # pylint: disable=missing-function-docstring
|
||||
def fromstring(text, parser=None, base_url=None, forbid_dtd=False, forbid_entities=True):
|
||||
"""Securely parse XML from a string and validate docinfo."""
|
||||
if parser is None:
|
||||
parser = getDefaultParser()
|
||||
parser = get_default_parser()
|
||||
rootelement = _etree.fromstring(text, parser, base_url=base_url)
|
||||
elementtree = rootelement.getroottree()
|
||||
check_docinfo(elementtree, forbid_dtd, forbid_entities)
|
||||
return rootelement
|
||||
|
||||
|
||||
XML = fromstring
|
||||
XML = fromstring # pylint: disable=invalid-name
|
||||
|
||||
|
||||
def iterparse(*args, **kwargs):
|
||||
"""Disabled XML iterparse function that always raises NotSupportedError."""
|
||||
raise NotSupportedError("iterparse not available")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-lines
|
||||
#
|
||||
# File: capa/capa_problem.py
|
||||
#
|
||||
@@ -27,12 +28,9 @@ from django.conf import settings
|
||||
from lxml import etree
|
||||
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.lib.safe_lxml.xmlparser import XML
|
||||
from xmodule.capa import customrender, inputtypes, responsetypes, xqueue_interface
|
||||
from xmodule.capa.correctmap import CorrectMap
|
||||
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
|
||||
@@ -80,7 +78,7 @@ log = logging.getLogger(__name__)
|
||||
# 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.
|
||||
|
||||
@@ -96,14 +94,14 @@ class LoncapaSystem(object):
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments
|
||||
self,
|
||||
ajax_url,
|
||||
anonymous_student_id,
|
||||
cache,
|
||||
can_execute_unsafe_code,
|
||||
get_python_lib_zip,
|
||||
DEBUG,
|
||||
DEBUG, # pylint: disable=invalid-name
|
||||
i18n,
|
||||
render_template,
|
||||
resources_fs,
|
||||
@@ -126,12 +124,12 @@ class LoncapaSystem(object):
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
def __init__( # pylint: disable=too-many-positional-arguments,too-many-arguments
|
||||
self,
|
||||
problem_text,
|
||||
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.problem_id = id
|
||||
self.capa_system = capa_system
|
||||
@@ -339,7 +337,7 @@ class LoncapaProblem(object):
|
||||
self.student_answers = initial_answers
|
||||
|
||||
def __str__(self):
|
||||
return "LoncapaProblem ({0})".format(self.problem_id)
|
||||
return f"LoncapaProblem ({self.problem_id})"
|
||||
|
||||
def get_state(self):
|
||||
"""
|
||||
@@ -439,7 +437,7 @@ class LoncapaProblem(object):
|
||||
if self.correct_map.is_queued(answer_id)
|
||||
]
|
||||
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)
|
||||
@@ -459,7 +457,7 @@ class LoncapaProblem(object):
|
||||
# if answers include File objects, convert them to filenames.
|
||||
self.student_answers = convert_files_to_filenames(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))
|
||||
return self.correct_map
|
||||
|
||||
@@ -515,7 +513,9 @@ class LoncapaProblem(object):
|
||||
# TODO: figure out where to get file submissions when rescoring.
|
||||
if "filesubmission" in responder.allowed_inputfields and student_answers is None:
|
||||
_ = 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
|
||||
# submission that would not exist in the persisted "student_answers".
|
||||
@@ -540,7 +540,7 @@ class LoncapaProblem(object):
|
||||
"""
|
||||
# dict of (id, correct_answer)
|
||||
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]
|
||||
answer_map.update(results)
|
||||
|
||||
@@ -560,7 +560,7 @@ class LoncapaProblem(object):
|
||||
get_question_answers may only return a subset of these.
|
||||
"""
|
||||
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]
|
||||
answer_ids.append(list(results.keys()))
|
||||
return answer_ids
|
||||
@@ -577,7 +577,7 @@ class LoncapaProblem(object):
|
||||
"""
|
||||
xml_elements = self.tree.xpath('//*[@id="' + answer_id + '"]')
|
||||
if not xml_elements:
|
||||
return
|
||||
return None
|
||||
xml_element = xml_elements[0]
|
||||
answer_text = xml_element.xpath("@answer")
|
||||
if answer_text:
|
||||
@@ -653,12 +653,12 @@ class LoncapaProblem(object):
|
||||
# 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,
|
||||
# stop early, and instead of a question we'll report "Question 2".
|
||||
SKIP_ELEMS = ["description"]
|
||||
LABEL_ELEMS = ["p", "label"]
|
||||
while questiontext_elem is not None and questiontext_elem.tag in SKIP_ELEMS:
|
||||
skip_elems = ["description"]
|
||||
label_elems = ["p", "label"]
|
||||
while questiontext_elem is not None and questiontext_elem.tag in skip_elems:
|
||||
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
|
||||
else:
|
||||
question_text = generate_default_question_label()
|
||||
@@ -695,11 +695,7 @@ class LoncapaProblem(object):
|
||||
elif isinstance(current_answer, str) and current_answer.startswith("choice_"):
|
||||
# Many problem (e.g. checkbox) report "choice_0" "choice_1" etc.
|
||||
# Here we transform it
|
||||
elems = self.tree.xpath(
|
||||
'//*[@id="{answer_id}"]//*[@name="{choice_number}"]'.format(
|
||||
answer_id=answer_id, choice_number=current_answer
|
||||
)
|
||||
)
|
||||
elems = self.tree.xpath(f'//*[@id="{answer_id}"]//*[@name="{current_answer}"]')
|
||||
if len(elems) == 0:
|
||||
log.warning("Answer Text Missing for answer id: %s and choice number: %s", answer_id, current_answer)
|
||||
answer_text = "Answer Text Missing"
|
||||
@@ -721,7 +717,7 @@ class LoncapaProblem(object):
|
||||
|
||||
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> --
|
||||
choice-level explanations shown to a student after submission.
|
||||
@@ -827,9 +823,9 @@ class LoncapaProblem(object):
|
||||
if self.inputs[input_id]:
|
||||
dispatch = data["dispatch"]
|
||||
return self.inputs[input_id].handle_ajax(dispatch, data)
|
||||
else:
|
||||
log.warning("Could not find matching input for id: %s", input_id)
|
||||
return {}
|
||||
|
||||
log.warning("Could not find matching input for id: %s", input_id)
|
||||
return {}
|
||||
|
||||
# ======= Private Methods Below ========
|
||||
|
||||
@@ -845,27 +841,25 @@ class LoncapaProblem(object):
|
||||
try:
|
||||
# open using LoncapaSystem OSFS filesystem
|
||||
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("Cannot find file %s in %s", filename, self.capa_system.resources_fs)
|
||||
# if debugging, don't fail - just log error
|
||||
# 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
|
||||
else:
|
||||
continue
|
||||
continue
|
||||
try:
|
||||
# read in and convert to XML
|
||||
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("Cannot parse XML in %s", (filename))
|
||||
# if debugging, don't fail - just log error
|
||||
# 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
|
||||
else:
|
||||
continue
|
||||
continue
|
||||
|
||||
# insert new XML into tree in place of include
|
||||
parent = inc.getparent()
|
||||
@@ -882,15 +876,15 @@ class LoncapaProblem(object):
|
||||
script : ?? (TODO)
|
||||
"""
|
||||
|
||||
DEFAULT_PATH = ["code"]
|
||||
default_path = ["code"]
|
||||
|
||||
# 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
|
||||
path = []
|
||||
|
||||
for dir in raw_path: # lint-amnesty, pylint: disable=redefined-builtin
|
||||
for dir in raw_path: # pylint: disable=redefined-builtin
|
||||
if not dir:
|
||||
continue
|
||||
|
||||
@@ -936,8 +930,8 @@ class LoncapaProblem(object):
|
||||
if d not in python_path and os.path.exists(d):
|
||||
python_path.append(d)
|
||||
|
||||
XMLESC = {"'": "'", """: '"'}
|
||||
code = unescape(script.text, XMLESC)
|
||||
xmlesc = {"'": "'", """: '"'}
|
||||
code = unescape(script.text, xmlesc)
|
||||
all_code += code
|
||||
|
||||
extra_files = []
|
||||
@@ -961,10 +955,8 @@ class LoncapaProblem(object):
|
||||
unsafely=self.capa_system.can_execute_unsafe_code(),
|
||||
)
|
||||
except Exception as err:
|
||||
log.exception( # lint-amnesty, pylint: disable=logging-not-lazy
|
||||
"Error while execing script code: " + all_code
|
||||
)
|
||||
msg = Text("Error while executing script code: %s" % str(err))
|
||||
log.exception("Error while execing script code: %s", all_code)
|
||||
msg = Text(f"Error while executing script code: {err}")
|
||||
raise responsetypes.LoncapaProblemError(msg)
|
||||
|
||||
# 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
|
||||
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.
|
||||
Calls itself recursively.
|
||||
@@ -988,14 +982,14 @@ class LoncapaProblem(object):
|
||||
# and we're ok leaving those behind.
|
||||
# BTW: etree gives us no good way to distinguish these things
|
||||
# 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"):
|
||||
# leave javascript intact.
|
||||
return deepcopy(problemtree)
|
||||
|
||||
if problemtree.tag in html_problem_semantics:
|
||||
return
|
||||
return None
|
||||
|
||||
problemid = problemtree.get("id") # my ID
|
||||
|
||||
@@ -1116,7 +1110,7 @@ class LoncapaProblem(object):
|
||||
for entry in inputfields:
|
||||
entry.attrib["response_id"] = str(response_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
|
||||
|
||||
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,
|
||||
# eg with externalresponse)
|
||||
self.responder_answers = {}
|
||||
for response in self.responders.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary
|
||||
for response, responder in self.responders.items():
|
||||
try:
|
||||
self.responder_answers[response] = self.responders[response].get_answers()
|
||||
except:
|
||||
log.debug(
|
||||
"responder %s failed to properly return get_answers()", self.responders[response]
|
||||
) # FIXME
|
||||
self.responder_answers[response] = responder.get_answers()
|
||||
except Exception:
|
||||
log.debug("responder %s failed to properly return get_answers()", responder) # FIXME
|
||||
raise
|
||||
|
||||
# <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).
|
||||
solution_id = 1
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -1173,7 +1167,7 @@ class LoncapaProblem(object):
|
||||
response.set("multiple_inputtypes", "true")
|
||||
group_label_tag = response.find("label")
|
||||
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 = ""
|
||||
if group_label_tag is not None:
|
||||
group_label_tag.tag = "p"
|
||||
@@ -1184,7 +1178,7 @@ class LoncapaProblem(object):
|
||||
|
||||
group_description_ids = []
|
||||
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.set("id", group_description_tag_id)
|
||||
group_description_tag.set("class", "multi-inputs-group-description question-description")
|
||||
@@ -1235,9 +1229,7 @@ class LoncapaProblem(object):
|
||||
description_id = 1
|
||||
descriptions = OrderedDict()
|
||||
for description in description_tags:
|
||||
descriptions["description_%s_%i" % (responsetype_id, description_id)] = HTML(
|
||||
stringify_children(description)
|
||||
)
|
||||
descriptions[f"description_{responsetype_id}_{description_id}"] = HTML(stringify_children(description))
|
||||
response.remove(description)
|
||||
description_id += 1
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
Commandline tool for doing operations on Problems
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
@@ -18,9 +17,11 @@ logging.basicConfig(format="%(levelname)s %(message)s")
|
||||
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):
|
||||
self.DEBUG = True
|
||||
self.DEBUG = True # pylint: disable=invalid-name
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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.add_argument("command", choices=["test", "show"]) # Watch? Render? Open?
|
||||
parser.add_argument("files", nargs="+", type=argparse.FileType("r"))
|
||||
@@ -47,14 +50,17 @@ def main(): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
system = DemoSystem()
|
||||
|
||||
for problem_file in args.files:
|
||||
log.info("Opening {0}".format(problem_file.name))
|
||||
log.info("Opening %s", problem_file.name)
|
||||
|
||||
try:
|
||||
problem = LoncapaProblem( # lint-amnesty, pylint: disable=no-value-for-parameter, unexpected-keyword-arg
|
||||
problem_file, "fakeid", seed=args.seed, system=system
|
||||
problem = LoncapaProblem( # pylint: disable=unexpected-keyword-arg, no-value-for-parameter
|
||||
problem_file,
|
||||
"fakeid",
|
||||
seed=args.seed,
|
||||
system=system,
|
||||
)
|
||||
except Exception as ex: # lint-amnesty, pylint: disable=broad-except
|
||||
log.error("Could not parse file {0}".format(problem_file.name))
|
||||
except Exception as ex: # pylint: disable=broad-exception-caught
|
||||
log.error("Could not parse file %s", problem_file.name)
|
||||
log.exception(ex)
|
||||
continue
|
||||
|
||||
@@ -73,7 +79,9 @@ def command_show(problem):
|
||||
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)
|
||||
old_stdout, old_stderr = sys.stdout, sys.stderr
|
||||
try:
|
||||
@@ -83,9 +91,9 @@ def command_test(problem): # lint-amnesty, pylint: disable=missing-function-doc
|
||||
check_that_suggested_answers_work(problem)
|
||||
check_that_blanks_fail(problem)
|
||||
|
||||
log_captured_output(sys.stdout, "captured stdout from {0}".format(problem))
|
||||
log_captured_output(sys.stderr, "captured stderr from {0}".format(problem))
|
||||
except Exception as e: # lint-amnesty, pylint: disable=broad-except
|
||||
log_captured_output(sys.stdout, f"captured stdout from {problem}")
|
||||
log_captured_output(sys.stderr, f"captured stderr from {problem}")
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
log.exception(e)
|
||||
finally:
|
||||
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())
|
||||
except AssertionError:
|
||||
log.error(
|
||||
"Blank accepted as correct answer in {0} for {1}".format(
|
||||
problem, [answer_id for answer_id, result in sorted(grading_results.items()) if result != "incorrect"]
|
||||
)
|
||||
"Blank accepted as correct answer in %s for %s",
|
||||
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_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:
|
||||
try:
|
||||
real_results = dict(
|
||||
@@ -135,28 +143,28 @@ def check_that_suggested_answers_work(problem):
|
||||
log.debug(real_results)
|
||||
assert all(result == "correct" for answer_id, result in real_results.items())
|
||||
except UndefinedVariable as uv_exc:
|
||||
log.error( # lint-amnesty, pylint: disable=logging-not-lazy
|
||||
'The variable "{0}" specified in the '.format(uv_exc)
|
||||
+ "solution isn't recognized (is it a units measure?)."
|
||||
log.error(
|
||||
'The variable "%s" specified in the solution isn\'t recognized (is it a units measure?).',
|
||||
uv_exc,
|
||||
)
|
||||
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()):
|
||||
if result != "correct":
|
||||
log.error(" {0} = {1}".format(question_id, real_answers[question_id]))
|
||||
except Exception as ex: # lint-amnesty, pylint: disable=broad-except
|
||||
log.error("Uncaught error in {0}".format(problem))
|
||||
log.error(" %s = %s", question_id, real_answers[question_id])
|
||||
except Exception as ex: # pylint: disable=broad-exception-caught
|
||||
log.error("Uncaught error in %s", problem)
|
||||
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_text = output_stream.read()
|
||||
if output_text:
|
||||
log.info(
|
||||
"##### Begin {0} #####\n".format(stream_name) + output_text
|
||||
) # lint-amnesty, pylint: disable=logging-not-lazy
|
||||
log.info("##### End {0} #####".format(stream_name))
|
||||
log.info("##### Begin %s #####\n%s", stream_name, output_text)
|
||||
log.info("##### End %s #####", stream_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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
|
||||
#
|
||||
# Used by responsetypes and capa_problem
|
||||
|
||||
|
||||
class CorrectMap(object):
|
||||
class CorrectMap:
|
||||
"""
|
||||
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
|
||||
@@ -39,8 +43,8 @@ class CorrectMap(object):
|
||||
return self.cmap.__iter__()
|
||||
|
||||
# See the documentation for 'set_dict' for the use of kwargs
|
||||
def set( # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
self, # lint-amnesty, pylint: disable=unused-argument
|
||||
def set( # pylint: disable=too-many-positional-arguments,too-many-arguments
|
||||
self,
|
||||
answer_id=None,
|
||||
correctness=None,
|
||||
npoints=None,
|
||||
@@ -49,8 +53,12 @@ class CorrectMap(object):
|
||||
hintmode=None,
|
||||
queuestate=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:
|
||||
self.cmap[answer_id] = {
|
||||
@@ -124,48 +132,59 @@ class CorrectMap(object):
|
||||
return None
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def get_queuetime_str(self, answer_id):
|
||||
"""Return the stored queue timestamp string for the given answer."""
|
||||
if self.cmap[answer_id]["queuestate"]:
|
||||
return self.cmap[answer_id]["queuestate"]["time"]
|
||||
else:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def get_npoints(self, answer_id):
|
||||
"""Return the number of points for an answer, used for partial credit."""
|
||||
npoints = self.get_property(answer_id, "npoints")
|
||||
if npoints is not None:
|
||||
return npoints
|
||||
elif self.is_correct(answer_id):
|
||||
|
||||
if self.is_correct(answer_id):
|
||||
return 1
|
||||
|
||||
# if not correct and no points have been assigned, 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:
|
||||
self.cmap[answer_id][property] = value
|
||||
self.cmap[answer_id][prop] = value
|
||||
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:
|
||||
return self.cmap[answer_id].get(property, default)
|
||||
return self.cmap[answer_id].get(prop, default)
|
||||
return default
|
||||
|
||||
def get_correctness(self, answer_id):
|
||||
"""Return the correctness value for the given answer."""
|
||||
return self.get_property(answer_id, "correctness")
|
||||
|
||||
def get_msg(self, answer_id):
|
||||
"""Return the feedback message for the given answer."""
|
||||
return self.get_property(answer_id, "msg", "")
|
||||
|
||||
def get_hint(self, answer_id):
|
||||
"""Return the hint text associated with the given answer."""
|
||||
return self.get_property(answer_id, "hint", "")
|
||||
|
||||
def get_hintmode(self, answer_id):
|
||||
"""Return the hint display mode for the given answer."""
|
||||
return self.get_property(answer_id, "hintmode", None)
|
||||
|
||||
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
|
||||
"""
|
||||
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.set_overall_message(other_cmap.get_overall_message())
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ and the xml element.
|
||||
|
||||
import logging
|
||||
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 .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"]
|
||||
|
||||
def __init__(self, system, xml):
|
||||
@@ -48,37 +52,30 @@ class MathRenderer(object): # lint-amnesty, pylint: disable=missing-class-docst
|
||||
mtag += "inline"
|
||||
else:
|
||||
mathstr = mathstr.replace(r"\displaystyle", "")
|
||||
self.mathstr = mathstr.replace("mathjaxinline]", "%s]" % mtag)
|
||||
self.mathstr = mathstr.replace("mathjaxinline]", f"{mtag}]")
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
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?
|
||||
# xss-lint: disable=python-interpolate-html
|
||||
html = "<html><html>%s</html><html>%s</html></html>" % ( # lint-amnesty, pylint: disable=redefined-outer-name
|
||||
self.mathstr,
|
||||
saxutils.escape(self.xml.tail),
|
||||
)
|
||||
|
||||
html = f"<html><html>{self.mathstr}</html><html>{saxutils.escape(self.xml.tail or '')}</html></html>"
|
||||
try:
|
||||
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:
|
||||
# xss-lint: disable=python-interpolate-html
|
||||
msg = '<html><div class="inline-error"><p>Error %s</p>' % (
|
||||
str(err).replace("<", "<") # xss-lint: disable=python-custom-escape
|
||||
msg = (
|
||||
f"<html><div class='inline-error'>"
|
||||
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(
|
||||
"<", "<" # xss-lint: disable=python-custom-escape
|
||||
)
|
||||
|
||||
msg += "</div></html>"
|
||||
log.error(msg)
|
||||
return etree.XML(msg)
|
||||
else:
|
||||
raise
|
||||
|
||||
raise
|
||||
|
||||
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
|
||||
extended answer (a problem "solution") after "show answers" is pressed.
|
||||
@@ -103,11 +100,10 @@ class SolutionRenderer(object):
|
||||
self.system = system
|
||||
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}
|
||||
html = self.system.render_template( # lint-amnesty, pylint: disable=redefined-outer-name
|
||||
"solutionspan.html", context
|
||||
)
|
||||
html = self.system.render_template("solutionspan.html", context)
|
||||
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
|
||||
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.
|
||||
"""
|
||||
# xss-lint: disable=python-wrap-html
|
||||
html_str = '<section class="targeted-feedback-span"><span>{}</span></section>'.format(
|
||||
etree.tostring(self.xml, encoding="unicode")
|
||||
html_str = (
|
||||
f'<section class="targeted-feedback-span">'
|
||||
f'<span>{etree.tostring(self.xml, encoding="unicode")}</span>'
|
||||
f"</section>"
|
||||
)
|
||||
try:
|
||||
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:
|
||||
# xss-lint: disable=python-wrap-html
|
||||
msg = """
|
||||
msg = f"""
|
||||
<html>
|
||||
<div class="inline-error">
|
||||
<p>Error {err}</p>
|
||||
<p>Failed to construct targeted feedback from <pre>{html}</pre></p>
|
||||
</div>
|
||||
<div class="inline-error">
|
||||
<p>Error {html_escape.escape(err)}</p>
|
||||
<p>Failed to construct targeted feedback from <pre>{html_escape.escape(html_str)}</pre></p>
|
||||
</div>
|
||||
</html>
|
||||
""".format(
|
||||
err=html.escape(err), html=html.escape(html_str)
|
||||
)
|
||||
"""
|
||||
log.error(msg)
|
||||
return etree.XML(msg)
|
||||
else:
|
||||
raise
|
||||
|
||||
raise
|
||||
|
||||
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
|
||||
hovers over it.
|
||||
@@ -188,9 +185,7 @@ class ClarificationRenderer(object):
|
||||
Return the contents of this tag, rendered to html, as an etree element.
|
||||
"""
|
||||
context = {"clarification": self.inner_html}
|
||||
html = self.system.render_template( # lint-amnesty, pylint: disable=redefined-outer-name
|
||||
"clarification.html", context
|
||||
)
|
||||
html = self.system.render_template("clarification.html", context)
|
||||
xml = etree.XML(html)
|
||||
# We must include any text that was following our original <clarification>...</clarification> XML node.:
|
||||
xml.tail = self.tail
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-lines
|
||||
#
|
||||
# File: courseware/capa/inputtypes.py
|
||||
#
|
||||
@@ -49,9 +50,9 @@ from datetime import datetime
|
||||
|
||||
import html5lib
|
||||
import nh3
|
||||
import pyparsing
|
||||
import six
|
||||
from calc.preview import latex_preview
|
||||
import pyparsing
|
||||
from chem import chemcalc
|
||||
from lxml import etree
|
||||
|
||||
@@ -65,10 +66,10 @@ from .util import sanitize_html
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
registry = TagRegistry() # pylint: disable=invalid-name
|
||||
registry = TagRegistry()
|
||||
|
||||
|
||||
class Status(object):
|
||||
class Status:
|
||||
"""
|
||||
Problem status
|
||||
attributes: classname, display_name, display_tooltip
|
||||
@@ -111,7 +112,7 @@ class Status(object):
|
||||
return self._status
|
||||
|
||||
def __repr__(self):
|
||||
return "Status(%r)" % self._status
|
||||
return f"Status({self._status!r})"
|
||||
|
||||
def __eq__(self, other):
|
||||
return self._status == str(other)
|
||||
@@ -120,7 +121,7 @@ class Status(object):
|
||||
return hash(str(self))
|
||||
|
||||
|
||||
class Attribute(object):
|
||||
class Attribute: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
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
|
||||
_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
|
||||
|
||||
@@ -160,7 +163,7 @@ class Attribute(object):
|
||||
"""
|
||||
val = element.get(self.name)
|
||||
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:
|
||||
# not required, so return default
|
||||
@@ -175,7 +178,7 @@ class Attribute(object):
|
||||
return val
|
||||
|
||||
|
||||
class InputTypeBase(object):
|
||||
class InputTypeBase: # pylint: disable=too-many-instance-attributes
|
||||
"""
|
||||
Abstract base class for input types.
|
||||
"""
|
||||
@@ -214,7 +217,7 @@ class InputTypeBase(object):
|
||||
|
||||
self.input_id = state.get("id", xml.get("id"))
|
||||
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", "")
|
||||
|
||||
@@ -242,9 +245,9 @@ class InputTypeBase(object):
|
||||
# super().__init__, and are isolated from changes to the input
|
||||
# constructor interface.
|
||||
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
|
||||
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])
|
||||
|
||||
@classmethod
|
||||
@@ -285,7 +288,6 @@ class InputTypeBase(object):
|
||||
If this method raises an exception, it will be wrapped with a message that includes the
|
||||
problem xml.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
"""
|
||||
@@ -298,7 +300,6 @@ class InputTypeBase(object):
|
||||
Output:
|
||||
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):
|
||||
"""
|
||||
@@ -356,7 +357,7 @@ class InputTypeBase(object):
|
||||
Return the html for this input, as an etree element.
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -366,11 +367,12 @@ class InputTypeBase(object):
|
||||
output = etree.XML(html)
|
||||
except etree.XMLSyntaxError as ex:
|
||||
# If `html` contains attrs with no values, like `controls` in <audio controls src='smth'/>,
|
||||
# XML parser will raise exception, so wee fallback to html5parser, which will set empty "" values for such attrs. # 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:
|
||||
output = html5lib.parseFragment(html, treebuilder="lxml", namespaceHTMLElements=False)[0]
|
||||
except IndexError:
|
||||
raise ex # lint-amnesty, pylint: disable=raise-missing-from
|
||||
except IndexError as exc:
|
||||
raise ex from exc
|
||||
|
||||
return output
|
||||
|
||||
@@ -489,7 +491,7 @@ class ChoiceGroup(InputTypeBase):
|
||||
_ = i18n.gettext
|
||||
# Translators: 'ChoiceGroup' is an input type and should not be translated.
|
||||
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_map = dict(
|
||||
@@ -500,7 +502,7 @@ class ChoiceGroup(InputTypeBase):
|
||||
def get_attributes(cls):
|
||||
# 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
|
||||
_ = lambda text: text
|
||||
_ = lambda text: text # pylint: disable=unnecessary-lambda-assignment
|
||||
return [Attribute("show_correctness", "always"), Attribute("submitted_message", _("Answer received."))]
|
||||
|
||||
def _extra_context(self):
|
||||
@@ -538,7 +540,7 @@ class ChoiceGroup(InputTypeBase):
|
||||
_("Expected a <choice> or <compoundhint> tag; got {given_tag} instead")
|
||||
).format(given_tag=choice.tag)
|
||||
)
|
||||
raise Exception(msg)
|
||||
raise Exception(msg) # pylint: disable=broad-exception-raised
|
||||
return choices
|
||||
|
||||
def get_user_visible_answer(self, internal_answer):
|
||||
@@ -608,8 +610,8 @@ class JSInput(InputTypeBase):
|
||||
|
||||
def _extra_context(self):
|
||||
context = {
|
||||
"jschannel_loader": "{static_url}js/capa/src/jschannel.js".format(static_url=self.capa_system.STATIC_URL),
|
||||
"jsinput_loader": "{static_url}js/capa/src/jsinput.js".format(static_url=self.capa_system.STATIC_URL),
|
||||
"jschannel_loader": f"{self.capa_system.STATIC_URL}js/capa/src/jschannel.js",
|
||||
"jsinput_loader": f"{self.capa_system.STATIC_URL}js/capa/src/jsinput.js",
|
||||
"saved_state": self.value,
|
||||
}
|
||||
|
||||
@@ -785,12 +787,12 @@ class CodeInput(InputTypeBase):
|
||||
self.value = self.xml.text.strip()
|
||||
|
||||
# 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
|
||||
# queue
|
||||
if self.status == "incomplete":
|
||||
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)
|
||||
|
||||
def setup(self):
|
||||
@@ -919,8 +921,8 @@ class MatlabInput(CodeInput):
|
||||
"""
|
||||
if self.status in ["correct", "incorrect", "partially-correct"]:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def _extra_context(self):
|
||||
"""Set up additional context variables"""
|
||||
@@ -942,9 +944,7 @@ class MatlabInput(CodeInput):
|
||||
"queue_len": str(self.queue_len),
|
||||
"queue_msg": queue_msg,
|
||||
"button_enabled": self.button_enabled(),
|
||||
"matlab_editor_js": "{static_url}js/vendor/CodeMirror/octave.js".format(
|
||||
static_url=self.capa_system.STATIC_URL
|
||||
),
|
||||
"matlab_editor_js": f"{self.capa_system.STATIC_URL}js/vendor/CodeMirror/octave.js",
|
||||
"msg": sanitize_html(self.msg), # sanitize msg before rendering into template
|
||||
}
|
||||
return extra_context
|
||||
@@ -984,7 +984,7 @@ class MatlabInput(CodeInput):
|
||||
|
||||
# construct xqueue headers
|
||||
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")
|
||||
anonymous_student_id = self.capa_system.anonymous_student_id
|
||||
# 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):
|
||||
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
|
||||
@@ -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.
|
||||
"""
|
||||
return {
|
||||
"previewer": "{static_url}js/capa/chemical_equation_preview.js".format(
|
||||
static_url=self.capa_system.STATIC_URL
|
||||
),
|
||||
"previewer": f"{self.capa_system.STATIC_URL}js/capa/chemical_equation_preview.js",
|
||||
}
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
@@ -1217,7 +1215,7 @@ class ChemicalEquationInput(InputTypeBase):
|
||||
result["preview"] = chemcalc.render_to_html(formula)
|
||||
except pyparsing.ParseException as err:
|
||||
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
|
||||
log.warning("Error while previewing chemical formula", exc_info=True)
|
||||
result["error"] = _("Error while rendering preview")
|
||||
@@ -1263,18 +1261,16 @@ class FormulaEquationInput(InputTypeBase):
|
||||
# `reported_status` is basically `status`, except we say 'unanswered'
|
||||
|
||||
return {
|
||||
"previewer": "{static_url}js/capa/src/formula_equation_preview.js".format(
|
||||
static_url=self.capa_system.STATIC_URL
|
||||
),
|
||||
"previewer": f"{self.capa_system.STATIC_URL}js/capa/src/formula_equation_preview.js",
|
||||
}
|
||||
|
||||
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
|
||||
matches the corresponding dispatch and send it through if it does
|
||||
"""
|
||||
if dispatch == "preview_formcalc":
|
||||
return self.preview_formcalc(get)
|
||||
return self.preview_formcalc(data)
|
||||
return {}
|
||||
|
||||
def preview_formcalc(self, get):
|
||||
@@ -1313,9 +1309,9 @@ class FormulaEquationInput(InputTypeBase):
|
||||
if not numeric_result["is_valid"]:
|
||||
result["formula"] = formula
|
||||
except pyparsing.ParseException:
|
||||
result['error'] = _("Sorry, couldn't parse formula")
|
||||
result['formula'] = formula
|
||||
except Exception: # lint-amnesty, pylint: disable=broad-except
|
||||
result["error"] = _("Sorry, couldn't parse formula")
|
||||
result["formula"] = formula
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
log.warning("Error while previewing formula", exc_info=True)
|
||||
result["error"] = _("Error while rendering preview")
|
||||
return result
|
||||
@@ -1327,17 +1323,17 @@ def preview_numeric_input(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:
|
||||
result['preview'] = latex_preview(formula)
|
||||
result["preview"] = latex_preview(formula)
|
||||
except pyparsing.ParseException:
|
||||
result["error"] = "Sorry, couldn't parse formula"
|
||||
result['is_valid'] = False
|
||||
result["is_valid"] = False
|
||||
return result
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
log.warning("Error while previewing formula", exc_info=True)
|
||||
result['error'] = "Error while rendering preview"
|
||||
result['is_valid'] = False
|
||||
result["error"] = "Error while rendering preview"
|
||||
result["is_valid"] = False
|
||||
return result
|
||||
|
||||
|
||||
@@ -1380,18 +1376,18 @@ class DragAndDropInput(InputTypeBase):
|
||||
"""
|
||||
tag_attrs = {}
|
||||
tag_attrs["draggable"] = {
|
||||
"id": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
|
||||
"id": Attribute._sentinel, # pylint: disable=protected-access
|
||||
"label": "",
|
||||
"icon": "",
|
||||
"can_reuse": "",
|
||||
}
|
||||
|
||||
tag_attrs["target"] = {
|
||||
"id": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
|
||||
"x": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
|
||||
"y": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
|
||||
"w": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
|
||||
"h": Attribute._sentinel, # lint-amnesty, pylint: disable=protected-access
|
||||
"id": Attribute._sentinel, # pylint: disable=protected-access
|
||||
"x": Attribute._sentinel, # pylint: disable=protected-access
|
||||
"y": Attribute._sentinel, # pylint: disable=protected-access
|
||||
"w": Attribute._sentinel, # pylint: disable=protected-access
|
||||
"h": Attribute._sentinel, # pylint: disable=protected-access
|
||||
}
|
||||
|
||||
dic = {}
|
||||
@@ -1458,7 +1454,7 @@ class DesignProtein2dInput(InputTypeBase):
|
||||
|
||||
def _extra_context(self):
|
||||
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
|
||||
@@ -1490,7 +1486,7 @@ class EditAGeneInput(InputTypeBase):
|
||||
|
||||
def _extra_context(self):
|
||||
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
|
||||
@@ -1500,7 +1496,7 @@ class EditAGeneInput(InputTypeBase):
|
||||
|
||||
|
||||
@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
|
||||
(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")
|
||||
for option in self.options:
|
||||
choice = option["choice"]
|
||||
if choice is None: # lint-amnesty, pylint: disable=no-else-raise
|
||||
if choice is None:
|
||||
raise ValueError("Missing required choice attribute.")
|
||||
elif choice not in valid_choices:
|
||||
raise ValueError(
|
||||
"Invalid choice attribute: {0}. Must be one of: {1}".format(choice, ", ".join(valid_choices))
|
||||
)
|
||||
if choice not in valid_choices:
|
||||
raise ValueError(f"Invalid choice attribute: {choice}. Must be one of: {', '.join(valid_choices)}")
|
||||
|
||||
def _unpack(self, json_value):
|
||||
"""Unpacks the json input state into a dict."""
|
||||
@@ -1688,7 +1682,7 @@ class ChoiceTextGroup(InputTypeBase):
|
||||
else:
|
||||
_ = self.capa_system.i18n.gettext
|
||||
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 == "":
|
||||
# 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
|
||||
# `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.")),
|
||||
@@ -1773,7 +1767,7 @@ class ChoiceTextGroup(InputTypeBase):
|
||||
given_tag=choice.tag,
|
||||
)
|
||||
)
|
||||
raise Exception(msg)
|
||||
raise Exception(msg) # pylint: disable=broad-exception-raised
|
||||
|
||||
components = []
|
||||
choice_text = ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""A registry for finding classes based on tags in the class."""
|
||||
|
||||
|
||||
class TagRegistry(object):
|
||||
class TagRegistry:
|
||||
"""
|
||||
A registry mapping tags to handlers.
|
||||
|
||||
@@ -23,7 +23,7 @@ class TagRegistry(object):
|
||||
|
||||
# Do all checks and complain before changing any state.
|
||||
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:
|
||||
if tag in self._mapping:
|
||||
@@ -32,12 +32,8 @@ class TagRegistry(object):
|
||||
# registering the same class multiple times seems silly, but ok
|
||||
continue
|
||||
raise ValueError(
|
||||
"Tag {0} already registered by class {1}."
|
||||
" Can't register for class {2}".format(
|
||||
tag,
|
||||
other_cls.__name__,
|
||||
cls.__name__,
|
||||
)
|
||||
f"Tag {tag} already registered by class {other_cls.__name__}. "
|
||||
f"Can't register for class {cls.__name__}"
|
||||
)
|
||||
|
||||
# Ok, should be good to change state now.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-lines
|
||||
"""
|
||||
Problem response evaluation. Handles checking of student responses,
|
||||
of a variety of types.
|
||||
@@ -35,19 +36,20 @@ from pyparsing import ParseException
|
||||
from pytz import UTC
|
||||
from shapely.geometry import MultiPoint, Point
|
||||
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.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 .registry import TagRegistry
|
||||
from .util import (
|
||||
DEFAULT_TOLERANCE,
|
||||
compare_with_tolerance,
|
||||
contextualize_text,
|
||||
convert_files_to_filenames,
|
||||
default_tolerance,
|
||||
find_with_default,
|
||||
get_course_id_from_capa_block,
|
||||
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
|
||||
# `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_INCORRECT_STYLE = "feedback-hint-incorrect"
|
||||
@@ -80,8 +82,6 @@ class LoncapaProblemError(Exception):
|
||||
Error in specification of a problem
|
||||
"""
|
||||
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
class ResponseError(Exception):
|
||||
"""
|
||||
@@ -89,8 +89,6 @@ class ResponseError(Exception):
|
||||
exceptions that occur when executing a custom script.
|
||||
"""
|
||||
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
class StudentInputError(Exception):
|
||||
"""
|
||||
@@ -98,15 +96,13 @@ class StudentInputError(Exception):
|
||||
For example, submitting a string when the problem expects a number
|
||||
"""
|
||||
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# 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,
|
||||
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.
|
||||
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:
|
||||
|
||||
@@ -180,19 +178,19 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
|
||||
# only displayed to the user when settings.DEBUG is True
|
||||
for abox in inputfields:
|
||||
if abox.tag not in self.allowed_inputfields:
|
||||
msg = "%s: cannot have input field %s" % (str(self), abox.tag)
|
||||
msg += "\nSee XML source line %s" % getattr(xml, "sourceline", "[unavailable]")
|
||||
msg = f"{self}: cannot have input field {abox.tag}"
|
||||
msg += f"\nSee XML source line {getattr(xml, 'sourceline', '[unavailable]')}"
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
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 += "\nSee XML source line %s" % getattr(xml, "sourceline", "[unavailable]")
|
||||
msg = f"{self}: cannot have more than {self.max_inputfields} input fields"
|
||||
msg += f"\nSee XML source line {getattr(xml, 'sourceline', '[unavailable]')}"
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
for prop in self.required_attributes:
|
||||
if not xml.get(prop):
|
||||
msg = "Error in problem specification: %s missing required attribute %s" % (str(self), prop)
|
||||
msg += "\nSee XML source line %s" % getattr(xml, "sourceline", "[unavailable]")
|
||||
msg = f"Error in problem specification: {self} missing required attribute {prop}"
|
||||
msg += f"\nSee XML source line {getattr(xml, 'sourceline', '[unavailable]')}"
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
# 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)
|
||||
return new_cmap
|
||||
|
||||
def make_hint_div(
|
||||
def make_hint_div( # pylint: disable=too-many-positional-arguments,too-many-arguments
|
||||
self,
|
||||
hint_node,
|
||||
correct,
|
||||
@@ -418,9 +416,8 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
|
||||
installing it in the new_map for display.
|
||||
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,
|
||||
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
|
||||
# callback procedure to a social hint generation system.
|
||||
|
||||
global CORRECTMAP_PY
|
||||
global CORRECTMAP_PY # pylint: disable=global-statement
|
||||
if CORRECTMAP_PY is None:
|
||||
# We need the CorrectMap code for hint functions. No, this is not great.
|
||||
CORRECTMAP_PY = inspect.getsource(correctmap)
|
||||
@@ -479,7 +476,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
|
||||
}
|
||||
|
||||
try:
|
||||
safe_exec.safe_exec(
|
||||
safe_exec(
|
||||
code,
|
||||
globals_dict,
|
||||
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)
|
||||
sourcenum = getattr(self.xml, "sourceline", _("(Source code line unavailable)"))
|
||||
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"])
|
||||
return
|
||||
@@ -524,7 +521,7 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
|
||||
):
|
||||
|
||||
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
|
||||
)
|
||||
# can be 'on_request' or 'always' (default)
|
||||
@@ -551,14 +548,12 @@ class LoncapaResponse(six.with_metaclass(abc.ABCMeta, object)):
|
||||
Arguments:
|
||||
- student_answers : dict of (answer_id, answer) where answer = student input (string)
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_answers(self):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
@@ -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
|
||||
to determine which hints are displayed.
|
||||
"""
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
def setup_response(self):
|
||||
pass
|
||||
"""Check if the given string matches any expected string, optionally case-insensitive."""
|
||||
|
||||
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):
|
||||
"""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
|
||||
# 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.text = str(response_msg)
|
||||
|
||||
@@ -733,10 +727,10 @@ class ChoiceResponse(LoncapaResponse):
|
||||
|
||||
if edc_current_grade == edc_max_grade:
|
||||
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)
|
||||
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):
|
||||
"""
|
||||
@@ -765,14 +759,16 @@ class ChoiceResponse(LoncapaResponse):
|
||||
if halves_error_count == 0:
|
||||
return_grade = self.get_max_score()
|
||||
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 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 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):
|
||||
"""
|
||||
@@ -790,8 +786,8 @@ class ChoiceResponse(LoncapaResponse):
|
||||
|
||||
if 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):
|
||||
|
||||
@@ -855,7 +851,7 @@ class ChoiceResponse(LoncapaResponse):
|
||||
def get_answers(self):
|
||||
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.
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
<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"
|
||||
]
|
||||
|
||||
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
|
||||
<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.
|
||||
"""
|
||||
if self.answer_id in student_answer_dict:
|
||||
student_answer = student_answer_dict[self.answer_id]
|
||||
if self.answer_id in student_answers:
|
||||
student_answer = student_answers[self.answer_id]
|
||||
|
||||
# Warning: mostly student_answer is a string, but sometimes it is a list of strings.
|
||||
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
|
||||
# choice name.
|
||||
choice = self.xml.find(
|
||||
'./choicegroup[@id="{0}"]/choice[@name="{1}"]'.format(self.answer_id, student_answer)
|
||||
)
|
||||
choice = self.xml.find(f'./choicegroup[@id="{self.answer_id}"]/choice[@name="{student_answer}"]')
|
||||
if choice is not None:
|
||||
hint_node = choice.find("./choicehint")
|
||||
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:
|
||||
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])
|
||||
credit_amount = self.partial_values[choice_index]
|
||||
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):
|
||||
"""
|
||||
@@ -1138,8 +1132,8 @@ class MultipleChoiceResponse(LoncapaResponse):
|
||||
|
||||
if self.answer_id in student_answers and student_answers[self.answer_id] in self.correct_choices:
|
||||
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):
|
||||
"""
|
||||
@@ -1265,12 +1259,12 @@ class MultipleChoiceResponse(LoncapaResponse):
|
||||
# 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
|
||||
# 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.
|
||||
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
|
||||
try:
|
||||
num_choices = int(num_str)
|
||||
except ValueError:
|
||||
except ValueError as exc:
|
||||
_ = self.capa_system.i18n.gettext
|
||||
# Translators: 'answer-pool' is an attribute name and should not be translated.
|
||||
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.
|
||||
# Both to avoid double-processing, and to feed the logs.
|
||||
@@ -1383,14 +1377,16 @@ class MultipleChoiceResponse(LoncapaResponse):
|
||||
|
||||
|
||||
@registry.register
|
||||
class TrueFalseResponse(
|
||||
MultipleChoiceResponse
|
||||
): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring
|
||||
class TrueFalseResponse(MultipleChoiceResponse):
|
||||
"""Response type for True/False multiple choice questions."""
|
||||
|
||||
human_name = _("True/False Choice")
|
||||
tags = ["truefalseresponse"]
|
||||
|
||||
def mc_setup_response(self):
|
||||
"""
|
||||
Sets up the XML structure for the True/False choices.
|
||||
"""
|
||||
i = 0
|
||||
for response in self.xml.xpath("choicegroup"):
|
||||
response.set("type", "TrueFalse")
|
||||
@@ -1402,6 +1398,9 @@ class TrueFalseResponse(
|
||||
choice.set("name", "choice_" + choice.get("name"))
|
||||
|
||||
def get_score(self, student_answers):
|
||||
"""
|
||||
Grade the student's answer for True/False.
|
||||
"""
|
||||
correct = set(self.correct_choices)
|
||||
answers = student_answers.get(self.answer_id, [])
|
||||
if not isinstance(answers, list):
|
||||
@@ -1412,6 +1411,10 @@ class TrueFalseResponse(
|
||||
|
||||
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
|
||||
|
||||
def get_answers(self):
|
||||
amap = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
|
||||
[
|
||||
(
|
||||
af.get("id"),
|
||||
contextualize_text(
|
||||
af.get("correct"),
|
||||
self.context,
|
||||
),
|
||||
)
|
||||
for af in self.answer_fields
|
||||
]
|
||||
)
|
||||
amap = {
|
||||
af.get("id"): contextualize_text(
|
||||
af.get("correct"),
|
||||
self.context,
|
||||
)
|
||||
for af in self.answer_fields
|
||||
}
|
||||
|
||||
return amap
|
||||
|
||||
def get_student_answer_variable_name(self, student_answers, aid):
|
||||
@@ -1516,10 +1515,10 @@ class NumericalResponse(LoncapaResponse):
|
||||
self.correct_answer = ""
|
||||
self.additional_answers = []
|
||||
self.additional_answer_index = -1
|
||||
self.tolerance = default_tolerance
|
||||
self.tolerance = DEFAULT_TOLERANCE
|
||||
self.range_tolerance = False
|
||||
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):
|
||||
xml = self.xml
|
||||
@@ -1531,18 +1530,16 @@ class NumericalResponse(LoncapaResponse):
|
||||
if answer.startswith(("[", "(")) and answer.endswith(("]", ")")): # range tolerance case
|
||||
self.range_tolerance = True
|
||||
self.inclusion = (
|
||||
True if answer.startswith("[") else False, # lint-amnesty, pylint: disable=simplifiable-if-expression
|
||||
True if answer.endswith("]") else False, # lint-amnesty, pylint: disable=simplifiable-if-expression
|
||||
answer.startswith("["),
|
||||
answer.endswith("]"),
|
||||
)
|
||||
try:
|
||||
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]
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.debug("Content error--answer '%s' is not a valid range tolerance answer", answer)
|
||||
_ = self.capa_system.i18n.gettext
|
||||
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
_("There was a problem with the staff answer to this problem.")
|
||||
)
|
||||
raise StudentInputError(_("There was a problem with the staff answer to this problem.")) from exc
|
||||
else:
|
||||
self.correct_answer = contextualize_text(answer, context)
|
||||
|
||||
@@ -1566,16 +1563,14 @@ class NumericalResponse(LoncapaResponse):
|
||||
# `complex` seems to only generate `ValueErrors`, only catch these.
|
||||
try:
|
||||
correct_ans = evaluator({}, {}, answer)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.debug("Content error--answer '%s' is not a valid number", answer)
|
||||
_ = self.capa_system.i18n.gettext
|
||||
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
_("There was a problem with the staff answer to this problem.")
|
||||
)
|
||||
raise StudentInputError(_("There was a problem with the staff answer to this problem.")) from exc
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -1602,28 +1597,27 @@ class NumericalResponse(LoncapaResponse):
|
||||
try:
|
||||
student_float = evaluator({}, {}, student_answer)
|
||||
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:
|
||||
raise StudentInputError(err.args[0]) # lint-amnesty, pylint: disable=raise-missing-from
|
||||
raise StudentInputError(err.args[0]) from 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
|
||||
# that evaluates on negative and/or non-integer inputs
|
||||
# str(ve) will be: `factorial() only accepts integral values` or
|
||||
# `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(
|
||||
student_answer=html.escape(student_answer)
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise general_exception # lint-amnesty, pylint: disable=raise-missing-from
|
||||
except ParseException:
|
||||
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
) from val_err
|
||||
raise general_exception from val_err
|
||||
except ParseException as exc:
|
||||
raise StudentInputError(
|
||||
_("Invalid math syntax: '{student_answer}'").format(student_answer=html.escape(student_answer))
|
||||
)
|
||||
except Exception:
|
||||
raise general_exception # lint-amnesty, pylint: disable=raise-missing-from
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise general_exception from exc
|
||||
# End `evaluator` block -- we figured out the student's answer!
|
||||
|
||||
tree = self.xml
|
||||
@@ -1720,11 +1714,11 @@ class NumericalResponse(LoncapaResponse):
|
||||
if compare_with_tolerance(student_float, value, self.tolerance):
|
||||
is_correct = "partially-correct"
|
||||
break
|
||||
elif "close" in self.credit_type:
|
||||
if "close" in self.credit_type:
|
||||
if compare_with_tolerance(student_float, correct_float, expanded_tolerance):
|
||||
is_correct = "partially-correct"
|
||||
break
|
||||
elif compare_with_tolerance(student_float, value, expanded_tolerance):
|
||||
if compare_with_tolerance(student_float, value, expanded_tolerance):
|
||||
is_correct = "partially-correct"
|
||||
partial_score = partial_score * partial_score
|
||||
break
|
||||
@@ -1748,8 +1742,8 @@ class NumericalResponse(LoncapaResponse):
|
||||
|
||||
if is_correct == "partially-correct":
|
||||
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):
|
||||
"""
|
||||
@@ -1860,6 +1854,7 @@ class StringResponse(LoncapaResponse):
|
||||
multi_device_support = True
|
||||
|
||||
def setup_response_backward(self):
|
||||
"""Prepare the correct answers for backward-compatible string responses."""
|
||||
self.correct_answer = [
|
||||
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")
|
||||
|
||||
def check_string_backward(self, expected, given):
|
||||
"""Check if the given string matches any expected string, optionally case-insensitive."""
|
||||
if self.case_insensitive:
|
||||
return given.lower() in [i.lower() for i in expected]
|
||||
return given in expected
|
||||
@@ -1974,13 +1970,13 @@ class StringResponse(LoncapaResponse):
|
||||
# We follow the check_string convention/exception, adding ^ and $
|
||||
regex = re.compile("^" + answer + "$", flags=flags | re.UNICODE)
|
||||
return re.search(regex, given)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
return False
|
||||
|
||||
if ci_mode:
|
||||
return answer.lower() == given.lower()
|
||||
else:
|
||||
return answer == given
|
||||
|
||||
return answer == given
|
||||
|
||||
def check_string(self, expected, given):
|
||||
"""
|
||||
@@ -2017,17 +2013,16 @@ class StringResponse(LoncapaResponse):
|
||||
regexp = re.compile("^" + "|".join(expected) + "$", flags=flags | re.UNICODE)
|
||||
result = re.search(regexp, given)
|
||||
except Exception as err:
|
||||
msg = "[courseware.capa.responsetypes.stringresponse] {error}: {message}".format(
|
||||
error=_("error"), message=str(err)
|
||||
)
|
||||
msg = f"[courseware.capa.responsetypes.stringresponse] {_('error')}: {err}"
|
||||
log.error(msg, exc_info=True)
|
||||
raise ResponseError(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
||||
raise ResponseError(msg) from err
|
||||
return bool(result)
|
||||
else: # string match
|
||||
if self.case_insensitive:
|
||||
return given.lower() in [i.lower() for i in expected]
|
||||
else:
|
||||
return given in expected
|
||||
|
||||
# string match
|
||||
if self.case_insensitive:
|
||||
return given.lower() in [i.lower() for i in expected]
|
||||
|
||||
return given in expected
|
||||
|
||||
def check_hint_condition(self, hxml_set, student_answers):
|
||||
given = student_answers[self.answer_id].strip()
|
||||
@@ -2114,14 +2109,14 @@ class CustomResponse(LoncapaResponse):
|
||||
# and invoke the function with the data needed.
|
||||
def make_check_function(script_code, cfn):
|
||||
def check_function(expect, ans, **kwargs):
|
||||
extra_args = "".join(", {0}={0}".format(k) for k in kwargs)
|
||||
code = script_code + "\n" + "cfn_return = %s(expect, ans%s)\n" % (cfn, extra_args)
|
||||
extra_args = "".join(f", {k}={k}" for k in kwargs)
|
||||
code = f"{script_code}\ncfn_return = {cfn}(expect, ans{extra_args})\n"
|
||||
globals_dict = {
|
||||
"expect": expect,
|
||||
"ans": ans,
|
||||
}
|
||||
globals_dict.update(kwargs)
|
||||
safe_exec.safe_exec(
|
||||
safe_exec(
|
||||
code,
|
||||
globals_dict,
|
||||
python_path=self.context["python_path"],
|
||||
@@ -2149,7 +2144,7 @@ class CustomResponse(LoncapaResponse):
|
||||
else:
|
||||
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
|
||||
of each key removed (the string before the first "_").
|
||||
@@ -2167,12 +2162,10 @@ class CustomResponse(LoncapaResponse):
|
||||
# ordered list of answers
|
||||
submission = [student_answers[k] for k in idset]
|
||||
except Exception as err:
|
||||
msg = "[courseware.capa.responsetypes.customresponse] {message}\n idset = {idset}, error = {err}".format(
|
||||
message=_("error getting student answer from {student_answers}").format(
|
||||
student_answers=student_answers,
|
||||
),
|
||||
idset=idset,
|
||||
err=err,
|
||||
msg = (
|
||||
f"[courseware.capa.responsetypes.customresponse] "
|
||||
f"{_('error getting student answer from {student_answers}').format(student_answers=student_answers)}\n"
|
||||
f"idset = {idset}, error = {err}"
|
||||
)
|
||||
|
||||
log.error(
|
||||
@@ -2183,7 +2176,7 @@ class CustomResponse(LoncapaResponse):
|
||||
idset,
|
||||
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
|
||||
# ordered list of dynamath responses
|
||||
@@ -2251,8 +2244,8 @@ class CustomResponse(LoncapaResponse):
|
||||
correct_map = CorrectMap()
|
||||
correct_map.set_overall_message(overall_message)
|
||||
|
||||
for k in range(len(idset)): # lint-amnesty, pylint: disable=consider-using-enumerate
|
||||
max_points = self.maxpoints[idset[k]]
|
||||
for k, item_id in enumerate(idset):
|
||||
max_points = self.maxpoints[item_id]
|
||||
if grade_decimals:
|
||||
npoints = max_points * grade_decimals[k]
|
||||
else:
|
||||
@@ -2267,11 +2260,14 @@ class CustomResponse(LoncapaResponse):
|
||||
|
||||
def execute_check_function(
|
||||
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
|
||||
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:
|
||||
safe_exec.safe_exec(
|
||||
safe_exec(
|
||||
self.code,
|
||||
self.context,
|
||||
cache=self.capa_system.cache,
|
||||
@@ -2282,7 +2278,7 @@ class CustomResponse(LoncapaResponse):
|
||||
random_seed=self.context["seed"],
|
||||
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)
|
||||
|
||||
else:
|
||||
@@ -2296,7 +2292,7 @@ class CustomResponse(LoncapaResponse):
|
||||
log.debug(" submission = %s", submission)
|
||||
try:
|
||||
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)
|
||||
log.debug("[courseware.capa.responsetypes.customresponse.get_score] ret = %s", ret)
|
||||
if isinstance(ret, dict):
|
||||
@@ -2432,7 +2428,9 @@ class CustomResponse(LoncapaResponse):
|
||||
|
||||
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
|
||||
# will return "</html>". To avoid this, we first check
|
||||
@@ -2459,8 +2457,7 @@ class CustomResponse(LoncapaResponse):
|
||||
return msg.strip()
|
||||
|
||||
# If we start with an empty string, then return an empty string
|
||||
else:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
def get_answers(self):
|
||||
"""
|
||||
@@ -2515,11 +2512,9 @@ class SymbolicResponse(CustomResponse):
|
||||
self.xml.set("cfn", "symmath_check")
|
||||
|
||||
# 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):
|
||||
from symmath import symmath_check
|
||||
|
||||
try:
|
||||
# Since we have limited max_inputfields to 1,
|
||||
# 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(
|
||||
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["correct"] = ["correct" if ret["ok"] else "incorrect"] * len(idset)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
## ScoreMessage named tuple ##
|
||||
## valid: Flag indicating valid score_msg format (Boolean)
|
||||
## correct: Correctness of submission (Boolean)
|
||||
## score: Points to be assigned (numeric, can be float)
|
||||
## msg: Message from grader to display to student (string)
|
||||
# ScoreMessage named tuple #
|
||||
# valid: Flag indicating valid score_msg format (Boolean)
|
||||
# correct: Correctness of submission (Boolean)
|
||||
# score: Points to be assigned (numeric, can be float)
|
||||
# msg: Message from grader to display to student (string)
|
||||
|
||||
ScoreMessage = namedtuple("ScoreMessage", ["valid", "correct", "points", "msg"])
|
||||
|
||||
@@ -2634,7 +2629,7 @@ class CodeResponse(LoncapaResponse):
|
||||
_ = self.capa_system.i18n.gettext
|
||||
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
|
||||
try:
|
||||
# Note that submission can be a file
|
||||
@@ -2646,7 +2641,7 @@ class CodeResponse(LoncapaResponse):
|
||||
self.answer_id,
|
||||
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.
|
||||
if self.capa_system.xqueue is None:
|
||||
@@ -2658,18 +2653,14 @@ class CodeResponse(LoncapaResponse):
|
||||
# ------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
# Generate header
|
||||
queuekey = xqueue_interface.make_hashkey(
|
||||
str(self.capa_system.seed) + qtime + anonymous_student_id + self.answer_id
|
||||
)
|
||||
queuekey = make_hashkey(str(self.capa_system.seed) + qtime + anonymous_student_id + self.answer_id)
|
||||
callback_url = self.capa_system.xqueue.construct_callback()
|
||||
xheader = xqueue_interface.make_xheader(
|
||||
lms_callback_url=callback_url, lms_key=queuekey, queue_name=self.queue_name
|
||||
)
|
||||
xheader = make_xheader(lms_callback_url=callback_url, lms_key=queuekey, queue_name=self.queue_name)
|
||||
|
||||
# Generate body
|
||||
if is_list_of_files(submission):
|
||||
@@ -2748,8 +2739,7 @@ class CodeResponse(LoncapaResponse):
|
||||
# matches
|
||||
if oldcmap.is_right_queuekey(self.answer_id, queuekey):
|
||||
# Sanity check on returned points
|
||||
if points < 0: # lint-amnesty, pylint: disable=consider-using-max-builtin
|
||||
points = 0
|
||||
points = max(points, 0)
|
||||
# Queuestate is consumed
|
||||
oldcmap.set(
|
||||
self.answer_id,
|
||||
@@ -2857,7 +2847,7 @@ class ExternalResponse(LoncapaResponse):
|
||||
self.url = ""
|
||||
self.tests = []
|
||||
self.code = ""
|
||||
super(ExternalResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
@@ -2876,8 +2866,8 @@ class ExternalResponse(LoncapaResponse):
|
||||
# no <answer> stanza; get code from <script>
|
||||
self.code = self.context["script_code"]
|
||||
if not self.code:
|
||||
msg = "%s: Missing answer script code for externalresponse" % str(self)
|
||||
msg += "\nSee XML source line %s" % getattr(self.xml, "sourceline", "[unavailable]")
|
||||
msg = f"{self}: Missing answer script code for externalresponse"
|
||||
msg += f"\nSee XML source line {getattr(self.xml, 'sourceline', '[unavailable]')}"
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
self.tests = xml.get("tests")
|
||||
@@ -2903,25 +2893,27 @@ class ExternalResponse(LoncapaResponse):
|
||||
try:
|
||||
# call external server. TODO: synchronous call, can block for a
|
||||
# long time
|
||||
req = requests.post(self.url, data=payload)
|
||||
req = requests.post(self.url, data=payload, timeout=10)
|
||||
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)
|
||||
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:
|
||||
log.info("response = %s", req.text)
|
||||
|
||||
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:
|
||||
# response is XML; parse it
|
||||
rxml = etree.fromstring(req.text)
|
||||
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)
|
||||
raise Exception(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
||||
raise Exception(msg) from err # pylint: disable=broad-exception-raised
|
||||
|
||||
return rxml
|
||||
|
||||
@@ -2934,7 +2926,7 @@ class ExternalResponse(LoncapaResponse):
|
||||
log.error(
|
||||
"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})
|
||||
|
||||
@@ -2942,7 +2934,7 @@ class ExternalResponse(LoncapaResponse):
|
||||
|
||||
try:
|
||||
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)
|
||||
if self.capa_system.DEBUG:
|
||||
cmap.set_dict(dict(list(zip(sorted(self.answer_ids), ["incorrect"] * len(idset)))))
|
||||
@@ -2972,7 +2964,7 @@ class ExternalResponse(LoncapaResponse):
|
||||
try:
|
||||
rxml = self.do_external_request("get_answers", {})
|
||||
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)
|
||||
if self.capa_system.DEBUG:
|
||||
msg = HTML('<span class="inline-error">{}</span>').format(err)
|
||||
@@ -2981,7 +2973,7 @@ class ExternalResponse(LoncapaResponse):
|
||||
|
||||
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))
|
||||
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)))
|
||||
|
||||
|
||||
@@ -3005,9 +2997,9 @@ class FormulaResponse(LoncapaResponse):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.correct_answer = ""
|
||||
self.samples = ""
|
||||
self.tolerance = default_tolerance
|
||||
self.tolerance = DEFAULT_TOLERANCE
|
||||
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):
|
||||
xml = self.xml
|
||||
@@ -3061,10 +3053,10 @@ class FormulaResponse(LoncapaResponse):
|
||||
)
|
||||
except UndefinedVariable as err:
|
||||
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:
|
||||
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:
|
||||
if "factorial" in str(err):
|
||||
# This is thrown when fact() or factorial() is used in a formularesponse answer
|
||||
@@ -3079,26 +3071,26 @@ class FormulaResponse(LoncapaResponse):
|
||||
),
|
||||
html.escape(answer),
|
||||
)
|
||||
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
raise StudentInputError(
|
||||
_(
|
||||
"Factorial function not permitted in answer "
|
||||
"for this problem. Provided answer was: "
|
||||
"{bad_input}"
|
||||
).format(bad_input=html.escape(answer))
|
||||
)
|
||||
) from err
|
||||
# If non-factorial related ValueError thrown, handle it the same as any other Exception
|
||||
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)
|
||||
)
|
||||
)
|
||||
) from err
|
||||
except Exception as err:
|
||||
# traceback.print_exc()
|
||||
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))
|
||||
)
|
||||
) from err
|
||||
return out
|
||||
|
||||
def randomize_variables(self, samples):
|
||||
@@ -3138,8 +3130,8 @@ class FormulaResponse(LoncapaResponse):
|
||||
)
|
||||
if correct:
|
||||
return "correct"
|
||||
else:
|
||||
return "incorrect"
|
||||
|
||||
return "incorrect"
|
||||
|
||||
def compare_answer(self, ans1, ans2):
|
||||
"""
|
||||
@@ -3165,13 +3157,12 @@ class FormulaResponse(LoncapaResponse):
|
||||
keys and all non-numeric values stripped out. All values also
|
||||
converted to float. Used so we can safely use Python contexts.
|
||||
"""
|
||||
inp_d = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
|
||||
[
|
||||
(k, numpy.complex(inp_d[k]))
|
||||
for k in inp_d
|
||||
if isinstance(k, str) and k.isalnum() and isinstance(inp_d[k], numbers.Number)
|
||||
]
|
||||
)
|
||||
inp_d = {
|
||||
k: numpy.complex(inp_d[k])
|
||||
for k in inp_d
|
||||
if isinstance(k, str) and k.isalnum() and isinstance(inp_d[k], numbers.Number)
|
||||
}
|
||||
|
||||
return inp_d
|
||||
|
||||
def check_hint_condition(self, hxml_set, student_answers):
|
||||
@@ -3181,10 +3172,10 @@ class FormulaResponse(LoncapaResponse):
|
||||
samples = hxml.get("samples")
|
||||
name = hxml.get("name")
|
||||
correct_answer = contextualize_text(hxml.get("answer"), self.context)
|
||||
# pylint: disable=broad-except
|
||||
|
||||
try:
|
||||
correctness = self.check_formula(correct_answer, given, samples)
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
correctness = "incorrect"
|
||||
if correctness == "correct":
|
||||
hints_to_show.append(name)
|
||||
@@ -3210,7 +3201,7 @@ class SchematicResponse(LoncapaResponse):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.code = ""
|
||||
super(SchematicResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
@@ -3226,7 +3217,7 @@ class SchematicResponse(LoncapaResponse):
|
||||
submission = [json.loads(student_answers[k]) for k in sorted(self.answer_ids)]
|
||||
self.context.update({"submission": submission})
|
||||
try:
|
||||
safe_exec.safe_exec(
|
||||
safe_exec(
|
||||
self.code,
|
||||
self.context,
|
||||
cache=self.capa_system.cache,
|
||||
@@ -3241,7 +3232,7 @@ class SchematicResponse(LoncapaResponse):
|
||||
_ = self.capa_system.i18n.gettext
|
||||
# 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)
|
||||
raise ResponseError(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
||||
raise ResponseError(msg) from err
|
||||
cmap = CorrectMap()
|
||||
cmap.set_dict(dict(list(zip(sorted(self.answer_ids), self.context["correct"]))))
|
||||
return cmap
|
||||
@@ -3288,13 +3279,13 @@ class ImageResponse(LoncapaResponse):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.ielements = []
|
||||
super(ImageResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def setup_response(self):
|
||||
self.ielements = self.inputfields
|
||||
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
|
||||
correct_map = CorrectMap()
|
||||
expectedset = self.get_mapped_answers()
|
||||
@@ -3310,7 +3301,9 @@ class ImageResponse(LoncapaResponse):
|
||||
msg = _("error grading {image_input_id} (input={user_input})").format(
|
||||
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()]
|
||||
|
||||
@@ -3331,7 +3324,9 @@ class ImageResponse(LoncapaResponse):
|
||||
msg = _("Error in problem specification! Cannot parse rectangle in {sr_coords}").format(
|
||||
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()]
|
||||
|
||||
@@ -3367,19 +3362,10 @@ class ImageResponse(LoncapaResponse):
|
||||
regions (dict) - a map of inputs to the defined region for that input
|
||||
"""
|
||||
answers = (
|
||||
dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
|
||||
[
|
||||
(
|
||||
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]
|
||||
),
|
||||
{ie.get("id"): ie.get("rectangle") for ie in self.ielements},
|
||||
{ie.get("id"): ie.get("regions") for ie in self.ielements},
|
||||
)
|
||||
|
||||
return answers
|
||||
|
||||
def get_answers(self):
|
||||
@@ -3421,7 +3407,7 @@ class AnnotationResponse(LoncapaResponse):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.scoring_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):
|
||||
self.scoring_map = self._get_scoring_map()
|
||||
@@ -3452,21 +3438,17 @@ class AnnotationResponse(LoncapaResponse):
|
||||
def _get_scoring_map(self):
|
||||
"""Returns a dict of option->scoring for each input."""
|
||||
scoring = self.default_scoring
|
||||
choices = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
|
||||
[(choice, choice) for choice in scoring]
|
||||
)
|
||||
choices = {choice: choice for choice in scoring}
|
||||
scoring_map = {}
|
||||
|
||||
for inputfield in self.inputfields:
|
||||
option_scoring = dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
|
||||
[
|
||||
(
|
||||
option["id"],
|
||||
{"correctness": choices.get(option["choice"]), "points": scoring.get(option["choice"])},
|
||||
)
|
||||
for option in self._find_options(inputfield)
|
||||
]
|
||||
)
|
||||
option_scoring = {
|
||||
option["id"]: {
|
||||
"correctness": choices.get(option["choice"]),
|
||||
"points": scoring.get(option["choice"]),
|
||||
}
|
||||
for option in self._find_options(inputfield)
|
||||
}
|
||||
|
||||
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."""
|
||||
scoring = self.default_scoring
|
||||
correct_points = scoring.get("correct")
|
||||
return dict( # lint-amnesty, pylint: disable=consider-using-dict-comprehension
|
||||
[(inputfield.get("id"), correct_points) for inputfield in self.inputfields]
|
||||
)
|
||||
return {inputfield.get("id"): correct_points for inputfield in self.inputfields}
|
||||
|
||||
def _find_options(self, inputfield):
|
||||
"""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):
|
||||
if option["choice"] == choice:
|
||||
return option
|
||||
return None
|
||||
|
||||
def _unpack(self, json_value):
|
||||
"""Unpacks a student response value submitted as JSON."""
|
||||
@@ -3550,7 +3531,7 @@ class ChoiceTextResponse(LoncapaResponse):
|
||||
self.correct_inputs = {}
|
||||
self.answer_values = {}
|
||||
self.correct_choices = {}
|
||||
super(ChoiceTextResponse, self).__init__(*args, **kwargs) # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def setup_response(self):
|
||||
"""
|
||||
@@ -3595,7 +3576,7 @@ class ChoiceTextResponse(LoncapaResponse):
|
||||
answer = contextualize_text(answer, context)
|
||||
input_name = child.get("name")
|
||||
# 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
|
||||
# numtolerance_input to `correct_inputs`
|
||||
self.correct_inputs[input_name] = {"answer": answer, "tolerance": tolerance}
|
||||
@@ -3808,28 +3789,26 @@ class ChoiceTextResponse(LoncapaResponse):
|
||||
|
||||
correct_ans = params["answer"]
|
||||
# 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
|
||||
try:
|
||||
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)
|
||||
raise StudentInputError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
_("The Staff answer could not be interpreted as a number.")
|
||||
)
|
||||
raise StudentInputError(_("The Staff answer could not be interpreted as a number.")) from exc
|
||||
# Compare the student answer to the staff answer/ or to 0
|
||||
# if all that is important is verifying numericality
|
||||
try:
|
||||
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
|
||||
# different type
|
||||
__, __, trace = sys.exc_info()
|
||||
msg = _("Could not interpret '{given_answer}' as a number.").format(
|
||||
given_answer=html.escape(answer_value)
|
||||
msg = _( # pylint: disable=translation-of-non-string
|
||||
f"Could not interpret '{html.escape(answer_value)}' as a number."
|
||||
)
|
||||
msg += " ({0})".format(trace)
|
||||
raise StudentInputError(msg) # lint-amnesty, pylint: disable=raise-missing-from
|
||||
msg += f" ({trace})"
|
||||
raise StudentInputError(msg) from exc
|
||||
|
||||
# Ignore the results of the comparisons which were just for
|
||||
# Numerical Validation.
|
||||
@@ -3844,22 +3823,20 @@ class ChoiceTextResponse(LoncapaResponse):
|
||||
# TEMPORARY: List of all response subclasses
|
||||
# FIXME: To be replaced by auto-registration
|
||||
|
||||
# pylint: disable=invalid-all-object
|
||||
__all__ = [
|
||||
CodeResponse,
|
||||
NumericalResponse,
|
||||
FormulaResponse,
|
||||
CustomResponse,
|
||||
SchematicResponse,
|
||||
ExternalResponse,
|
||||
ImageResponse,
|
||||
OptionResponse,
|
||||
SymbolicResponse,
|
||||
StringResponse,
|
||||
ChoiceResponse,
|
||||
MultipleChoiceResponse,
|
||||
TrueFalseResponse,
|
||||
AnnotationResponse,
|
||||
ChoiceTextResponse,
|
||||
"CodeResponse",
|
||||
"NumericalResponse",
|
||||
"FormulaResponse",
|
||||
"CustomResponse",
|
||||
"SchematicResponse",
|
||||
"ExternalResponse",
|
||||
"ImageResponse",
|
||||
"OptionResponse",
|
||||
"SymbolicResponse",
|
||||
"StringResponse",
|
||||
"ChoiceResponse",
|
||||
"MultipleChoiceResponse",
|
||||
"TrueFalseResponse",
|
||||
"AnnotationResponse",
|
||||
"ChoiceTextResponse",
|
||||
]
|
||||
# pylint: enable=invalid-all-object
|
||||
|
||||
@@ -8,7 +8,7 @@ in the public domain.
|
||||
import sys
|
||||
|
||||
|
||||
class LazyModule(object):
|
||||
class LazyModule: # pylint: disable=too-few-public-methods
|
||||
"""A lazy module proxy."""
|
||||
|
||||
def __init__(self, modname):
|
||||
@@ -32,14 +32,12 @@ class LazyModule(object):
|
||||
|
||||
if hasattr(mod, name):
|
||||
return getattr(mod, name)
|
||||
else:
|
||||
try:
|
||||
subname = "%s.%s" % (self.__name__, name)
|
||||
__import__(subname)
|
||||
submod = getattr(mod, name) # lint-amnesty, pylint: disable=unused-variable
|
||||
except ImportError:
|
||||
raise AttributeError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
"'module' object has no attribute %r" % name
|
||||
)
|
||||
self.__dict__[name] = LazyModule(subname)
|
||||
return self.__dict__[name]
|
||||
|
||||
try:
|
||||
subname = f"{self.__name__}.{name}"
|
||||
__import__(subname)
|
||||
submod = getattr(mod, name) # pylint: disable=unused-variable
|
||||
except ImportError as exc:
|
||||
raise AttributeError(f"'module' object has no attribute {name!r}") from exc
|
||||
self.__dict__[name] = LazyModule(subname)
|
||||
return self.__dict__[name]
|
||||
|
||||
@@ -42,6 +42,7 @@ ENABLE_CODEJAIL_DARKLAUNCH = SettingToggle("ENABLE_CODEJAIL_DARKLAUNCH", default
|
||||
|
||||
|
||||
def is_codejail_rest_service_enabled():
|
||||
"""Return whether the 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():
|
||||
"""Return the endpoint URL for the codejail REST service."""
|
||||
return f"{settings.CODE_JAIL_REST_SERVICE_HOST}/api/v0/code-exec"
|
||||
|
||||
|
||||
|
||||
@@ -69,16 +69,16 @@ ASSUMED_IMPORTS = [
|
||||
]
|
||||
|
||||
# We'll need the code from lazymod.py for use in safe_exec, so read it now.
|
||||
lazymod_py_file = lazymod.__file__
|
||||
if lazymod_py_file.endswith("c"):
|
||||
lazymod_py_file = lazymod_py_file[:-1]
|
||||
LAZYMOD_PY_FILE = lazymod.__file__
|
||||
if LAZYMOD_PY_FILE.endswith("c"):
|
||||
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()
|
||||
|
||||
LAZY_IMPORTS = [lazymod_py]
|
||||
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)
|
||||
|
||||
@@ -107,7 +107,7 @@ def update_hash(hasher, obj):
|
||||
|
||||
|
||||
@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,
|
||||
globals_dict,
|
||||
random_seed=None,
|
||||
@@ -117,7 +117,7 @@ def safe_exec(
|
||||
limit_overrides_context=None,
|
||||
slug=None,
|
||||
unsafely=False,
|
||||
): # pylint: disable=too-many-statements
|
||||
):
|
||||
"""
|
||||
Execute python code safely.
|
||||
|
||||
@@ -155,7 +155,7 @@ def safe_exec(
|
||||
md5er = hashlib.md5()
|
||||
md5er.update(repr(code).encode("utf-8"))
|
||||
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)
|
||||
if cached is not None:
|
||||
# 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,
|
||||
slug=slug,
|
||||
)
|
||||
except BaseException as e:
|
||||
except BaseException as e: # pylint: disable=broad-exception-caught
|
||||
# Saving SafeExecException e in exception to be used later.
|
||||
exception = e
|
||||
emsg = str(e)
|
||||
@@ -263,7 +263,7 @@ def safe_exec(
|
||||
# SafeExecException wrapped around emsg (if present).
|
||||
remote_emsg, _ = get_remote_exec(data)
|
||||
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
|
||||
# deploy.
|
||||
remote_emsg = None
|
||||
@@ -282,7 +282,7 @@ def safe_exec(
|
||||
emsg_remote=remote_emsg,
|
||||
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.")
|
||||
record_exception()
|
||||
|
||||
@@ -376,7 +376,7 @@ def emsg_normalizers():
|
||||
custom_setting = getattr(settings, "CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS", [])
|
||||
try:
|
||||
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")
|
||||
record_exception()
|
||||
return default_normalizers
|
||||
@@ -390,8 +390,9 @@ def emsg_normalizers():
|
||||
combine = getattr(settings, "CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE", "append")
|
||||
if combine == "replace":
|
||||
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):
|
||||
@@ -407,7 +408,7 @@ def normalize_error_message(emsg):
|
||||
return emsg
|
||||
|
||||
|
||||
def report_darklaunch_results(
|
||||
def report_darklaunch_results( # pylint: disable=too-many-arguments
|
||||
*,
|
||||
limit_overrides_context,
|
||||
slug,
|
||||
@@ -454,22 +455,32 @@ def report_darklaunch_results(
|
||||
set_custom_attribute("codejail.darklaunch.globals_match", "N/A")
|
||||
set_custom_attribute("codejail.darklaunch.emsg_match", "N/A")
|
||||
log.info(
|
||||
"Codejail darklaunch had unexpected exception for "
|
||||
f"course={limit_overrides_context!r}, slug={slug!r}:\n"
|
||||
f"Local exception: {unexpected_exc_local!r}\n"
|
||||
f"Remote exception: {unexpected_exc_remote!r}"
|
||||
"Codejail darklaunch had unexpected exception for course=%r, slug=%r:\n"
|
||||
"Local exception: %r\nRemote exception: %r",
|
||||
limit_overrides_context,
|
||||
slug,
|
||||
unexpected_exc_local,
|
||||
unexpected_exc_remote,
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
globals_match = globals_local == globals_remote
|
||||
emsg_match = normalize_error_message(emsg_local) == normalize_error_message(emsg_remote)
|
||||
|
||||
if not globals_match or not emsg_match:
|
||||
log.info(
|
||||
f"Codejail darklaunch had mismatch for course={limit_overrides_context!r}, slug={slug!r}:\n"
|
||||
f"{emsg_match=}, {globals_match=}\n"
|
||||
f"Local: globals={globals_local!r}, emsg={emsg_local!r}\n"
|
||||
f"Remote: globals={globals_remote!r}, emsg={emsg_remote!r}"
|
||||
"Codejail darklaunch had mismatch for course=%r, slug=%r:\n"
|
||||
"emsg_match=%r, globals_match=%r\n"
|
||||
"Local: globals=%r, emsg=%r\n"
|
||||
"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
|
||||
@@ -484,10 +495,11 @@ def report_darklaunch_results(
|
||||
# the randomized directory names used for sandboxes. 'N/A' when either
|
||||
# arm raised an uncaught error.
|
||||
set_custom_attribute("codejail.darklaunch.emsg_match", emsg_match)
|
||||
return None
|
||||
|
||||
|
||||
@receiver(setting_changed)
|
||||
def reset_caches(sender, **kwargs):
|
||||
def reset_caches(sender, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Reset cached settings during unit tests.
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
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.
|
||||
|
||||
@@ -18,7 +18,9 @@ class ModuleIsolation(object):
|
||||
# Save all the names of all the imported 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
|
||||
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.
|
||||
@@ -26,14 +28,16 @@ class ModuleIsolation(object):
|
||||
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):
|
||||
super(TestLazyMod, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
# Each test will remove modules that it imported.
|
||||
self.addCleanup(ModuleIsolation().clean_up)
|
||||
|
||||
def test_simple(self):
|
||||
"""Test lazy import of a standard module and verify functionality."""
|
||||
# Import some stdlib module that has not been imported before
|
||||
module_name = "colorsys"
|
||||
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
|
||||
|
||||
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.
|
||||
# Any similar module would do. This test demonstrates that the module
|
||||
# is not already imported
|
||||
|
||||
@@ -20,6 +20,7 @@ class TestRemoteExec(TestCase):
|
||||
)
|
||||
@patch("requests.post")
|
||||
def test_json_encode(self, mock_post):
|
||||
"""Verify that get_remote_exec correctly JSON-encodes payload with globals."""
|
||||
get_remote_exec(
|
||||
{
|
||||
"code": "out = 1 + 1",
|
||||
|
||||
@@ -21,31 +21,40 @@ from six.moves import range
|
||||
|
||||
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.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.tests.test_util import use_unsafe_codejail
|
||||
from xmodule.capa.tests.test_util import UseUnsafeCodejail
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
@UseUnsafeCodejail()
|
||||
class TestSafeExec(unittest.TestCase):
|
||||
"""Unit tests for verifying functionality and restrictions of safe_exec."""
|
||||
|
||||
def test_set_values(self):
|
||||
"""Verify assignment of values in safe_exec."""
|
||||
g = {}
|
||||
safe_exec("a = 17", g)
|
||||
assert g["a"] == 17
|
||||
|
||||
def test_division(self):
|
||||
"""Verify division operation in safe_exec."""
|
||||
g = {}
|
||||
# Future division: 1/2 is 0.5.
|
||||
safe_exec("a = 1/2", g)
|
||||
assert g["a"] == 0.5
|
||||
|
||||
def test_assumed_imports(self):
|
||||
"""Check assumed standard imports in safe_exec."""
|
||||
g = {}
|
||||
# Math is always available.
|
||||
safe_exec("a = int(math.pi)", g)
|
||||
assert g["a"] == 3
|
||||
|
||||
def test_random_seeding(self):
|
||||
"""Test predictable random results with seeding in safe_exec."""
|
||||
g = {}
|
||||
r = random.Random(17)
|
||||
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
|
||||
|
||||
def test_random_is_still_importable(self):
|
||||
"""Ensure random module works with seeding in safe_exec."""
|
||||
g = {}
|
||||
r = random.Random(17)
|
||||
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
|
||||
|
||||
def test_python_lib(self):
|
||||
"""Test importing Python library from custom path in safe_exec."""
|
||||
pylib = os.path.dirname(__file__) + "/test_files/pylib"
|
||||
g = {}
|
||||
safe_exec("import constant; a = constant.THE_CONST", g, python_path=[pylib])
|
||||
|
||||
def test_raising_exceptions(self):
|
||||
"""Ensure exceptions are raised correctly in safe_exec."""
|
||||
g = {}
|
||||
with pytest.raises(SafeExecException) as cm:
|
||||
safe_exec("1/0", g)
|
||||
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
|
||||
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.codejail_safe_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.
|
||||
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.codejail_safe_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.
|
||||
mock_remote_exec.return_value = (None, None)
|
||||
# Test with only the service enabled.
|
||||
@@ -164,7 +178,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
assert mock_remote_exec.called
|
||||
|
||||
@override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True)
|
||||
def run_dark_launch(
|
||||
def run_dark_launch( # pylint: disable=too-many-positional-arguments,too-many-arguments
|
||||
self,
|
||||
globals_dict,
|
||||
local,
|
||||
@@ -202,7 +216,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
limit_overrides_context="course-v1:org+course+run",
|
||||
slug="hw1",
|
||||
)
|
||||
except BaseException as e:
|
||||
except BaseException as e: # pylint: disable=broad-exception-caught
|
||||
safe_exec_e = e
|
||||
else:
|
||||
safe_exec_e = None
|
||||
@@ -234,7 +248,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
local_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
|
||||
nonlocal local_globals
|
||||
local_globals = copy.deepcopy(globals_dict)
|
||||
@@ -264,11 +278,18 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
],
|
||||
expect_log_info_calls=[
|
||||
call(
|
||||
"Codejail darklaunch had mismatch for "
|
||||
"course='course-v1:org+course+run', slug='hw1':\n"
|
||||
"emsg_match=True, globals_match=False\n"
|
||||
"Local: globals={'overwrite': 'mock local'}, emsg=None\n"
|
||||
"Remote: globals={'overwrite': 'mock remote'}, emsg=None"
|
||||
"Codejail darklaunch had mismatch for course=%r, slug=%r:\n"
|
||||
"emsg_match=%r, globals_match=%r\n"
|
||||
"Local: globals=%r, emsg=%r\n"
|
||||
"Remote: globals=%r, emsg=%r",
|
||||
"course-v1:org+course+run",
|
||||
"hw1",
|
||||
True,
|
||||
False,
|
||||
{"overwrite": "mock local"},
|
||||
None,
|
||||
{"overwrite": "mock remote"},
|
||||
None,
|
||||
),
|
||||
],
|
||||
# Should only see behavior of local exec
|
||||
@@ -283,12 +304,13 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
|
||||
def test_remote_runs_even_if_local_raises(self):
|
||||
"""Test that remote exec runs even if local raises."""
|
||||
expected_error = BaseException("unexpected")
|
||||
|
||||
def local_exec(code, globals_dict, **kwargs):
|
||||
# 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)
|
||||
|
||||
results = self.run_dark_launch(
|
||||
@@ -306,10 +328,12 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
],
|
||||
expect_log_info_calls=[
|
||||
call(
|
||||
"Codejail darklaunch had unexpected exception "
|
||||
"for course='course-v1:org+course+run', slug='hw1':\n"
|
||||
"Local exception: BaseException('unexpected')\n"
|
||||
"Remote exception: None"
|
||||
"Codejail darklaunch had unexpected exception for course=%r, slug=%r:\n"
|
||||
"Local exception: %r\nRemote exception: %r",
|
||||
"course-v1:org+course+run",
|
||||
"hw1",
|
||||
expected_error,
|
||||
None,
|
||||
),
|
||||
],
|
||||
expect_globals_contains={},
|
||||
@@ -325,7 +349,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
def local_exec(code, globals_dict, **kwargs):
|
||||
raise SafeExecException("oops")
|
||||
|
||||
def remote_exec(data):
|
||||
def remote_exec(data): # pylint: disable=unused-argument
|
||||
return ("OH NO", SafeExecException("OH NO"))
|
||||
|
||||
results = self.run_dark_launch(
|
||||
@@ -343,11 +367,18 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
],
|
||||
expect_log_info_calls=[
|
||||
call(
|
||||
"Codejail darklaunch had mismatch for "
|
||||
"course='course-v1:org+course+run', slug='hw1':\n"
|
||||
"emsg_match=False, globals_match=True\n"
|
||||
"Local: globals={}, emsg='oops'\n"
|
||||
"Remote: globals={}, emsg='OH NO'"
|
||||
"Codejail darklaunch had mismatch for course=%r, slug=%r:\n"
|
||||
"emsg_match=%r, globals_match=%r\n"
|
||||
"Local: globals=%r, emsg=%r\n"
|
||||
"Remote: globals=%r, emsg=%r",
|
||||
"course-v1:org+course+run",
|
||||
"hw1",
|
||||
False,
|
||||
True,
|
||||
{},
|
||||
"oops",
|
||||
{},
|
||||
"OH NO",
|
||||
),
|
||||
],
|
||||
expect_globals_contains={},
|
||||
@@ -361,7 +392,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
def local_exec(code, globals_dict, **kwargs):
|
||||
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"
|
||||
return (emsg, SafeExecException(emsg))
|
||||
|
||||
@@ -449,7 +480,7 @@ class TestCodeJailDarkLaunch(unittest.TestCase):
|
||||
@patch("xmodule.capa.safe_exec.safe_exec.log.error")
|
||||
def test_normalizers_validate(self, mock_log_error, mock_record_exception):
|
||||
"""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_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
|
||||
|
||||
|
||||
class DictCache(object):
|
||||
class DictCache:
|
||||
"""A cache implementation over a simple dict, for testing."""
|
||||
|
||||
def __init__(self, d):
|
||||
self.cache = d
|
||||
|
||||
def get(self, key):
|
||||
"""Get value from cache by key with length check."""
|
||||
# Actual cache implementations have limits on key length
|
||||
assert len(key) <= 250
|
||||
return self.cache.get(key)
|
||||
|
||||
def set(self, key, value):
|
||||
"""Set value in cache by key with length check."""
|
||||
# Actual cache implementations have limits on key length
|
||||
assert len(key) <= 250
|
||||
self.cache[key] = value
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class TestSafeExecCaching(unittest.TestCase):
|
||||
"""Test that caching works on safe_exec."""
|
||||
|
||||
def test_cache_miss_then_hit(self):
|
||||
"""Test caching works on miss and hit in safe_exec."""
|
||||
g = {}
|
||||
cache = {}
|
||||
|
||||
@@ -568,6 +602,7 @@ class TestSafeExecCaching(unittest.TestCase):
|
||||
assert g["a"] == 17
|
||||
|
||||
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.
|
||||
# Check that it doesn't any more.
|
||||
code = "a = 0\n" + ("a += 1\n" * 12345)
|
||||
@@ -578,6 +613,7 @@ class TestSafeExecCaching(unittest.TestCase):
|
||||
assert g["a"] == 12345
|
||||
|
||||
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
|
||||
# the result. Check that now it does.
|
||||
code = "1/0"
|
||||
@@ -588,7 +624,7 @@ class TestSafeExecCaching(unittest.TestCase):
|
||||
|
||||
# The exception should be in the cache now.
|
||||
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
|
||||
|
||||
# Change the value stored in the cache, the result should change.
|
||||
@@ -607,6 +643,7 @@ class TestSafeExecCaching(unittest.TestCase):
|
||||
assert g["a"] == 17
|
||||
|
||||
def test_unicode_submission(self):
|
||||
"""Test safe_exec handles non-ASCII unicode."""
|
||||
# Check that using non-ASCII unicode does not raise an encoding error.
|
||||
# Try several non-ASCII unicode characters.
|
||||
for code in [129, 500, 2**8 - 1, 2**16 - 1]:
|
||||
@@ -614,7 +651,7 @@ class TestSafeExecCaching(unittest.TestCase):
|
||||
try:
|
||||
safe_exec(code_with_unichr, {}, cache=DictCache({}))
|
||||
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):
|
||||
@@ -644,6 +681,7 @@ class TestUpdateHash(unittest.TestCase):
|
||||
return d1, d2
|
||||
|
||||
def test_simple_cases(self):
|
||||
"""Test hashing of simple objects."""
|
||||
h1 = self.hash_obj(1)
|
||||
h10 = self.hash_obj(10)
|
||||
hs1 = self.hash_obj("1")
|
||||
@@ -652,17 +690,20 @@ class TestUpdateHash(unittest.TestCase):
|
||||
assert h1 != hs1
|
||||
|
||||
def test_list_ordering(self):
|
||||
"""Test that list ordering affects hash."""
|
||||
h1 = self.hash_obj({"a": [1, 2, 3]})
|
||||
h2 = self.hash_obj({"a": [3, 2, 1]})
|
||||
assert h1 != h2
|
||||
|
||||
def test_dict_ordering(self):
|
||||
"""Test that dict ordering does not affect hash."""
|
||||
d1, d2 = self.equal_but_different_dicts()
|
||||
h1 = self.hash_obj(d1)
|
||||
h2 = self.hash_obj(d2)
|
||||
assert h1 == h2
|
||||
|
||||
def test_deep_ordering(self):
|
||||
"""Test that nested structures are hashed consistently."""
|
||||
d1, d2 = self.equal_but_different_dicts()
|
||||
o1 = {"a": [1, 2, [d1], 3, 4]}
|
||||
o2 = {"a": [1, 2, [d2], 3, 4]}
|
||||
@@ -671,9 +712,12 @@ class TestUpdateHash(unittest.TestCase):
|
||||
assert h1 == h2
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
class TestRealProblems(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
@UseUnsafeCodejail()
|
||||
class TestRealProblems(unittest.TestCase):
|
||||
"""Unit tests for executing real problem code snippets safely."""
|
||||
|
||||
def test_802x(self):
|
||||
"Test execution of real problem code snippet safely."
|
||||
code = textwrap.dedent(
|
||||
"""\
|
||||
import math
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Score rendering when submission is evaluated for external grader and has been saved successfully
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
@@ -20,10 +21,10 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_xblock_for_external_grader(
|
||||
user_id: str,
|
||||
course_key: CourseKey,
|
||||
usage_key: UsageKey,
|
||||
course=None,
|
||||
user_id: str,
|
||||
course_key: CourseKey,
|
||||
usage_key: UsageKey,
|
||||
course=None,
|
||||
):
|
||||
"""
|
||||
Load a single XBlock for external grading without user access checks.
|
||||
@@ -35,22 +36,16 @@ def load_xblock_for_external_grader(
|
||||
try:
|
||||
block = modulestore().get_item(usage_key)
|
||||
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
|
||||
|
||||
field_data_cache = FieldDataCache.cache_for_block_descendents(
|
||||
course_key, user, block, depth=0
|
||||
)
|
||||
field_data_cache = FieldDataCache.cache_for_block_descendents(course_key, user, block, depth=0)
|
||||
|
||||
student_kvs = DjangoKeyValueStore(field_data_cache)
|
||||
student_data = KvsFieldData(student_kvs)
|
||||
|
||||
instance = get_block_for_descriptor_without_access_check(
|
||||
user=user,
|
||||
block=block,
|
||||
student_data=student_data,
|
||||
course_key=course_key,
|
||||
course=course
|
||||
user=user, block=block, student_data=student_data, course_key=course_key, course=course
|
||||
)
|
||||
|
||||
if instance is None:
|
||||
|
||||
@@ -4,8 +4,8 @@ import gettext
|
||||
import io
|
||||
import os
|
||||
import os.path
|
||||
import xml.sax.saxutils as saxutils
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from xml.sax import saxutils
|
||||
|
||||
import fs.osfs
|
||||
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
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the stubbed XQueueService instance."""
|
||||
self.interface = MagicMock()
|
||||
self.interface.send_to_queue.return_value = (0, "Success!")
|
||||
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.
|
||||
"""
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
"""Factories to build CAPA response XML."""
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
@@ -28,7 +28,7 @@ class ResponseXMLFactory(six.with_metaclass(ABCMeta, object)):
|
||||
representing the capa input XML (such as <textline />)"""
|
||||
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
|
||||
based on **kwargs.
|
||||
|
||||
@@ -364,13 +364,12 @@ class CodeResponseXMLFactory(ResponseXMLFactory):
|
||||
"""Factory for creating <coderesponse> XML trees"""
|
||||
|
||||
def build_xml(self, **kwargs):
|
||||
"""Build a <coderesponse> XML tree."""
|
||||
# Since we are providing an <answer> tag,
|
||||
# we should override the default behavior
|
||||
# of including a <solution> tag as well
|
||||
kwargs["explanation_text"] = None
|
||||
return super(CodeResponseXMLFactory, self).build_xml( # lint-amnesty, pylint: disable=super-with-arguments
|
||||
**kwargs
|
||||
)
|
||||
return super().build_xml(**kwargs)
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
"""
|
||||
@@ -452,7 +451,7 @@ class ChoiceResponseXMLFactory(ResponseXMLFactory):
|
||||
class FormulaResponseXMLFactory(ResponseXMLFactory):
|
||||
"""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.
|
||||
|
||||
*sample_dict*: A dictionary of the form:
|
||||
@@ -534,9 +533,9 @@ class FormulaResponseXMLFactory(ResponseXMLFactory):
|
||||
def create_input_element(self, **kwargs):
|
||||
return ResponseXMLFactory.textline_input_xml(**kwargs)
|
||||
|
||||
def _sample_str(
|
||||
self, sample_dict, num_samples, tolerance
|
||||
): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument
|
||||
def _sample_str(self, sample_dict, num_samples, tolerance): # pylint: disable=unused-argument
|
||||
"""Generate a sample string for Loncapa using variable ranges and repetition count."""
|
||||
|
||||
# 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)
|
||||
# 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
|
||||
# Format: "('first', 'second', 'third')"
|
||||
options_attr_string = ",".join(["'{}'".format(o) for o in options_list])
|
||||
options_attr_string = "({})".format(options_attr_string)
|
||||
options_attr_string = ",".join([f"'{o}'" for o in options_list])
|
||||
options_attr_string = f"({options_attr_string})"
|
||||
optioninput_element.set("options", options_attr_string)
|
||||
|
||||
# Set the "correct" attribute
|
||||
@@ -694,7 +693,7 @@ class OptionResponseXMLFactory(ResponseXMLFactory):
|
||||
class StringResponseXMLFactory(ResponseXMLFactory):
|
||||
"""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.
|
||||
|
||||
Uses **kwargs:
|
||||
@@ -897,7 +896,7 @@ class ChoiceTextResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
for ind, choice in enumerate(choice_inputs):
|
||||
# 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)
|
||||
|
||||
return input_element
|
||||
|
||||
@@ -14,7 +14,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
"""Capa Answer Pool Test"""
|
||||
|
||||
def setUp(self):
|
||||
super(CapaAnswerPoolTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.system = mock_capa_system()
|
||||
|
||||
# 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):
|
||||
"""Test answer-pool=4 with one multiplechoiceresponse and a fixed seed."""
|
||||
problem = new_loncapa_problem(self.common_question_xml, seed=723)
|
||||
the_html = problem.get_html()
|
||||
# [('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"]
|
||||
|
||||
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)
|
||||
the_html = problem.get_html()
|
||||
# [('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"]
|
||||
|
||||
def test_no_answer_pool_4_choices_1_multiplechoiceresponse(self):
|
||||
"""Test behavior when no answer-pool attribute is provided."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -121,7 +124,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
the_html = problem.get_html()
|
||||
self.assertRegex(
|
||||
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>")
|
||||
assert the_html == problem.get_html(), "should be able to call get_html() twice"
|
||||
# Check about masking
|
||||
@@ -130,6 +133,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
assert not response.has_answerpool()
|
||||
|
||||
def test_0_answer_pool_4_choices_1_multiplechoiceresponse(self):
|
||||
"""Test behavior when answer-pool is explicitly set to 0."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -171,13 +175,14 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
the_html = problem.get_html()
|
||||
self.assertRegex(
|
||||
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>")
|
||||
response = list(problem.responders.values())[0]
|
||||
assert not response.has_mask()
|
||||
assert not response.has_answerpool()
|
||||
|
||||
def test_invalid_answer_pool_value(self):
|
||||
"""Ensure error is raised for non-integer answer-pool values."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -219,6 +224,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
new_loncapa_problem(xml_str)
|
||||
|
||||
def test_invalid_answer_pool_none_correct(self):
|
||||
"""Ensure error is raised if no correct choice exists in answer-pool."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -239,6 +245,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
new_loncapa_problem(xml_str)
|
||||
|
||||
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(
|
||||
"""
|
||||
<problem>
|
||||
@@ -258,6 +265,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
new_loncapa_problem(xml_str)
|
||||
|
||||
def test_answer_pool_5_choices_1_multiplechoiceresponse_seed1(self):
|
||||
"""Test answer-pool=5 with one multiplechoiceresponse and fixed seed."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -304,6 +312,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
assert response.unmask_order() == ["choice_5", "choice_0", "choice_1", "choice_3", "choice_4"]
|
||||
|
||||
def test_answer_pool_2_multiplechoiceresponses_seed1(self):
|
||||
"""Test two multiplechoiceresponses with different answer-pools and fixed seed."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -390,6 +399,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
self.assertRegex(without_new_lines, str3 + r".*" + str4)
|
||||
|
||||
def test_answer_pool_2_multiplechoiceresponses_seed2(self):
|
||||
"""Test two multiplechoiceresponses with different answer-pools and second seed."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -544,6 +554,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
self.assertRegex(without_new_lines, str1)
|
||||
|
||||
def test_no_answer_pool(self):
|
||||
"""Test multiplechoiceresponse behavior when answer-pool attribute is missing."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -575,6 +586,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
assert not response.has_answerpool()
|
||||
|
||||
def test_answer_pool_and_no_answer_pool(self):
|
||||
"""Test combination of responses with and without answer-pools."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -650,6 +662,7 @@ class CapaAnswerPoolTest(unittest.TestCase):
|
||||
self.assertRegex(without_new_lines, str3 + r".*" + str4)
|
||||
|
||||
def test_answer_pool_without_solutionset(self):
|
||||
"""Test answer-pool behavior when no solutionset is provided."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
|
||||
@@ -17,14 +17,14 @@ from openedx.core.djangolib.markup import HTML
|
||||
from xmodule.capa.correctmap import CorrectMap
|
||||
from xmodule.capa.responsetypes import LoncapaProblemError
|
||||
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["ENABLE_GRADING_METHOD_IN_PROBLEMS"] = True
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class CAPAProblemTest(unittest.TestCase):
|
||||
"""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
|
||||
problem with multiple-questions separated as per the new format.
|
||||
"""
|
||||
xml = """
|
||||
xml = f"""
|
||||
<problem>
|
||||
<choiceresponse>
|
||||
<label>{question}</label>
|
||||
<description>Only the paranoid survive.</description>
|
||||
<checkboxgroup>
|
||||
<choice correct="true">over-suspicious</choice>
|
||||
<choice correct="false">funny</choice>
|
||||
<choice correct="false">sad</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
question=question
|
||||
)
|
||||
"""
|
||||
problem = new_loncapa_problem(xml)
|
||||
assert problem.problem_data == {
|
||||
"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.
|
||||
"""
|
||||
xml = """
|
||||
xml = f"""
|
||||
<problem>
|
||||
<p>Be sure to check your spelling.</p>
|
||||
<p>{}</p>
|
||||
<p>{question}</p>
|
||||
<stringresponse answer="vulnerable" type="ci">
|
||||
<textline label="{}" size="40"/>
|
||||
<textline label="{label_attr}" size="40"/>
|
||||
</stringresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
question, label_attr
|
||||
)
|
||||
"""
|
||||
problem = new_loncapa_problem(xml)
|
||||
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.data(
|
||||
@@ -112,31 +108,29 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
tag and label attribute inside responsetype. But we have a label tag
|
||||
before the responsetype.
|
||||
"""
|
||||
xml = """
|
||||
xml = f"""
|
||||
<problem>
|
||||
<p>Be sure to check your spelling.</p>
|
||||
<label>{}</label>
|
||||
<label>{question1}</label>
|
||||
<stringresponse answer="hide" type="ci">
|
||||
<textline size="40"/>
|
||||
</stringresponse>
|
||||
<choiceresponse>
|
||||
<label>{}</label>
|
||||
<label>{question2}</label>
|
||||
<checkboxgroup>
|
||||
<choice correct="true">over-suspicious</choice>
|
||||
<choice correct="false">funny</choice>
|
||||
<choice correct="false">shy</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
question1, question2
|
||||
)
|
||||
"""
|
||||
problem = new_loncapa_problem(xml)
|
||||
assert problem.problem_data == {
|
||||
"1_2_1": {"label": question1, "descriptions": {}},
|
||||
"1_3_1": {"label": question2, "descriptions": {}},
|
||||
}
|
||||
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):
|
||||
"""
|
||||
@@ -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>."
|
||||
desc2 = "Anyone who looks the world as if it was a game of chess deserves to lose."
|
||||
xml = """
|
||||
xml = f"""
|
||||
<problem>
|
||||
<p>Be sure to check your spelling.</p>
|
||||
<stringresponse answer="War" type="ci">
|
||||
<label>___ requires sacrifices.</label>
|
||||
<description>{}</description>
|
||||
<description>{}</description>
|
||||
<description>{desc1}</description>
|
||||
<description>{desc2}</description>
|
||||
<textline size="40"/>
|
||||
</stringresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
desc1, desc2
|
||||
)
|
||||
"""
|
||||
problem = new_loncapa_problem(xml)
|
||||
assert problem.problem_data == {
|
||||
"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.
|
||||
"""
|
||||
question = "Click the country which is home to the Pyramids."
|
||||
# lint-amnesty, pylint: disable=duplicate-string-formatting-argument
|
||||
|
||||
xml = """
|
||||
<problem>
|
||||
<p>{}</p>
|
||||
<p>{question}</p>
|
||||
<imageresponse>
|
||||
<imageinput label="{}"
|
||||
<imageinput label="{question}"
|
||||
src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/>
|
||||
</imageresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
question, question
|
||||
question=question
|
||||
)
|
||||
problem = new_loncapa_problem(xml)
|
||||
assert problem.problem_data == {"1_2_1": {"label": question, "descriptions": {}}}
|
||||
# <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):
|
||||
"""
|
||||
@@ -210,17 +202,15 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
attribute is missing and responsetype is not fully accessible.
|
||||
"""
|
||||
question = "Click the country which is home to the Pyramids."
|
||||
xml = """
|
||||
xml = f"""
|
||||
<problem>
|
||||
<p>{}</p>
|
||||
<p>{question}</p>
|
||||
<imageresponse>
|
||||
<imageinput
|
||||
src="/static/Africa.png" width="600" height="638" rectangle="(338,98)-(412,168)"/>
|
||||
</imageresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
question
|
||||
)
|
||||
"""
|
||||
problem = new_loncapa_problem(xml)
|
||||
assert problem.problem_data == {"1_2_1": {"label": "", "descriptions": {}}}
|
||||
|
||||
@@ -238,7 +228,7 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
<description>Only the paranoid survive.</description>
|
||||
<checkboxgroup>
|
||||
<choice correct="true">over-suspicious</choice>
|
||||
<choice correct="false">funny</choice>
|
||||
<choice correct="false">happy</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
<multiplechoiceresponse>
|
||||
@@ -276,21 +266,19 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
responsetype to contain other elements. We do not want to delete information in that case.
|
||||
"""
|
||||
question = "Is egg plant a fruit?"
|
||||
xml = """
|
||||
xml = f"""
|
||||
<problem>
|
||||
<p>Choose wisely.</p>
|
||||
<p>Select the correct synonym of paranoid?</p>
|
||||
<p><img src="" /></p>
|
||||
<choiceresponse>
|
||||
<checkboxgroup label="{}">
|
||||
<checkboxgroup label="{question}">
|
||||
<choice correct="true">over-suspicious</choice>
|
||||
<choice correct="false">funny</choice>
|
||||
</checkboxgroup>
|
||||
</choiceresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
question
|
||||
)
|
||||
"""
|
||||
problem = new_loncapa_problem(xml)
|
||||
assert problem.problem_data == {"1_2_1": {"label": "", "descriptions": {}}}
|
||||
assert len(problem.tree.xpath("//p/img")) == 1
|
||||
@@ -306,17 +294,15 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
"""
|
||||
input1_label = "What color is the sky?"
|
||||
input2_label = "What color are pine needles?"
|
||||
xml = """
|
||||
xml = f"""
|
||||
<problem>
|
||||
<optionresponse>
|
||||
<label>{}</label>
|
||||
<optioninput options="('yellow','blue','green')" correct="blue" label="{}"/>
|
||||
<optioninput options="('yellow','blue','green')" correct="green" label="{}"/>
|
||||
<label>{group_label}</label>
|
||||
<optioninput options="('yellow','blue','green')" correct="blue" label="{input1_label}"/>
|
||||
<optioninput options="('orange','blue','green')" correct="green" label="{input2_label}"/>
|
||||
</optionresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
group_label, input1_label, input2_label
|
||||
)
|
||||
"""
|
||||
|
||||
problem = new_loncapa_problem(xml)
|
||||
assert problem.problem_data == {
|
||||
@@ -330,20 +316,18 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
"""
|
||||
question = "Enter sum of 1+2"
|
||||
xml = textwrap.dedent(
|
||||
"""
|
||||
f"""
|
||||
<problem>
|
||||
<customresponse cfn="test_sum" expect="3">
|
||||
<script type="loncapa/python">
|
||||
def test_sum(expect, ans):
|
||||
return int(expect) == int(ans)
|
||||
</script>
|
||||
<label>{}</label>
|
||||
<label>{question}</label>
|
||||
<textline size="20" correct_answer="3" />
|
||||
</customresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
question
|
||||
)
|
||||
"""
|
||||
)
|
||||
problem = new_loncapa_problem(xml, use_capa_render_template=True)
|
||||
problem_html = etree.XML(problem.get_html())
|
||||
@@ -353,18 +337,18 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
assert len(multi_inputs_group) == 0
|
||||
|
||||
# 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
|
||||
|
||||
def assert_question_tag(self, question1, question2, tag, label_attr=False):
|
||||
"""
|
||||
Verify question tag correctness.
|
||||
"""
|
||||
question1_tag = "<{tag}>{}</{tag}>".format(question1, tag=tag) if question1 else ""
|
||||
question2_tag = "<{tag}>{}</{tag}>".format(question2, tag=tag) if question2 else ""
|
||||
question1_label_attr = 'label="{}"'.format(question1) if label_attr else ""
|
||||
question2_label_attr = 'label="{}"'.format(question2) if label_attr else ""
|
||||
xml = """
|
||||
question1_tag = f"<{tag}>{question1}</{tag}>" if question1 else ""
|
||||
question2_tag = f"<{tag}>{question2}</{tag}>" if question2 else ""
|
||||
question1_label_attr = f'label="{question1}"' if label_attr else ""
|
||||
question2_label_attr = f'label="{question2}"' if label_attr else ""
|
||||
xml = f"""
|
||||
<problem>
|
||||
{question1_tag}
|
||||
<choiceresponse>
|
||||
@@ -381,18 +365,13 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</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)
|
||||
assert problem.problem_data == {
|
||||
"1_2_1": {"label": question1, "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.data(
|
||||
@@ -422,7 +401,9 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
xml = """
|
||||
<problem>
|
||||
<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>
|
||||
<description>You can add an optional tip or note related to the prompt like this. </description>
|
||||
<optioninput>
|
||||
@@ -460,7 +441,7 @@ class CAPAProblemTest(unittest.TestCase):
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class CAPAMultiInputProblemTest(unittest.TestCase):
|
||||
"""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
|
||||
# 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 = 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 multi_inputs_group_label[0].text == group_label
|
||||
|
||||
# verify that label for each input comes only once
|
||||
for input_label in input_labels:
|
||||
# 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
|
||||
|
||||
@ddt.unpack
|
||||
@@ -518,7 +499,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
|
||||
"""
|
||||
input1_label = "What color is the sky?"
|
||||
input2_label = "What color are pine needles?"
|
||||
xml = """
|
||||
xml = f"""
|
||||
<problem>
|
||||
<optionresponse>
|
||||
{label_html}
|
||||
@@ -526,9 +507,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
|
||||
<optioninput options="('yellow','blue','green')" correct="green" label="{input2_label}"/>
|
||||
</optionresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
label_html=label_html, input1_label=input1_label, input2_label=input2_label
|
||||
)
|
||||
"""
|
||||
problem = self.capa_problem(xml)
|
||||
self.assert_problem_html(problem.get_html(), group_label, input1_label, input2_label)
|
||||
self.assert_problem_data(problem.problem_data)
|
||||
@@ -544,21 +523,19 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
|
||||
input1_label = "Integer 1"
|
||||
input2_label = "Integer 2"
|
||||
xml = textwrap.dedent(
|
||||
"""
|
||||
f"""
|
||||
<problem>
|
||||
<customresponse cfn="test_add_to_ten">
|
||||
<script type="loncapa/python">
|
||||
def test_add_to_ten(expect, ans):
|
||||
return test_add(10, ans)
|
||||
</script>
|
||||
<label>{}</label>
|
||||
<{inputtype} size="40" correct_answer="3" label="{}" /><br/>
|
||||
<{inputtype} size="40" correct_answer="7" label="{}" />
|
||||
<label>{group_label}</label>
|
||||
<{inputtype} size="40" correct_answer="3" label="{input1_label}" /><br/>
|
||||
<{inputtype} size="40" correct_answer="7" label="{input2_label}" />
|
||||
</customresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
group_label, input1_label, input2_label, inputtype=inputtype
|
||||
)
|
||||
"""
|
||||
)
|
||||
problem = self.capa_problem(xml)
|
||||
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.
|
||||
"""
|
||||
xml = """
|
||||
xml = f"""
|
||||
<problem>
|
||||
<optionresponse>
|
||||
<label>group label</label>
|
||||
@@ -585,9 +562,7 @@ class CAPAMultiInputProblemTest(unittest.TestCase):
|
||||
<optioninput options="('yellow','blue','green')" correct="green" label="second label"/>
|
||||
</optionresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
descriptions_html=descriptions_html
|
||||
)
|
||||
"""
|
||||
problem = self.capa_problem(xml)
|
||||
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 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 description_element[0].text == descriptions[index]
|
||||
|
||||
@@ -618,13 +593,15 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
|
||||
)
|
||||
@ddt.unpack
|
||||
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 ""}}
|
||||
with patch.object(problem, "problem_data", mock_problem_data):
|
||||
assert problem.find_question_label(answer_id) == stripped_label
|
||||
|
||||
@ddt.data(None, {}, [None])
|
||||
def test_find_answer_test_not_implemented(self, current_answer):
|
||||
"""Ensure find_answer_text raises NotImplementedError for unsupported responses."""
|
||||
problem = new_loncapa_problem("<problem/>")
|
||||
self.assertRaises(NotImplementedError, problem.find_answer_text, "", current_answer)
|
||||
|
||||
@@ -639,6 +616,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
|
||||
)
|
||||
@ddt.unpack
|
||||
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>
|
||||
@@ -676,6 +654,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
|
||||
)
|
||||
@ddt.unpack
|
||||
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>
|
||||
@@ -739,6 +718,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
|
||||
assert problem.find_correct_answer_text(answer_id) == answer_text
|
||||
|
||||
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>
|
||||
@@ -751,6 +731,7 @@ class CAPAProblemReportHelpersTest(unittest.TestCase):
|
||||
assert problem.find_answer_text("1_2_1", "hide") == "hide"
|
||||
|
||||
def test_get_question_answer(self):
|
||||
"""Ensure get_question_answers returns answer text as strings suitable for JSON serialization."""
|
||||
problem = new_loncapa_problem(
|
||||
"""
|
||||
<problem>
|
||||
|
||||
@@ -16,10 +16,11 @@ class CorrectMapTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(CorrectMapTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.cmap = CorrectMap()
|
||||
|
||||
def test_set_input_properties(self):
|
||||
"""Verify setting input properties and correctness-related methods behave correctly."""
|
||||
# Set the correctmap properties for three inputs
|
||||
self.cmap.set(
|
||||
answer_id="1_2_1",
|
||||
@@ -96,6 +97,7 @@ class CorrectMapTest(unittest.TestCase):
|
||||
assert not self.cmap.is_right_queuekey("2_2_1", None)
|
||||
|
||||
def test_get_npoints(self):
|
||||
"""Ensure get_npoints returns correct values based on correctness and assigned points."""
|
||||
# Set the correctmap properties for 4 inputs
|
||||
# 1) correct, 5 points
|
||||
# 2) correct, None points
|
||||
@@ -132,6 +134,7 @@ class CorrectMapTest(unittest.TestCase):
|
||||
assert self.cmap.get_npoints("7_2_1") == 1
|
||||
|
||||
def test_set_overall_message(self):
|
||||
"""Verify setting and retrieving the overall message works correctly."""
|
||||
|
||||
# Default is an empty string string
|
||||
assert self.cmap.get_overall_message() == ""
|
||||
@@ -147,6 +150,7 @@ class CorrectMapTest(unittest.TestCase):
|
||||
assert self.cmap.get_overall_message() == ""
|
||||
|
||||
def test_update_from_correctmap(self):
|
||||
"""Test updating one CorrectMap from another preserves all properties."""
|
||||
# Initialize a CorrectMap with some properties
|
||||
self.cmap.set(
|
||||
answer_id="1_2_1",
|
||||
@@ -171,6 +175,7 @@ class CorrectMapTest(unittest.TestCase):
|
||||
assert other_cmap.get_dict() == self.cmap.get_dict()
|
||||
|
||||
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
|
||||
# with a non-CorrectMap value
|
||||
invalid_list = [None, "string", 5, datetime.datetime.today()]
|
||||
|
||||
@@ -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 xml.sax.saxutils as saxutils
|
||||
from xml.sax import saxutils
|
||||
|
||||
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
|
||||
original context
|
||||
"""
|
||||
return eval(xml.text) # lint-amnesty, pylint: disable=eval-used
|
||||
return eval(xml.text) # pylint: disable=eval-used
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -30,10 +31,12 @@ class HelperTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def check(self, d):
|
||||
"""Check that rendering and extracting context returns the original data."""
|
||||
xml = etree.XML(mock_capa_system().render_template("blah", d))
|
||||
assert d == extract_context(xml)
|
||||
|
||||
def test_extract_context(self):
|
||||
"""Test that the context can be extracted correctly from rendered XML."""
|
||||
self.check({})
|
||||
self.check({1, 2})
|
||||
self.check({"id", "an id"})
|
||||
@@ -46,8 +49,9 @@ class SolutionRenderTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def test_rendering(self):
|
||||
"""Ensure that <solution> elements are rendered correctly with proper IDs."""
|
||||
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)
|
||||
|
||||
renderer = lookup_tag("solution")(mock_capa_system(), element)
|
||||
@@ -65,8 +69,9 @@ class MathRenderTest(unittest.TestCase):
|
||||
Make sure math renders properly.
|
||||
"""
|
||||
|
||||
def check_parse(self, latex_in, mathjax_out): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
xml_str = """<math>{tex}</math>""".format(tex=latex_in)
|
||||
def check_parse(self, latex_in, mathjax_out):
|
||||
"""Check that LaTeX input is correctly converted to MathJax output."""
|
||||
xml_str = f"""<math>{latex_in}</math>"""
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
renderer = lookup_tag("math")(mock_capa_system(), element)
|
||||
@@ -74,6 +79,7 @@ class MathRenderTest(unittest.TestCase):
|
||||
assert renderer.mathstr == mathjax_out
|
||||
|
||||
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", "$abc")
|
||||
self.check_parse(r"$\displaystyle 2+2$", "[mathjax] 2+2[/mathjax]")
|
||||
|
||||
@@ -15,30 +15,35 @@ from xmodule.capa.errors import (
|
||||
|
||||
|
||||
def test_json_parsing_error():
|
||||
"""Verify that JSONParsingError is raised with the correct message."""
|
||||
with pytest.raises(JSONParsingError) as excinfo:
|
||||
raise JSONParsingError("test_name", "test_error")
|
||||
assert str(excinfo.value) == "Error parsing test_name: test_error"
|
||||
|
||||
|
||||
def test_missing_key_error():
|
||||
"""Ensure MissingKeyError is raised with the correct key in the message."""
|
||||
with pytest.raises(MissingKeyError) as excinfo:
|
||||
raise MissingKeyError("test_key")
|
||||
assert str(excinfo.value) == "Missing key: test_key"
|
||||
|
||||
|
||||
def test_validation_error():
|
||||
"""Check that ValidationError is raised with the expected message."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
raise ValidationError("test_error")
|
||||
assert str(excinfo.value) == "Validation error: test_error"
|
||||
|
||||
|
||||
def test_type_error_submission():
|
||||
"""Confirm TypeErrorSubmission is raised with the appropriate message."""
|
||||
with pytest.raises(TypeErrorSubmission) as excinfo:
|
||||
raise TypeErrorSubmission("test_error")
|
||||
assert str(excinfo.value) == "Type error: test_error"
|
||||
|
||||
|
||||
def test_runtime_error_submission():
|
||||
"""Validate that RuntimeErrorSubmission is raised with the correct message."""
|
||||
with pytest.raises(RuntimeErrorSubmission) as excinfo:
|
||||
raise RuntimeErrorSubmission("test_error")
|
||||
assert str(excinfo.value) == "Runtime error: test_error"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-lines
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
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
|
||||
# of text text without whitespace. I think it's best to leave such lines intact
|
||||
# in the test code. Therefore:
|
||||
# pylint: disable=line-too-long
|
||||
# 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)
|
||||
if adict:
|
||||
return adict["msg"]
|
||||
else:
|
||||
return ""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# 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",
|
||||
"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.Ω</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.Ω</div></div>'
|
||||
),
|
||||
},
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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!Ω</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!Ω</div></div>'
|
||||
),
|
||||
},
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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!Ω</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!Ω</div></div>'
|
||||
),
|
||||
},
|
||||
{"problem_id": "1_2_1", "choice": "Mexico", "expected_string": ""},
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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.Ω</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.Ω</div></div>"
|
||||
),
|
||||
},
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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.Ω</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.Ω</div></div>"
|
||||
),
|
||||
},
|
||||
{"problem_id": "1_2_1", "choice": "uSAxΩ", "expected_string": ""},
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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Ω</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Ω</div></div>'
|
||||
),
|
||||
},
|
||||
{
|
||||
"problem_id": "1_3_1",
|
||||
"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": "b", "expected_string": ""},
|
||||
)
|
||||
@unpack
|
||||
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)
|
||||
assert hint == expected_string
|
||||
|
||||
@@ -126,47 +155,72 @@ class TextInputExtendedHintsCaseInsensitive(HintTest):
|
||||
{
|
||||
"problem_id": "1_5_1",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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
|
||||
{
|
||||
"problem_id": "1_5_1",
|
||||
"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",
|
||||
"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
|
||||
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)
|
||||
assert hint == expected_string
|
||||
|
||||
@@ -183,31 +237,46 @@ class TextInputExtendedHintsCaseSensitive(HintTest):
|
||||
{
|
||||
"problem_id": "1_6_1",
|
||||
"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": "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": "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": ""},
|
||||
# regexp cases
|
||||
{
|
||||
"problem_id": "1_6_1",
|
||||
"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": ""},
|
||||
)
|
||||
@unpack
|
||||
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)
|
||||
assert message_text == expected_string
|
||||
|
||||
@@ -226,14 +295,22 @@ class TextInputExtendedHintsCompatible(HintTest):
|
||||
"problem_id": "1_7_1",
|
||||
"choice": "A",
|
||||
"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": "C",
|
||||
"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": ""},
|
||||
# check going through conversion with difficult chars
|
||||
@@ -241,6 +318,7 @@ class TextInputExtendedHintsCompatible(HintTest):
|
||||
)
|
||||
@unpack
|
||||
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)
|
||||
assert message_text == expected_string
|
||||
assert self.correctness(problem_id, choice) == correct
|
||||
@@ -261,59 +339,96 @@ class TextInputExtendedHintsRegex(HintTest):
|
||||
"problem_id": "1_8_1",
|
||||
"choice": "ABC",
|
||||
"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",
|
||||
"choice": "ABBBBC",
|
||||
"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",
|
||||
"choice": "aBc",
|
||||
"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",
|
||||
"choice": "BBBB",
|
||||
"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",
|
||||
"choice": "bbb",
|
||||
"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",
|
||||
"choice": "C",
|
||||
"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",
|
||||
"choice": "c",
|
||||
"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",
|
||||
"choice": "D",
|
||||
"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",
|
||||
"choice": "d",
|
||||
"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
|
||||
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)
|
||||
assert message_text == expected_string
|
||||
assert self.correctness(problem_id, choice) == correct
|
||||
@@ -329,6 +444,7 @@ class NumericInputHintsTest(HintTest):
|
||||
problem = new_loncapa_problem(xml) # this problem is properly constructed
|
||||
|
||||
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.problem.capa_block.runtime.publish.assert_called_with(
|
||||
self.problem.capa_block,
|
||||
@@ -349,30 +465,47 @@ class NumericInputHintsTest(HintTest):
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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",
|
||||
"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
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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
|
||||
{"problem_id": "1_2_1", "choice": "2", "expected_string": ""},
|
||||
)
|
||||
@unpack
|
||||
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)
|
||||
assert hint == expected_string
|
||||
|
||||
@@ -390,122 +523,248 @@ class CheckboxHintsTest(HintTest):
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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
|
||||
{
|
||||
"problem_id": "1_4_1",
|
||||
"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",
|
||||
"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
|
||||
# These are some tricky cases
|
||||
{
|
||||
"problem_id": "1_5_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",
|
||||
"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": [],
|
||||
"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",
|
||||
"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",
|
||||
"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
|
||||
{
|
||||
"problem_id": "1_7_1",
|
||||
"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
|
||||
{"problem_id": "1_7_1", "choice": ["choice_1"], "expected_string": ""},
|
||||
@@ -513,11 +772,18 @@ class CheckboxHintsTest(HintTest):
|
||||
{
|
||||
"problem_id": "1_7_1",
|
||||
"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
|
||||
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
|
||||
hint = self.get_hint(problem_id, choice)
|
||||
assert hint == expected_string
|
||||
@@ -648,28 +914,44 @@ class MultpleChoiceHintsTest(HintTest):
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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_3_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",
|
||||
"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",
|
||||
"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": ""},
|
||||
)
|
||||
@unpack
|
||||
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)
|
||||
assert hint == expected_string
|
||||
|
||||
@@ -707,21 +989,34 @@ class MultpleChoiceHintsWithHtmlTest(HintTest):
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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",
|
||||
"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",
|
||||
"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
|
||||
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)
|
||||
assert hint == expected_string
|
||||
|
||||
@@ -758,44 +1053,73 @@ class DropdownHintsTest(HintTest):
|
||||
{
|
||||
"problem_id": "1_2_1",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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": ""},
|
||||
# Regression case where feedback includes answer substring
|
||||
{
|
||||
"problem_id": "1_4_1",
|
||||
"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",
|
||||
"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": ""},
|
||||
)
|
||||
@unpack
|
||||
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)
|
||||
assert hint == expected_string
|
||||
|
||||
@@ -806,6 +1130,7 @@ class ErrorConditionsTest(HintTest):
|
||||
"""
|
||||
|
||||
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")
|
||||
with pytest.raises(Exception):
|
||||
new_loncapa_problem(xml_with_errors) # this problem is improperly constructed
|
||||
|
||||
@@ -12,20 +12,20 @@ from lxml import etree
|
||||
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
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
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class CapaHtmlRenderTest(unittest.TestCase):
|
||||
"""
|
||||
CAPA HTML rendering tests class.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(CapaHtmlRenderTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.capa_system = mock_capa_system()
|
||||
|
||||
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
|
||||
|
||||
def test_include_html(self):
|
||||
"""Verify that <include> files are embedded correctly in rendered HTML."""
|
||||
# Create a test file to include
|
||||
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"
|
||||
|
||||
def test_process_outtext(self):
|
||||
"""Ensure <startouttext/> and <endouttext/> are converted to <span> tags."""
|
||||
# Generate some XML with <startouttext /> and <endouttext />
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
@@ -88,6 +90,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
assert span_element.text == "Test text"
|
||||
|
||||
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
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
@@ -108,6 +111,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
assert span_element.text == "Welcome student"
|
||||
|
||||
def test_render_script(self):
|
||||
"""Ensure <script> tags are removed from rendered HTML."""
|
||||
# Generate some XML with a <script> tag
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
@@ -128,6 +132,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
assert script_element is None
|
||||
|
||||
def test_render_javascript(self):
|
||||
"""Verify JavaScript in <script> tags remains in rendered HTML."""
|
||||
# Generate some XML with a <script> tag
|
||||
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")
|
||||
|
||||
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
|
||||
kwargs = {
|
||||
"question_text": "Test question",
|
||||
@@ -214,6 +220,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
assert the_system.render_template.call_args_list == expected_calls
|
||||
|
||||
def test_correct_aria_label(self):
|
||||
"""Check that rendered responses have correct aria-label attributes."""
|
||||
xml = """
|
||||
<problem>
|
||||
<choiceresponse>
|
||||
@@ -237,6 +244,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
assert response_elements[1].attrib["aria-label"] == "Question 2"
|
||||
|
||||
def test_render_response_with_overall_msg(self):
|
||||
"""Verify that CustomResponse overall messages are rendered correctly."""
|
||||
# CustomResponse script that sets an overall_message
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
@@ -275,6 +283,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
assert msg_p_elements[1].text == "Test message 2"
|
||||
|
||||
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
|
||||
# and used later as attributes
|
||||
xml_str = textwrap.dedent(
|
||||
@@ -295,6 +304,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
assert span_element.get("attr") == "TEST"
|
||||
|
||||
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.
|
||||
xml_str = textwrap.dedent(
|
||||
"""\
|
||||
@@ -314,7 +324,9 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
the_html = problem.get_html()
|
||||
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.write(content_str)
|
||||
test_fp.close()
|
||||
|
||||
@@ -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
|
||||
@@ -21,8 +22,6 @@ class TemplateError(Exception):
|
||||
Error occurred while rendering a Django template.
|
||||
"""
|
||||
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
class TemplateTestCase(unittest.TestCase):
|
||||
"""
|
||||
@@ -45,7 +44,7 @@ class TemplateTestCase(unittest.TestCase):
|
||||
"""
|
||||
Initialize the context.
|
||||
"""
|
||||
super(TemplateTestCase, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.context = {}
|
||||
|
||||
def render_to_xml(self, context_dict):
|
||||
@@ -68,9 +67,7 @@ class TemplateTestCase(unittest.TestCase):
|
||||
try:
|
||||
xml = etree.fromstring("<test>" + xml_str + "</test>")
|
||||
except Exception as exc:
|
||||
raise TemplateError( # lint-amnesty, pylint: disable=raise-missing-from
|
||||
"Could not parse XML from '{0}': {1}".format(xml_str, str(exc))
|
||||
)
|
||||
raise TemplateError(f"Could not parse XML from '{xml_str}': {exc}") from exc
|
||||
return xml
|
||||
|
||||
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
|
||||
`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" % (
|
||||
exact_num,
|
||||
str(xpath),
|
||||
etree.tostring(xml_root),
|
||||
str(context_dict),
|
||||
message = (
|
||||
f"XML does not have {exact_num} match(es) for xpath '{xpath}'\n"
|
||||
f"XML: {etree.tostring(xml_root)}\nContext: {context_dict}"
|
||||
)
|
||||
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
assert text == element_list[0].text.strip()
|
||||
else:
|
||||
@@ -183,14 +178,14 @@ class TemplateTestCase(unittest.TestCase):
|
||||
|
||||
# Expect that we get a <div> with correct class
|
||||
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)
|
||||
|
||||
# Expect that we get a <span> with class="status"
|
||||
# (used to by CSS to draw the green check / red x)
|
||||
self.assert_has_text(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -221,7 +216,7 @@ class TemplateTestCase(unittest.TestCase):
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
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:
|
||||
element_list = xml.xpath(xpath)
|
||||
assert len(element_list) == 1
|
||||
@@ -236,7 +231,7 @@ class ChoiceGroupTemplateTest(TemplateTestCase):
|
||||
TEMPLATE_NAME = "choicegroup.html"
|
||||
|
||||
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")]
|
||||
self.context = {
|
||||
"id": "1",
|
||||
@@ -466,7 +461,7 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
TEMPLATE_NAME = "textline.html"
|
||||
|
||||
def setUp(self):
|
||||
super(TextlineTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.context = {
|
||||
"id": "1",
|
||||
"status": Status("correct"),
|
||||
@@ -478,6 +473,7 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
}
|
||||
|
||||
def test_section_class(self):
|
||||
"""Verify CSS classes for <textline> input under different context combinations."""
|
||||
cases = [
|
||||
({}, " 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.update(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)
|
||||
|
||||
def test_status(self):
|
||||
@@ -505,6 +501,7 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
self.assert_label(xpath="//label[@class='problem-group-label']")
|
||||
|
||||
def test_hidden(self):
|
||||
"""Ensure that hidden inputs and containers are rendered with display:none style."""
|
||||
self.context["hidden"] = True
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
@@ -515,6 +512,7 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
def test_do_math(self):
|
||||
"""Verify that math-related elements and classes are rendered for do_math context."""
|
||||
self.context["do_math"] = True
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
@@ -528,6 +526,7 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
def test_size(self):
|
||||
"""Ensure that the size attribute is correctly applied to input elements."""
|
||||
self.context["size"] = "20"
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
@@ -535,6 +534,7 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
def test_preprocessor(self):
|
||||
"""Verify that preprocessor-related data attributes are rendered correctly."""
|
||||
self.context["preprocessor"] = {"class_name": "test_class", "script_src": "test_script"}
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
@@ -545,6 +545,7 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
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["inline"] = True
|
||||
xml = self.render_to_xml(self.context)
|
||||
@@ -553,6 +554,7 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
def test_do_inline(self):
|
||||
"""Verify inline class is applied correctly based on status context."""
|
||||
cases = [
|
||||
("correct", "correct"),
|
||||
("unsubmitted", "unanswered"),
|
||||
@@ -567,10 +569,11 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
# 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)
|
||||
|
||||
def test_message(self):
|
||||
"""Check that message text is rendered inside the proper span element."""
|
||||
self.context["msg"] = "Test message"
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
@@ -587,14 +590,12 @@ class TextlineTemplateTest(TemplateTestCase):
|
||||
|
||||
|
||||
class FormulaEquationInputTemplateTest(TemplateTestCase):
|
||||
"""
|
||||
Test make template for `<formulaequationinput>`s.
|
||||
"""
|
||||
"""Test django template for `<formulaequationinput>` input."""
|
||||
|
||||
TEMPLATE_NAME = "formulaequationinput.html"
|
||||
|
||||
def setUp(self):
|
||||
super(FormulaEquationInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.context = {
|
||||
"id": 2,
|
||||
"value": "PREFILLED_VALUE",
|
||||
@@ -607,10 +608,12 @@ class FormulaEquationInputTemplateTest(TemplateTestCase):
|
||||
}
|
||||
|
||||
def test_no_size(self):
|
||||
"""Ensure no size attribute is present when not specified in context."""
|
||||
xml = self.render_to_xml(self.context)
|
||||
self.assert_no_xpath(xml, "//input[@size]", self.context)
|
||||
|
||||
def test_size(self):
|
||||
"""Verify that size attribute is correctly applied to formula equation input."""
|
||||
self.context["size"] = "40"
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
@@ -645,7 +648,7 @@ class AnnotationInputTemplateTest(TemplateTestCase):
|
||||
TEMPLATE_NAME = "annotationinput.html"
|
||||
|
||||
def setUp(self):
|
||||
super(AnnotationInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.context = {
|
||||
"id": 2,
|
||||
"value": "<p>Test value</p>",
|
||||
@@ -689,7 +692,7 @@ class AnnotationInputTemplateTest(TemplateTestCase):
|
||||
# Create options 0-4 and select option 2
|
||||
self.context["options_value"] = [2]
|
||||
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)
|
||||
]
|
||||
|
||||
@@ -699,8 +702,8 @@ class AnnotationInputTemplateTest(TemplateTestCase):
|
||||
# with unescaped HTML.
|
||||
# Since the HTML is unescaped, we can traverse the XML tree
|
||||
for id_num in range(5):
|
||||
xpath = "//span[@data-id='{0}']/p/b".format(id_num)
|
||||
self.assert_has_text(xml, xpath, "HTML {0}".format(id_num), exact=False)
|
||||
xpath = f"//span[@data-id='{id_num}']/p/b"
|
||||
self.assert_has_text(xml, xpath, f"HTML {id_num}", exact=False)
|
||||
|
||||
# Expect that the correct option is selected
|
||||
xpath = "//span[contains(@class,'selected')]/p/b"
|
||||
@@ -718,7 +721,7 @@ class AnnotationInputTemplateTest(TemplateTestCase):
|
||||
self.context["status"] = Status(input_status)
|
||||
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)
|
||||
|
||||
# If individual options are being marked, then expect
|
||||
@@ -770,10 +773,11 @@ class MathStringTemplateTest(TemplateTestCase):
|
||||
TEMPLATE_NAME = "mathstring.html"
|
||||
|
||||
def setUp(self):
|
||||
super(MathStringTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.context = {"isinline": False, "mathstr": "", "tail": ""}
|
||||
|
||||
def test_math_string_inline(self):
|
||||
"""Verify that math string is rendered inline correctly with MathJax tags."""
|
||||
self.context["isinline"] = True
|
||||
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]")
|
||||
|
||||
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["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]")
|
||||
|
||||
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>"
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
@@ -810,7 +816,7 @@ class OptionInputTemplateTest(TemplateTestCase):
|
||||
TEMPLATE_NAME = "optioninput.html"
|
||||
|
||||
def setUp(self):
|
||||
super(OptionInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.context = {
|
||||
"id": 2,
|
||||
"options": [],
|
||||
@@ -822,9 +828,9 @@ class OptionInputTemplateTest(TemplateTestCase):
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
xml = self.render_to_xml(self.context)
|
||||
@@ -834,8 +840,8 @@ class OptionInputTemplateTest(TemplateTestCase):
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
for id_num in range(5):
|
||||
xpath = "//option[@value='{0}']".format(id_num)
|
||||
self.assert_has_text(xml, xpath, "Option {0}".format(id_num))
|
||||
xpath = f"//option[@value='{id_num}']"
|
||||
self.assert_has_text(xml, xpath, f"Option {id_num}")
|
||||
|
||||
# Should have the correct option selected
|
||||
xpath = "//option[@selected='true']"
|
||||
@@ -870,11 +876,11 @@ class DragAndDropTemplateTest(TemplateTestCase):
|
||||
TEMPLATE_NAME = "drag_and_drop_input.html"
|
||||
|
||||
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": ""}
|
||||
|
||||
def test_status(self):
|
||||
|
||||
"""Verify that drag-and-drop input renders correct status CSS classes and text."""
|
||||
# Test cases, where each tuple represents
|
||||
# `(input_status, expected_css_class, expected_text)`
|
||||
test_cases = [
|
||||
@@ -889,14 +895,15 @@ class DragAndDropTemplateTest(TemplateTestCase):
|
||||
xml = self.render_to_xml(self.context)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
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>"})
|
||||
self.context["drag_and_drop_json"] = json_with_html
|
||||
@@ -931,7 +938,7 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super(ChoiceTextGroupTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
choices = [
|
||||
(
|
||||
"1_choiceinput_0bc",
|
||||
@@ -1013,9 +1020,9 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
|
||||
|
||||
# Should NOT mark individual options
|
||||
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):
|
||||
"""Test all conditions under which the entire problem
|
||||
@@ -1041,9 +1048,9 @@ class ChoiceTextGroupTemplateTest(TemplateTestCase):
|
||||
|
||||
# Should NOT mark individual options
|
||||
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):
|
||||
"""Test conditions under which a particular option
|
||||
@@ -1096,7 +1103,7 @@ class ChemicalEquationTemplateTest(TemplateTestCase):
|
||||
TEMPLATE_NAME = "chemicalequationinput.html"
|
||||
|
||||
def setUp(self):
|
||||
super(ChemicalEquationTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.context = {
|
||||
"id": "1",
|
||||
"status": Status("correct"),
|
||||
@@ -1117,7 +1124,7 @@ class SchematicInputTemplateTest(TemplateTestCase):
|
||||
TEMPLATE_NAME = "schematicinput.html"
|
||||
|
||||
def setUp(self):
|
||||
super(SchematicInputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.context = {
|
||||
"id": "1",
|
||||
"status": Status("correct"),
|
||||
@@ -1149,7 +1156,7 @@ class CodeinputTemplateTest(TemplateTestCase):
|
||||
TEMPLATE_NAME = "codeinput.html"
|
||||
|
||||
def setUp(self):
|
||||
super(CodeinputTemplateTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.context = {
|
||||
"id": "1",
|
||||
"status": Status("correct"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-lines
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests of input types.
|
||||
@@ -21,9 +22,9 @@ TODO:
|
||||
import json
|
||||
import textwrap
|
||||
import unittest
|
||||
import xml.sax.saxutils as saxutils
|
||||
from collections import OrderedDict
|
||||
from unittest.mock import ANY, patch
|
||||
from xml.sax import saxutils
|
||||
|
||||
import pytest
|
||||
import six
|
||||
@@ -50,6 +51,7 @@ RESPONSE_DATA = {"label": "question text 101", "descriptions": DESCRIPTIONS}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -59,6 +61,7 @@ class OptionInputTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
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"/>"""
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -89,9 +92,10 @@ class OptionInputTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
def test_option_parsing(self):
|
||||
"""Test that OptionInput.parse_options correctly parses various option strings."""
|
||||
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
|
||||
"""
|
||||
@@ -117,9 +121,8 @@ class ChoiceGroupTest(unittest.TestCase):
|
||||
Test choice groups, radio groups, and checkbox groups
|
||||
"""
|
||||
|
||||
def check_group(
|
||||
self, tag, expected_input_type, expected_suffix
|
||||
): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
def check_group(self, tag, expected_input_type, expected_suffix):
|
||||
"""Test that choice group inputs render expected context."""
|
||||
xml_str = """
|
||||
<{tag}>
|
||||
<choice correct="false" name="foil1"><text>This is foil One.</text></choice>
|
||||
@@ -161,12 +164,15 @@ class ChoiceGroupTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
def test_choicegroup(self):
|
||||
"""Test that a <choicegroup> renders correctly as radio inputs."""
|
||||
self.check_group("choicegroup", "radio", "")
|
||||
|
||||
def test_radiogroup(self):
|
||||
"""Test that a <radiogroup> renders correctly as radio inputs with name suffix."""
|
||||
self.check_group("radiogroup", "radio", "[]")
|
||||
|
||||
def test_checkboxgroup(self):
|
||||
"""Test that a <checkboxgroup> renders correctly as checkbox inputs with name suffix."""
|
||||
self.check_group("checkboxgroup", "checkbox", "[]")
|
||||
|
||||
|
||||
@@ -256,8 +262,9 @@ class TextLineTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def test_rendering(self):
|
||||
"""Test that a standard textline input renders correctly."""
|
||||
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)
|
||||
|
||||
@@ -284,15 +291,14 @@ class TextLineTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
def test_math_rendering(self):
|
||||
"""Test that a math-enabled textline input renders correctly with preprocessor."""
|
||||
size = "42"
|
||||
preprocessorClass = "preParty"
|
||||
preprocessor_class = "preParty"
|
||||
script = "foo/party.js"
|
||||
|
||||
xml_str = """<textline math="True" id="prob_1_2" size="{size}"
|
||||
preprocessorClassName="{pp}"
|
||||
preprocessorSrc="{sc}"/>""".format(
|
||||
size=size, pp=preprocessorClass, sc=script
|
||||
)
|
||||
xml_str = f"""<textline math="True" id="prob_1_2" size="{size}"
|
||||
preprocessorClassName="{preprocessor_class}"
|
||||
preprocessorSrc="{script}"/>"""
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -313,7 +319,7 @@ class TextLineTest(unittest.TestCase):
|
||||
"trailing_text": "",
|
||||
"do_math": True,
|
||||
"preprocessor": {
|
||||
"class_name": preprocessorClass,
|
||||
"class_name": preprocessor_class,
|
||||
"script_src": script,
|
||||
},
|
||||
"response_data": RESPONSE_DATA,
|
||||
@@ -322,6 +328,7 @@ class TextLineTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
def test_trailing_text_rendering(self):
|
||||
"""Test that trailing text in textline inputs is correctly rendered and escaped."""
|
||||
size = "42"
|
||||
# store (xml_text, expected)
|
||||
trailing_text = []
|
||||
@@ -334,12 +341,10 @@ class TextLineTest(unittest.TestCase):
|
||||
trailing_text.append(("a < b", "a < b"))
|
||||
|
||||
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}"
|
||||
trailing_text="{tt}"
|
||||
/>""".format(
|
||||
size=size, tt=xml_text
|
||||
)
|
||||
trailing_text="{xml_text}"
|
||||
/>"""
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -372,16 +377,14 @@ class FileSubmissionTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def test_rendering(self):
|
||||
"""Test that a filesubmission input renders correctly with allowed and required files."""
|
||||
allowed_files = "runme.py nooooo.rb ohai.java"
|
||||
required_files = "cookies.py"
|
||||
|
||||
xml_str = """<filesubmission id="prob_1_2"
|
||||
allowed_files="{af}"
|
||||
required_files="{rf}"
|
||||
/>""".format(
|
||||
af=allowed_files,
|
||||
rf=required_files,
|
||||
)
|
||||
xml_str = f"""<filesubmission id="prob_1_2"
|
||||
allowed_files="{allowed_files}"
|
||||
required_files="{required_files}"
|
||||
/>"""
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -418,21 +421,20 @@ class CodeInputTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def test_rendering(self):
|
||||
"""Test that a codeinput input renders correctly with specified editor settings."""
|
||||
mode = "parrot"
|
||||
linenumbers = "false"
|
||||
rows = "37"
|
||||
cols = "11"
|
||||
tabsize = "7"
|
||||
|
||||
xml_str = """<codeinput id="prob_1_2"
|
||||
mode="{m}"
|
||||
cols="{c}"
|
||||
rows="{r}"
|
||||
linenumbers="{ln}"
|
||||
tabsize="{ts}"
|
||||
/>""".format(
|
||||
m=mode, c=cols, r=rows, ln=linenumbers, ts=tabsize
|
||||
)
|
||||
xml_str = f"""<codeinput id="prob_1_2"
|
||||
mode="{mode}"
|
||||
cols="{cols}"
|
||||
rows="{rows}"
|
||||
linenumbers="{linenumbers}"
|
||||
tabsize="{tabsize}"
|
||||
/>"""
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -461,7 +463,7 @@ class CodeInputTest(unittest.TestCase):
|
||||
"hidden": "",
|
||||
"tabsize": int(tabsize),
|
||||
"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",
|
||||
"response_data": RESPONSE_DATA,
|
||||
"describedby_html": DESCRIBEDBY.format(status_id=prob_id),
|
||||
@@ -470,29 +472,27 @@ class CodeInputTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
|
||||
class MatlabTest(unittest.TestCase):
|
||||
class MatlabTest(unittest.TestCase): # pylint: disable=too-many-instance-attributes
|
||||
"""
|
||||
Test Matlab input types
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(MatlabTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.rows = "10"
|
||||
self.cols = "80"
|
||||
self.tabsize = "4"
|
||||
self.mode = ""
|
||||
self.payload = "payload"
|
||||
self.linenumbers = "true"
|
||||
self.xml = """<matlabinput id="prob_1_2"
|
||||
rows="{r}" cols="{c}"
|
||||
tabsize="{tabsize}" mode="{m}"
|
||||
linenumbers="{ln}">
|
||||
self.xml = f"""<matlabinput id="prob_1_2"
|
||||
rows="{self.rows}" cols="{self.cols}"
|
||||
tabsize="{self.tabsize}" mode="{self.mode}"
|
||||
linenumbers="{self.linenumbers}">
|
||||
<plot_payload>
|
||||
{payload}
|
||||
{self.payload}
|
||||
</plot_payload>
|
||||
</matlabinput>""".format(
|
||||
r=self.rows, c=self.cols, tabsize=self.tabsize, m=self.mode, payload=self.payload, ln=self.linenumbers
|
||||
)
|
||||
</matlabinput>"""
|
||||
elt = etree.fromstring(self.xml)
|
||||
state = {
|
||||
"value": 'print "good evening"',
|
||||
@@ -505,6 +505,7 @@ class MatlabTest(unittest.TestCase):
|
||||
self.the_input = self.input_class(mock_capa_system(), elt, state)
|
||||
|
||||
def test_rendering(self):
|
||||
"""Check that Matlab input renders with default context."""
|
||||
context = self.the_input._get_render_context() # pylint: disable=protected-access
|
||||
|
||||
expected = {
|
||||
@@ -530,6 +531,7 @@ class MatlabTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
def test_rendering_with_state(self):
|
||||
"""Verify rendering when Matlab input has a pre-existing state."""
|
||||
state = {
|
||||
"value": 'print "good evening"',
|
||||
"status": "incomplete",
|
||||
@@ -565,6 +567,7 @@ class MatlabTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
def test_rendering_when_completed(self):
|
||||
"""Ensure rendering is correct when Matlab input status is completed."""
|
||||
for status in ["correct", "incorrect"]:
|
||||
state = {
|
||||
"value": 'print "good evening"',
|
||||
@@ -599,7 +602,8 @@ class MatlabTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
@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 = {
|
||||
"value": 'print "good evening"',
|
||||
"status": "incomplete",
|
||||
@@ -633,6 +637,7 @@ class MatlabTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
def test_plot_data(self):
|
||||
"""Verify that plot submission sends data to Xqueue successfully."""
|
||||
data = {"submission": "x = 1234;"}
|
||||
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)
|
||||
@@ -641,6 +646,7 @@ class MatlabTest(unittest.TestCase):
|
||||
assert self.the_input.input_state["queuestate"] == "queued"
|
||||
|
||||
def test_plot_data_failure(self):
|
||||
"""Check behavior when plot submission to Xqueue fails."""
|
||||
data = {"submission": "x = 1234;"}
|
||||
error_message = "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
|
||||
|
||||
@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"
|
||||
input_state = {"queuekey": queuekey, "queuestate": "queued", "queuetime": 5}
|
||||
state = {
|
||||
@@ -672,7 +679,8 @@ class MatlabTest(unittest.TestCase):
|
||||
assert input_state["queue_msg"] == inner_msg
|
||||
|
||||
@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"
|
||||
input_state = {"queuekey": queuekey, "queuestate": "queued", "queuetime": 5}
|
||||
state = {
|
||||
@@ -693,7 +701,8 @@ class MatlabTest(unittest.TestCase):
|
||||
assert "queue_msg" not in input_state
|
||||
|
||||
@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}}
|
||||
elt = etree.fromstring(self.xml)
|
||||
@@ -702,17 +711,18 @@ class MatlabTest(unittest.TestCase):
|
||||
assert the_input.status == "queued"
|
||||
|
||||
@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}}
|
||||
elt = etree.fromstring(self.xml)
|
||||
|
||||
the_input = self.input_class(mock_capa_system(), elt, state)
|
||||
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)
|
||||
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.
|
||||
"""
|
||||
@@ -732,14 +742,15 @@ class MatlabTest(unittest.TestCase):
|
||||
the_input = lookup_tag("matlabinput")(system, elt, {})
|
||||
|
||||
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"]
|
||||
payload = json.loads(body)
|
||||
assert "test_api_key" == payload["token"]
|
||||
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
|
||||
output = self.the_input.get_html()
|
||||
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
|
||||
|
||||
def test_malformed_queue_msg(self):
|
||||
"""Verify handling of malformed queue messages in Matlab input."""
|
||||
# an actual malformed response
|
||||
queue_msg = textwrap.dedent(
|
||||
"""
|
||||
@@ -867,16 +879,34 @@ class MatlabTest(unittest.TestCase):
|
||||
|
||||
the_input = self.input_class(mock_capa_system(), elt, state)
|
||||
context = the_input._get_render_context() # pylint: disable=protected-access
|
||||
self.maxDiff = None
|
||||
self.maxDiff = None # pylint: disable=invalid-name
|
||||
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 ==, <, >, <=, >=, 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 ==, <, >, <=, >=, 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"])
|
||||
html_tree_equal(received, expected)
|
||||
|
||||
def test_rendering_with_invalid_queue_msg(self):
|
||||
"""Ensure invalid queue messages are sanitized and handled."""
|
||||
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>"
|
||||
)
|
||||
context = self.the_input._get_render_context() # pylint: disable=protected-access
|
||||
@@ -900,7 +930,7 @@ class MatlabTest(unittest.TestCase):
|
||||
"queue_len": "3",
|
||||
"matlab_editor_js": "/dummy-static/js/vendor/CodeMirror/octave.js",
|
||||
"response_data": {},
|
||||
"describedby_html": 'aria-describedby="status_{id}"'.format(id=prob_id),
|
||||
"describedby_html": f'aria-describedby="status_{prob_id}"',
|
||||
}
|
||||
assert context == expected
|
||||
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"]
|
||||
for tag in allowed_tags:
|
||||
queue_msg = "<{0}>Test message</{0}>".format(tag)
|
||||
queue_msg = f"<{tag}>Test message</{tag}>"
|
||||
state = {
|
||||
"input_state": {"queue_msg": queue_msg},
|
||||
"status": "queued",
|
||||
@@ -926,7 +956,7 @@ class MatlabTest(unittest.TestCase):
|
||||
Test not allowed tag.
|
||||
"""
|
||||
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 = {
|
||||
"input_state": {"queue_msg": queue_msg},
|
||||
"status": "queued",
|
||||
@@ -941,7 +971,7 @@ class MatlabTest(unittest.TestCase):
|
||||
Check that the_input.msg is sanitized.
|
||||
"""
|
||||
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 = ""
|
||||
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):
|
||||
"""Check that schematic input renders with expected context."""
|
||||
height = "12"
|
||||
width = "33"
|
||||
parts = "resistors, capacitors, and flowers"
|
||||
@@ -973,16 +1004,14 @@ class SchematicTest(unittest.TestCase):
|
||||
initial_value = "two large batteries"
|
||||
submit_analyses = "maybe"
|
||||
|
||||
xml_str = """<schematic id="prob_1_2"
|
||||
height="{h}"
|
||||
width="{w}"
|
||||
parts="{p}"
|
||||
analyses="{a}"
|
||||
initial_value="{iv}"
|
||||
submit_analyses="{sa}"
|
||||
/>""".format(
|
||||
h=height, w=width, p=parts, a=analyses, iv=initial_value, sa=submit_analyses
|
||||
)
|
||||
xml_str = f"""<schematic id="prob_1_2"
|
||||
height="{height}"
|
||||
width="{width}"
|
||||
parts="{parts}"
|
||||
analyses="{analyses}"
|
||||
initial_value="{initial_value}"
|
||||
submit_analyses="{submit_analyses}"
|
||||
/>"""
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -1018,18 +1047,17 @@ class ImageInputTest(unittest.TestCase):
|
||||
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"
|
||||
width = "427"
|
||||
src = "http://www.edx.org/cowclicker.jpg"
|
||||
|
||||
xml_str = """<imageinput id="prob_1_2"
|
||||
src="{s}"
|
||||
height="{h}"
|
||||
width="{w}"
|
||||
/>""".format(
|
||||
s=src, h=height, w=width
|
||||
)
|
||||
xml_str = f"""<imageinput id="prob_1_2"
|
||||
src="{src}"
|
||||
height="{height}"
|
||||
width="{width}"
|
||||
/>"""
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -1057,13 +1085,16 @@ class ImageInputTest(unittest.TestCase):
|
||||
assert context == expected
|
||||
|
||||
def test_with_value(self):
|
||||
"""Test image input rendering when a value is provided."""
|
||||
# Check that compensating for the dot size works properly.
|
||||
self.check("[50,40]", 35, 25)
|
||||
|
||||
def test_without_value(self):
|
||||
"""Test image input rendering when no value is provided."""
|
||||
self.check("", 0, 0)
|
||||
|
||||
def test_corrupt_values(self):
|
||||
"""Ensure image input handles malformed or corrupt values safely."""
|
||||
self.check("[12", 0, 0)
|
||||
self.check("[12, a]", 0, 0)
|
||||
self.check("[12 10]", 0, 0)
|
||||
@@ -1077,15 +1108,14 @@ class CrystallographyTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def test_rendering(self):
|
||||
"""Check that crystallography input renders with expected context."""
|
||||
height = "12"
|
||||
width = "33"
|
||||
|
||||
xml_str = """<crystallography id="prob_1_2"
|
||||
height="{h}"
|
||||
width="{w}"
|
||||
/>""".format(
|
||||
h=height, w=width
|
||||
)
|
||||
xml_str = f"""<crystallography id="prob_1_2"
|
||||
height="{height}"
|
||||
width="{width}"
|
||||
/>"""
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -1117,19 +1147,18 @@ class VseprTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def test_rendering(self):
|
||||
"""Test that a vsepr input renders correctly with molecules and geometries."""
|
||||
height = "12"
|
||||
width = "33"
|
||||
molecules = "H2O, C2O"
|
||||
geometries = "AX12,TK421"
|
||||
|
||||
xml_str = """<vsepr id="prob_1_2"
|
||||
height="{h}"
|
||||
width="{w}"
|
||||
molecules="{m}"
|
||||
geometries="{g}"
|
||||
/>""".format(
|
||||
h=height, w=width, m=molecules, g=geometries
|
||||
)
|
||||
xml_str = f"""<vsepr id="prob_1_2"
|
||||
height="{height}"
|
||||
width="{width}"
|
||||
molecules="{molecules}"
|
||||
geometries="{geometries}"
|
||||
/>"""
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -1163,9 +1192,9 @@ class ChemicalEquationTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(ChemicalEquationTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
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)
|
||||
|
||||
@@ -1248,9 +1277,9 @@ class FormulaEquationTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(FormulaEquationTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
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)
|
||||
|
||||
@@ -1294,12 +1323,10 @@ class FormulaEquationTest(unittest.TestCase):
|
||||
trailing_text.append(("a < b", "a < b"))
|
||||
|
||||
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}"
|
||||
trailing_text="{tt}"
|
||||
/>""".format(
|
||||
size=size, tt=xml_text
|
||||
)
|
||||
trailing_text="{xml_text}"
|
||||
/>"""
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
@@ -1361,7 +1388,7 @@ class FormulaEquationTest(unittest.TestCase):
|
||||
With parse errors, FormulaEquationInput should give an error message
|
||||
"""
|
||||
# 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")
|
||||
response = self.the_input.handle_ajax(
|
||||
"preview_formcalc",
|
||||
@@ -1379,7 +1406,7 @@ class FormulaEquationTest(unittest.TestCase):
|
||||
"""
|
||||
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()
|
||||
response = self.the_input.handle_ajax(
|
||||
"preview_formcalc",
|
||||
@@ -1399,6 +1426,7 @@ class DragAndDropTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def test_rendering(self):
|
||||
"""Test that a drag-and-drop input renders correctly with draggables and targets."""
|
||||
path_to_images = "/dummy-static/images/"
|
||||
|
||||
xml_str = """
|
||||
@@ -1436,14 +1464,14 @@ class DragAndDropTest(unittest.TestCase):
|
||||
"id": "name_with_icon",
|
||||
"icon": "/dummy-static/images/cc.jpg",
|
||||
"target_fields": [],
|
||||
}, # lint-amnesty, pylint: disable=line-too-long
|
||||
},
|
||||
{
|
||||
"can_reuse": "",
|
||||
"label": "arrow-left",
|
||||
"id": "with_icon",
|
||||
"icon": "/dummy-static/images/arrow-left.png",
|
||||
"target_fields": [],
|
||||
}, # lint-amnesty, pylint: disable=line-too-long
|
||||
},
|
||||
{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "target_fields": []},
|
||||
{
|
||||
"can_reuse": "",
|
||||
@@ -1451,21 +1479,21 @@ class DragAndDropTest(unittest.TestCase):
|
||||
"id": "2",
|
||||
"icon": "/dummy-static/images/mute.png",
|
||||
"target_fields": [],
|
||||
}, # lint-amnesty, pylint: disable=line-too-long
|
||||
},
|
||||
{
|
||||
"can_reuse": "",
|
||||
"label": "spinner",
|
||||
"id": "name_label_icon3",
|
||||
"icon": "/dummy-static/images/spinner.gif",
|
||||
"target_fields": [],
|
||||
}, # lint-amnesty, pylint: disable=line-too-long
|
||||
},
|
||||
{
|
||||
"can_reuse": "",
|
||||
"label": "Star",
|
||||
"id": "name4",
|
||||
"icon": "/dummy-static/images/volume.png",
|
||||
"target_fields": [],
|
||||
}, # lint-amnesty, pylint: disable=line-too-long
|
||||
},
|
||||
{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "target_fields": []},
|
||||
],
|
||||
"one_per_target": "True",
|
||||
@@ -1503,6 +1531,7 @@ class AnnotationInputTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def test_rendering(self):
|
||||
"""Test that an annotationinput renders correctly with comments, options, and state."""
|
||||
xml_str = """
|
||||
<annotationinput>
|
||||
<title>foo</title>
|
||||
@@ -1554,7 +1583,7 @@ class AnnotationInputTest(unittest.TestCase):
|
||||
"describedby_html": DESCRIBEDBY.format(status_id=prob_id),
|
||||
}
|
||||
|
||||
self.maxDiff = None
|
||||
self.maxDiff = None # pylint: disable=invalid-name
|
||||
self.assertDictEqual(context, expected)
|
||||
|
||||
|
||||
@@ -1575,7 +1604,7 @@ class TestChoiceText(unittest.TestCase):
|
||||
choice = {"type": node_type, "contents": contents, "tail_text": tail_text, "value": value}
|
||||
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
|
||||
expected output.
|
||||
@@ -1699,7 +1728,10 @@ class TestStatus(unittest.TestCase):
|
||||
"""
|
||||
Test that display names are "translated"
|
||||
"""
|
||||
func = lambda t: t.upper()
|
||||
|
||||
def func(t):
|
||||
return t.upper()
|
||||
|
||||
# status is in the mapping
|
||||
statobj = inputtypes.Status("queued", func)
|
||||
assert statobj.display_name == "PROCESSING"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-lines
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests of responsetypes
|
||||
@@ -19,8 +20,16 @@ import requests
|
||||
from pytz import UTC
|
||||
|
||||
from xmodule.capa.correctmap import CorrectMap
|
||||
from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError
|
||||
from xmodule.capa.tests.helpers import load_fixture, mock_capa_system, new_loncapa_problem
|
||||
from xmodule.capa.responsetypes import (
|
||||
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 (
|
||||
AnnotationResponseXMLFactory,
|
||||
ChoiceResponseXMLFactory,
|
||||
@@ -37,9 +46,9 @@ from xmodule.capa.tests.response_xml_factory import (
|
||||
SymbolicResponseXMLFactory,
|
||||
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.xqueue_interface import dateformat
|
||||
from xmodule.capa.xqueue_interface import DATEFORMAT
|
||||
|
||||
|
||||
class ResponseTest(unittest.TestCase):
|
||||
@@ -51,16 +60,18 @@ class ResponseTest(unittest.TestCase):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
super(ResponseTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
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):
|
||||
"""Build a Loncapa problem using the XML factory and provided arguments."""
|
||||
xml = self.xml_factory.build_xml(**kwargs)
|
||||
return new_loncapa_problem(xml, capa_system=capa_system)
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
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}
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
if msg is None:
|
||||
@@ -69,11 +80,12 @@ class ResponseTest(unittest.TestCase):
|
||||
assert correct_map.get_correctness("1_2_1") == expected_correctness, msg
|
||||
|
||||
def assert_answer_format(self, problem):
|
||||
"""Assert that the problem's answers are in a valid format."""
|
||||
answers = problem.get_question_answers()
|
||||
assert answers["1_2_1"] is not None
|
||||
|
||||
# pylint: disable=missing-function-docstring
|
||||
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:
|
||||
result = problem.grade_answers({"1_2_1": input_str}).get_correctness("1_2_1")
|
||||
assert result == "correct"
|
||||
@@ -109,11 +121,14 @@ class ResponseTest(unittest.TestCase):
|
||||
return str(rand.randint(0, 1e9))
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
@UseUnsafeCodejail()
|
||||
class MultiChoiceResponseTest(ResponseTest):
|
||||
"""Unit tests for the MultipleChoiceResponse class."""
|
||||
|
||||
xml_factory_class = MultipleChoiceResponseXMLFactory
|
||||
|
||||
def test_multiple_choice_grade(self):
|
||||
"""Test grading of a standard multiple-choice problem."""
|
||||
problem = self.build_problem(choices=[False, True, False])
|
||||
|
||||
# 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")
|
||||
|
||||
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")
|
||||
|
||||
# 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")
|
||||
|
||||
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"])
|
||||
|
||||
# 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")
|
||||
|
||||
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.
|
||||
# Change this test if that changes.
|
||||
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)
|
||||
|
||||
def test_partial_points_multiple_choice_grade(self):
|
||||
"""Test that multiple-choice choices return the correct partial points."""
|
||||
problem = self.build_problem(
|
||||
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
|
||||
|
||||
def test_contextualized_choices(self):
|
||||
"""Test grading for multiple-choice responses with contextualized expressions."""
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
class TrueFalseResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
class TrueFalseResponseTest(ResponseTest):
|
||||
"""Unit tests for the TrueFalseResponse class."""
|
||||
|
||||
xml_factory_class = TrueFalseResponseXMLFactory
|
||||
|
||||
def test_true_false_grade(self):
|
||||
"""Test grading for standard True/False response problems."""
|
||||
problem = self.build_problem(choices=[False, True, True])
|
||||
|
||||
# Check the results
|
||||
@@ -215,6 +238,7 @@ class TrueFalseResponseTest(ResponseTest): # pylint: disable=missing-class-docs
|
||||
self.assert_grade(problem, "not_a_choice", "incorrect")
|
||||
|
||||
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"])
|
||||
|
||||
# Check the results
|
||||
@@ -232,15 +256,19 @@ class TrueFalseResponseTest(ResponseTest): # pylint: disable=missing-class-docs
|
||||
self.assert_grade(problem, "not_a_choice", "incorrect")
|
||||
|
||||
def test_single_correct_response(self):
|
||||
"""Test grading when there is a single correct True/False choice."""
|
||||
problem = self.build_problem(choices=[True, False])
|
||||
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
|
||||
|
||||
def test_rectangle_grade(self):
|
||||
"""Test grading for a single rectangular region in ImageResponse."""
|
||||
# Define a rectangle with corners (10,10) and (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)
|
||||
|
||||
def test_multiple_rectangles_grade(self):
|
||||
"""Test grading for multiple rectangles in ImageResponse."""
|
||||
# Define two rectangles
|
||||
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)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
def test_multiple_regions_grade(self):
|
||||
"""Test grading for multiple regions in ImageResponse."""
|
||||
# Define multiple regions that the user can select
|
||||
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)
|
||||
|
||||
def test_region_and_rectangle_grade(self):
|
||||
"""Test grading for combined rectangle and region in ImageResponse."""
|
||||
rectangle_str = "(100,100)-(200,200)"
|
||||
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)
|
||||
|
||||
def test_show_answer(self):
|
||||
"""Test that ImageResponse answers are returned in the correct format."""
|
||||
rectangle_str = "(100,100)-(200,200)"
|
||||
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)
|
||||
|
||||
|
||||
class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
class SymbolicResponseTest(ResponseTest):
|
||||
"""Unit tests for the SymbolicResponse class."""
|
||||
|
||||
xml_factory_class = SymbolicResponseXMLFactory
|
||||
|
||||
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")
|
||||
|
||||
# Incorrect answers
|
||||
@@ -323,6 +359,7 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
|
||||
self._assert_symbolic_grade(problem, input_str, input_mathml, "incorrect")
|
||||
|
||||
def test_complex_number_grade_incorrect(self):
|
||||
"""Test grading of incorrect complex number symbolic input."""
|
||||
|
||||
problem = self.build_problem(
|
||||
math_display=True,
|
||||
@@ -348,13 +385,16 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
|
||||
)
|
||||
|
||||
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
|
||||
# only one "expect" value
|
||||
with pytest.raises(Exception):
|
||||
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.
|
||||
|
||||
@@ -375,11 +415,14 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst
|
||||
assert correct_map.get_correctness("1_2_1") == expected_correctness
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
@UseUnsafeCodejail()
|
||||
class OptionResponseTest(ResponseTest):
|
||||
"""Unit tests for the OptionResponse class."""
|
||||
|
||||
xml_factory_class = OptionResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
"""Test grading of OptionResponse problem with multiple options."""
|
||||
problem = self.build_problem(options=["first", "second", "third"], correct_option="second")
|
||||
|
||||
# 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")
|
||||
|
||||
def test_quote_option(self):
|
||||
"""Test that OptionResponse handles options containing quotes correctly."""
|
||||
# Test that option response properly escapes quotes inside options strings
|
||||
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"
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class FormulaResponseTest(ResponseTest):
|
||||
"""
|
||||
Test the FormulaResponse class
|
||||
@@ -553,8 +597,10 @@ class FormulaResponseTest(ResponseTest):
|
||||
assert not list(problem.responders.values())[0].validate_answer("3*y+2*x")
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
@UseUnsafeCodejail()
|
||||
class StringResponseTest(ResponseTest):
|
||||
"""Unit and integration tests for the StringResponse class."""
|
||||
|
||||
xml_factory_class = StringResponseXMLFactory
|
||||
|
||||
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")
|
||||
|
||||
def test_regexp(self):
|
||||
"""Test grading with various regular expression patterns and options."""
|
||||
problem = self.build_problem(answer="Second", case_sensitive=False, regexp=True)
|
||||
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.
|
||||
|
||||
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,
|
||||
because the regular expression must be \\,
|
||||
@@ -680,14 +727,17 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
self.assert_grade(problem, "5\\æ", "correct")
|
||||
|
||||
def test_backslash(self):
|
||||
"""Test grading of answers containing literal backslashes."""
|
||||
problem = self.build_problem(answer="a\\\\c1", case_sensitive=False, regexp=True)
|
||||
self.assert_grade(problem, "a\\c1", "correct")
|
||||
|
||||
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)
|
||||
self.assert_grade(problem, "a 1", "correct")
|
||||
|
||||
def test_case_sensitive(self):
|
||||
"""Test that case-sensitive answers are graded correctly."""
|
||||
# Test single answer
|
||||
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")
|
||||
|
||||
def test_case_insensitive(self):
|
||||
"""Test that case-insensitive answers are graded correctly."""
|
||||
# Test single answer
|
||||
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")
|
||||
|
||||
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"])
|
||||
self.assert_grade(problem, "Donut", "correct")
|
||||
self.assert_grade(problem, "Sprinkles", "correct")
|
||||
self.assert_grade(problem, "Meh", "incorrect")
|
||||
|
||||
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.?"])
|
||||
self.assert_grade(problem, "a3", "correct")
|
||||
self.assert_grade(problem, "3a", "correct")
|
||||
|
||||
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?"])
|
||||
with pytest.raises(Exception) as cm:
|
||||
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
|
||||
|
||||
def test_hints(self):
|
||||
"""Test that hints are provided correctly based on student answers."""
|
||||
|
||||
hints = [
|
||||
("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") == ""
|
||||
|
||||
def test_hints_regexp_and_answer_regexp(self):
|
||||
"""Test that hints work correctly with regex patterns in answers."""
|
||||
different_student_answers = [
|
||||
"May be it is Boston",
|
||||
"Boston, really?",
|
||||
@@ -862,6 +918,7 @@ class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
assert correct_map.get_hint("1_2_1") == ""
|
||||
|
||||
def test_computed_hints(self):
|
||||
"""Test that computed hints from a hint function are returned correctly."""
|
||||
problem = self.build_problem(
|
||||
answer="Michigan",
|
||||
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??"
|
||||
|
||||
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.
|
||||
problem = self.build_problem(
|
||||
answer="1",
|
||||
hintfn="gimme_a_random_hint",
|
||||
script=textwrap.dedent(
|
||||
"""
|
||||
f"""
|
||||
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")
|
||||
|
||||
""".format(
|
||||
code=self._get_random_number_code()
|
||||
)
|
||||
"""
|
||||
),
|
||||
)
|
||||
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")
|
||||
|
||||
|
||||
class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
class CodeResponseTest(ResponseTest):
|
||||
"""Unit and integration tests for the CodeResponse class."""
|
||||
|
||||
xml_factory_class = CodeResponseXMLFactory
|
||||
|
||||
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"})
|
||||
self.problem = self.build_problem(
|
||||
@@ -921,7 +978,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
@staticmethod
|
||||
def make_queuestate(key, time):
|
||||
"""Create queuestate dict"""
|
||||
timestr = datetime.strftime(time, dateformat)
|
||||
timestr = datetime.strftime(time, DATEFORMAT)
|
||||
return {"key": key, "time": timestr}
|
||||
|
||||
def test_is_queued(self):
|
||||
@@ -948,7 +1005,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
|
||||
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
|
||||
"""
|
||||
@@ -982,7 +1039,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
|
||||
for answer_id in answer_ids:
|
||||
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
|
||||
for correctness in ["correct", "incorrect"]:
|
||||
@@ -995,7 +1052,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
npoints = 1 if correctness == "correct" else 0
|
||||
new_cmap.set(
|
||||
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)
|
||||
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):
|
||||
if j == i:
|
||||
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:
|
||||
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):
|
||||
"""
|
||||
@@ -1032,7 +1089,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
self.problem.correct_map.update(cmap)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
@@ -1043,7 +1100,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
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")
|
||||
with open(problem_file) as fp:
|
||||
with open(problem_file, encoding="utf-8") as fp:
|
||||
answers_with_file = {
|
||||
"1_2_1": "String-based answer",
|
||||
"1_3_1": ["answer1", "answer2", "answer3"],
|
||||
@@ -1062,10 +1119,22 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
"<span>MESSAGE</span>", # Valid XML
|
||||
textwrap.dedent(
|
||||
"""
|
||||
<div class='matlabResponse'><div id='mwAudioPlaceHolder'>
|
||||
<audio controls autobuffer autoplay src='data:audio/wav;base64='>Audio is not supported on this browser.</audio>
|
||||
<div>Right click <a href=https://endpoint.mss-mathworks.com/media/filename.wav>here</a> and click \"Save As\" to download the file</div></div>
|
||||
<div style='white-space:pre' class='commandWindowOutput'></div><ul></ul></div>
|
||||
<div class='matlabResponse'>
|
||||
<div id='mwAudioPlaceHolder'>
|
||||
<audio controls autobuffer autoplay src='data:audio/wav;base64='>
|
||||
Audio is not supported on this browser.
|
||||
</audio>
|
||||
<div>
|
||||
Right click
|
||||
<a href=https://endpoint.mss-mathworks.com/media/filename.wav>
|
||||
here
|
||||
</a>
|
||||
and click \"Save As\" to download the file
|
||||
</div>
|
||||
</div>
|
||||
<div style='white-space:pre' class='commandWindowOutput'></div>
|
||||
<ul></ul>
|
||||
</div>
|
||||
"""
|
||||
).replace(
|
||||
"\n", ""
|
||||
@@ -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."
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
@UseUnsafeCodejail()
|
||||
class ChoiceResponseTest(ResponseTest):
|
||||
"""Unit and integration tests for the ChoiceResponse class."""
|
||||
|
||||
xml_factory_class = ChoiceResponseXMLFactory
|
||||
|
||||
def test_radio_group_grade(self):
|
||||
"""Test grading behavior for radio choice groups."""
|
||||
problem = self.build_problem(choice_type="radio", choices=[False, True, False])
|
||||
|
||||
# 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")
|
||||
|
||||
def test_checkbox_group_grade(self):
|
||||
"""Test grading behavior for checkbox choice groups."""
|
||||
problem = self.build_problem(choice_type="checkbox", choices=[False, True, True])
|
||||
|
||||
# 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")
|
||||
|
||||
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.
|
||||
# Change this test if that changes.
|
||||
problem = self.build_problem(
|
||||
@@ -1163,6 +1237,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
problem.grade_answers(input_dict)
|
||||
|
||||
def test_checkbox_group_partial_credit_grade(self):
|
||||
"""Test partial credit grading behavior for checkbox groups."""
|
||||
# First: Every Decision Counts grading style
|
||||
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")
|
||||
|
||||
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
|
||||
# Using assertAlmostEqual to avoid floating point issues
|
||||
# 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"
|
||||
|
||||
def test_contextualized_choices(self):
|
||||
"""Test grading of checkbox choices that depend on contextual script variables."""
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
a = 6
|
||||
@@ -1256,14 +1333,17 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
self.assert_grade(problem, ["choice_1", "choice_3"], "incorrect")
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
@UseUnsafeCodejail()
|
||||
class NumericalResponseTest(ResponseTest): # pylint: disable=too-many-public-methods
|
||||
"""Unit and integration tests for the NumericalResponse class."""
|
||||
|
||||
xml_factory_class = NumericalResponseXMLFactory
|
||||
|
||||
# We blend the line between integration (using evaluator) and exclusively
|
||||
# unit testing the NumericalResponse (mocking out the evaluator)
|
||||
# For simple things its not worth the effort.
|
||||
def test_grade_range_tolerance(self):
|
||||
"""Test that numerical responses are graded correctly within a range tolerance."""
|
||||
problem_setup = [
|
||||
# [given_answer, [list of correct responses], [list of incorrect responses]]
|
||||
["[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"
|
||||
|
||||
def test_grade_range_tolerance_partial_credit(self):
|
||||
"""Test that partially correct answers are graded properly within range tolerance."""
|
||||
problem_setup = [
|
||||
# [given_answer,
|
||||
# [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)
|
||||
|
||||
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
|
||||
problem = self.build_problem(answer="[1j, 5]")
|
||||
input_dict = {"1_2_1": "3"}
|
||||
@@ -1368,12 +1450,14 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
|
||||
problem.grade_answers(input_dict)
|
||||
|
||||
def test_grade_exact(self):
|
||||
"""Test that exact numerical answers are graded correctly."""
|
||||
problem = self.build_problem(answer=4)
|
||||
correct_responses = ["4", "4.0", "4.00"]
|
||||
incorrect_responses = ["", "3.9", "4.1", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
def test_grade_partial(self):
|
||||
"""Test grading of partially correct answers for different grading schemes."""
|
||||
# First: "list"-style grading scheme.
|
||||
problem = self.build_problem(answer=4, credit_type="list", partial_answers="2,8,-4")
|
||||
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)
|
||||
|
||||
def test_numerical_valid_grading_schemes(self):
|
||||
"""Test that invalid grading schemes raise an error."""
|
||||
# 'bongo' is not a valid grading scheme.
|
||||
problem = self.build_problem(answer=4, tolerance=0.1, credit_type="bongo")
|
||||
input_dict = {"1_2_1": "4"}
|
||||
@@ -1412,12 +1497,14 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs
|
||||
problem.grade_answers(input_dict)
|
||||
|
||||
def test_grade_decimal_tolerance(self):
|
||||
"""Test grading of numerical answers with decimal tolerance."""
|
||||
problem = self.build_problem(answer=4, tolerance=0.1)
|
||||
correct_responses = ["4.0", "4.00", "4.09", "3.91"]
|
||||
incorrect_responses = ["", "4.11", "3.89", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
def test_grade_percent_tolerance(self):
|
||||
"""Test grading of numerical answers with percentage-based tolerance."""
|
||||
# Positive only range
|
||||
problem = self.build_problem(answer=4, tolerance="10%")
|
||||
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)
|
||||
|
||||
def test_grade_with_script(self):
|
||||
"""Test that script-based answers are graded correctly."""
|
||||
script_text = "computed_response = math.sqrt(4)"
|
||||
problem = self.build_problem(answer="$computed_response", script=script_text)
|
||||
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.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."""
|
||||
staff_ans = "clearly bad syntax )[+1e"
|
||||
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."""
|
||||
|
||||
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:
|
||||
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."""
|
||||
if math_string != "4":
|
||||
raise err # lint-amnesty, pylint: disable=cell-var-from-loop
|
||||
raise err
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstring
|
||||
@UseUnsafeCodejail()
|
||||
class CustomResponseTest(ResponseTest): # pylint: disable=too-many-public-methods
|
||||
"""Unit tests for validating CustomResponse behavior"""
|
||||
|
||||
xml_factory_class = CustomResponseXMLFactory
|
||||
|
||||
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
|
||||
# 'answers' is a list of answers provided to us
|
||||
# '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")
|
||||
|
||||
def test_inline_message(self):
|
||||
"""Verify that inline code can set per-input and overall messages."""
|
||||
# Inline code can update the global messages list
|
||||
# to pass messages to the CorrectMap for a particular input
|
||||
# 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"
|
||||
|
||||
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.
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
#
|
||||
# 'expect' is the expect attribute of the <customresponse>
|
||||
@@ -1724,6 +1818,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
assert npoints == 0
|
||||
|
||||
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:
|
||||
#
|
||||
# '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"
|
||||
|
||||
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,
|
||||
|
||||
script = script = textwrap.dedent(
|
||||
@@ -1805,6 +1901,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
assert correctness == "correct"
|
||||
|
||||
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
|
||||
# a single boolean or string value
|
||||
@@ -1859,6 +1956,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
assert correctness == "incorrect"
|
||||
|
||||
def test_function_code_multiple_inputs(self):
|
||||
"""Verify multiple-input function grading with individual messages."""
|
||||
|
||||
# If the <customresponse> has multiple inputs associated with it,
|
||||
# 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"
|
||||
|
||||
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,
|
||||
# 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
|
||||
|
||||
def test_function_code_with_extra_args(self):
|
||||
"""Test function code receiving extra arguments from the problem context."""
|
||||
script = textwrap.dedent(
|
||||
"""\
|
||||
def check_func(expect, answer_given, options, dynamath):
|
||||
@@ -2016,6 +2116,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
assert msg == "Message text"
|
||||
|
||||
def test_function_code_with_attempt_number(self):
|
||||
"""Verify that the function code can access and use the attempt number."""
|
||||
script = textwrap.dedent(
|
||||
"""\
|
||||
def gradeit(expect, ans, **kwargs):
|
||||
@@ -2053,6 +2154,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
assert msg == "This is attempt number 2"
|
||||
|
||||
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
|
||||
# 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"
|
||||
|
||||
def test_script_exception_function(self):
|
||||
"""Check that exceptions in function scripts raise a ResponseError."""
|
||||
|
||||
# Construct a script that will raise an exception
|
||||
script = textwrap.dedent(
|
||||
@@ -2127,6 +2230,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
problem.grade_answers({"1_2_1": "42"})
|
||||
|
||||
def test_script_exception_inline(self):
|
||||
"""Check that exceptions in inline scripts raise a ResponseError."""
|
||||
|
||||
# Construct a script that will raise an exception
|
||||
script = 'raise Exception("Test")'
|
||||
@@ -2137,6 +2241,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
problem.grade_answers({"1_2_1": "42"})
|
||||
|
||||
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
|
||||
script = textwrap.dedent(
|
||||
@@ -2153,26 +2258,20 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
problem.grade_answers({"1_2_1": "42"})
|
||||
|
||||
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.
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
num = {code}
|
||||
""".format(
|
||||
code=self._get_random_number_code()
|
||||
)
|
||||
)
|
||||
script = textwrap.dedent(f"num = {self._get_random_number_code()}")
|
||||
problem = self.build_problem(script=script)
|
||||
assert problem.context["num"] == self._get_random_number_result(problem.seed)
|
||||
|
||||
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.
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
f"""
|
||||
def check_func(expect, answer_given):
|
||||
return {{'ok': True, 'msg': {code} }}
|
||||
""".format(
|
||||
code=self._get_random_number_code()
|
||||
)
|
||||
return {{'ok': True, 'msg': {self._get_random_number_code()} }}
|
||||
"""
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
# File "<string>", line 61, in <module>
|
||||
# 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
|
||||
# will raise an exception
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
f"""
|
||||
correct[0] = 'correct'
|
||||
assert('%s' in globals())"""
|
||||
% module_name
|
||||
assert('{module_name}' in globals())"""
|
||||
)
|
||||
|
||||
# Create the problem
|
||||
@@ -2239,7 +2338,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
problem.grade_answers({"1_2_1": "42"})
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -2264,11 +2363,10 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
# If the name is not defined, then the script
|
||||
# will raise an exception
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
f"""
|
||||
def check_func(expect, answer_given):
|
||||
assert('%s' in globals())
|
||||
assert('{module_name}' in globals())
|
||||
return True"""
|
||||
% module_name
|
||||
)
|
||||
|
||||
# Create the problem
|
||||
@@ -2280,24 +2378,24 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
problem.grade_answers({"1_2_1": "42"})
|
||||
|
||||
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):
|
||||
"""Test importing Python code from a zipfile in custom response scripts."""
|
||||
# Prove that we can import code from a zipfile passed down to us.
|
||||
|
||||
# Make a zipfile with one module in it with one function.
|
||||
zipstring = io.BytesIO()
|
||||
zipf = zipfile.ZipFile(zipstring, "w") # lint-amnesty, pylint: disable=consider-using-with
|
||||
zipf.writestr(
|
||||
"my_helper.py",
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
def seventeen():
|
||||
return 17
|
||||
"""
|
||||
),
|
||||
)
|
||||
zipf.close()
|
||||
with zipfile.ZipFile(zipstring, "w") as zipf:
|
||||
zipf.writestr(
|
||||
"my_helper.py",
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
def seventeen():
|
||||
return 17
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# Use that module in our Python script.
|
||||
script = textwrap.dedent(
|
||||
@@ -2307,13 +2405,12 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
"""
|
||||
)
|
||||
capa_system = mock_capa_system()
|
||||
capa_system.get_python_lib_zip = (
|
||||
lambda: zipstring.getvalue() # lint-amnesty, pylint: disable=unnecessary-lambda
|
||||
)
|
||||
capa_system.get_python_lib_zip = zipstring.getvalue
|
||||
problem = self.build_problem(script=script, capa_system=capa_system)
|
||||
assert problem.context["num"] == 17
|
||||
|
||||
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
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
@@ -2393,7 +2490,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri
|
||||
assert correct_map.get_msg("1_2_11") == "11"
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class SchematicResponseTest(ResponseTest):
|
||||
"""
|
||||
Class containing setup and tests for Schematic responsetype.
|
||||
@@ -2402,6 +2499,7 @@ class SchematicResponseTest(ResponseTest):
|
||||
xml_factory_class = SchematicResponseXMLFactory
|
||||
|
||||
def test_grade(self):
|
||||
"""Test that SchematicResponse grades answers correctly using a script."""
|
||||
# Most of the schematic-specific work is handled elsewhere
|
||||
# (in client-side JavaScript)
|
||||
# The <schematicresponse> is responsible only for executing the
|
||||
@@ -2426,10 +2524,9 @@ class SchematicResponseTest(ResponseTest):
|
||||
assert correct_map.get_correctness("1_2_1") == "correct"
|
||||
|
||||
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.
|
||||
script = "correct = ['correct' if (submission[0]['num'] == {code}) else 'incorrect']".format(
|
||||
code=self._get_random_number_code()
|
||||
) # lint-amnesty, pylint: disable=line-too-long
|
||||
script = f"correct = ['correct' if (submission[0]['num'] == {self._get_random_number_code()}) else 'incorrect']"
|
||||
problem = self.build_problem(answer=script)
|
||||
|
||||
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"
|
||||
|
||||
def test_script_exception(self):
|
||||
"""Test that exceptions in schematic scripts are properly raised."""
|
||||
# Construct a script that will raise an exception
|
||||
script = "raise Exception('test')"
|
||||
problem = self.build_problem(answer=script)
|
||||
@@ -2450,15 +2548,20 @@ class SchematicResponseTest(ResponseTest):
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
answer_id = "1_2_1"
|
||||
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 = [
|
||||
{"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_points = correct_map.get_npoints(answer_id)
|
||||
|
||||
assert expected_correctness == actual_correctness, "%s should be marked %s" % (
|
||||
answer_id,
|
||||
expected_correctness,
|
||||
)
|
||||
assert expected_points == actual_points, "%s should have %d points" % (answer_id, expected_points)
|
||||
assert expected_correctness == actual_correctness, f"{answer_id} should be marked {expected_correctness}"
|
||||
assert expected_points == actual_points, f"{answer_id} should have {expected_points} points"
|
||||
|
||||
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class ChoiceTextResponseTest(ResponseTest):
|
||||
"""
|
||||
Class containing setup and tests for ChoiceText responsetype.
|
||||
@@ -2615,8 +2715,8 @@ class ChoiceTextResponseTest(ResponseTest):
|
||||
if choice:
|
||||
# Radio/Checkbox inputs in choicetext problems follow
|
||||
# a naming convention that gives them names ending with "bc"
|
||||
choice_id = "1_2_1_choiceinput_{index}bc".format(index=index)
|
||||
choice_value = "choiceinput_{index}".format(index=index)
|
||||
choice_id = f"1_2_1_choiceinput_{index}bc"
|
||||
choice_value = f"choiceinput_{index}"
|
||||
answer_dict[choice_id] = choice_value
|
||||
# Build the names for the numtolerance_inputs and add their answers
|
||||
# to `answer_dict`.
|
||||
@@ -2624,7 +2724,7 @@ class ChoiceTextResponseTest(ResponseTest):
|
||||
# In `answer_id` `index` represents the ordinality of the
|
||||
# choice and `ind` represents the ordinality of the
|
||||
# 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
|
||||
|
||||
return answer_dict
|
||||
@@ -2671,6 +2771,7 @@ class ChoiceTextResponseTest(ResponseTest):
|
||||
)
|
||||
|
||||
def test_staff_answer_error(self):
|
||||
"""Test that invalid staff answers raise a StudentInputError."""
|
||||
broken_problem = self._make_problem(
|
||||
[("true", {"answer": "Platypus", "tolerance": "0"}), ("true", {"answer": "edX", "tolerance": "0"})],
|
||||
"checkboxtextgroup",
|
||||
@@ -2697,7 +2798,7 @@ class ChoiceTextResponseTest(ResponseTest):
|
||||
# Build the actual problem for the test.
|
||||
test_problem = self._make_problem(test_choices, "radiotextgroup", test_script)
|
||||
# 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):
|
||||
"""
|
||||
@@ -2744,4 +2845,4 @@ class ChoiceTextResponseTest(ResponseTest):
|
||||
problem = problems[problem_name]
|
||||
|
||||
# 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}")
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
"""
|
||||
Test for xmodule.capa.score_render module
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.http import Http404
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
|
||||
from common.djangoapps.student.models import AnonymousUserId
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
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.factories import CourseFactory, BlockFactory
|
||||
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
|
||||
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
|
||||
|
||||
|
||||
class ScoreEvent:
|
||||
class ScoreEvent: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
Mock class to represent an external grader score event.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
self,
|
||||
score_msg=None,
|
||||
course_id=None,
|
||||
@@ -31,7 +29,7 @@ class ScoreEvent:
|
||||
module_id=None,
|
||||
submission_id=None,
|
||||
queue_key=None,
|
||||
queue_name=None
|
||||
queue_name=None,
|
||||
):
|
||||
self.score_msg = score_msg
|
||||
self.course_id = course_id
|
||||
@@ -54,21 +52,15 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
super().setUp()
|
||||
self.course = CourseFactory.create()
|
||||
self.user = UserFactory.create()
|
||||
self.problem = BlockFactory.create(
|
||||
category='problem',
|
||||
parent=self.course,
|
||||
display_name='Test Problem'
|
||||
)
|
||||
self.anonymous_user_id = '12345'
|
||||
self.problem = BlockFactory.create(category="problem", parent=self.course, display_name="Test Problem")
|
||||
self.anonymous_user_id = "12345"
|
||||
# Create AnonymousUserId instance
|
||||
AnonymousUserId.objects.create(
|
||||
user=self.user,
|
||||
anonymous_user_id=self.anonymous_user_id,
|
||||
course_id=self.course.id
|
||||
user=self.user, anonymous_user_id=self.anonymous_user_id, course_id=self.course.id
|
||||
)
|
||||
|
||||
@patch('xmodule.capa.score_render.modulestore')
|
||||
@patch('xmodule.capa.score_render.FieldDataCache')
|
||||
@patch("xmodule.capa.score_render.modulestore")
|
||||
@patch("xmodule.capa.score_render.FieldDataCache")
|
||||
def test_load_xblock_for_external_grader(self, mock_field_data_cache, mock_modulestore):
|
||||
"""
|
||||
Test loading an XBlock for external grading.
|
||||
@@ -78,15 +70,12 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
mock_modulestore.return_value.get_item.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()
|
||||
|
||||
# Call the function
|
||||
result = load_xblock_for_external_grader(
|
||||
self.anonymous_user_id,
|
||||
str(self.course.id),
|
||||
str(self.problem.location),
|
||||
self.course
|
||||
self.anonymous_user_id, str(self.course.id), str(self.problem.location), self.course
|
||||
)
|
||||
|
||||
# Assertions
|
||||
@@ -95,8 +84,8 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
mock_field_data_cache.cache_for_block_descendents.assert_called_once()
|
||||
mock_get_block.assert_called_once()
|
||||
|
||||
@patch('xmodule.capa.score_render.modulestore')
|
||||
@patch('xmodule.capa.score_render.AnonymousUserId.objects.get')
|
||||
@patch("xmodule.capa.score_render.modulestore")
|
||||
@patch("xmodule.capa.score_render.AnonymousUserId.objects.get")
|
||||
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.
|
||||
@@ -109,13 +98,10 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
# Test that Http404 is raised
|
||||
with self.assertRaises(Http404):
|
||||
load_xblock_for_external_grader(
|
||||
self.anonymous_user_id,
|
||||
str(self.course.id),
|
||||
str(self.problem.location),
|
||||
self.course
|
||||
self.anonymous_user_id, 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):
|
||||
"""
|
||||
Test initializing an XBlock instance without access checks.
|
||||
@@ -127,11 +113,7 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
|
||||
# Call the function
|
||||
result = get_block_for_descriptor_without_access_check(
|
||||
self.user,
|
||||
block,
|
||||
student_data,
|
||||
self.course.id,
|
||||
self.course
|
||||
self.user, block, student_data, self.course.id, self.course
|
||||
)
|
||||
|
||||
# Assertions
|
||||
@@ -139,8 +121,8 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
mock_prepare_runtime.assert_called_once()
|
||||
block.bind_for_student.assert_called_once()
|
||||
|
||||
@patch('xmodule.capa.score_render.modulestore')
|
||||
@patch('xmodule.capa.score_render.load_xblock_for_external_grader')
|
||||
@patch("xmodule.capa.score_render.modulestore")
|
||||
@patch("xmodule.capa.score_render.load_xblock_for_external_grader")
|
||||
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.
|
||||
@@ -156,9 +138,9 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
course_id=str(self.course.id),
|
||||
user_id=self.anonymous_user_id,
|
||||
module_id=str(self.problem.location),
|
||||
submission_id='sub_123',
|
||||
queue_key='key_456',
|
||||
queue_name='test_queue'
|
||||
submission_id="sub_123",
|
||||
queue_key="key_456",
|
||||
queue_name="test_queue",
|
||||
)
|
||||
|
||||
# Call the handler
|
||||
@@ -174,18 +156,18 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
self.assertIsInstance(call_args[2], UsageKey)
|
||||
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()
|
||||
ajax_args, _ = mock_instance.handle_ajax.call_args
|
||||
self.assertEqual(ajax_args[0], 'score_update')
|
||||
self.assertIn('xqueue_header', ajax_args[1])
|
||||
self.assertIn('xqueue_body', ajax_args[1])
|
||||
self.assertIn('queuekey', ajax_args[1])
|
||||
self.assertEqual(ajax_args[0], "score_update")
|
||||
self.assertIn("xqueue_header", ajax_args[1])
|
||||
self.assertIn("xqueue_body", ajax_args[1])
|
||||
self.assertIn("queuekey", ajax_args[1])
|
||||
mock_instance.save.assert_called_once()
|
||||
|
||||
@patch('xmodule.capa.score_render.modulestore')
|
||||
@patch('xmodule.capa.score_render.load_xblock_for_external_grader')
|
||||
@patch("xmodule.capa.score_render.modulestore")
|
||||
@patch("xmodule.capa.score_render.load_xblock_for_external_grader")
|
||||
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.
|
||||
@@ -202,9 +184,9 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
course_id=str(self.course.id),
|
||||
user_id=self.anonymous_user_id,
|
||||
module_id=str(self.problem.location),
|
||||
submission_id='sub_123',
|
||||
queue_key='key_456',
|
||||
queue_name='test_queue'
|
||||
submission_id="sub_123",
|
||||
queue_key="key_456",
|
||||
queue_name="test_queue",
|
||||
)
|
||||
|
||||
# json.loads must fail BEFORE anything else runs
|
||||
@@ -218,8 +200,8 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
|
||||
mock_instance.save.assert_not_called()
|
||||
|
||||
@patch('xmodule.capa.score_render.modulestore')
|
||||
@patch('xmodule.capa.score_render.load_xblock_for_external_grader')
|
||||
@patch("xmodule.capa.score_render.modulestore")
|
||||
@patch("xmodule.capa.score_render.load_xblock_for_external_grader")
|
||||
def test_handle_external_grader_score_exception(self, mock_load_xblock, mock_modulestore):
|
||||
"""
|
||||
Test handling an exception during score processing.
|
||||
@@ -234,21 +216,22 @@ class TestScoreRender(ModuleStoreTestCase):
|
||||
course_id=str(self.course.id),
|
||||
user_id=self.anonymous_user_id,
|
||||
module_id=str(self.problem.location),
|
||||
submission_id='sub_123',
|
||||
queue_key='key_456',
|
||||
queue_name='test_queue'
|
||||
submission_id="sub_123",
|
||||
queue_key="key_456",
|
||||
queue_name="test_queue",
|
||||
)
|
||||
|
||||
# Call the handler and expect exception to be raised
|
||||
with self.assertRaises(Exception):
|
||||
handle_external_grader_score(None, None, score)
|
||||
|
||||
@patch('xmodule.capa.score_render.AnonymousUserId.objects.get')
|
||||
@patch('xmodule.capa.score_render.modulestore')
|
||||
@patch('xmodule.capa.score_render.FieldDataCache')
|
||||
@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,
|
||||
mock_modulestore, mock_anon_user):
|
||||
@patch("xmodule.capa.score_render.AnonymousUserId.objects.get")
|
||||
@patch("xmodule.capa.score_render.modulestore")
|
||||
@patch("xmodule.capa.score_render.FieldDataCache")
|
||||
@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, mock_modulestore, mock_anon_user
|
||||
):
|
||||
"""
|
||||
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
|
||||
with self.assertRaises(Http404) as context:
|
||||
load_xblock_for_external_grader(
|
||||
self.anonymous_user_id,
|
||||
str(self.course.id),
|
||||
str(self.problem.location)
|
||||
)
|
||||
load_xblock_for_external_grader(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)}"
|
||||
self.assertEqual(str(context.exception), expected_msg)
|
||||
@@ -290,26 +269,20 @@ class TestScoreRenderIntegration(ModuleStoreTestCase):
|
||||
super().setUp()
|
||||
self.course = CourseFactory.create()
|
||||
self.user = UserFactory.create()
|
||||
self.problem = BlockFactory.create(
|
||||
category='problem',
|
||||
parent=self.course,
|
||||
display_name='Test Problem'
|
||||
)
|
||||
self.anonymous_user_id = '12345'
|
||||
self.problem = BlockFactory.create(category="problem", parent=self.course, display_name="Test Problem")
|
||||
self.anonymous_user_id = "12345"
|
||||
# Create AnonymousUserId instance
|
||||
AnonymousUserId.objects.create(
|
||||
user=self.user,
|
||||
anonymous_user_id=self.anonymous_user_id,
|
||||
course_id=self.course.id
|
||||
user=self.user, anonymous_user_id=self.anonymous_user_id, course_id=self.course.id
|
||||
)
|
||||
|
||||
@patch('xmodule.capa.score_render.modulestore')
|
||||
def test_end_to_end_grading_flow(self, mock_modulestore):
|
||||
@patch("xmodule.capa.score_render.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.
|
||||
"""
|
||||
# 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
|
||||
mock_instance = MagicMock()
|
||||
mock_load_xblock.return_value = mock_instance
|
||||
@@ -320,9 +293,9 @@ class TestScoreRenderIntegration(ModuleStoreTestCase):
|
||||
course_id=str(self.course.id),
|
||||
user_id=self.anonymous_user_id,
|
||||
module_id=str(self.problem.location),
|
||||
submission_id='sub_123',
|
||||
queue_key='key_456',
|
||||
queue_name='test_queue'
|
||||
submission_id="sub_123",
|
||||
queue_key="key_456",
|
||||
queue_name="test_queue",
|
||||
)
|
||||
|
||||
# Call the handler
|
||||
@@ -335,19 +308,19 @@ class TestScoreRenderIntegration(ModuleStoreTestCase):
|
||||
|
||||
# Verify the data structure passed to handle_ajax
|
||||
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]
|
||||
self.assertIn('xqueue_header', data)
|
||||
self.assertIn('xqueue_body', data)
|
||||
self.assertIn('queuekey', data)
|
||||
self.assertIn("xqueue_header", data)
|
||||
self.assertIn("xqueue_body", data)
|
||||
self.assertIn("queuekey", data)
|
||||
|
||||
header = json.loads(data['xqueue_header'])
|
||||
self.assertEqual(header['lms_key'], 'sub_123')
|
||||
self.assertEqual(header['queue_name'], 'test_queue')
|
||||
header = json.loads(data["xqueue_header"])
|
||||
self.assertEqual(header["lms_key"], "sub_123")
|
||||
self.assertEqual(header["queue_name"], "test_queue")
|
||||
|
||||
# Verify the body is the correct JSON
|
||||
body = json.loads(data['xqueue_body'])
|
||||
self.assertEqual(body['score'], 1)
|
||||
self.assertEqual(body['max_score'], 1)
|
||||
self.assertTrue(body['correct'])
|
||||
body = json.loads(data["xqueue_body"])
|
||||
self.assertEqual(body["score"], 1)
|
||||
self.assertEqual(body["max_score"], 1)
|
||||
self.assertTrue(body["correct"])
|
||||
|
||||
@@ -11,10 +11,11 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
"""Capa problem tests for shuffling and choice-name masking."""
|
||||
|
||||
def setUp(self):
|
||||
super(CapaShuffleTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.system = mock_capa_system()
|
||||
|
||||
def test_shuffle_4_choices(self):
|
||||
"""Verify shuffling and name-masking for four choices."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -41,6 +42,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
assert the_html == problem.get_html(), "should be able to call get_html() twice"
|
||||
|
||||
def test_shuffle_custom_names(self):
|
||||
"""Verify shuffling preserves custom choice names."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -64,6 +66,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
assert response.unmask_order() == ["choice_0", "choice_aaa", "choice_1", "choice_ddd"]
|
||||
|
||||
def test_shuffle_different_seed(self):
|
||||
"""Check that shuffling produces different order with a different seed."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -83,6 +86,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
self.assertRegex(the_html, r"<div>.*\[.*'Donut'.*'Apple'.*'Banana'.*'Chocolate'.*\].*</div>")
|
||||
|
||||
def test_shuffle_1_choice(self):
|
||||
"""Verify shuffling behavior with only one choice."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -103,6 +107,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
assert response.unmask_order() == ["choice_0"]
|
||||
|
||||
def test_shuffle_6_choices(self):
|
||||
"""Test shuffling with six choices to ensure proper randomization."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -124,9 +129,10 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
the_html = problem.get_html()
|
||||
self.assertRegex(
|
||||
the_html, r"<div>.*\[.*'Chocolate'.*'Eggplant'.*'Apple'.*'Banana'.*'Zonut'.*'Filet Mignon'.*\].*</div>"
|
||||
) # lint-amnesty, pylint: disable=line-too-long
|
||||
)
|
||||
|
||||
def test_shuffle_false(self):
|
||||
"""Verify that shuffle='false' keeps original order."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -149,6 +155,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
assert not response.has_shuffle()
|
||||
|
||||
def test_shuffle_fixed_head_end(self):
|
||||
"""Ensure choices fixed at the head remain in place during shuffle."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -171,6 +178,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*\].*</div>")
|
||||
|
||||
def test_shuffle_fixed_tail_end(self):
|
||||
"""Ensure choices fixed at the tail remain in place during shuffle."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -193,6 +201,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
self.assertRegex(the_html, r"<div>.*\[.*'B'.*'A'.*'C'.*'D'.*'Alpha'.*'Beta'.*\].*</div>")
|
||||
|
||||
def test_shuffle_fixed_both_ends(self):
|
||||
"""Ensure choices fixed at both ends remain in place during shuffle."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -217,6 +226,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'Beta'.*'B'.*'A'.*'C'.*'D'.*'Psi'.*'Omega'.*\].*</div>")
|
||||
|
||||
def test_shuffle_fixed_both_ends_thin(self):
|
||||
"""Test shuffling with only three choices, two of which are fixed."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -235,6 +245,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
self.assertRegex(the_html, r"<div>.*\[.*'Alpha'.*'A'.*'Omega'.*\].*</div>")
|
||||
|
||||
def test_shuffle_fixed_all(self):
|
||||
"""Verify that all fixed choices remain in order with no shuffle."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -274,6 +285,7 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
self.assertRegex(the_html, r"<div>.*\[.*'A'.*'Mid'.*'Mid'.*'C'.*'D'.*\].*</div>")
|
||||
|
||||
def test_multiple_shuffle_responses(self):
|
||||
"""Check shuffling for multiple choice groups in the same problem."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -301,7 +313,6 @@ class CapaShuffleTest(unittest.TestCase):
|
||||
orig_html = problem.get_html()
|
||||
assert orig_html == problem.get_html(), "should be able to call get_html() twice"
|
||||
html = orig_html.replace("\n", " ") # avoid headaches with .* matching
|
||||
print(html)
|
||||
self.assertRegex(
|
||||
html,
|
||||
r"<div>.*\[.*'Banana'.*'Apple'.*'Chocolate'.*'Donut'.*\].*</div>.*"
|
||||
|
||||
@@ -15,10 +15,11 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(CapaTargetedFeedbackTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
self.system = mock_capa_system()
|
||||
|
||||
def test_no_targeted_feedback(self):
|
||||
"""Verify that no targeted feedback is shown when not finished."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -84,6 +85,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertRegex(without_new_lines, r"feedback1|feedback2|feedback3|feedbackC")
|
||||
|
||||
def test_targeted_feedback_not_finished(self):
|
||||
"""Check HTML output when targeted feedback is incomplete."""
|
||||
problem = new_loncapa_problem(load_fixture("targeted_feedback.xml"))
|
||||
the_html = problem.get_html()
|
||||
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"
|
||||
|
||||
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.done = True
|
||||
problem.student_answers = {"1_2_1": "choice_3"}
|
||||
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\\n", "").replace("\n", "")
|
||||
# pylint: disable=line-too-long
|
||||
self.assertRegex(
|
||||
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")
|
||||
# Check that calling it multiple times yields the same thing
|
||||
@@ -110,16 +116,20 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
assert the_html == the_html2
|
||||
|
||||
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.done = True
|
||||
problem.student_answers = {"1_2_1": "choice_0"}
|
||||
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\\n", "").replace("\n", "")
|
||||
# pylint: disable=line-too-long
|
||||
self.assertRegex(
|
||||
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.assertNotRegex(without_new_lines, r"feedback2|feedback3|feedbackC")
|
||||
@@ -132,10 +142,13 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\\n", "").replace("\n", "")
|
||||
# pylint: disable=line-too-long
|
||||
self.assertRegex(
|
||||
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")
|
||||
|
||||
@@ -211,6 +224,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertRegex(the_html, r"<targetedfeedbackset>\s*</targetedfeedbackset>")
|
||||
|
||||
def test_targeted_feedback_no_solution_element(self):
|
||||
"""Check behavior when the solution element is missing."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -245,6 +259,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertRegex(without_new_lines, r"<div>.*<targetedfeedbackset>.*</targetedfeedbackset>\s*</div>")
|
||||
|
||||
def test_targeted_feedback_show_solution_explanation(self):
|
||||
"""Verify that solution explanation is shown when configured to always show."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -307,10 +322,12 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\n", "")
|
||||
# pylint: disable=line-too-long
|
||||
self.assertRegex(
|
||||
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.assertNotRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
|
||||
@@ -320,6 +337,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
assert the_html == the_html2
|
||||
|
||||
def test_targeted_feedback_no_show_solution_explanation(self):
|
||||
"""Verify that solution explanation is hidden when not configured to show."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -382,16 +400,19 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\n", "")
|
||||
# pylint: disable=line-too-long
|
||||
self.assertRegex(
|
||||
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.assertRegex(without_new_lines, r"<div>\{.*'1_solution_1'.*\}</div>")
|
||||
self.assertNotRegex(without_new_lines, r"feedback2|feedback3|feedbackC")
|
||||
|
||||
def test_targeted_feedback_with_solutionset_explanation(self):
|
||||
"""Test targeted feedback when multiple correct solutions exist."""
|
||||
xml_str = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -464,10 +485,12 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
|
||||
the_html = problem.get_html()
|
||||
without_new_lines = the_html.replace("\n", "")
|
||||
# pylint: disable=line-too-long
|
||||
self.assertRegex(
|
||||
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=\"feedbackC2\".*other solution explanation"
|
||||
@@ -476,6 +499,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertNotRegex(without_new_lines, r"feedback2|feedback3")
|
||||
|
||||
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(
|
||||
"""
|
||||
<problem>
|
||||
@@ -541,6 +565,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertNotRegex(without_new_lines, r"feedback1|feedback3")
|
||||
|
||||
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(
|
||||
"""
|
||||
<problem>
|
||||
@@ -605,6 +630,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
self.assertNotRegex(without_new_lines, r"feedback1|feedback3|feedbackC")
|
||||
|
||||
def test_targeted_feedback_multiple_not_answered(self):
|
||||
"""Ensure empty targeted feedback is rendered for unanswered multiple questions."""
|
||||
# Not answered -> empty targeted feedback
|
||||
problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml"))
|
||||
the_html = problem.get_html()
|
||||
@@ -616,6 +642,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
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.done = True
|
||||
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):
|
||||
"""Test feedback rendering for multiple answered questions in a multi-question problem."""
|
||||
problem = new_loncapa_problem(load_fixture("targeted_feedback_multiple.xml"))
|
||||
problem.done = True
|
||||
problem.student_answers = {"1_2_1": "choice_0", "1_3_1": "choice_2"} # Q1 wrong, Q2 correct
|
||||
|
||||
@@ -26,10 +26,11 @@ class UtilTest(unittest.TestCase):
|
||||
"""Tests for util"""
|
||||
|
||||
def setUp(self):
|
||||
super(UtilTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
|
||||
super().setUp()
|
||||
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)
|
||||
result = compare_with_tolerance(100.0, 100.0)
|
||||
assert result
|
||||
@@ -67,7 +68,7 @@ class UtilTest(unittest.TestCase):
|
||||
assert result
|
||||
result = compare_with_tolerance(112.0, 100.0, 0.1, True)
|
||||
assert not result
|
||||
##### Infinite values #####
|
||||
# Infinite values #
|
||||
infinity = float("Inf")
|
||||
# Test relative tolerance (float)
|
||||
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"]
|
||||
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
|
||||
|
||||
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 = ""
|
||||
assert sanitize_html(queue_msg) == expected
|
||||
|
||||
@@ -170,7 +171,7 @@ class UtilTest(unittest.TestCase):
|
||||
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.
|
||||
Use this as a decorator on Django TestCase classes or methods.
|
||||
@@ -188,8 +189,10 @@ class use_unsafe_codejail(TestContextDecorator):
|
||||
super().__init__()
|
||||
|
||||
def enable(self):
|
||||
"""Enable unsafe mode for codejail within the test scope."""
|
||||
self.old_be_unsafe = codejail.safe_exec.ALWAYS_BE_UNSAFE
|
||||
codejail.safe_exec.ALWAYS_BE_UNSAFE = True
|
||||
|
||||
def disable(self):
|
||||
"""Restore the previous codejail unsafe mode state."""
|
||||
codejail.safe_exec.ALWAYS_BE_UNSAFE = self.old_be_unsafe
|
||||
|
||||
@@ -40,7 +40,7 @@ class XQueueServiceTest(TestCase):
|
||||
assert self.service.interface.session.auth.password == "agarwal"
|
||||
|
||||
@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."""
|
||||
usage_id = self.block.scope_ids.usage_id
|
||||
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"
|
||||
|
||||
@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."""
|
||||
usage_id = self.block.scope_ids.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
|
||||
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True)
|
||||
@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."""
|
||||
url = "http://example.com/xqueue"
|
||||
django_auth = {"username": "user", "password": "pass"}
|
||||
block = Mock() # Mock block for the constructor
|
||||
xqueue_interface = XQueueInterface(url, django_auth, block=block)
|
||||
|
||||
header = json.dumps({
|
||||
"lms_callback_url": (
|
||||
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
|
||||
"xqueue/block@item_id/type@problem"
|
||||
),
|
||||
"lms_key": "default"
|
||||
})
|
||||
body = json.dumps({
|
||||
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
|
||||
"student_response": "student_answer",
|
||||
})
|
||||
header = json.dumps(
|
||||
{
|
||||
"lms_callback_url": (
|
||||
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
|
||||
"xqueue/block@item_id/type@problem"
|
||||
),
|
||||
"lms_key": "default",
|
||||
}
|
||||
)
|
||||
body = json.dumps(
|
||||
{
|
||||
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
|
||||
"student_response": "student_answer",
|
||||
}
|
||||
)
|
||||
files_to_upload = None
|
||||
|
||||
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", {})
|
||||
|
||||
@@ -112,28 +116,32 @@ def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag):
|
||||
@pytest.mark.django_db
|
||||
@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False)
|
||||
@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."""
|
||||
url = "http://example.com/xqueue"
|
||||
django_auth = {"username": "user", "password": "pass"}
|
||||
block = Mock() # Mock block for the constructor
|
||||
xqueue_interface = XQueueInterface(url, django_auth, block=block)
|
||||
|
||||
header = json.dumps({
|
||||
"lms_callback_url": (
|
||||
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
|
||||
"xqueue/block@item_id/type@problem"
|
||||
),
|
||||
"lms_key": "default"
|
||||
})
|
||||
body = json.dumps({
|
||||
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
|
||||
"student_response": "student_answer",
|
||||
})
|
||||
header = json.dumps(
|
||||
{
|
||||
"lms_callback_url": (
|
||||
"http://example.com/courses/course-v1:test_org+test_course+test_run/"
|
||||
"xqueue/block@item_id/type@problem"
|
||||
),
|
||||
"lms_key": "default",
|
||||
}
|
||||
)
|
||||
body = json.dumps(
|
||||
{
|
||||
"student_info": json.dumps({"anonymous_student_id": "student_id"}),
|
||||
"student_response": "student_answer",
|
||||
}
|
||||
)
|
||||
files_to_upload = None
|
||||
|
||||
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(
|
||||
"http://example.com/xqueue/xqueue/submit/",
|
||||
|
||||
@@ -23,7 +23,7 @@ def xqueue_service():
|
||||
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.
|
||||
"""
|
||||
@@ -54,7 +54,7 @@ def test_get_submission_params(xqueue_service):
|
||||
|
||||
@pytest.mark.django_db
|
||||
@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.
|
||||
"""
|
||||
@@ -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",
|
||||
"student_id": "student_id",
|
||||
},
|
||||
'student_answer',
|
||||
queue_name='default',
|
||||
queue_key='default',
|
||||
grader_file_name='test.py',
|
||||
"student_answer",
|
||||
queue_name="default",
|
||||
queue_key="default",
|
||||
grader_file_name="test.py",
|
||||
points_possible=10,
|
||||
files=None,
|
||||
)
|
||||
@@ -98,7 +98,9 @@ def test_send_to_submission(mock_create_external_grader_detail, xqueue_service):
|
||||
|
||||
@pytest.mark.django_db
|
||||
@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.
|
||||
"""
|
||||
|
||||
@@ -16,11 +16,11 @@ from openedx.core.djangolib.markup import HTML
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Utility functions used in CAPA responsetypes
|
||||
default_tolerance = "0.001%"
|
||||
DEFAULT_TOLERANCE = "0.001%"
|
||||
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.
|
||||
|
||||
@@ -42,7 +42,7 @@ def compare_with_tolerance(student_complex, instructor_complex, tolerance=defaul
|
||||
instructor_complex = 10, student_complex = 20, tolerance = '10%' will give
|
||||
[8.0, 12.0].
|
||||
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
|
||||
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
|
||||
"""
|
||||
if isinstance(tolerance, str):
|
||||
if tolerance == default_tolerance:
|
||||
if tolerance == DEFAULT_TOLERANCE:
|
||||
relative_tolerance = True
|
||||
if tolerance.endswith("%"):
|
||||
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))
|
||||
return abs(student_decimal - instructor_decimal) <= tolerance_decimal
|
||||
|
||||
else:
|
||||
# v1 and v2 are, in general, complex numbers:
|
||||
# there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()).
|
||||
return abs(student_complex - instructor_complex) <= tolerance
|
||||
# v1 and v2 are, in general, complex numbers:
|
||||
# there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()).
|
||||
return abs(student_complex - instructor_complex) <= tolerance
|
||||
|
||||
|
||||
def contextualize_text(text, context): # private
|
||||
@@ -144,6 +143,7 @@ def convert_files_to_filenames(answers):
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -171,8 +171,8 @@ def find_with_default(node, path, default):
|
||||
v = node.find(path)
|
||||
if v is not None:
|
||||
return v.text
|
||||
else:
|
||||
return default
|
||||
|
||||
return default
|
||||
|
||||
|
||||
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")
|
||||
# strips outer tag from html string
|
||||
# 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()
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
|
||||
from xmodule.capa_block import ProblemBlock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
dateformat = "%Y%m%d%H%M%S"
|
||||
DATEFORMAT = "%Y%m%d%H%M%S"
|
||||
|
||||
XQUEUE_METRIC_NAME = "edxapp.xqueue"
|
||||
|
||||
@@ -99,7 +99,7 @@ def parse_xreply(xreply):
|
||||
return (return_code, content)
|
||||
|
||||
|
||||
class XQueueInterface:
|
||||
class XQueueInterface: # pylint: disable=too-few-public-methods
|
||||
"""Initializes the XQueue interface."""
|
||||
|
||||
def __init__(
|
||||
@@ -143,7 +143,7 @@ class XQueueInterface:
|
||||
|
||||
# log the send to xqueue
|
||||
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
|
||||
(error, msg) = self._send_to_queue(header, body, files_to_upload)
|
||||
@@ -163,11 +163,13 @@ class XQueueInterface:
|
||||
|
||||
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"]}
|
||||
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}
|
||||
files = {}
|
||||
if files_to_upload is not None:
|
||||
@@ -184,15 +186,19 @@ class XQueueInterface:
|
||||
|
||||
course_key = self.block.scope_ids.usage_id.context_key
|
||||
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):
|
||||
submission = self.submission.send_to_submission(header, body, queue_key, files)
|
||||
return None, ''
|
||||
submission = self.submission.send_to_submission( # pylint: disable=unused-variable
|
||||
header, body, queue_key, files
|
||||
)
|
||||
return None, ""
|
||||
|
||||
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:
|
||||
response = self.session.post(url, data=data, files=files, timeout=(CONNECT_TIMEOUT, READ_TIMEOUT))
|
||||
except requests.exceptions.ConnectionError as err:
|
||||
@@ -204,7 +210,7 @@ class XQueueInterface:
|
||||
return 1, "failed to read from the server"
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class XQueueInterfaceSubmission:
|
||||
Submits the extracted student data to the edx-submissions system.
|
||||
"""
|
||||
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(
|
||||
header, body
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pylint: disable=too-many-lines
|
||||
"""
|
||||
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
|
||||
# `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
|
||||
NUM_RANDOMIZATION_BINS = 20
|
||||
@@ -83,7 +84,7 @@ except ImproperlyConfigured:
|
||||
FEATURES = {}
|
||||
|
||||
|
||||
class SHOWANSWER:
|
||||
class SHOWANSWER: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
Constants for when to show answer
|
||||
"""
|
||||
@@ -102,7 +103,7 @@ class SHOWANSWER:
|
||||
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.
|
||||
"""
|
||||
@@ -113,7 +114,7 @@ class GRADING_METHOD:
|
||||
AVERAGE_SCORE = "average_score"
|
||||
|
||||
|
||||
class RANDOMIZATION:
|
||||
class RANDOMIZATION: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
Constants for problem randomization
|
||||
"""
|
||||
@@ -124,16 +125,19 @@ class RANDOMIZATION:
|
||||
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.
|
||||
"""
|
||||
|
||||
def from_json(self, value):
|
||||
"""Convert stored randomization flags into their internal enum values."""
|
||||
if value in ("", "true"):
|
||||
return RANDOMIZATION.ALWAYS
|
||||
elif value == "false":
|
||||
|
||||
if value == "false":
|
||||
return RANDOMIZATION.PER_STUDENT
|
||||
|
||||
return value
|
||||
|
||||
to_json = from_json
|
||||
@@ -146,7 +150,7 @@ class Randomization(String):
|
||||
@XBlock.needs("sandbox")
|
||||
@XBlock.needs("replace_urls")
|
||||
@XBlock.wants("call_to_action")
|
||||
class _BuiltInProblemBlock(
|
||||
class _BuiltInProblemBlock( # pylint: disable=too-many-public-methods,too-many-instance-attributes,too-many-ancestors
|
||||
ScorableXBlockMixin,
|
||||
RawMixin,
|
||||
XmlMixin,
|
||||
@@ -361,7 +365,7 @@ class _BuiltInProblemBlock(
|
||||
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)
|
||||
|
||||
# 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.
|
||||
try:
|
||||
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)
|
||||
else:
|
||||
html = self.get_html()
|
||||
@@ -397,9 +401,9 @@ class _BuiltInProblemBlock(
|
||||
# normal student_view. To prevent anonymous users from viewing specific problems, adjust course policies
|
||||
# and/or content groups.
|
||||
return self.student_view(context)
|
||||
else:
|
||||
# Show a message that this content requires users to login/enroll.
|
||||
return super().public_view(context)
|
||||
|
||||
# Show a message that this content requires users to login/enroll.
|
||||
return super().public_view(context)
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
@@ -420,7 +424,7 @@ class _BuiltInProblemBlock(
|
||||
shim_xmodule_js(fragment, "MarkdownEditingDescriptor")
|
||||
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.
|
||||
|
||||
@@ -432,7 +436,7 @@ class _BuiltInProblemBlock(
|
||||
<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.lcp # lint-amnesty, pylint: disable=pointless-statement
|
||||
self.lcp # pylint: disable=pointless-statement
|
||||
handlers = {
|
||||
"hint_button": self.hint_button,
|
||||
"problem_get": self.get_problem,
|
||||
@@ -475,7 +479,7 @@ class _BuiltInProblemBlock(
|
||||
_, _, traceback_obj = sys.exc_info()
|
||||
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(
|
||||
"Unknown error when dispatching %s to %s for user %s",
|
||||
dispatch,
|
||||
@@ -574,6 +578,7 @@ class _BuiltInProblemBlock(
|
||||
# edited in the cms
|
||||
@classmethod
|
||||
def backcompat_paths(cls, path):
|
||||
"""Return legacy filesystem paths for backward compatibility."""
|
||||
return [
|
||||
"problems/" + path[8:],
|
||||
path[8:],
|
||||
@@ -581,6 +586,7 @@ class _BuiltInProblemBlock(
|
||||
|
||||
@property
|
||||
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.extend(
|
||||
[
|
||||
@@ -606,7 +612,7 @@ class _BuiltInProblemBlock(
|
||||
try:
|
||||
tree = etree.XML(self.data)
|
||||
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.
|
||||
registered_tags = responsetypes.registry.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)
|
||||
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
|
||||
value for the multi-device functionality.
|
||||
@@ -702,7 +708,7 @@ class _BuiltInProblemBlock(
|
||||
minimal_init=True,
|
||||
)
|
||||
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
|
||||
else:
|
||||
maximum_score = lcp.get_max_score()
|
||||
@@ -843,8 +849,8 @@ class _BuiltInProblemBlock(
|
||||
|
||||
if self.graceperiod is not None and due_date:
|
||||
return due_date + self.graceperiod
|
||||
else:
|
||||
return due_date
|
||||
|
||||
return due_date
|
||||
|
||||
def get_seed(self):
|
||||
"""
|
||||
@@ -855,11 +861,12 @@ class _BuiltInProblemBlock(
|
||||
return self.seed
|
||||
|
||||
@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:
|
||||
lcp = self.new_lcp(self.get_state_for_lcp())
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
msg = "cannot create LoncapaProblem {loc}: {err}".format(loc=str(self.location), err=err)
|
||||
except Exception as err:
|
||||
msg = f"cannot create LoncapaProblem {str(self.location)}: {err}"
|
||||
raise LoncapaProblemError(msg).with_traceback(sys.exc_info()[2])
|
||||
|
||||
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
|
||||
log.exception(f"LcpFatalError Encountered for {str(self.location)}")
|
||||
def handle_fatal_lcp_error(self, error):
|
||||
"""
|
||||
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:
|
||||
return HTML('<p>Error formatting HTML for problem:</p><p><pre style="color:red">{msg}</pre></p>').format(
|
||||
msg=str(error)
|
||||
)
|
||||
else:
|
||||
return HTML(
|
||||
"<p>Could not format HTML for problem. "
|
||||
"Contact course staff in the discussion forum for assistance.</p>"
|
||||
)
|
||||
|
||||
return HTML(
|
||||
"<p>Could not format HTML for problem. Contact course staff in the discussion forum for assistance.</p>"
|
||||
)
|
||||
|
||||
def submit_button_name(self):
|
||||
"""
|
||||
@@ -1065,8 +1074,8 @@ class _BuiltInProblemBlock(
|
||||
# for the user to reset a randomized problem
|
||||
if self.closed() or submitted_without_reset:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
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
|
||||
if self.rerandomize in [RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET] and self.is_submitted():
|
||||
return True
|
||||
else:
|
||||
# Do NOT show the button if the problem is correct
|
||||
if self.is_correct():
|
||||
return False
|
||||
else:
|
||||
return self.show_reset_button
|
||||
|
||||
# Do NOT show the button if the problem is correct
|
||||
if self.is_correct():
|
||||
return False
|
||||
|
||||
return self.show_reset_button
|
||||
|
||||
def should_show_save_button(self):
|
||||
"""
|
||||
@@ -1099,33 +1108,33 @@ class _BuiltInProblemBlock(
|
||||
# (past due / too many attempts)
|
||||
if self.force_save_button:
|
||||
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
|
||||
# are not randomized, then we do not need a save button
|
||||
# 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
|
||||
is_survey_question = self.max_attempts == 0
|
||||
needs_reset = self.is_submitted() and self.rerandomize == RANDOMIZATION.ALWAYS
|
||||
|
||||
# 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
|
||||
elif (self.closed() and not is_survey_question) or needs_reset:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
# If the student has unlimited attempts, and their answers
|
||||
# are not randomized, then we do not need a save button
|
||||
# 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),
|
||||
# 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):
|
||||
"""
|
||||
@@ -1279,7 +1288,7 @@ class _BuiltInProblemBlock(
|
||||
"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.
|
||||
|
||||
@@ -1294,7 +1303,7 @@ class _BuiltInProblemBlock(
|
||||
|
||||
# If we cannot construct the problem HTML,
|
||||
# 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.remove_tags_from_html(html)
|
||||
@@ -1365,7 +1374,7 @@ class _BuiltInProblemBlock(
|
||||
|
||||
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.
|
||||
|
||||
@@ -1449,7 +1458,7 @@ class _BuiltInProblemBlock(
|
||||
for tag in tags:
|
||||
html = re.sub(
|
||||
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
|
||||
# Note: could probably speed this up by calling sub() once with a big regex
|
||||
# vs. simply calling sub() many times as we have here.
|
||||
@@ -1510,7 +1519,7 @@ class _BuiltInProblemBlock(
|
||||
self.lcp # pylint: disable=pointless-statement
|
||||
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?
|
||||
"""
|
||||
@@ -1518,41 +1527,41 @@ class _BuiltInProblemBlock(
|
||||
if not self.correctness_available():
|
||||
# If correctness is being withheld, then don't show answers either.
|
||||
return False
|
||||
elif self.showanswer == "":
|
||||
if self.showanswer == "":
|
||||
return False
|
||||
elif self.showanswer == SHOWANSWER.NEVER:
|
||||
if self.showanswer == SHOWANSWER.NEVER:
|
||||
return False
|
||||
elif user_is_staff:
|
||||
if user_is_staff:
|
||||
# This is after the 'never' check because admins can see the answer
|
||||
# unless the problem explicitly prevents it
|
||||
return True
|
||||
elif self.showanswer == SHOWANSWER.ATTEMPTED:
|
||||
if self.showanswer == SHOWANSWER.ATTEMPTED:
|
||||
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
|
||||
# makes lcp.done False, but leaves attempts unchanged.
|
||||
return self.is_correct()
|
||||
elif self.showanswer == SHOWANSWER.CLOSED:
|
||||
if self.showanswer == SHOWANSWER.CLOSED:
|
||||
return self.closed()
|
||||
elif self.showanswer == SHOWANSWER.FINISHED:
|
||||
if self.showanswer == SHOWANSWER.FINISHED:
|
||||
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()
|
||||
elif self.showanswer == SHOWANSWER.PAST_DUE:
|
||||
if self.showanswer == SHOWANSWER.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
|
||||
if self.max_attempts and required_attempts >= self.max_attempts:
|
||||
required_attempts = self.max_attempts
|
||||
return self.attempts >= required_attempts
|
||||
elif self.showanswer == SHOWANSWER.ALWAYS:
|
||||
if self.showanswer == SHOWANSWER.ALWAYS:
|
||||
return True
|
||||
elif self.showanswer == SHOWANSWER.AFTER_ALL_ATTEMPTS:
|
||||
if self.showanswer == SHOWANSWER.AFTER_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()
|
||||
elif self.showanswer == SHOWANSWER.ATTEMPTED_NO_PAST_DUE:
|
||||
if self.showanswer == SHOWANSWER.ATTEMPTED_NO_PAST_DUE:
|
||||
return self.is_attempted()
|
||||
return False
|
||||
|
||||
@@ -1640,28 +1649,29 @@ class _BuiltInProblemBlock(
|
||||
event_info = {}
|
||||
event_info["problem_id"] = str(self.location)
|
||||
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")
|
||||
else:
|
||||
answers = self.lcp.get_question_answers()
|
||||
self.set_state_from_lcp()
|
||||
|
||||
answers = self.lcp.get_question_answers()
|
||||
self.set_state_from_lcp()
|
||||
|
||||
# answers (eg <solution>) may have embedded images
|
||||
# but be careful, some problems are using non-string answer dicts
|
||||
new_answers = {}
|
||||
for answer_id in answers:
|
||||
for answer_id, answer_value in answers.items():
|
||||
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}
|
||||
except TypeError:
|
||||
log.debug("Unable to perform URL substitution on answers[%s]: %s", answer_id, answers[answer_id])
|
||||
new_answer = {answer_id: answers[answer_id]}
|
||||
log.debug("Unable to perform URL substitution on answers[%s]: %s", answer_id, answer_value)
|
||||
new_answer = {answer_id: answer_value}
|
||||
new_answers.update(new_answer)
|
||||
|
||||
return {
|
||||
"answers": new_answers,
|
||||
"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
|
||||
# will return (key, '', '')
|
||||
# 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")
|
||||
|
||||
# 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:
|
||||
# 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
|
||||
val = data[key]
|
||||
|
||||
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):
|
||||
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
|
||||
# to override it. Raise an error instead
|
||||
if name in answers:
|
||||
raise ValueError(f"Key {name} already exists in answers dict")
|
||||
|
||||
# If the name already exists, then we don't want
|
||||
# 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
|
||||
answers[name] = val
|
||||
|
||||
return answers
|
||||
|
||||
@@ -1777,8 +1784,9 @@ class _BuiltInProblemBlock(
|
||||
|
||||
return {"grade": self.score.raw_earned, "max_grade": self.score.raw_possible}
|
||||
|
||||
# pylint: disable=too-many-statements
|
||||
def submit_problem(self, data, override_time=False):
|
||||
def submit_problem( # pylint: disable=too-many-statements,too-many-branches,too-many-locals
|
||||
self, data, override_time=False
|
||||
):
|
||||
"""
|
||||
Checks whether answers to a problem are correct
|
||||
|
||||
@@ -1796,7 +1804,6 @@ class _BuiltInProblemBlock(
|
||||
self.student_answers_history.append(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
|
||||
current_time = datetime.datetime.now(utc)
|
||||
if override_time is not False:
|
||||
@@ -1931,8 +1938,6 @@ class _BuiltInProblemBlock(
|
||||
|
||||
return {"success": success, "contents": html}
|
||||
|
||||
# pylint: enable=too-many-statements
|
||||
|
||||
def get_score_with_grading_method(self, current_score: Score) -> Score:
|
||||
"""
|
||||
Calculate and return the current score based on the grading method.
|
||||
@@ -2040,7 +2045,7 @@ class _BuiltInProblemBlock(
|
||||
"""
|
||||
try:
|
||||
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
|
||||
# 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.
|
||||
@@ -2137,10 +2142,9 @@ class _BuiltInProblemBlock(
|
||||
self.publish_unmasked("save_problem_fail", event_info)
|
||||
return {
|
||||
"success": False,
|
||||
# pylint: disable=line-too-long
|
||||
# Translators: 'closed' means the problem's due date has passed. You may no longer attempt to solve the problem.
|
||||
# Translators: 'closed' means the problem's due date has passed.
|
||||
# You may no longer attempt to solve the problem.
|
||||
"msg": _("Problem is closed."),
|
||||
# pylint: enable=line-too-long
|
||||
}
|
||||
|
||||
# Problem submitted. Student should reset before saving
|
||||
@@ -2186,10 +2190,9 @@ class _BuiltInProblemBlock(
|
||||
self.publish_unmasked("reset_problem_fail", event_info)
|
||||
return {
|
||||
"success": False,
|
||||
# pylint: disable=line-too-long
|
||||
# Translators: 'closed' means the problem's due date has passed. You may no longer attempt to solve the problem.
|
||||
# Translators: 'closed' means the problem's due date has passed.
|
||||
# You may no longer attempt to solve the problem.
|
||||
"msg": _("You cannot select Reset for a problem that is closed."),
|
||||
# pylint: enable=line-too-long
|
||||
}
|
||||
|
||||
if not self.is_submitted():
|
||||
@@ -2250,10 +2253,10 @@ class _BuiltInProblemBlock(
|
||||
if not self.lcp.supports_rescoring():
|
||||
event_info["failure"] = "unsupported"
|
||||
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."))
|
||||
# pylint: enable=line-too-long
|
||||
|
||||
if not self.done:
|
||||
event_info["failure"] = "unanswered"
|
||||
@@ -2270,7 +2273,7 @@ class _BuiltInProblemBlock(
|
||||
StudentInputError,
|
||||
ResponseError,
|
||||
LoncapaProblemError,
|
||||
) as inst: # lint-amnesty, pylint: disable=unused-variable
|
||||
):
|
||||
log.warning("Input error in capa_block:problem_rescore", exc_info=True)
|
||||
event_info["failure"] = "input_error"
|
||||
self.publish_unmasked("problem_rescore_fail", event_info)
|
||||
@@ -2325,6 +2328,7 @@ class _BuiltInProblemBlock(
|
||||
return grading_method_handler.get_score()
|
||||
|
||||
def has_submitted_answer(self):
|
||||
"""Return True if the learner has already submitted an answer."""
|
||||
return self.done
|
||||
|
||||
def set_score(self, score):
|
||||
@@ -2503,13 +2507,13 @@ class ComplexEncoder(json.JSONEncoder):
|
||||
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
|
||||
"""
|
||||
if isinstance(obj, complex):
|
||||
return f"{obj.real:.7g}{obj.imag:+.7g}*j"
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
if isinstance(o, complex):
|
||||
return f"{o.real:.7g}{o.imag:+.7g}*j"
|
||||
return json.JSONEncoder.default(self, o)
|
||||
|
||||
|
||||
def randomization_bin(seed, problem_id):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,8 @@ def load_function(path):
|
||||
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:
|
||||
class_ = load_function(settings.CONTENTSTORE["ENGINE"])
|
||||
options = {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
"""Mixin classes for handling raw XML data in XBlocks."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
@@ -22,12 +23,11 @@ class RawMixin:
|
||||
data = String(help="XML data for the block", default="", scope=Scope.content)
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(
|
||||
cls, xml_object, system
|
||||
): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument
|
||||
def definition_from_xml(cls, xml_object, system): # pylint: disable=unused-argument
|
||||
"""Convert XML node into a dictionary with 'data' key for XBlock."""
|
||||
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.
|
||||
"""
|
||||
@@ -54,11 +54,11 @@ class RawMixin:
|
||||
# re-raise
|
||||
lines = self.data.split("\n")
|
||||
line, offset = err.position
|
||||
msg = ("Unable to create xml for block {loc}. " "Context: '{context}'").format(
|
||||
context=lines[line - 1][offset - 40 : offset + 40],
|
||||
loc=self.location,
|
||||
msg = (
|
||||
f"Unable to create xml for block {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
|
||||
def parse_xml_new_runtime(cls, node, runtime, keys):
|
||||
@@ -93,12 +93,14 @@ class EmptyDataRawMixin:
|
||||
data = String(default="", scope=Scope.content)
|
||||
|
||||
@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:
|
||||
return {"data": ""}, []
|
||||
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:
|
||||
return etree.fromstring(self.data)
|
||||
return etree.Element(self.category)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# pylint: disable=too-many-lines
|
||||
"""
|
||||
Tests of the Capa XModule
|
||||
"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
|
||||
import datetime
|
||||
import json
|
||||
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 xmodule.capa import responsetypes
|
||||
from xmodule.capa.correctmap import CorrectMap
|
||||
from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError
|
||||
from xmodule.capa.tests.test_util import use_unsafe_codejail
|
||||
from xmodule.capa.responsetypes import (
|
||||
LoncapaProblemError,
|
||||
ResponseError,
|
||||
StudentInputError,
|
||||
)
|
||||
from xmodule.capa.tests.test_util import UseUnsafeCodejail
|
||||
from xmodule.capa.xqueue_interface import XQueueInterface
|
||||
from xmodule.capa_block import ComplexEncoder, ProblemBlock
|
||||
from xmodule.tests import DATA_DIR
|
||||
@@ -70,6 +72,7 @@ class CapaFactory:
|
||||
|
||||
@classmethod
|
||||
def next_num(cls):
|
||||
"""Increment and return a unique number for naming problems."""
|
||||
cls.num += 1
|
||||
return cls.num
|
||||
|
||||
@@ -85,14 +88,12 @@ class CapaFactory:
|
||||
"""
|
||||
Return the key stored in the capa problem answer dict
|
||||
"""
|
||||
return "%s_%d_%d" % (
|
||||
"-".join(["i4x", "edX", "capa_test", "problem", "SampleProblem%d" % cls.num]),
|
||||
response_num,
|
||||
input_num,
|
||||
return (
|
||||
f"{'-'.join(['i4x', 'edX', 'capa_test', 'problem', f'SampleProblem{cls.num}'])}_{response_num}_{input_num}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
def create( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
cls,
|
||||
attempts=None,
|
||||
problem_state=None,
|
||||
@@ -206,7 +207,8 @@ if submission[0] == '':
|
||||
|
||||
@ddt.ddt
|
||||
@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):
|
||||
super().setUp()
|
||||
@@ -221,6 +223,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
self.two_day_delta_str = "2 days"
|
||||
|
||||
def test_import(self):
|
||||
"""Verify CapaFactory creates blocks with zero initial score and unique URLs."""
|
||||
block = CapaFactory.create()
|
||||
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())
|
||||
with patch.object(xqueue_interface.session, "post", side_effect=exception):
|
||||
# pylint: disable = protected-access
|
||||
response = xqueue_interface._http_post("http://some/fake/url", {})
|
||||
response = xqueue_interface._http_post("http://some/fake/url", {}) # pylint: disable=protected-access
|
||||
assert response == result
|
||||
|
||||
def test_showanswer_attempted(self):
|
||||
"""Check answer availability changes after attempting the problem."""
|
||||
problem = CapaFactory.create(showanswer="attempted")
|
||||
assert not problem.answer_available()
|
||||
problem.attempts = 1
|
||||
@@ -351,6 +354,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert problem.answer_available() == answer_available_after_attempt
|
||||
|
||||
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
|
||||
used_all_attempts = CapaFactory.create(
|
||||
@@ -695,6 +699,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert problem.correctness_available() == expected_result
|
||||
|
||||
def test_closed(self):
|
||||
"""Verify problem closed status based on attempts and due date."""
|
||||
|
||||
# Attempts < Max attempts --> NOT closed
|
||||
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)
|
||||
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
|
||||
def past_datetime(days):
|
||||
@@ -752,6 +758,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert not block.closed()
|
||||
|
||||
def test_parse_get_params(self):
|
||||
"""Test parsing of GET parameters into response dictionaries with validation."""
|
||||
|
||||
# Valid GET param dict
|
||||
# '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
|
||||
# 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
|
||||
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]
|
||||
|
||||
# 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)
|
||||
|
||||
def test_submit_problem_correct(self):
|
||||
"""Verify submitting a correct problem updates attempts, grading, and HTML content."""
|
||||
|
||||
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)
|
||||
|
||||
def test_submit_problem_incorrect(self):
|
||||
"""Verify submitting an incorrect answer marks failure and increments attempts."""
|
||||
|
||||
block = CapaFactory.create(attempts=0)
|
||||
|
||||
@@ -1192,6 +1201,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert block.lcp.context["attempt"] == 1
|
||||
|
||||
def test_submit_problem_closed(self):
|
||||
"""Ensure submitting a closed problem raises NotFoundError and does not increment attempts."""
|
||||
block = CapaFactory.create(attempts=3)
|
||||
|
||||
# Problem closed -- cannot submit
|
||||
@@ -1207,6 +1217,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
|
||||
@ddt.data(RANDOMIZATION.ALWAYS, "true")
|
||||
def test_submit_problem_resubmitted_with_randomize(self, rerandomize):
|
||||
"""Verify resubmission is blocked when rerandomization is enabled and problem is done."""
|
||||
# Randomize turned on
|
||||
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)
|
||||
def test_submit_problem_resubmitted_no_randomize(self, rerandomize):
|
||||
"""Verify resubmission succeeds when rerandomization is disabled."""
|
||||
# Randomize turned off
|
||||
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
|
||||
|
||||
def test_submit_problem_queued(self):
|
||||
"""Ensure queued problems return a wait message and do not increment attempts."""
|
||||
block = CapaFactory.create(attempts=1)
|
||||
|
||||
# Simulate that the problem is queued
|
||||
@@ -1259,13 +1272,13 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
@pytest.mark.django_db
|
||||
@patch.object(XQueueInterface, "_http_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.
|
||||
# pylint: disable=protected-access
|
||||
|
||||
# The files we'll be uploading.
|
||||
fnames = ["prog1.py", "prog2.py", "prog3.py"]
|
||||
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:
|
||||
self.addCleanup(fileobj.close)
|
||||
|
||||
@@ -1282,24 +1295,42 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
|
||||
block.submit_problem(get_request_dict)
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
# _http_post is called like this:
|
||||
# _http_post(
|
||||
# 'http://example.com/xqueue/xqueue/submit/',
|
||||
# {
|
||||
# 'xqueue_header': '{"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": ""}',
|
||||
# 'xqueue_header':
|
||||
# '{"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={
|
||||
# path(u'/home/ned/edx/edx-platform/common/test/data/uploads/asset.html'):
|
||||
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/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>,
|
||||
# path(
|
||||
# u'/home/ned/edx/edx-platform/common/test/data/uploads/'
|
||||
# 'asset.html'
|
||||
# ):
|
||||
# <open file u'/home/ned/edx/edx-platform/common/test/data/uploads/'
|
||||
# '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
|
||||
_, kwargs = mock_xqueue_post.call_args
|
||||
@@ -1310,15 +1341,16 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
@pytest.mark.django_db
|
||||
@patch.object(XQueueInterface, "_http_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.
|
||||
# pylint: disable=protected-access
|
||||
|
||||
# The files we'll be uploading.
|
||||
fnames = ["prog1.py", "prog2.py", "prog3.py"]
|
||||
fpaths = [os.path.join(DATA_DIR, "capa", fname) for fname in fnames]
|
||||
fileobjs = [open(fpath) for fpath in fpaths]
|
||||
for fileobj in fileobjs:
|
||||
self.addCleanup(fileobj.close)
|
||||
fileobjs = []
|
||||
for fpath in fpaths:
|
||||
with open(fpath, encoding="utf-8") as f:
|
||||
fileobjs.append(f.read())
|
||||
|
||||
block = CapaFactoryWithFiles.create()
|
||||
|
||||
@@ -1341,6 +1373,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert fpath == fileobj.name
|
||||
|
||||
def test_submit_problem_error(self):
|
||||
"""Ensure expected grading errors return messages without incrementing attempts."""
|
||||
|
||||
# Try each exception that capa_block should handle
|
||||
exception_classes = [StudentInputError, LoncapaProblemError, ResponseError]
|
||||
@@ -1366,6 +1399,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert block.lcp.context["attempt"] == 2
|
||||
|
||||
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
|
||||
exception_classes = [StudentInputError, LoncapaProblemError, ResponseError]
|
||||
@@ -1436,6 +1470,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
block.submit_problem(get_request_dict)
|
||||
|
||||
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
|
||||
exception_classes = [StudentInputError, LoncapaProblemError, ResponseError]
|
||||
@@ -1461,6 +1496,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert block.lcp.context["attempt"] == 2
|
||||
|
||||
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
|
||||
for exception_class in [StudentInputError, LoncapaProblemError, ResponseError]:
|
||||
@@ -1495,6 +1531,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
)
|
||||
@ddt.unpack
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
def test_reset_problem(self):
|
||||
"""Ensure resetting a completed problem regenerates state and HTML."""
|
||||
block = CapaFactory.create(done=True)
|
||||
block.new_lcp = Mock(wraps=block.new_lcp)
|
||||
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)
|
||||
|
||||
def test_reset_problem_closed(self):
|
||||
"""Verify reset is blocked when the problem is closed."""
|
||||
# pre studio default
|
||||
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"])
|
||||
|
||||
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
|
||||
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"])
|
||||
|
||||
def test_rescore_problem_correct(self):
|
||||
"""Ensure rescoring marks the problem correct without incrementing attempts."""
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
block = CapaFactory.create(attempts=0)
|
||||
answer_id = CapaFactory.answer_key()
|
||||
@@ -1631,6 +1673,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert block.lcp.context["attempt"] == 1
|
||||
|
||||
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,
|
||||
# so add this to the test:
|
||||
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)
|
||||
|
||||
def test_rescore_problem_not_done(self):
|
||||
"""Ensure rescoring an unfinished problem raises NotFoundError."""
|
||||
# Simulate that the problem is NOT done
|
||||
block = CapaFactory.create(done=False)
|
||||
|
||||
@@ -1852,6 +1896,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
block.rescore(only_if_higher=False)
|
||||
|
||||
def test_rescore_problem_not_supported(self):
|
||||
"""Ensure rescoring raises NotImplementedError when unsupported by the problem."""
|
||||
block = CapaFactory.create(done=True)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
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
|
||||
|
||||
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.
|
||||
# Create a problem with some script attached.
|
||||
xml_str = textwrap.dedent(
|
||||
@@ -2048,15 +2096,19 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert block.lcp.context["attempt"] == 1
|
||||
|
||||
def test_rescore_problem_student_input_error(self):
|
||||
"""Ensure StudentInputError during rescore is handled correctly."""
|
||||
self._rescore_problem_error_helper(StudentInputError)
|
||||
|
||||
def test_rescore_problem_problem_error(self):
|
||||
"""Ensure LoncapaProblemError during rescore is handled correctly."""
|
||||
self._rescore_problem_error_helper(LoncapaProblemError)
|
||||
|
||||
def test_rescore_problem_response_error(self):
|
||||
"""Ensure ResponseError during rescore is handled correctly."""
|
||||
self._rescore_problem_error_helper(ResponseError)
|
||||
|
||||
def test_save_problem(self):
|
||||
"""Verify saving a problem persists answers and returns success."""
|
||||
block = CapaFactory.create(done=False)
|
||||
|
||||
# Save the problem
|
||||
@@ -2071,6 +2123,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert ("success" in result) and result["success"]
|
||||
|
||||
def test_save_problem_closed(self):
|
||||
"""Ensure saving a closed problem fails."""
|
||||
block = CapaFactory.create(done=False)
|
||||
|
||||
# Simulate that the problem is closed
|
||||
@@ -2086,6 +2139,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
|
||||
@ddt.data(RANDOMIZATION.ALWAYS, "true")
|
||||
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
|
||||
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)
|
||||
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
|
||||
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"]
|
||||
|
||||
def test_submit_button_name(self):
|
||||
"""Verify the submit button label is correct."""
|
||||
block = CapaFactory.create(attempts=0)
|
||||
assert block.submit_button_name() == "Submit"
|
||||
|
||||
def test_submit_button_submitting_name(self):
|
||||
"""Verify the submitting button label is correct."""
|
||||
block = CapaFactory.create(attempts=1, max_attempts=10)
|
||||
assert block.submit_button_submitting_name() == "Submitting"
|
||||
|
||||
def test_should_enable_submit_button(self):
|
||||
"""Verify submit button enablement logic across deadlines, attempts, and states."""
|
||||
|
||||
attempts = random.randint(1, 10)
|
||||
|
||||
@@ -2159,6 +2217,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert block.should_enable_submit_button()
|
||||
|
||||
def test_should_show_reset_button(self):
|
||||
"""Verify reset button visibility logic across problem states and settings."""
|
||||
|
||||
attempts = random.randint(1, 10)
|
||||
|
||||
@@ -2203,6 +2262,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert block.should_show_reset_button()
|
||||
|
||||
def test_should_show_save_button(self):
|
||||
"""Verify save button visibility logic across attempts, deadlines, and randomization."""
|
||||
|
||||
attempts = random.randint(1, 10)
|
||||
|
||||
@@ -2253,6 +2313,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
assert block.should_show_save_button()
|
||||
|
||||
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
|
||||
# even though we're forcing a save
|
||||
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()
|
||||
|
||||
def test_no_max_attempts(self):
|
||||
"""Ensure problems with empty max_attempts render without errors."""
|
||||
block = CapaFactory.create(max_attempts="")
|
||||
html = block.get_problem_html()
|
||||
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")
|
||||
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>"
|
||||
block = CapaFactory.create()
|
||||
|
||||
@@ -2341,6 +2404,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
|
||||
@patch("xmodule.capa_block.render_to_string")
|
||||
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
|
||||
# the context dict passed into HTML generation.
|
||||
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):
|
||||
"""Verify input_state keys remain consistent and isolated across block instances."""
|
||||
block1 = 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
|
||||
)
|
||||
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
|
||||
|
||||
@@ -2551,7 +2617,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
# If we're not rerandomizing, the seed is always set
|
||||
# to the same value (1)
|
||||
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
|
||||
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)
|
||||
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.
|
||||
# Get a bunch of seeds, they should all be in 0-999.
|
||||
i = 200
|
||||
@@ -2846,7 +2913,9 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss
|
||||
|
||||
|
||||
@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(
|
||||
"""
|
||||
<problem>
|
||||
@@ -3242,6 +3311,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
}
|
||||
|
||||
def test_response_types_ignores_non_response_tags(self):
|
||||
"""Ensure non-response XML tags are ignored when determining problem response types."""
|
||||
xml = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -3268,6 +3338,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
}
|
||||
|
||||
def test_response_types_multiple_tags(self):
|
||||
"""Verify indexing behavior when multiple response types are present in a single problem."""
|
||||
xml = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -3309,6 +3380,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
)
|
||||
|
||||
def test_solutions_not_indexed(self):
|
||||
"""Confirm that solutions, scripts, styles, answers, and hints are excluded from indexing."""
|
||||
xml = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -3350,6 +3422,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
}
|
||||
|
||||
def test_indexing_checkboxes(self):
|
||||
"""Verify correct indexing of checkbox-based problems and extracted content."""
|
||||
name = "Checkboxes"
|
||||
block = self._create_block(self.sample_checkbox_problem_xml, name=name)
|
||||
capa_content = textwrap.dedent(
|
||||
@@ -3374,6 +3447,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
}
|
||||
|
||||
def test_indexing_dropdown(self):
|
||||
"""Verify correct indexing of dropdown-based problems and extracted content."""
|
||||
name = "Dropdown"
|
||||
block = self._create_block(self.sample_dropdown_problem_xml, name=name)
|
||||
capa_content = textwrap.dedent(
|
||||
@@ -3392,6 +3466,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
}
|
||||
|
||||
def test_indexing_multiple_choice(self):
|
||||
"""Verify correct indexing of multiple-choice problems and extracted content."""
|
||||
name = "Multiple Choice"
|
||||
block = self._create_block(self.sample_multichoice_problem_xml, name=name)
|
||||
capa_content = textwrap.dedent(
|
||||
@@ -3414,6 +3489,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
}
|
||||
|
||||
def test_indexing_numerical_input(self):
|
||||
"""Verify correct indexing of numerical input problems and extracted content."""
|
||||
name = "Numerical Input"
|
||||
block = self._create_block(self.sample_numerical_input_problem_xml, name=name)
|
||||
capa_content = textwrap.dedent(
|
||||
@@ -3439,6 +3515,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
}
|
||||
|
||||
def test_indexing_text_input(self):
|
||||
"""Verify correct indexing of text input problems and extracted content."""
|
||||
name = "Text Input"
|
||||
block = self._create_block(self.sample_text_input_problem_xml, name=name)
|
||||
capa_content = textwrap.dedent(
|
||||
@@ -3461,6 +3538,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
}
|
||||
|
||||
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(
|
||||
"""
|
||||
<problem>
|
||||
@@ -3477,6 +3555,7 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
assert block_dict["content"]["capa_content"] == smart_str(capa_content)
|
||||
|
||||
def test_indexing_checkboxes_with_hints_and_feedback(self):
|
||||
"""Verify indexing of checkbox problems containing hints and feedback."""
|
||||
name = "Checkboxes with Hints and Feedback"
|
||||
block = self._create_block(self.sample_checkboxes_with_hints_and_feedback_problem_xml, name=name)
|
||||
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):
|
||||
"""Verify indexing of dropdown problems containing hints and feedback."""
|
||||
name = "Dropdown with Hints and Feedback"
|
||||
block = self._create_block(self.sample_dropdown_with_hints_and_feedback_problem_xml, name=name)
|
||||
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):
|
||||
"""Verify indexing of multiple-choice problems containing 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)
|
||||
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):
|
||||
"""Verify indexing of numerical input problems containing 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)
|
||||
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):
|
||||
"""Verify indexing of text input problems containing 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)
|
||||
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):
|
||||
"""Ensure HTML tags, comments, scripts, and styles are safely ignored during indexing."""
|
||||
sample_problem_xml = textwrap.dedent(
|
||||
"""
|
||||
<problem>
|
||||
@@ -3678,7 +3762,8 @@ class ProblemBlockXMLTest(unittest.TestCase): # lint-amnesty, pylint: disable=m
|
||||
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):
|
||||
"""
|
||||
@@ -3692,7 +3777,7 @@ class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=mi
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@use_unsafe_codejail()
|
||||
@UseUnsafeCodejail()
|
||||
class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
"""
|
||||
Ensure correct tracking information is included in events emitted during problem checks.
|
||||
@@ -3700,9 +3785,10 @@ class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.maxDiff = None
|
||||
self.maxDiff = None # pylint: disable=invalid-name
|
||||
|
||||
def test_choice_answer_text(self):
|
||||
"""Verify tracked submission data for multiple choice, option, and checkbox responses."""
|
||||
xml = """\
|
||||
<problem display_name="Multiple Choice Questions">
|
||||
<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):
|
||||
"""
|
||||
A factory for creating a Capa problem with arbitrary xml.
|
||||
@@ -3784,9 +3872,8 @@ class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
|
||||
return CustomCapaFactory
|
||||
|
||||
def get_event_for_answers(
|
||||
self, block, answer_input_dict
|
||||
): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
def get_event_for_answers(self, block, answer_input_dict):
|
||||
"""Submit answers and return the emitted tracking event payload."""
|
||||
with patch.object(block.runtime, "publish") as mock_publish:
|
||||
block.submit_problem(answer_input_dict)
|
||||
|
||||
@@ -3798,6 +3885,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
return event
|
||||
|
||||
def test_numerical_textline(self):
|
||||
"""Verify tracking data for numerical textline responses."""
|
||||
factory = CapaFactory
|
||||
block = factory.create()
|
||||
|
||||
@@ -3817,21 +3905,20 @@ class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
}
|
||||
|
||||
def test_multiple_inputs(self):
|
||||
"""Verify tracking data for multiple inputs within a single response group."""
|
||||
group_label = "Choose the correct color"
|
||||
input1_label = "What color is the sky?"
|
||||
input2_label = "What color are pine needles?"
|
||||
factory = self.capa_factory_for_problem_xml(
|
||||
"""\
|
||||
f"""\
|
||||
<problem display_name="Multiple Inputs">
|
||||
<optionresponse>
|
||||
<label>{}</label>
|
||||
<optioninput options="('yellow','blue','green')" correct="blue" label="{}"/>
|
||||
<optioninput options="('yellow','blue','green')" correct="green" label="{}"/>
|
||||
</optionresponse>
|
||||
<optionresponse>
|
||||
<label>{group_label}</label>
|
||||
<optioninput options="('yellow','blue','green')" correct="blue" label="{input1_label}"/>
|
||||
<optioninput options="('yellow','blue','green')" correct="green" label="{input2_label}"/>
|
||||
</optionresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
group_label, input1_label, input2_label
|
||||
)
|
||||
"""
|
||||
)
|
||||
block = factory.create()
|
||||
answer_input_dict = {
|
||||
@@ -3867,11 +3954,11 @@ class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
input1_label = "input 1 label"
|
||||
input2_label = "input 2 label"
|
||||
factory = self.capa_factory_for_problem_xml(
|
||||
"""\
|
||||
f"""\
|
||||
<problem display_name="Woo Hoo">
|
||||
<optionresponse>
|
||||
<label>{}</label>
|
||||
<optioninput label="{}">
|
||||
<label>{group_label}</label>
|
||||
<optioninput label="{input1_label}">
|
||||
<option correct="True" label="Good Job">
|
||||
apple
|
||||
<optionhint>
|
||||
@@ -3886,7 +3973,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
</option>
|
||||
</optioninput>
|
||||
|
||||
<optioninput label="{}">
|
||||
<optioninput label="{input2_label}">
|
||||
<option correct="True">
|
||||
apple
|
||||
<optionhint>
|
||||
@@ -3902,9 +3989,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
</optioninput>
|
||||
</optionresponse>
|
||||
</problem>
|
||||
""".format(
|
||||
group_label, input1_label, input2_label
|
||||
)
|
||||
"""
|
||||
)
|
||||
block = factory.create()
|
||||
|
||||
@@ -3936,6 +4021,7 @@ class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
}
|
||||
|
||||
def test_rerandomized_inputs(self):
|
||||
"""Ensure variant seed is included in tracking data for rerandomized problems."""
|
||||
factory = CapaFactory
|
||||
block = factory.create(rerandomize=RANDOMIZATION.ALWAYS)
|
||||
|
||||
@@ -3957,9 +4043,10 @@ class ProblemCheckTrackingTest(unittest.TestCase):
|
||||
@pytest.mark.django_db
|
||||
@patch.object(XQueueInterface, "_http_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"]
|
||||
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:
|
||||
self.addCleanup(fileobj.close)
|
||||
|
||||
@@ -4031,7 +4118,7 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
|
||||
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(
|
||||
"xmodule.capa.capa_problem.LoncapaProblem.find_question_label", lambda self, answer_id: answer_id
|
||||
)
|
||||
@@ -4063,7 +4150,8 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
|
||||
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")
|
||||
block = ProblemBlock(get_test_system(), scope_ids=scope_ids)
|
||||
block.runtime = Mock()
|
||||
@@ -4071,17 +4159,20 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
|
||||
return block
|
||||
|
||||
def test_generate_report_data_not_implemented(self):
|
||||
"""Verify report generation is not supported for non-problem blocks."""
|
||||
scope_ids = Mock(block_type="noproblem")
|
||||
block = ProblemBlock(get_test_system(), scope_ids=scope_ids)
|
||||
with pytest.raises(NotImplementedError):
|
||||
next(block.generate_report_data(iter([])))
|
||||
|
||||
def test_generate_report_data_limit_responses(self):
|
||||
"""Ensure report generation respects the response limit."""
|
||||
block = self._get_block()
|
||||
report_data = list(block.generate_report_data(self._mock_user_state_generator(), 2))
|
||||
assert 2 == len(report_data)
|
||||
|
||||
def test_generate_report_data_dont_limit_responses(self):
|
||||
"""Verify all responses are included when no limit is provided."""
|
||||
block = self._get_block()
|
||||
user_count = 5
|
||||
response_count = 10
|
||||
@@ -4096,16 +4187,18 @@ class ProblemBlockReportGenerationTest(unittest.TestCase):
|
||||
assert (user_count * response_count) == len(report_data)
|
||||
|
||||
def test_generate_report_data_skip_dynamath(self):
|
||||
"""Ensure Dynamath responses are excluded from reports."""
|
||||
block = self._get_block()
|
||||
iterator = iter([self._user_state(suffix="_dynamath")])
|
||||
report_data = list(block.generate_report_data(iterator))
|
||||
assert 0 == len(report_data)
|
||||
|
||||
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.
|
||||
block = self._get_block()
|
||||
with patch("xmodule.capa_block.LoncapaProblem") as mock_LoncapaProblem:
|
||||
mock_LoncapaProblem.side_effect = LoncapaProblemError
|
||||
with patch("xmodule.capa_block.LoncapaProblem") as mock_loncapa_problem:
|
||||
mock_loncapa_problem.side_effect = LoncapaProblemError
|
||||
report_data = list(
|
||||
block.generate_report_data(
|
||||
self._mock_user_state_generator(
|
||||
|
||||
@@ -8,6 +8,7 @@ from xmodule.stringify import stringify_children
|
||||
|
||||
|
||||
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>'
|
||||
html = f"""<html a="b" foo="bar">{text}</html>"""
|
||||
xml = etree.fromstring(html)
|
||||
@@ -16,6 +17,7 @@ def test_stringify():
|
||||
|
||||
|
||||
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!
|
||||
<div align="center">
|
||||
<img src="/static/images/circuits/voltage-source.png"/>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
"""Unit tests for XBlock field serialization, deserialization, and XML attributes."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
import dateutil.parser
|
||||
from lxml import etree
|
||||
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
|
||||
from xblock.field_data import DictFieldData
|
||||
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
|
||||
|
||||
|
||||
class CrazyJsonString(String):
|
||||
class CrazyJsonString(String): # pylint: disable=too-few-public-methods
|
||||
"""String field that appends ' JSON' when serialized."""
|
||||
|
||||
def to_json(self, value):
|
||||
"""Return the string value appended with ' 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.
|
||||
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.
|
||||
@@ -60,7 +64,7 @@ class InheritingFieldDataTest(unittest.TestCase):
|
||||
Tests of InheritingFieldData.
|
||||
"""
|
||||
|
||||
class TestableInheritingXBlock(XmlMixin): # lint-amnesty, pylint: disable=abstract-method
|
||||
class TestableInheritingXBlock(XmlMixin):
|
||||
"""
|
||||
An XBlock we can use in these tests.
|
||||
"""
|
||||
@@ -68,6 +72,13 @@ class InheritingFieldDataTest(unittest.TestCase):
|
||||
inherited = String(scope=Scope.settings, default="the default")
|
||||
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):
|
||||
super().setUp()
|
||||
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(
|
||||
block_type="library_content",
|
||||
block_id="parent",
|
||||
fields=dict(inherited="changed!"),
|
||||
defaults=dict(inherited="parent's default"),
|
||||
fields={"inherited": "changed!"},
|
||||
defaults={"inherited": "parent's default"},
|
||||
)
|
||||
assert parent_block.inherited == "changed!"
|
||||
|
||||
@@ -200,8 +211,8 @@ class InheritingFieldDataTest(unittest.TestCase):
|
||||
parent_block = self.get_block_using_split_kvs(
|
||||
block_type="library_content",
|
||||
block_id="parent",
|
||||
fields=dict(inherited="changed!"),
|
||||
defaults=dict(inherited="parent's default"),
|
||||
fields={"inherited": "changed!"},
|
||||
defaults={"inherited": "parent's default"},
|
||||
)
|
||||
assert parent_block.inherited == "changed!"
|
||||
|
||||
@@ -209,19 +220,29 @@ class InheritingFieldDataTest(unittest.TestCase):
|
||||
block_type="library_content",
|
||||
block_id="parent",
|
||||
fields={},
|
||||
defaults=dict(inherited="child's default"),
|
||||
defaults={"inherited": "child's default"},
|
||||
)
|
||||
child.parent = parent_block.location
|
||||
assert child.inherited == "child's default"
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@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):
|
||||
"""Test filtering and values of display_name editable metadata field."""
|
||||
editable_fields = self.get_xml_editable_fields(DictFieldData({}))
|
||||
# Tests that the xblock fields (currently tags and name) get filtered out.
|
||||
# Also tests that xml_attributes is filtered out of XmlMixin.
|
||||
@@ -236,6 +257,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
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).
|
||||
editable_fields = self.get_xml_editable_fields(DictFieldData({"display_name": "foo"}))
|
||||
self.assert_field_values(
|
||||
@@ -248,6 +270,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_integer_field(self):
|
||||
"""Test serialization and options of Integer metadata fields."""
|
||||
block = self.get_block(DictFieldData({"max_attempts": "7"}))
|
||||
editable_fields = block.editable_metadata_fields
|
||||
assert 8 == len(editable_fields)
|
||||
@@ -283,6 +306,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_inherited_field(self):
|
||||
"""Test inheritance behavior of editable metadata fields."""
|
||||
kvs = InheritanceKeyValueStore(initial_values={}, inherited_settings={"showanswer": "inherited"})
|
||||
model_data = KvsFieldData(kvs)
|
||||
block = self.get_block(model_data)
|
||||
@@ -313,6 +337,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
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_integer_field verifies that a Integer field is of type "Integer".
|
||||
|
||||
@@ -380,6 +405,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
|
||||
# Start of helper methods
|
||||
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()
|
||||
return runtime.construct_xblock_from_class(
|
||||
self.TestableXmlXBlock,
|
||||
@@ -388,7 +414,11 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
).editable_metadata_fields
|
||||
|
||||
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
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super().non_editable_metadata_fields
|
||||
@@ -398,7 +428,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
system = get_test_descriptor_system(render_template=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,
|
||||
editable_fields,
|
||||
name,
|
||||
@@ -406,9 +436,10 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
explicitly_set,
|
||||
value,
|
||||
default_value,
|
||||
type="Generic",
|
||||
type="Generic", # pylint: disable=redefined-builtin
|
||||
options=[],
|
||||
): # lint-amnesty, pylint: disable=redefined-builtin
|
||||
):
|
||||
"""Assert correctness of field values, type, options, and explicitness."""
|
||||
test_field = editable_fields[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."""
|
||||
|
||||
def test_serialize(self):
|
||||
"""Test serialization of various field types to JSON-compatible strings."""
|
||||
assert serialize_field(None) == "null"
|
||||
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("hat box") == "hat box"
|
||||
serialized_dict = serialize_field({"bar": "hat", "frog": "green"})
|
||||
assert (
|
||||
serialized_dict # lint-amnesty, pylint: disable=consider-using-in, line-too-long
|
||||
== '{"bar": "hat", "frog": "green"}'
|
||||
or serialized_dict == '{"frog": "green", "bar": "hat"}'
|
||||
)
|
||||
assert serialized_dict in ('{"bar": "hat", "frog": "green"}', '{"frog": "green", "bar": "hat"}')
|
||||
assert serialize_field([3.5, 5.6]) == "[3.5, 5.6]"
|
||||
assert serialize_field(["foo", "bar"]) == '["foo", "bar"]'
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
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.
|
||||
For all types, 'null' is also always returned as None.
|
||||
"""
|
||||
self.assertDeserializeEqual(None, None)
|
||||
self.assertDeserializeEqual(3.14, 3.14)
|
||||
self.assertDeserializeEqual(True, True)
|
||||
self.assertDeserializeEqual([10], [10])
|
||||
self.assertDeserializeEqual({}, {})
|
||||
self.assertDeserializeEqual([], [])
|
||||
self.assertDeserializeEqual(None, "null")
|
||||
self.assert_deserialize_equal(None, None)
|
||||
self.assert_deserialize_equal(3.14, 3.14)
|
||||
self.assert_deserialize_equal(True, True)
|
||||
self.assert_deserialize_equal([10], [10])
|
||||
self.assert_deserialize_equal({}, {})
|
||||
self.assert_deserialize_equal([], [])
|
||||
self.assert_deserialize_equal(None, "null")
|
||||
|
||||
|
||||
class TestDeserializeInteger(TestDeserialize):
|
||||
@@ -478,23 +507,25 @@ class TestDeserializeInteger(TestDeserialize):
|
||||
field_type = Integer
|
||||
|
||||
def test_deserialize(self):
|
||||
self.assertDeserializeEqual(-2, "-2")
|
||||
self.assertDeserializeEqual("450", '"450"')
|
||||
"""Test deserialization of Integer field values."""
|
||||
self.assert_deserialize_equal(-2, "-2")
|
||||
self.assert_deserialize_equal("450", '"450"')
|
||||
|
||||
# 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)
|
||||
self.assertDeserializeEqual(True, "true")
|
||||
self.assert_deserialize_equal(True, "true")
|
||||
# 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):
|
||||
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
|
||||
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
|
||||
self.assertDeserializeEqual('"false"', '"false"')
|
||||
self.assertDeserializeNonString()
|
||||
self.assert_deserialize_equal('"false"', '"false"')
|
||||
self.assert_deserialize_non_string()
|
||||
|
||||
|
||||
class TestDeserializeFloat(TestDeserialize):
|
||||
@@ -503,21 +534,23 @@ class TestDeserializeFloat(TestDeserialize):
|
||||
field_type = Float
|
||||
|
||||
def test_deserialize(self):
|
||||
self.assertDeserializeEqual(-2, "-2")
|
||||
self.assertDeserializeEqual("450", '"450"')
|
||||
self.assertDeserializeEqual(-2.78, "-2.78")
|
||||
self.assertDeserializeEqual("0.45", '"0.45"')
|
||||
"""Test deserialization of Float field values."""
|
||||
self.assert_deserialize_equal(-2, "-2")
|
||||
self.assert_deserialize_equal("450", '"450"')
|
||||
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)
|
||||
self.assertDeserializeEqual(False, "false")
|
||||
self.assert_deserialize_equal(False, "false")
|
||||
# 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):
|
||||
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
|
||||
self.assertDeserializeEqual('"false"', '"false"')
|
||||
self.assertDeserializeNonString()
|
||||
self.assert_deserialize_equal('"false"', '"false"')
|
||||
self.assert_deserialize_non_string()
|
||||
|
||||
|
||||
class TestDeserializeBoolean(TestDeserialize):
|
||||
@@ -526,23 +559,24 @@ class TestDeserializeBoolean(TestDeserialize):
|
||||
field_type = Boolean
|
||||
|
||||
def test_deserialize(self):
|
||||
"""Test deserialization of Boolean field values."""
|
||||
# json.loads converts the value to Python bool
|
||||
self.assertDeserializeEqual(False, "false")
|
||||
self.assertDeserializeEqual(True, "true")
|
||||
self.assert_deserialize_equal(False, "false")
|
||||
self.assert_deserialize_equal(True, "true")
|
||||
|
||||
# json.loads fails, string value is returned.
|
||||
self.assertDeserializeEqual("False", "False")
|
||||
self.assertDeserializeEqual("True", "True")
|
||||
self.assert_deserialize_equal("False", "False")
|
||||
self.assert_deserialize_equal("True", "True")
|
||||
|
||||
# json.loads deserializes as a string
|
||||
self.assertDeserializeEqual("false", '"false"')
|
||||
self.assertDeserializeEqual("fAlse", '"fAlse"')
|
||||
self.assertDeserializeEqual("TruE", '"TruE"')
|
||||
self.assert_deserialize_equal("false", '"false"')
|
||||
self.assert_deserialize_equal("fAlse", '"fAlse"')
|
||||
self.assert_deserialize_equal("TruE", '"TruE"')
|
||||
|
||||
# 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):
|
||||
@@ -551,16 +585,18 @@ class TestDeserializeString(TestDeserialize):
|
||||
field_type = String
|
||||
|
||||
def test_deserialize(self):
|
||||
self.assertDeserializeEqual("hAlf", '"hAlf"')
|
||||
self.assertDeserializeEqual("false", '"false"')
|
||||
self.assertDeserializeEqual("single quote", "single quote")
|
||||
"""Test deserialization of String field values."""
|
||||
self.assert_deserialize_equal("hAlf", '"hAlf"')
|
||||
self.assert_deserialize_equal("false", '"false"')
|
||||
self.assert_deserialize_equal("single quote", "single quote")
|
||||
|
||||
def test_deserialize_unsupported_types(self):
|
||||
self.assertDeserializeEqual("3.4", "3.4")
|
||||
self.assertDeserializeEqual("false", "false")
|
||||
self.assertDeserializeEqual("2", "2")
|
||||
self.assertDeserializeEqual("[3]", "[3]")
|
||||
self.assertDeserializeNonString()
|
||||
"""Test deserialization handles unsupported String inputs gracefully."""
|
||||
self.assert_deserialize_equal("3.4", "3.4")
|
||||
self.assert_deserialize_equal("false", "false")
|
||||
self.assert_deserialize_equal("2", "2")
|
||||
self.assert_deserialize_equal("[3]", "[3]")
|
||||
self.assert_deserialize_non_string()
|
||||
|
||||
|
||||
class TestDeserializeAny(TestDeserialize):
|
||||
@@ -569,14 +605,15 @@ class TestDeserializeAny(TestDeserialize):
|
||||
field_type = Any
|
||||
|
||||
def test_deserialize(self):
|
||||
self.assertDeserializeEqual("hAlf", '"hAlf"')
|
||||
self.assertDeserializeEqual("false", '"false"')
|
||||
self.assertDeserializeEqual({"bar": "hat", "frog": "green"}, '{"bar": "hat", "frog": "green"}')
|
||||
self.assertDeserializeEqual([3.5, 5.6], "[3.5, 5.6]")
|
||||
self.assertDeserializeEqual("[", "[")
|
||||
self.assertDeserializeEqual(False, "false")
|
||||
self.assertDeserializeEqual(3.4, "3.4")
|
||||
self.assertDeserializeNonString()
|
||||
"""Test deserialization of Any-type field values."""
|
||||
self.assert_deserialize_equal("hAlf", '"hAlf"')
|
||||
self.assert_deserialize_equal("false", '"false"')
|
||||
self.assert_deserialize_equal({"bar": "hat", "frog": "green"}, '{"bar": "hat", "frog": "green"}')
|
||||
self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]")
|
||||
self.assert_deserialize_equal("[", "[")
|
||||
self.assert_deserialize_equal(False, "false")
|
||||
self.assert_deserialize_equal(3.4, "3.4")
|
||||
self.assert_deserialize_non_string()
|
||||
|
||||
|
||||
class TestDeserializeList(TestDeserialize):
|
||||
@@ -585,26 +622,29 @@ class TestDeserializeList(TestDeserialize):
|
||||
field_type = List
|
||||
|
||||
def test_deserialize(self):
|
||||
self.assertDeserializeEqual(["foo", "bar"], '["foo", "bar"]')
|
||||
self.assertDeserializeEqual([3.5, 5.6], "[3.5, 5.6]")
|
||||
self.assertDeserializeEqual([], "[]")
|
||||
"""Test deserialization of List field values."""
|
||||
self.assert_deserialize_equal(["foo", "bar"], '["foo", "bar"]')
|
||||
self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]")
|
||||
self.assert_deserialize_equal([], "[]")
|
||||
|
||||
def test_deserialize_unsupported_types(self):
|
||||
self.assertDeserializeEqual("3.4", "3.4")
|
||||
self.assertDeserializeEqual("false", "false")
|
||||
self.assertDeserializeEqual("2", "2")
|
||||
self.assertDeserializeNonString()
|
||||
"""Test deserialization handles unsupported List inputs gracefully."""
|
||||
self.assert_deserialize_equal("3.4", "3.4")
|
||||
self.assert_deserialize_equal("false", "false")
|
||||
self.assert_deserialize_equal("2", "2")
|
||||
self.assert_deserialize_non_string()
|
||||
|
||||
|
||||
class TestDeserializeDate(TestDeserialize):
|
||||
"""Tests deserialize as related to Date type."""
|
||||
"""Test deserialization of Date field values."""
|
||||
|
||||
field_type = Date
|
||||
|
||||
def test_deserialize(self):
|
||||
self.assertDeserializeEqual("2012-12-31T23:59:59Z", "2012-12-31T23:59:59Z")
|
||||
self.assertDeserializeEqual("2012-12-31T23:59:59Z", '"2012-12-31T23:59:59Z"')
|
||||
self.assertDeserializeNonString()
|
||||
"""Test deserialization of Timedelta field values."""
|
||||
self.assert_deserialize_equal("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.assert_deserialize_non_string()
|
||||
|
||||
|
||||
class TestDeserializeTimedelta(TestDeserialize):
|
||||
@@ -613,9 +653,10 @@ class TestDeserializeTimedelta(TestDeserialize):
|
||||
field_type = Timedelta
|
||||
|
||||
def test_deserialize(self):
|
||||
self.assertDeserializeEqual("1 day 12 hours 59 minutes 59 seconds", "1 day 12 hours 59 minutes 59 seconds")
|
||||
self.assertDeserializeEqual("1 day 12 hours 59 minutes 59 seconds", '"1 day 12 hours 59 minutes 59 seconds"')
|
||||
self.assertDeserializeNonString()
|
||||
"""Test deserialization of RelativeTime field values."""
|
||||
self.assert_deserialize_equal("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.assert_deserialize_non_string()
|
||||
|
||||
|
||||
class TestDeserializeRelativeTime(TestDeserialize):
|
||||
@@ -627,8 +668,8 @@ class TestDeserializeRelativeTime(TestDeserialize):
|
||||
"""
|
||||
There is no check for
|
||||
|
||||
self.assertDeserializeEqual('10:20:30', '10:20:30')
|
||||
self.assertDeserializeNonString()
|
||||
self.assert_deserialize_equal('10:20:30', '10:20:30')
|
||||
self.assert_deserialize_non_string()
|
||||
|
||||
because these two tests work only because json.loads fires exception,
|
||||
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
|
||||
self.assertDeserializeEqual("10:20:30", '"10:20:30"')
|
||||
self.assert_deserialize_equal("10:20:30", '"10:20:30"')
|
||||
|
||||
|
||||
class TestXmlAttributes(XModuleXmlImportTest):
|
||||
"""Tests XML import/export of XBlock attributes, including known, unknown, and inheritable attributes."""
|
||||
|
||||
def test_unknown_attribute(self):
|
||||
"""Test processing and retention of unknown XML attributes."""
|
||||
assert not hasattr(CourseBlock, "unknown_attr")
|
||||
course = self.process_xml(CourseFactory.build(unknown_attr="value"))
|
||||
assert not hasattr(course, "unknown_attr")
|
||||
assert course.xml_attributes["unknown_attr"] == "value"
|
||||
|
||||
def test_known_attribute(self):
|
||||
"""Test that known XML attributes are correctly assigned to XBlock fields."""
|
||||
assert hasattr(CourseBlock, "show_calculator")
|
||||
course = self.process_xml(CourseFactory.build(show_calculator="true"))
|
||||
assert course.show_calculator
|
||||
assert "show_calculator" not in course.xml_attributes
|
||||
|
||||
def test_rerandomize_in_policy(self):
|
||||
"""Test that rerandomize attribute from policy is correctly processed."""
|
||||
# Rerandomize isn't a basic attribute of Sequence
|
||||
assert not hasattr(SequenceBlock, "rerandomize")
|
||||
|
||||
@@ -671,6 +716,7 @@ class TestXmlAttributes(XModuleXmlImportTest):
|
||||
assert "rerandomize" not in seq.xml_attributes
|
||||
|
||||
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
|
||||
assert not hasattr(SequenceBlock, "attempts")
|
||||
|
||||
@@ -689,6 +735,7 @@ class TestXmlAttributes(XModuleXmlImportTest):
|
||||
assert "attempts" in seq.xml_attributes
|
||||
|
||||
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
|
||||
assert not hasattr(SequenceBlock, attribute)
|
||||
|
||||
@@ -715,6 +762,7 @@ class TestXmlAttributes(XModuleXmlImportTest):
|
||||
assert attribute not in seq.xml_attributes
|
||||
|
||||
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("max_attempts", 5)
|
||||
self.check_inheritable_attribute("visible_to_staff_only", True)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
"""Utilities for managing course code libraries and sandbox execution."""
|
||||
|
||||
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)
|
||||
if zip_lib is not None:
|
||||
return zip_lib.data
|
||||
else:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class SandboxService:
|
||||
|
||||
@@ -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 os
|
||||
import time
|
||||
import warnings
|
||||
from collections import namedtuple
|
||||
from functools import partial
|
||||
from importlib.resources import as_file, files
|
||||
|
||||
import yaml
|
||||
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
|
||||
# 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:
|
||||
fragment.initialize_js("XBlockToXModuleShim")
|
||||
@@ -204,7 +205,7 @@ def shim_xmodule_js(fragment, js_module_name):
|
||||
add_webpack_js_to_fragment(fragment, "XModuleShim")
|
||||
|
||||
|
||||
class XModuleFields:
|
||||
class XModuleFields: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
Common fields for XModules.
|
||||
"""
|
||||
@@ -220,7 +221,7 @@ class XModuleFields:
|
||||
|
||||
|
||||
@XBlock.needs("i18n")
|
||||
class XModuleMixin(XModuleFields, XBlock):
|
||||
class XModuleMixin(XModuleFields, XBlock): # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
Fields and methods used by XModules internally.
|
||||
|
||||
@@ -273,6 +274,7 @@ class XModuleMixin(XModuleFields, XBlock):
|
||||
|
||||
@property
|
||||
def runtime(self):
|
||||
"""Return the runtime for this XBlock instance."""
|
||||
return self._runtime
|
||||
|
||||
@runtime.setter
|
||||
@@ -309,14 +311,17 @@ class XModuleMixin(XModuleFields, XBlock):
|
||||
|
||||
@property
|
||||
def course_id(self):
|
||||
"""Return the course key for this block."""
|
||||
return self.location.course_key
|
||||
|
||||
@property
|
||||
def category(self):
|
||||
"""Return the block type/category."""
|
||||
return self.scope_ids.block_type
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
"""Return the usage key identifying this block instance."""
|
||||
return self.scope_ids.usage_id
|
||||
|
||||
@location.setter
|
||||
@@ -329,6 +334,7 @@ class XModuleMixin(XModuleFields, XBlock):
|
||||
|
||||
@property
|
||||
def url_name(self):
|
||||
"""Return the URL-friendly name for this block."""
|
||||
return block_metadata_utils.url_name_for_block(self)
|
||||
|
||||
@property
|
||||
@@ -391,15 +397,13 @@ class XModuleMixin(XModuleFields, XBlock):
|
||||
any set to None.)
|
||||
"""
|
||||
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):
|
||||
try:
|
||||
result[field.name] = field.read_json(self)
|
||||
except TypeError as exception:
|
||||
exception_message = "{message}, Block-location:{location}, Field-name:{field_name}".format(
|
||||
message=str(exception), location=str(self.location), field_name=field.name
|
||||
)
|
||||
raise TypeError(exception_message) # lint-amnesty, pylint: disable=raise-missing-from
|
||||
exception_message = f"{exception}, Block-location:{self.location}, Field-name:{field.name}"
|
||||
raise TypeError(exception_message) from exception
|
||||
return result
|
||||
|
||||
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
|
||||
for depth > 2
|
||||
"""
|
||||
if depth < 0: # lint-amnesty, pylint: disable=no-else-raise
|
||||
if depth < 0:
|
||||
raise ValueError("negative depth argument is invalid")
|
||||
elif depth == 0:
|
||||
|
||||
if depth == 0:
|
||||
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):
|
||||
r"""
|
||||
@@ -447,11 +452,11 @@ class XModuleMixin(XModuleFields, XBlock):
|
||||
"""
|
||||
if self.has_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
|
||||
this module"""
|
||||
|
||||
@@ -580,7 +585,7 @@ class XModuleMixin(XModuleFields, XBlock):
|
||||
self.clear_child_cache()
|
||||
|
||||
# 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):
|
||||
continue
|
||||
|
||||
@@ -659,8 +664,8 @@ class XModuleMixin(XModuleFields, XBlock):
|
||||
"""Localize a text value that might be None."""
|
||||
if value is 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
|
||||
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."
|
||||
).format(display_name=self.display_name)
|
||||
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))
|
||||
|
||||
@@ -736,7 +741,7 @@ class XModuleToXBlockMixin:
|
||||
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.
|
||||
|
||||
@@ -801,7 +806,7 @@ class ResourceTemplates:
|
||||
if not os.path.exists(template_path):
|
||||
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["template_id"] = template_id
|
||||
return template
|
||||
@@ -839,21 +844,21 @@ class ResourceTemplates:
|
||||
return list(templates.values())
|
||||
|
||||
@classmethod
|
||||
def get_template_dir(cls): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
def get_template_dir(cls):
|
||||
"""Return the directory name for the class’s built-in resource templates."""
|
||||
if getattr(cls, "template_dir_name", None):
|
||||
dirname = os.path.join("templates", cls.template_dir_name) # lint-amnesty, pylint: disable=no-member
|
||||
template_path = resources.files(__name__.rsplit(".", 1)[0]) / dirname
|
||||
dirname = os.path.join("templates", cls.template_dir_name)
|
||||
template_path = files(__name__.rsplit(".", 1)[0]) / dirname
|
||||
|
||||
if not template_path.is_dir():
|
||||
log.warning(
|
||||
"No resource directory {dir} found when loading {cls_name} templates".format(
|
||||
dir=dirname,
|
||||
cls_name=cls.__name__,
|
||||
)
|
||||
"No resource directory %s found when loading %s templates",
|
||||
dirname,
|
||||
cls.__name__,
|
||||
)
|
||||
return
|
||||
return None
|
||||
return dirname
|
||||
return
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_template_dirpaths(cls):
|
||||
@@ -863,9 +868,9 @@ class ResourceTemplates:
|
||||
template_dirpaths = []
|
||||
template_dirname = cls.get_template_dir()
|
||||
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():
|
||||
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))
|
||||
|
||||
custom_template_dir = cls.get_custom_template_dir()
|
||||
@@ -882,7 +887,7 @@ class ResourceTemplates:
|
||||
template_dir_name = getattr(cls, "template_dir_name", None)
|
||||
|
||||
if template_dir_name is None:
|
||||
return
|
||||
return None
|
||||
|
||||
resource_dir = settings.CUSTOM_RESOURCE_TEMPLATES_DIRECTORY
|
||||
|
||||
@@ -893,6 +898,7 @@ class ResourceTemplates:
|
||||
|
||||
if os.path.exists(template_dir_path):
|
||||
return template_dir_path
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@@ -906,6 +912,8 @@ class ResourceTemplates:
|
||||
if os.path.exists(abs_path):
|
||||
return cls._load_template(abs_path, template_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _ConfigurableFragmentWrapper:
|
||||
"""
|
||||
@@ -939,7 +947,9 @@ class _ConfigurableFragmentWrapper:
|
||||
|
||||
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`
|
||||
"""
|
||||
@@ -973,7 +983,7 @@ def block_global_local_resource_url(block, uri):
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Applications must monkey-patch this function before using local_resource_url for studio_view"
|
||||
) # lint-amnesty, pylint: disable=line-too-long
|
||||
)
|
||||
|
||||
|
||||
class _MetricsMixin:
|
||||
@@ -981,7 +991,8 @@ class _MetricsMixin:
|
||||
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 {}
|
||||
start_time = time.time()
|
||||
try:
|
||||
@@ -997,9 +1008,8 @@ class _MetricsMixin:
|
||||
getattr(block, "location", ""),
|
||||
)
|
||||
|
||||
def handle(
|
||||
self, block, handler_name, request, suffix=""
|
||||
): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
def handle(self, block, handler_name, request, suffix=""):
|
||||
"""Handle a block request while recording execution time."""
|
||||
start_time = time.time()
|
||||
try:
|
||||
return super().handle(block, handler_name, request, suffix=suffix)
|
||||
@@ -1037,7 +1047,7 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
user_service = self._services.get("user")
|
||||
user_service = self._services.get("user") # pylint: disable=no-member
|
||||
if user_service:
|
||||
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID)
|
||||
return None
|
||||
@@ -1069,7 +1079,7 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
user_service = self._services.get("user")
|
||||
user_service = self._services.get("user") # pylint: disable=no-member
|
||||
if user_service:
|
||||
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_ID)
|
||||
return None
|
||||
@@ -1086,7 +1096,7 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
user_service = self._services.get("user")
|
||||
user_service = self._services.get("user") # pylint: disable=no-member
|
||||
if user_service:
|
||||
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_STAFF)
|
||||
return None
|
||||
@@ -1103,7 +1113,7 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
user_service = self._services.get("user")
|
||||
user_service = self._services.get("user") # pylint: disable=no-member
|
||||
if user_service:
|
||||
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_REQUEST_COUNTRY_CODE)
|
||||
return None
|
||||
@@ -1124,7 +1134,7 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
user_service = self._services.get("user")
|
||||
user_service = self._services.get("user") # pylint: disable=no-member
|
||||
if user_service:
|
||||
return user_service.get_user_by_anonymous_id
|
||||
return None
|
||||
@@ -1143,10 +1153,12 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
user_service = self._services.get("user")
|
||||
user_service = self._services.get("user") # pylint: disable=no-member
|
||||
if user_service:
|
||||
return partial(user_service.get_current_user().opt_attrs.get, ATTR_KEY_USER_ROLE)
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def user_is_beta_tester(self):
|
||||
"""
|
||||
@@ -1159,10 +1171,12 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
user_service = self._services.get("user")
|
||||
user_service = self._services.get("user") # pylint: disable=no-member
|
||||
if user_service:
|
||||
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_BETA_TESTER)
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def user_is_admin(self):
|
||||
"""
|
||||
@@ -1175,10 +1189,12 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
user_service = self._services.get("user")
|
||||
user_service = self._services.get("user") # pylint: disable=no-member
|
||||
if user_service:
|
||||
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_GLOBAL_STAFF)
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def render_template(self):
|
||||
"""
|
||||
@@ -1194,7 +1210,7 @@ class _ModuleSystemShim:
|
||||
)
|
||||
if hasattr(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:
|
||||
return render_service.render_template
|
||||
return None
|
||||
@@ -1221,7 +1237,7 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
sandbox_service = self._services.get("sandbox")
|
||||
sandbox_service = self._services.get("sandbox") # pylint: disable=no-member
|
||||
if sandbox_service:
|
||||
return sandbox_service.can_execute_unsafe_code
|
||||
# Default to saying "no unsafe code".
|
||||
@@ -1242,7 +1258,7 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
sandbox_service = self._services.get("sandbox")
|
||||
sandbox_service = self._services.get("sandbox") # pylint: disable=no-member
|
||||
if sandbox_service:
|
||||
return sandbox_service.get_python_lib_zip
|
||||
# Default to saying "no lib data"
|
||||
@@ -1262,7 +1278,7 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self._services.get("cache") or DoNothingCache()
|
||||
return self._services.get("cache") or DoNothingCache() # pylint: disable=no-member
|
||||
|
||||
@property
|
||||
def filestore(self):
|
||||
@@ -1276,7 +1292,7 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.resources_fs
|
||||
return self.resources_fs # pylint: disable=no-member
|
||||
|
||||
@property
|
||||
def node_path(self):
|
||||
@@ -1317,10 +1333,12 @@ class _ModuleSystemShim:
|
||||
DeprecationWarning,
|
||||
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:
|
||||
return partial(rebind_user_service.rebind_noauth_module_to_user)
|
||||
|
||||
return None
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
@property
|
||||
def STATIC_URL(self): # pylint: disable=invalid-name
|
||||
@@ -1350,6 +1368,8 @@ class _ModuleSystemShim:
|
||||
if hasattr(self, "_deprecated_course_id"):
|
||||
return self._deprecated_course_id.for_branch(None)
|
||||
|
||||
return None
|
||||
|
||||
@course_id.setter
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
self,
|
||||
load_item,
|
||||
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
|
||||
# fetching factory-created blocks.
|
||||
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()
|
||||
|
||||
@@ -1459,20 +1479,23 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
|
||||
:param xblock:
|
||||
:param field:
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
# 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
|
||||
# about the kvs, dbmodel, etc.
|
||||
|
||||
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:
|
||||
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:
|
||||
result["default_value"] = field.to_json(field.default)
|
||||
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
|
||||
# global function that the application can override, unless a specific function is
|
||||
# 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")
|
||||
|
||||
def add_block_as_child_node(self, block, node):
|
||||
"""Append the block’s XML to the given parent XML node."""
|
||||
child = etree.SubElement(node, block.category)
|
||||
child.set("url_name", block.url_name)
|
||||
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`.
|
||||
This ensures that the correct track method is used for Instructor tasks.
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
@@ -1543,13 +1567,18 @@ class ModuleStoreRuntime(_MetricsMixin, _ConfigurableFragmentWrapper, _ModuleSys
|
||||
return service(block)
|
||||
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.
|
||||
if getattr(self, "wrap_asides_override", None):
|
||||
return self.wrap_asides_override(block, aside, view, frag, context, request_token=self.request_token)
|
||||
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.
|
||||
if getattr(self, "layout_asides_override", None):
|
||||
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."""
|
||||
|
||||
def get(self, _key):
|
||||
"""Return None for any requested cache key."""
|
||||
return None
|
||||
|
||||
def set(self, key, value, timeout=None):
|
||||
pass
|
||||
"""Ignore cache set calls and store nothing."""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# lint-amnesty, pylint: disable=missing-module-docstring
|
||||
"""Utilities for XML parsing and XBlock/XModuleDescriptor serialization."""
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
@@ -61,9 +62,11 @@ def serialize_field(value):
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
elif isinstance(value, datetime.datetime):
|
||||
|
||||
if isinstance(value, datetime.datetime):
|
||||
if value.tzinfo is not None and value.utcoffset() is None:
|
||||
return value.isoformat() + "Z"
|
||||
|
||||
return value.isoformat()
|
||||
|
||||
return json.dumps(value, cls=EdxJSONEncoder)
|
||||
@@ -170,7 +173,7 @@ class XmlMixin:
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
@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,
|
||||
returning the lxml object.
|
||||
@@ -207,9 +210,11 @@ class XmlMixin:
|
||||
try:
|
||||
with fs.open(filepath) as 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
|
||||
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
|
||||
def load_definition(cls, xml_object, system, def_id, id_generator):
|
||||
@@ -301,7 +306,7 @@ class XmlMixin:
|
||||
metadata[attr] = value
|
||||
|
||||
@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.
|
||||
|
||||
@@ -316,7 +321,10 @@ class XmlMixin:
|
||||
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:
|
||||
# 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
|
||||
try:
|
||||
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)
|
||||
metadata["definition_metadata_err"] = str(err)
|
||||
|
||||
@@ -481,9 +489,10 @@ class XmlMixin:
|
||||
val = serialize_field(self.fields[attr].to_json(getattr(self, attr)))
|
||||
try:
|
||||
xml_object.set(attr, val)
|
||||
except Exception: # lint-amnesty, pylint: disable=broad-except
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
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,
|
||||
val,
|
||||
self.url_name,
|
||||
@@ -527,7 +536,7 @@ class XmlMixin:
|
||||
"""
|
||||
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
|
||||
def non_editable_metadata_fields(self):
|
||||
|
||||
Reference in New Issue
Block a user