Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 3.2.24 on 2026-07-03 04:48
from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

dependencies = [
("contentcuration", "0167_add_organization"),
]

operations = [
migrations.AlterField(
model_name="assessmentitem",
name="type",
field=models.CharField(
choices=[
("input_question", "Input Question"),
("multiple_selection", "Multiple Selection"),
("single_selection", "Single Selection"),
("free_response", "Free Response"),
("perseus_question", "Perseus Question"),
("QTI", "QTI"),
("true_false", "True/False"),
],
default="multiple_selection",
max_length=50,
),
),
]
223 changes: 223 additions & 0 deletions contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,34 @@
from contentcuration import models
from contentcuration.tests import testdata
from contentcuration.tests.base import StudioAPITestCase
from contentcuration.tests.utils.qti.test_validation import _item_xml
from contentcuration.tests.utils.qti.test_validation import VALID_CHOICE_ITEM
from contentcuration.tests.viewsets.base import generate_create_event
from contentcuration.tests.viewsets.base import generate_delete_event
from contentcuration.tests.viewsets.base import generate_update_event
from contentcuration.tests.viewsets.base import SyncTestMixin
from contentcuration.viewsets.sync.constants import ASSESSMENTITEM


QTI_ITEM_WITH_FILES = _item_xml(
"item_1",
"Sample Item",
'<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">'
"<qti-correct-response><qti-value>choice_0</qti-value></qti-correct-response>"
"</qti-response-declaration>",
'<qti-choice-interaction response-identifier="RESPONSE" max-choices="1" '
'min-choices="0" orientation="vertical"><qti-prompt>Select the correct answer. '
'<img src="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png" '
'srcset="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png 1x, cccccccccccccccccccccccccccccccc.png 2x" '
'alt="diagram" /><object data="dddddddddddddddddddddddddddddddd.pdf" '
'type="application/pdf"></object></qti-prompt>'
'<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false">Option A</qti-simple-choice>'
'<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false">'
'<a href="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.pdf">Option B</a></qti-simple-choice>'
"</qti-choice-interaction>",
)


class SyncTestCase(SyncTestMixin, StudioAPITestCase):
@property
def assessmentitem_metadata(self):
Expand Down Expand Up @@ -440,6 +461,160 @@ def test_update_assessmentitem_to_true_false(self):
"true_false",
)

def test_update_assessmentitem_to_qti(self):
assessmentitem = models.AssessmentItem.objects.create(
**self.assessmentitem_db_metadata
)
self.client.force_authenticate(user=self.user)
response = self.sync_changes(
[
generate_update_event(
[assessmentitem.contentnode_id, assessmentitem.assessment_id],
ASSESSMENTITEM,
{"type": "QTI", "raw_data": VALID_CHOICE_ITEM},
channel_id=self.channel.id,
)
],
)
self.assertEqual(response.status_code, 200, response.content)
updated = models.AssessmentItem.objects.get(id=assessmentitem.id)
self.assertEqual(updated.type, "QTI")
self.assertEqual(updated.raw_data, VALID_CHOICE_ITEM)

def test_create_assessmentitem_preserves_raw_data_whitespace(self):
self.client.force_authenticate(user=self.user)
assessmentitem = self.assessmentitem_metadata
# Trailing whitespace only: a leading pad before the XML declaration
# would be invalid XML syntax and get rejected by QTI schema validation.
padded_raw_data = VALID_CHOICE_ITEM + "\n\n "
assessmentitem["type"] = "QTI"
assessmentitem["raw_data"] = padded_raw_data
response = self.sync_changes(
[
generate_create_event(
[assessmentitem["contentnode"], assessmentitem["assessment_id"]],
ASSESSMENTITEM,
assessmentitem,
channel_id=self.channel.id,
)
],
)
self.assertEqual(response.status_code, 200, response.content)
created = models.AssessmentItem.objects.get(
assessment_id=assessmentitem["assessment_id"]
)
self.assertEqual(created.raw_data, padded_raw_data)

def test_create_qti_assessmentitem_bypasses_hints_answers_coercion(self):
self.client.force_authenticate(user=self.user)
assessmentitem = self.assessmentitem_metadata
assessmentitem["type"] = "QTI"
assessmentitem["raw_data"] = VALID_CHOICE_ITEM
assessmentitem["hints"] = "not a json array"
assessmentitem["answers"] = "also not a json array"
response = self.sync_changes(
[
generate_create_event(
[assessmentitem["contentnode"], assessmentitem["assessment_id"]],
ASSESSMENTITEM,
assessmentitem,
channel_id=self.channel.id,
)
],
)
self.assertEqual(response.status_code, 200, response.content)
created = models.AssessmentItem.objects.get(
assessment_id=assessmentitem["assessment_id"]
)
self.assertEqual(created.hints, "not a json array")
self.assertEqual(created.answers, "also not a json array")

def _create_qti_referenced_files(self, checksums):
for checksum, ext in zip(checksums, ["png", "png", "pdf", "pdf"]):
image_file = testdata.fileobj_exercise_image()
image_file.checksum = checksum
image_file.file_format_id = ext
image_file.uploaded_by = self.user
image_file.save()

def test_create_qti_assessmentitem_extracts_files(self):
self.client.force_authenticate(user=self.user)
checksums = ["a" * 32, "c" * 32, "d" * 32, "b" * 32]
self._create_qti_referenced_files(checksums)

