Skip to content

Commit cf6eaf2

Browse files
committed
feat: run the item XSD's Schematron rules in validate_qti_item
The XSD's extensionAnyTypeLax.Attr wildcard admits any unrecognised attribute, so the XSD alone could not catch the xml:lang bug. The schema's own Schematron rules do enumerate the legal attributes, but etree.XMLSchema never executes them. Surfaced a second violation: srcset is not a QTI 3.0 img attribute, so the assessmentitem viewset test's sample item now wraps it in <picture><source>. The pydantic Img model still permits it — separate fix.
1 parent 1c2e67b commit cf6eaf2

4 files changed

Lines changed: 92 additions & 10 deletions

File tree

‎contentcuration/contentcuration/tests/utils/qti/test_validation.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from lxml import etree
66

77
from contentcuration.utils.assessment.qti.validation import _compiled_schema
8+
from contentcuration.utils.assessment.qti.validation import _compiled_schematron
89
from contentcuration.utils.assessment.qti.validation import validate_qti_item
910

1011

@@ -24,7 +25,7 @@ def _item_xml(identifier, title, response_declaration, item_body):
2425
'xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 '
2526
'https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" '
2627
'identifier="%s" title="%s" adaptive="false" time-dependent="false" '
27-
'language="en-US" tool-name="kolibri" tool-version="0.1">'
28+
'xml:lang="en-US" tool-name="kolibri" tool-version="0.1">'
2829
"%s"
2930
'<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />'
3031
"<qti-item-body>%s</qti-item-body>"
@@ -77,6 +78,25 @@ def test_rejects_missing_required_attribute(self):
7778
self.assertFalse(result.is_valid)
7879
self.assertTrue(any("time-dependent" in e.message for e in result.errors))
7980

81+
def test_rejects_undeclared_root_attribute(self):
82+
xml = VALID_CHOICE_ITEM.replace('xml:lang="en-US"', 'language="en-US"')
83+
result = validate_qti_item(xml)
84+
self.assertFalse(result.is_valid)
85+
self.assertTrue(
86+
any(
87+
"Invalid XML attribute" in e.message and "language" in e.message
88+
for e in result.errors
89+
)
90+
)
91+
92+
def test_accepts_data_prefixed_root_attribute(self):
93+
xml = VALID_CHOICE_ITEM.replace(
94+
'tool-name="kolibri"', 'data-foo="bar" tool-name="kolibri"'
95+
)
96+
result = validate_qti_item(xml)
97+
self.assertTrue(result.is_valid)
98+
self.assertEqual(result.errors, [])
99+
80100
def test_rejects_malformed_xml_without_raising(self):
81101
result = validate_qti_item("<qti-assessment-item><unclosed>")
82102
self.assertFalse(result.is_valid)
@@ -161,3 +181,14 @@ def test_schema_compiled_once_across_multiple_validate_calls(self):
161181
validate_qti_item(ORDER_INTERACTION_ITEM)
162182
self.assertEqual(_compiled_schema.cache_info().misses, 1)
163183
self.assertEqual(_compiled_schema.cache_info().hits, 2)
184+
185+
186+
class SchematronReuseTests(unittest.TestCase):
187+
def test_schematron_compiled_once_across_multiple_validate_calls(self):
188+
_compiled_schematron.cache_clear()
189+
self.addCleanup(_compiled_schematron.cache_clear)
190+
validate_qti_item(VALID_CHOICE_ITEM)
191+
validate_qti_item(MATCH_INTERACTION_ITEM)
192+
validate_qti_item(ORDER_INTERACTION_ITEM)
193+
self.assertEqual(_compiled_schematron.cache_info().misses, 1)
194+
self.assertEqual(_compiled_schematron.cache_info().hits, 2)

‎contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@
2525
"</qti-response-declaration>",
2626
'<qti-choice-interaction response-identifier="RESPONSE" max-choices="1" '
2727
'min-choices="0" orientation="vertical"><qti-prompt>Select the correct answer. '
28-
'<img src="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png" '
29-
'srcset="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png 1x, cccccccccccccccccccccccccccccccc.png 2x" '
30-
'alt="diagram" /><object data="dddddddddddddddddddddddddddddddd.pdf" '
28+
# srcset belongs on <source>, not <img> — QTI 3.0's ImgDType has no such attribute.
29+
"<picture>"
30+
'<source srcset="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png 1x, cccccccccccccccccccccccccccccccc.png 2x" />'
31+
'<img src="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png" alt="diagram" />'
32+
"</picture>"
33+
'<object data="dddddddddddddddddddddddddddddddd.pdf" '
3134
'type="application/pdf"></object></qti-prompt>'
3235
'<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false">Option A</qti-simple-choice>'
3336
'<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false">'

‎contentcuration/contentcuration/utils/assessment/qti/schema/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ XSD files backing `contentcuration.utils.assessment.qti.validation`. Source: QTI
1414
SSML markup for text-to-speech hints).
1515
Source: https://purl.imsglobal.org/spec/ssml/v1p1/schema/xsd/
1616

