Skip to content

Commit 4c74c10

Browse files
authored
Merge pull request learningequality#6047 from rtibblesbot/issue-6006-e39e54
Publish mixed Perseus + native QTI exercise nodes as one QTI package
2 parents 402f22e + 203e25e commit 4c74c10

10 files changed

Lines changed: 558 additions & 86 deletions

File tree

contentcuration/contentcuration/tests/test_exportchannel.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,42 @@ def setUp(self):
377377
randomize=False,
378378
)
379379

380+
# A node mixing a native QTI item with a raw perseus_question item ->
381+
# must route to a single QTI package (Perseus embedded as custom
382+
# interactions), not a separate Perseus archive.
383+
mixed_perseus_qti_exercise = create_node(
384+
{
385+
"kind_id": "exercise",
386+
"title": "Perseus + Native QTI Mixed Exercise",
387+
"extra_fields": qti_extra_fields,
388+
}
389+
)
390+
mixed_perseus_qti_exercise.complete = True
391+
mixed_perseus_qti_exercise.parent = current_exercise.parent
392+
mixed_perseus_qti_exercise.save()
393+
cc.AssessmentItem.objects.create(
394+
contentnode=mixed_perseus_qti_exercise,
395+
assessment_id=uuid.uuid4().hex,
396+
type=exercises.QTI,
397+
question="",
398+
answers="[]",
399+
hints="[]",
400+
raw_data=VALID_CHOICE_ITEM,
401+
order=1,
402+
randomize=False,
403+
)
404+
cc.AssessmentItem.objects.create(
405+
contentnode=mixed_perseus_qti_exercise,
406+
assessment_id=uuid.uuid4().hex,
407+
type=exercises.PERSEUS_QUESTION,
408+
question="",
409+
answers="[]",
410+
hints="[]",
411+
raw_data="{}",
412+
order=2,
413+
randomize=False,
414+
)
415+
380416
first_topic = self.content_channel.main_tree.get_descendants().first()
381417

382418
# Add a publishable topic to ensure it does not inherit but that its children do
@@ -671,12 +707,19 @@ def test_localfile_large_file_size_bigint(self):
671707
self.assertIsNone(local_file.file_size)
672708

673709
def test_file_included_presets_renderable(self):
674-
# Every non-supplementary (renderable) exported file carries its own preset bit.
710+
# Every non-supplementary (renderable) exported file carries its own
711+
# preset bit. A mixed Perseus + native QTI package additionally sets the
712+
# exercise bit (see test_mixed_qti_file_included_presets); no other file
713+
# is augmented.
675714
files = kolibri_models.File.objects.filter(supplementary=False)
676715
assert files.count() > 0
716+
exercise_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(format_presets.EXERCISE)
677717
for file in files:
678-
expected = 2 ** RENDERABLE_PRESETS_ORDER.index(file.preset)
679-
self.assertEqual(file.included_presets, expected)
718+
own_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(file.preset)
719+
if file.preset == format_presets.QTI_ZIP:
720+
self.assertIn(file.included_presets, (own_bit, own_bit | exercise_bit))
721+
else:
722+
self.assertEqual(file.included_presets, own_bit)
680723

681724
def test_file_included_presets_supplementary_null(self):
682725
# Supplementary files (e.g. thumbnails) leave included_presets NULL.
@@ -685,6 +728,31 @@ def test_file_included_presets_supplementary_null(self):
685728
for file in files:
686729
self.assertIsNone(file.included_presets)
687730

731+
def test_mixed_qti_file_included_presets(self):
732+
# A mixed Perseus + native QTI package embeds raw Perseus questions as
733+
# custom interactions, so its qti File must also flag the exercise
734+
# (Perseus) renderer via included_presets = qti | exercise.
735+
qti_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(format_presets.QTI_ZIP)
736+
exercise_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(format_presets.EXERCISE)
737+
738+
mixed_node = kolibri_models.ContentNode.objects.get(
739+
title="Perseus + Native QTI Mixed Exercise"
740+
)
741+
mixed_qti_file = kolibri_models.File.objects.get(
742+
contentnode=mixed_node, preset=format_presets.QTI_ZIP
743+
)
744+
self.assertEqual(mixed_qti_file.included_presets, qti_bit | exercise_bit)
745+
746+
# A native-QTI-only node embeds no Perseus questions, so its qti File
747+
# keeps only the qti bit (guards against over-tagging).
748+
native_node = kolibri_models.ContentNode.objects.get(
749+
title="Native QTI Exercise"
750+
)
751+
native_qti_file = kolibri_models.File.objects.get(
752+
contentnode=native_node, preset=format_presets.QTI_ZIP
753+
)
754+
self.assertEqual(native_qti_file.included_presets, qti_bit)
755+
688756
def test_channel_icon_encoding(self):
689757
self.assertIsNotNone(self.content_channel.icon_encoding)
690758