assessmentitem = self.assessmentitem_metadata
assessmentitem["type"] = "QTI"
assessmentitem["raw_data"] = QTI_ITEM_WITH_FILES
response = self.sync_changes(
[
generate_create_event(
[assessmentitem["contentnode"], assessmentitem["assessment_id"]],
ASSESSMENTITEM,
assessmentitem,
channel_id=self.channel.id,
)
],
)
self.assertEqual(response.status_code, 200, response.content)
ai = models.AssessmentItem.objects.get(
assessment_id=assessmentitem["assessment_id"]
)
linked_checksums = set(ai.files.values_list("checksum", flat=True))
self.assertEqual(linked_checksums, set(checksums))

def test_qti_assessmentitem_full_round_trip(self):
self.client.force_authenticate(user=self.user)
checksums = ["a" * 32, "c" * 32, "d" * 32, "b" * 32]
self._create_qti_referenced_files(checksums)

assessmentitem = self.assessmentitem_metadata
assessmentitem["type"] = "QTI"
assessmentitem["raw_data"] = QTI_ITEM_WITH_FILES

create_response = self.sync_changes(
[
generate_create_event(
[assessmentitem["contentnode"], assessmentitem["assessment_id"]],
ASSESSMENTITEM,
assessmentitem,
channel_id=self.channel.id,
)
],
)
self.assertEqual(create_response.status_code, 200, create_response.content)
ai = models.AssessmentItem.objects.get(
assessment_id=assessmentitem["assessment_id"]
)
self.assertEqual(ai.raw_data, QTI_ITEM_WITH_FILES)
self.assertEqual(ai.type, "QTI")
self.assertEqual(
set(ai.files.values_list("checksum", flat=True)), set(checksums)
)

# Drop the object/data reference (checksum "d"*32) and change the title.
updated_raw_data = QTI_ITEM_WITH_FILES.replace(
'<object data="dddddddddddddddddddddddddddddddd.pdf" '
'type="application/pdf"></object>',
"",
).replace('title="Sample Item"', 'title="Updated Item"')
update_response = self.sync_changes(
[
generate_update_event(
[assessmentitem["contentnode"], assessmentitem["assessment_id"]],
ASSESSMENTITEM,
{"raw_data": updated_raw_data},
channel_id=self.channel.id,
)
],
)
self.assertEqual(update_response.status_code, 200, update_response.content)
ai.refresh_from_db()
self.assertEqual(ai.raw_data, updated_raw_data)
self.assertEqual(
set(ai.files.values_list("checksum", flat=True)),
set(checksums) - {"d" * 32},
)

def test_attempt_update_missing_assessmentitem(self):

self.client.force_authenticate(user=self.user)
Expand Down Expand Up @@ -744,6 +919,54 @@ def test_invalid_hints_assessmentitem(self):
assessment_id=assessmentitem["assessment_id"]
)

def test_invalid_qti_assessmentitem(self):
self.client.force_authenticate(user=self.user)
assessmentitem = self.assessmentitem_metadata
assessmentitem["type"] = "QTI"
assessmentitem["raw_data"] = VALID_CHOICE_ITEM.replace(
'orientation="vertical"', 'orientation="sideways"'
)
response = self.sync_changes(
[
generate_create_event(
[assessmentitem["contentnode"], assessmentitem["assessment_id"]],
ASSESSMENTITEM,
assessmentitem,
channel_id=self.channel.id,
),
],
)
self.assertEqual(response.json()["errors"][0]["table"], "assessmentitem")
self.assertTrue(response.json()["errors"][0]["errors"]["raw_data"])
self.assertEqual(len(response.json()["errors"]), 1)
with self.assertRaises(
models.AssessmentItem.DoesNotExist, msg="AssessmentItem was created"
):
models.AssessmentItem.objects.get(
assessment_id=assessmentitem["assessment_id"]
)

def test_invalid_qti_xml_syntax_assessmentitem(self):
self.client.force_authenticate(user=self.user)
assessmentitem = self.assessmentitem_metadata
assessmentitem["type"] = "QTI"
assessmentitem["raw_data"] = "<qti-assessment-item><unclosed>"
response = self.sync_changes(
[
generate_create_event(
[assessmentitem["contentnode"], assessmentitem["assessment_id"]],
ASSESSMENTITEM,
assessmentitem,
channel_id=self.channel.id,
),
],
)
self.assertTrue(response.json()["errors"][0]["errors"]["raw_data"])
with self.assertRaises(models.AssessmentItem.DoesNotExist):
models.AssessmentItem.objects.get(
assessment_id=assessmentitem["assessment_id"]
)

def test_valid_answers_assessmentitem(self):
self.client.force_authenticate(user=self.user)
assessmentitem = self.assessmentitem_metadata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class QTIValidationResult:
errors: List[QTIValidationError] = field(default_factory=list)


def _secure_parser() -> etree.XMLParser:
def secure_parser() -> etree.XMLParser:
# resolve_entities=False + load_dtd=False blocks XXE/entity-expansion attacks;
# no_network=True blocks remote schemaLocation/entity fetches. QTI reaches this
# validator from ricecooker uploads and direct sync-API writes, not just the
Expand All @@ -54,12 +54,16 @@ def _secure_parser() -> etree.XMLParser:
)


def parse_qti_xml(xml: bytes) -> etree._Element:
return etree.parse(BytesIO(xml), parser=secure_parser())


def validate_qti_item(xml: Union[str, bytes]) -> QTIValidationResult:
if isinstance(xml, str):
xml = xml.encode("utf-8")

try:
doc = etree.parse(BytesIO(xml), parser=_secure_parser())
doc = parse_qti_xml(xml)
except etree.XMLSyntaxError as exc:
return QTIValidationResult(
is_valid=False,
Expand Down
Loading
Loading