Skip to content

Commit 3ae5aca

Browse files
authored
Merge pull request #6029 from rtibblesbot/issue-6000-33c560
Package native QTI items as QTI/IMSCP archives
2 parents 817284d + b7d1406 commit 3ae5aca

8 files changed

Lines changed: 562 additions & 57 deletions

File tree

contentcuration/contentcuration/tests/test_exportchannel.py

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from .testdata import slideshow
3636
from .testdata import thumbnail_bytes
3737
from .testdata import tree
38+
from .utils.qti.test_validation import VALID_CHOICE_ITEM
3839
from .utils.restricted_filesystemstorage import RestrictedFileSystemStorage
3940
from contentcuration import models as cc
4041
from contentcuration.models import CustomTaskMetadata
@@ -258,6 +259,75 @@ def setUp(self):
258259
ai.contentnode = qti_exercise
259260
ai.save()
260261

262+
# Native QTI item, no perseus_question present -> must route to QTI packaging
263+
native_qti_exercise = create_node(
264+
{
265+
"kind_id": "exercise",
266+
"title": "Native QTI Exercise",
267+
"extra_fields": qti_extra_fields,
268+
}
269+
)
270+
native_qti_exercise.complete = True
271+
native_qti_exercise.parent = current_exercise.parent
272+
native_qti_exercise.save()
273+
cc.AssessmentItem.objects.create(
274+
contentnode=native_qti_exercise,
275+
assessment_id=uuid.uuid4().hex,
276+
type=exercises.QTI,
277+
question="",
278+
answers="[]",
279+
hints="[]",
280+
raw_data=VALID_CHOICE_ITEM,
281+
order=1,
282+
randomize=False,
283+
)
284+
285+
# Only legacy structured-field items, no perseus_question -> must now route to QTI (was Perseus)
286+
legacy_no_perseus_exercise = create_node(
287+
{
288+
"kind_id": "exercise",
289+
"title": "Legacy No Perseus Exercise",
290+
"extra_fields": qti_extra_fields,
291+
}
292+
)
293+
legacy_no_perseus_exercise.complete = True
294+
legacy_no_perseus_exercise.parent = current_exercise.parent
295+
legacy_no_perseus_exercise.save()
296+
cc.AssessmentItem.objects.create(
297+
contentnode=legacy_no_perseus_exercise,
298+
assessment_id=uuid.uuid4().hex,
299+
type=exercises.SINGLE_SELECTION,
300+
question="What is 2+2?",
301+
answers=json.dumps([{"answer": "4", "correct": True, "order": 1}]),
302+
hints=json.dumps([]),
303+
raw_data="{}",
304+
order=1,
305+
randomize=False,
306+
)
307+
308+
# A perseus_question item present -> must still route to Perseus packaging
309+
perseus_only_exercise = create_node(
310+
{
311+
"kind_id": "exercise",
312+
"title": "Perseus Only Exercise",
313+
"extra_fields": qti_extra_fields,
314+
}
315+
)
316+
perseus_only_exercise.complete = True
317+
perseus_only_exercise.parent = current_exercise.parent
318+
perseus_only_exercise.save()
319+
cc.AssessmentItem.objects.create(
320+
contentnode=perseus_only_exercise,
321+
assessment_id=uuid.uuid4().hex,
322+
type=exercises.PERSEUS_QUESTION,
323+
question="",
324+
answers="[]",
325+
hints="[]",
326+
raw_data="{}",
327+
order=1,
328+
randomize=False,
329+
)
330+
261331
first_topic = self.content_channel.main_tree.get_descendants().first()
262332

263333
# Add a publishable topic to ensure it does not inherit but that its children do
@@ -709,6 +779,21 @@ def test_qti_exercise_generates_qti_archive(self):
709779
"QTI file should be a zip archive",
710780
)
711781

