Skip to content

Commit 99c9787

Browse files
rtibblesbotclaude
andauthored
feat: land ricecooker assessment items as QTI (learningequality#6032)
* feat: land ricecooker assessment items as QTI Refactor create_exercises to delegate item construction and schema validation to AssessmentItemSerializer instead of duplicating that logic. Native QTI (editor-shaped or arbitrary) is persisted and schema-validated directly; legacy/structured questions are converted to QTI on ingest; raw Perseus passes through unchanged; a Perseus custom interaction is unwrapped back to its raw perseus_question by reading its data-perseus-path payload. Referenced media is mapped via the existing map_files_to_assessment_item helper. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: reject Perseus-wrapped QTI outright, reuse ricecooker's pre-staged File Per review: uploading a QTI item that wraps a Perseus custom interaction is a categorical mistake ricecooker should prevent on its side, so reject it instead of unwrapping it. Also drop the create-three-files-then-clean- up-two watermarking dance for QTI media - AssessmentItemSerializer.set_files() already reuses the File row ricecooker's file_upload_url request created, so just let it, and surface any storage-layer lookup failure as a NodeValidationError. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor: simplify create_exercises error handling and dedupe test QTI XML builder Merge the two try/except blocks around serializer validation and save into one, and factor the custom-interaction test fixture to reuse _item_xml instead of duplicating the QTI item XML template. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e6db10e commit 99c9787

8 files changed

Lines changed: 520 additions & 29 deletions

File tree

contentcuration/contentcuration/tests/testdata.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,13 +288,16 @@ def create_temp_file(filebytes, preset="document", ext="pdf", original_filename=
288288
)
289289

290290

291-
def create_studio_file(filebytes, preset="document", ext="pdf", original_filename=None):
291+
def create_studio_file(
292+
filebytes, preset="document", ext="pdf", original_filename=None, uploaded_by=None
293+
):
292294
"""
293295
Create a file with contents of `filebytes` and the associated cc.File object for it.
294296
:param filebytes: The data to be stored in the file (as bytes)
295297
:param preset: String identifying the format preset (defaults to ``document``)
296298
:param ext: File extension, omitting the initial period
297299
:param original_filename: Original filename (needed for exercise_images)
300+
:param uploaded_by: User the file should appear to have been uploaded by
298301
Returns a dict containing the following:
299302
- name (str): the filename within the content storage system (= md5 hash of the contents + .ext )
300303
- data (bytes): file content (echo of `filebytes`)
@@ -335,6 +338,7 @@ def create_studio_file(filebytes, preset="document", ext="pdf", original_filenam
335338
preset=preset,
336339
original_filename=original_filename,
337340
file_on_disk=storage_file_path,
341+
uploaded_by=uploaded_by,
338342
)
339343

340344
return {
@@ -414,20 +418,23 @@ def create_test_file(filebytes, ext="pdf"):
414418
]
415419

416420

417-
def fileobj_exercise_image(size=(100, 100), color="red"):
421+
def fileobj_exercise_image(size=(100, 100), color="red", uploaded_by=None):
418422
"""
419423
Create a generic exercise image file in storage and return a File model pointing to it.
420424
"""
421425
image = Image.new("RGB", size, color=color)
422426
buffer = BytesIO()
423427
image.save(buffer, "JPEG")
424428
temp_file_dict = create_studio_file(
425-
buffer.getvalue(), preset=format_presets.EXERCISE_IMAGE, ext="jpg"
429+
buffer.getvalue(),
430+
preset=format_presets.EXERCISE_IMAGE,
431+
ext="jpg",
432+
uploaded_by=uploaded_by,
426433
)
427434
return temp_file_dict["db_file"]
428435

429436

430-
def fileobj_exercise_graphie(original_filename=None):
437+
def fileobj_exercise_graphie(original_filename=None, uploaded_by=None):
431438
"""
432439
Create an graphi exercise image file in storage and return a File model pointing to it.
433440
"""
@@ -439,6 +446,7 @@ def fileobj_exercise_graphie(original_filename=None):
439446
preset=format_presets.EXERCISE_GRAPHIE,
440447
ext="graphie",
441448
original_filename=original_filename or "theoriginalfilename",
449+
uploaded_by=uploaded_by,
442450
)
443451
return temp_file_dict["db_file"]
444452

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import unittest
2+
3+
from contentcuration.tests.utils.qti.test_validation import _item_xml
4+
from contentcuration.tests.utils.qti.test_validation import VALID_CHOICE_ITEM
5+
from contentcuration.utils.assessment.qti.ingest import convert_legacy_question_to_qti
6+
from contentcuration.utils.assessment.qti.ingest import (
7+
find_perseus_custom_interaction_path,
8+
)
9+
from contentcuration.utils.assessment.qti.ingest import (
10+
strip_content_storage_placeholder,
11+
)
12+
from contentcuration.utils.assessment.qti.media import get_qti_media_references
13+
from contentcuration.utils.assessment.qti.validation import validate_qti_item
14+
15+
16+
class StripContentStoragePlaceholderTests(unittest.TestCase):
17+
def test_strips_placeholder_leaving_bare_filename(self):
18+
text = "Look: ![](${☣ CONTENTSTORAGE}/abc123.png)"
19+
self.assertEqual(
20+
strip_content_storage_placeholder(text), "Look: ![](abc123.png)"
21+
)
22+
23+
24+
class ConvertLegacyQuestionToQTITests(unittest.TestCase):
25+
def test_convert_legacy_question_to_qti_strips_placeholder_from_question_and_answers(
26+
self,
27+
):
28+
question_data = {
29+
"type": "multiple_selection",
30+
"assessment_id": "abf45e8fd7f151adb1b3df2d751e945e",
31+
"question": "Which is red? ![](${☣ CONTENTSTORAGE}/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png)", # noqa
32+
"answers": '[{"answer": "Apple ![](${☣ CONTENTSTORAGE}/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png)", "correct": true, "order": 0}, {"answer": "Sky", "correct": false, "order": 1}]', # noqa
33+
"randomize": False,
34+
}
35+
result = convert_legacy_question_to_qti(question_data)
36+
validation_result = validate_qti_item(result.xml)
37+
self.assertTrue(validation_result.is_valid, validation_result.errors)
38+
self.assertEqual(
39+
get_qti_media_references(result.xml),
40+
{
41+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png",
42+
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.png",
43+
},
44+
)
45+
46+
47+
def _custom_interaction_item_xml(data_type, path_attr, path_value):
48+
return _item_xml(
49+
"item1",
50+
"t",
51+
"",
52+
'<qti-custom-interaction response-identifier="RESPONSE" data-type="%s" %s="%s"/>'
53+
% (data_type, path_attr, path_value),
54+
)
55+
56+
57+
class FindPerseusCustomInteractionPathTests(unittest.TestCase):
58+
def test_find_perseus_custom_interaction_path_detects_wrapper(self):
59+
raw_data = _custom_interaction_item_xml(
60+
"perseus", "data-perseus-path", "cccccccccccccccccccccccccccccccc.json"
61+
)
62+
self.assertEqual(
63+
find_perseus_custom_interaction_path(raw_data),
64+
"cccccccccccccccccccccccccccccccc.json",
65+
)
66+
67+
def test_find_perseus_custom_interaction_path_returns_none_when_absent(self):
68+
self.assertIsNone(find_perseus_custom_interaction_path(VALID_CHOICE_ITEM))
69+
70+
def test_find_perseus_custom_interaction_path_ignores_other_vendor_types(self):
71+
raw_data = _custom_interaction_item_xml(
72+
"other-vendor", "data-other-path", "cccccccccccccccccccccccccccccccc.json"
73+
)
74+
self.assertIsNone(find_perseus_custom_interaction_path(raw_data))
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from django.core.exceptions import ValidationError as DjangoValidationError
2+
from le_utils.constants import exercises
3+
4+
from contentcuration.tests.base import StudioTestCase
5+
from contentcuration.tests.testdata import channel
6+
from contentcuration.tests.utils.qti.test_validation import _item_xml
7+
from contentcuration.views.internal import create_exercises
8+
9+
10+
class CreateExercisesInvalidQTITestCase(StudioTestCase):
11+
def test_invalid_qti_raw_data_raises_node_validation_error(self):
12+
node = channel().main_tree
13+
questions = [
14+
{
15+
"assessment_id": "dddddddddddddddddddddddddddddddd",
16+
"type": exercises.QTI,
17+
"raw_data": "<not-qti>this is not valid QTI</not-qti>",
18+
"files": [],
19+
"source_url": None,
20+
"randomize": False,
21+
}
22+
]
23+
with self.assertRaises(DjangoValidationError):
24+
create_exercises(self.admin_user, node, questions)
25+
26+
def test_unsupported_legacy_question_type_raises_node_validation_error(self):
27+
node = channel().main_tree
28+
questions = [
29+
{
30+
"assessment_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
31+
"type": "not_a_real_question_type",
32+
"question": "What?",
33+
"answers": "[]",
34+
"files": [],
35+
"source_url": None,
36+
"randomize": False,
37+
}
38+
]
39+
with self.assertRaises(DjangoValidationError):
40+
create_exercises(self.admin_user, node, questions)
41+
42+
def test_qti_raw_data_referencing_missing_file_raises_node_validation_error(self):
43+
node = channel().main_tree
44+
raw_data = _item_xml(
45+
"item_1",
46+
"Sample Item",
47+
'<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">'
48+
"<qti-correct-response><qti-value>choice_0</qti-value></qti-correct-response>"
49+
"</qti-response-declaration>",
50+
'<qti-choice-interaction response-identifier="RESPONSE" max-choices="1" '
51+
'min-choices="0" orientation="vertical"><qti-prompt>Pick. '
52+
'<img src="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png" alt="diagram" /></qti-prompt>'
53+
'<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false">A</qti-simple-choice>'
54+
'<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false">B</qti-simple-choice>'
55+
"</qti-choice-interaction>",
56+
)
57+
questions = [
58+
{
59+
"assessment_id": "ffffffffffffffffffffffffffffffff",
60+
"type": exercises.QTI,
61+
"raw_data": raw_data,
62+
"files": [],
63+
"source_url": None,
64+
"randomize": False,
65+
}
66+
]
67+
with self.assertRaises(DjangoValidationError):
68+
create_exercises(self.admin_user, node, questions)
69+
70+
def test_qti_wrapping_perseus_custom_interaction_rejected(self):
71+
node = channel().main_tree
72+
raw_data = _item_xml(
73+
"item_2",
74+
"Sample Item",
75+
"",
76+
'<qti-custom-interaction response-identifier="RESPONSE" data-type="perseus" '
77+
'data-perseus-path="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json"/>',
78+
)
79+
questions = [
80+
{
81+
"assessment_id": "cccccccccccccccccccccccccccccccc",
82+
"type": exercises.QTI,
83+
"raw_data": raw_data,
84+
"files": [],
85+
"source_url": None,
86+
"randomize": False,
87+
}
88+
]
89+
with self.assertRaises(DjangoValidationError):
90+
create_exercises(self.admin_user, node, questions)

0 commit comments

Comments
 (0)