@@ -932,6 +1000,11 @@ def test_perseus_question_item_routes_to_perseus_packaging(self):
9321000
self.assertTrue(node.files.filter(preset_id=format_presets.EXERCISE).exists())
9331001
self.assertFalse(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
9341002

1003+
def test_mixed_perseus_and_native_qti_routes_to_qti(self):
1004+
node = cc.ContentNode.objects.get(title="Perseus + Native QTI Mixed Exercise")
1005+
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
1006+
self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())
1007+
9351008
def test_qti_archive_contains_manifest_and_assessment_ids(self):
9361009

9371010
published_qti_exercise = kolibri_models.ContentNode.objects.get(

contentcuration/contentcuration/tests/utils/qti/test_convert.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@
33

44
from le_utils.constants import exercises
55

6+
from contentcuration.utils.assessment.qti.convert import (
7+
build_perseus_custom_interaction_item,
8+
)
69
from contentcuration.utils.assessment.qti.convert import (
710
convert_legacy_assessment_item_to_qti,
811
)
12+
from contentcuration.utils.assessment.qti.convert import hex_to_qti_id
913
from contentcuration.utils.assessment.qti.convert import LegacyAssessmentItem
14+
from contentcuration.utils.assessment.qti.interaction_types.custom import (
15+
CustomInteraction,
16+
)
1017
from contentcuration.utils.assessment.qti.validation import validate_qti_item
1118

1219

@@ -232,6 +239,65 @@ def test_free_response_with_maths(self):
232239
)
233240

234241