17+
`validation.py` also runs the Schematron rules `imsqti_itemv3p0p1_v1p0.xsd` carries inline
18+
in `xs:appinfo` — without them the XSD's `extensionAnyTypeLax.Attr` wildcard admits any
19+
attribute. Do not strip `xs:appinfo`.
20+
1721
All `schemaLocation` attributes that originally pointed at `https://purl.imsglobal.org/...`
1822
have been rewritten to local relative filenames so the schema compiles with no network
1923
access at runtime — required since bulk publish and ricecooker upload validate many items

‎contentcuration/contentcuration/utils/assessment/qti/validation.py‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,20 @@
88
from typing import Union
99

1010
from lxml import etree
11+
from lxml import isoschematron
1112

1213

1314
SCHEMA_DIR = Path(__file__).parent / "schema" / "xsd"
1415
ITEM_SCHEMA_PATH = SCHEMA_DIR / "imsqti_itemv3p0p1_v1p0.xsd"
1516

17+
_SCHEMATRON_NS = "http://purl.oclc.org/dsdl/schematron"
18+
_SVRL_NS = "http://purl.oclc.org/dsdl/svrl"
19+
1620
# lxml's XMLSchema.validate() writes results to the schema object's shared
17-
# error_log, so concurrent validate() calls on the same cached schema instance
18-
# (reused across items per the module's design) can read each other's errors.
19-
# Serialize validate + error_log read to keep results correctly attributed.
21+
# error_log, and Schematron.validate() likewise to its validation_report, so
22+
# concurrent validate() calls on the same cached instance (reused across items
23+
# per the module's design) can read each other's errors. Serialize validate +
24+
# report read to keep results correctly attributed.
2025
_VALIDATION_LOCK = threading.Lock()
2126

2227

@@ -26,6 +31,20 @@ def _compiled_schema() -> etree.XMLSchema:
2631
return etree.XMLSchema(schema_doc)
2732

2833

34+
@lru_cache(maxsize=1)
35+
def _compiled_schematron() -> isoschematron.Schematron:
36+
# The item XSD's Schematron rules sit inline in xs:appinfo and
37+
# etree.XMLSchema never runs them, so its extensionAnyTypeLax.Attr wildcard
38+
# admits attributes the Schematron's allow-list rejects. lxml takes only a
39+
# standalone sch:schema, whose sch:ns bindings must precede its patterns.
40+
xsd = etree.parse(str(ITEM_SCHEMA_PATH))
41+
schema = etree.Element(f"{{{_SCHEMATRON_NS}}}schema", nsmap={"sch": _SCHEMATRON_NS})
42+
# list() first — extend reparents each node out of the tree being iterated.
43+
rules = list(xsd.iter(f"{{{_SCHEMATRON_NS}}}ns", f"{{{_SCHEMATRON_NS}}}pattern"))
44+
schema.extend(rules)
45+
return isoschematron.Schematron(schema, store_report=True)
46+
47+
2948
@dataclass(frozen=True)
3049
class QTIValidationError:
3150
message: str
@@ -39,6 +58,26 @@ class QTIValidationResult:
3958
errors: List[QTIValidationError] = field(default_factory=list)
4059

4160

61+
def _schematron_errors(
62+
schematron: isoschematron.Schematron, doc: etree._ElementTree
63+
) -> List[QTIValidationError]:
64+
if schematron.validate(doc):
65+
return []
66+
errors = []
67+
for failure in schematron.validation_report.iter(f"{{{_SVRL_NS}}}failed-assert"):
68+
text = failure.findtext(f"{{{_SVRL_NS}}}text") or ""
69+
# SVRL reports a location XPath, not a line number.
70+
located = doc.xpath(failure.get("location"))
71+
errors.append(
72+
QTIValidationError(
73+
message=" ".join(text.split()),
74+
line=located[0].sourceline if located else 0,
75+
column=0,
76+
)
77+
)
78+
return errors
79+
80+
4281
def secure_parser() -> etree.XMLParser:
4382
# resolve_entities=False + load_dtd=False blocks XXE/entity-expansion attacks;
4483
# no_network=True blocks remote schemaLocation/entity fetches. QTI reaches this
@@ -75,10 +114,15 @@ def validate_qti_item(xml: Union[str, bytes]) -> QTIValidationResult:
75114
)
76115

77116
schema = _compiled_schema()
117+
schematron = _compiled_schematron()
78118
with _VALIDATION_LOCK:
79-
is_valid = schema.validate(doc)
80-
errors = [
119+
xsd_valid = schema.validate(doc)
120+
xsd_errors = [
81121
QTIValidationError(message=err.message, line=err.line, column=err.column)
82122
for err in schema.error_log
83123
]
84-
return QTIValidationResult(is_valid=is_valid, errors=errors)
124+
schematron_errors = _schematron_errors(schematron, doc)
125+
return QTIValidationResult(
126+
is_valid=xsd_valid and not schematron_errors,
127+
errors=xsd_errors + schematron_errors,
128+
)

0 commit comments

Comments
 (0)