782+
def test_native_qti_item_routes_to_qti_packaging(self):
783+
node = cc.ContentNode.objects.get(title="Native QTI Exercise")
784+
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
785+
self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())
786+
787+
def test_legacy_items_without_perseus_question_route_to_qti_packaging(self):
788+
node = cc.ContentNode.objects.get(title="Legacy No Perseus Exercise")
789+
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
790+
self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())
791+
792+
def test_perseus_question_item_routes_to_perseus_packaging(self):
793+
node = cc.ContentNode.objects.get(title="Perseus Only Exercise")
794+
self.assertTrue(node.files.filter(preset_id=format_presets.EXERCISE).exists())
795+
self.assertFalse(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
796+
712797
def test_qti_archive_contains_manifest_and_assessment_ids(self):
713798

714799
published_qti_exercise = kolibri_models.ContentNode.objects.get(
@@ -733,15 +818,17 @@ def test_unit_topic_publishes_with_exercise_zip(self):
733818
attached assessment items compiled into a zip file during publishing."""
734819
unit_topic = cc.ContentNode.objects.get(title="Test Unit Topic")
735820

736-
# Assert UNIT topic has exercise file in Studio
821+
# Assert UNIT topic has a QTI archive file in Studio. Its assessment
822+
# items are legacy structured-field (SINGLE_SELECTION) with no
823+
# perseus_question item, so they route to QTI packaging.
737824
unit_files = cc.File.objects.filter(
738825
contentnode=unit_topic,
739-
preset_id=format_presets.EXERCISE,
826+
preset_id=format_presets.QTI_ZIP,
740827
)
741828
self.assertEqual(
742829
unit_files.count(),
743830
1,
744-
"UNIT topic should have exactly one exercise archive file",
831+
"UNIT topic should have exactly one QTI archive file",
745832
)
746833

747834
# Assert NO assessment metadata in Kolibri export for UNIT topics
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from contentcuration.utils.assessment.qti.media import get_qti_media_references
2+
from contentcuration.utils.assessment.qti.media import rewrite_qti_media_paths
3+
4+
CHECKSUM_A = "a" * 32
5+
CHECKSUM_B = "b" * 32
6+
7+
8+
def test_extracts_checksum_from_src_href_data():
9+
xml = (
10+
f'<item><img src="{CHECKSUM_A}.png"/>'
11+
f'<a href="{CHECKSUM_B}.pdf">x</a>'
12+
f'<object data="{CHECKSUM_A}.png"></object></item>'
13+
)
14+
assert get_qti_media_references(xml) == {f"{CHECKSUM_A}.png", f"{CHECKSUM_B}.pdf"}
15+
16+
17+
def test_extracts_checksums_from_srcset():
18+
xml = f'<item><img srcset="{CHECKSUM_A}.png 1x, {CHECKSUM_B}.png 2x"/></item>'
19+
assert get_qti_media_references(xml) == {f"{CHECKSUM_A}.png", f"{CHECKSUM_B}.png"}
20+
21+
22+
def test_ignores_non_checksum_values():
23+
xml = '<item><img src="https://example.com/x.png"/><a href="notachecksum.png">x</a></item>'
24+
assert get_qti_media_references(xml) == set()
25+
26+
27+
def test_returns_empty_set_for_malformed_xml():
28+
assert get_qti_media_references("<item><unclosed>") == set()
29+
30+
31+
def test_accepts_bytes():
32+
xml = f'<item><img src="{CHECKSUM_A}.png"/></item>'.encode("utf-8")
33+
assert get_qti_media_references(xml) == {f"{CHECKSUM_A}.png"}
34+
35+
36+
def test_rewrite_leaves_input_untouched_with_no_mapping():
37+
xml = f'<item><img src="{CHECKSUM_A}.png" /></item>'
38+
assert rewrite_qti_media_paths(xml, {}) == xml
39+
40+
41+
def test_rewrite_remaps_src_href_data_and_preserves_formatting():
42+
xml = (
43+
f'<item><img src="{CHECKSUM_A}.png" alt="diagram" />'
44+
f'<a href="{CHECKSUM_B}.pdf">x</a>'
45+
f'<object data="{CHECKSUM_A}.png"></object></item>'
46+
)
47+
result = rewrite_qti_media_paths(
48+
xml,
49+
{
50+
f"{CHECKSUM_A}.png": f"images/{CHECKSUM_A}.png",
51+
f"{CHECKSUM_B}.pdf": f"images/{CHECKSUM_B}.pdf",
52+
},
53+
)
54+
assert result == (
55+
f'<item><img src="images/{CHECKSUM_A}.png" alt="diagram" />'
56+
f'<a href="images/{CHECKSUM_B}.pdf">x</a>'
57+
f'<object data="images/{CHECKSUM_A}.png"></object></item>'
58+
)
59+
60+
61+
def test_rewrite_remaps_srcset_entries_preserving_descriptors():
62+
xml = f'<item><img srcset="{CHECKSUM_A}.png 1x, {CHECKSUM_B}.png 2x"/></item>'
63+
result = rewrite_qti_media_paths(
64+
xml,
65+
{
66+
f"{CHECKSUM_A}.png": f"images/{CHECKSUM_A}.png",
67+
f"{CHECKSUM_B}.png": f"images/{CHECKSUM_B}.png",
68+
},
69+
)
70+
assert result == (
71+
f'<item><img srcset="images/{CHECKSUM_A}.png 1x, images/{CHECKSUM_B}.png 2x"/></item>'
72+
)
73+
74+
75+
def test_rewrite_ignores_values_not_in_mapping():
76+
xml = f'<item><img src="{CHECKSUM_A}.png"/><a href="{CHECKSUM_B}.pdf">x</a></item>'
77+
result = rewrite_qti_media_paths(
78+
xml, {f"{CHECKSUM_A}.png": f"images/{CHECKSUM_A}.png"}
79+
)
80+
assert result == (
81+
f'<item><img src="images/{CHECKSUM_A}.png"/><a href="{CHECKSUM_B}.pdf">x</a></item>'
82+
)

0 commit comments

Comments
 (0)