242+
class CustomInteractionTests(unittest.TestCase):
243+
ASSESSMENT_ID = "2b1c3d4e5f60718293a4b5c6d7e8f900"
244+
245+
def _build_item(self):
246+
return build_perseus_custom_interaction_item(
247+
self.ASSESSMENT_ID,
248+
f"perseus/{self.ASSESSMENT_ID}.json",
249+
"Q 1",
250+
"en",
251+
)
252+
253+
def test_custom_interaction_element_and_attributes(self):
254+
interaction = CustomInteraction(
255+
response_identifier="RESPONSE",
256+
data_type="perseus",
257+
data_perseus_path="perseus/abc.json",
258+
)
259+
260+
xml = interaction.to_xml_string()
261+
262+
self.assertEqual(
263+
_normalize_xml(
264+
'<qti-custom-interaction response-identifier="RESPONSE" '
265+
'data-type="perseus" data-perseus-path="perseus/abc.json" />'
266+
),
267+
_normalize_xml(xml),
268+
)
269+
270+
def test_builder_identifier_and_validity(self):
271+
result = self._build_item()
272+
273+
self.assertEqual(result.identifier, hex_to_qti_id(self.ASSESSMENT_ID))
274+
self.assertEqual(result.file_dependencies, [])
275+
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
276+
self.assertIn('data-type="perseus"', result.xml)
277+
self.assertIn(
278+
f'data-perseus-path="perseus/{self.ASSESSMENT_ID}.json"', result.xml
279+
)
280+
281+
def test_builder_grades_from_record_correct_field(self):
282+
"""
283+
The Perseus renderer reports its result through a record RESPONSE, and
284+
the item grades itself off that record's ``correct`` field.
285+
"""
286+
result = self._build_item()
287+
288+
normalized = _normalize_xml(result.xml)
289+
# RESPONSE is a record so it can carry correct/simpleAnswer/answerState.
290+
self.assertIn(
291+
'<qti-response-declaration identifier="RESPONSE" cardinality="record"',
292+
normalized,
293+
)
294+
# SCORE outcome plus response processing that reads the correct field.
295+
self.assertIn('<qti-outcome-declaration identifier="SCORE"', normalized)
296+
self.assertIn('<qti-field-value field-identifier="correct">', normalized)
297+
self.assertIn('<qti-variable identifier="RESPONSE"', normalized)
298+
self.assertIn('<qti-set-outcome-value identifier="SCORE">', normalized)
299+
300+
235301
class UnsupportedTypeConversionTests(unittest.TestCase):
236302
def test_unsupported_type_raises(self):
237303
item = _make_item(

contentcuration/contentcuration/tests/utils/test_exercise_creation.py

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import re
88
import zipfile
99
from io import BytesIO
10+
from tempfile import TemporaryDirectory
1011
from uuid import uuid4
1112

1213
from django.core.files.storage import default_storage as storage
@@ -27,6 +28,7 @@
2728
from contentcuration.utils.assessment.qti.archive import hex_to_qti_id
2829
from contentcuration.utils.assessment.qti.archive import QTIExerciseGenerator
2930
from contentcuration.utils.assessment.qti.validation import parse_qti_xml
31+
from contentcuration.utils.assessment.qti.validation import validate_qti_item
3032

3133

3234
class TestPerseusExerciseCreation(StudioTestCase):
@@ -480,6 +482,45 @@ def _create_perseus_item(self):
480482

481483
return item, graphie_files
482484

485+
def test_write_raw_perseus_assets_returns_paths_and_writes_files(self):
486+
"""`_write_raw_perseus_assets` writes an item's images/graphie assets into
487+
the given directory and returns their package-relative paths."""
488+
image_file = fileobj_exercise_image()
489+
graphie_file = fileobj_exercise_graphie(original_filename="mygraphie")
490+
491+
item = AssessmentItem.objects.create(
492+
contentnode=self.exercise_node,
493+
assessment_id="fedcba0987654321fedcba0987654321",
494+
type=exercises.PERSEUS_QUESTION,
495+
raw_data="{}",
496+
order=1,
497+
randomize=True,
498+
)
499+
image_file.assessment_item = item
500+
image_file.save()
501+
graphie_file.assessment_item = item
502+
graphie_file.save()
503+
504+
generator = PerseusExerciseGenerator(
505+
self.exercise_node, {}, self.channel.id, "en-US", user_id=self.user.id
506+
)
507+
with TemporaryDirectory() as tempdir:
508+
generator.tempdir = tempdir
509+
written = generator._write_raw_perseus_assets(item, "perseus/images")
510+
511+
image_path = (
512+
f"perseus/images/{image_file.checksum}.{image_file.file_format_id}"
513+
)
514+
svg_path = f"perseus/images/{graphie_file.original_filename}.svg"
515+
json_path = f"perseus/images/{graphie_file.original_filename}-data.json"
516+
517+
self.assertIn(image_path, written)
518+
self.assertIn(svg_path, written)
519+
self.assertIn(json_path, written)
520+
521+
for path in (image_path, svg_path, json_path):
522+
self.assertTrue(os.path.exists(os.path.join(tempdir, path)))
523+
483524
def test_exercise_with_graphie(self):
484525
"""Test creating an exercise with graphie files (SVG+JSON pairs)"""
485526

@@ -1454,32 +1495,83 @@ def test_qti_exercise_without_hints_produces_no_catalog_info(self):
14541495
item_xml = self._render_single_item_xml("abcdef1234567890abcdef1234567890", [])
14551496
self.assertNotIn("<qti-catalog-info", item_xml)
14561497

1457-
def test_perseus_question_rejection(self):
1458-
"""Test that Perseus questions are properly rejected"""
1459-
assessment_id = "aaaa1111bbbb2222cccc3333dddd4444"
1460-
# Create a mock Perseus question
1461-
item = AssessmentItem.objects.create(
1498+
def test_perseus_custom_interaction_embedded_with_native_qti(self):
1499+
"""A node mixing a native QTI item and a raw Perseus question yields one
1500+
QTI package: the native item, plus the Perseus question wrapped as a
1501+
``qti-custom-interaction`` with its JSON and image packaged and declared."""
1502+
native_id = "1234567890abcdef1234567890abcdef"
1503+
native_item = self._create_native_qti_item(
1504+
VALID_CHOICE_ITEM, assessment_id=native_id
1505+
)
1506+
1507+
image_file = fileobj_exercise_image()
1508+
image_url = exercises.CONTENT_STORAGE_FORMAT.format(image_file.filename())
1509+
perseus_id = "aaaa1111bbbb2222cccc3333dddd4444"
1510+
perseus_item = AssessmentItem.objects.create(
14621511
contentnode=self.exercise_node,
1463-
assessment_id=assessment_id,
1512+
assessment_id=perseus_id,
14641513
type=exercises.PERSEUS_QUESTION,
1465-
raw_data='{"question": {"content": "Perseus content"}}',
1466-
order=1,
1514+
raw_data=json.dumps(
1515+
{"question": {"content": f"See ![shape]({image_url})", "images": {}}},
1516+
ensure_ascii=False,
1517+
),
1518+
order=2,
1519+
randomize=False,
14671520
)
1521+
image_file.assessment_item = perseus_item
1522+
image_file.save()
14681523

14691524
exercise_data = {
14701525
"mastery_model": exercises.M_OF_N,
14711526
"randomize": True,
14721527
"n": 1,
14731528
"m": 1,
1474-
"all_assessment_items": [item.assessment_id],
1475-
"assessment_mapping": {item.assessment_id: exercises.PERSEUS_QUESTION},
1529+
"all_assessment_items": [
1530+
native_item.assessment_id,
1531+
perseus_item.assessment_id,
1532+
],
1533+
"assessment_mapping": {
1534+
native_item.assessment_id: exercises.QTI,
1535+
perseus_item.assessment_id: exercises.PERSEUS_QUESTION,
1536+
},
14761537
}
14771538

1478-
# Should raise ValueError for Perseus questions
1479-
with self.assertRaises(ValueError) as context:
1480-
self._create_qti_zip(exercise_data)
1539+
self._create_qti_zip(exercise_data)
1540+
exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP)
1541+
zip_file = self._validate_qti_zip_structure(exercise_file)
1542+
1543+
namelist = zip_file.namelist()
1544+
perseus_item_path = f"items/{hex_to_qti_id(perseus_id)}.xml"
1545+
perseus_json_path = f"perseus/{perseus_id}.json"
1546+
image_path = f"perseus/images/{image_file.filename()}"
1547+
1548+
# Native QTI item and the Perseus wrapper item both present.
1549+
self.assertIn("items/item_1.xml", namelist)
1550+
self.assertIn(perseus_item_path, namelist)
1551+
# Perseus JSON and its image packaged.
1552+
self.assertIn(perseus_json_path, namelist)
1553+
self.assertIn(image_path, namelist)
1554+
1555+
# The wrapper item validates and references the Perseus JSON.
1556+
wrapper_xml = zip_file.read(perseus_item_path).decode("utf-8")
1557+
self.assertTrue(validate_qti_item(wrapper_xml.encode("utf-8")).is_valid)
1558+
parsed = parse_qti_xml(wrapper_xml.encode("utf-8"))
1559+
custom = parsed.getroot().iter("{*}qti-custom-interaction")
1560+
custom_el = next(custom)
1561+
self.assertEqual(custom_el.get("data-type"), "perseus")
1562+
self.assertEqual(custom_el.get("data-perseus-path"), perseus_json_path)
1563+
1564+
# The Perseus JSON's image reference was rewritten to the packaged path.
1565+
packaged_json = zip_file.read(perseus_json_path).decode("utf-8")
1566+
self.assertIn(
1567+
f"${exercises.IMG_PLACEHOLDER}/perseus/images/{image_file.filename()}",
1568+
packaged_json,
1569+
)
14811570

1482-
self.assertIn("Perseus questions are not supported", str(context.exception))
1571+
# The manifest lists the JSON and image as files of the perseus resource.
1572+
manifest_xml = zip_file.read("imsmanifest.xml").decode("utf-8")
1573+
self.assertIn(f'<file href="{perseus_json_path}" />', manifest_xml)
1574+
self.assertIn(f'<file href="{image_path}" />', manifest_xml)
14831575

14841576
def test_exercise_with_image(self):
14851577
"""Test QTI exercise generation with images"""

0 commit comments

Comments
 (0)