Skip to content

Commit 6eb2139

Browse files
rtibblesbotclaude
andcommitted
feat: publish Perseus alongside QTI for derivable nodes
recurse_nodes emits both the QTI package and a Perseus archive when a node has native QTI items and every item is Perseus-expressible; QTI only otherwise, so a node containing any non-expressible interaction never ships a partial or invalid Perseus. The expressible-types gate is sourced from PerseusExerciseGenerator.TEMPLATE_MAP so it can't drift from what the generator can render, and the stale-preset cleanup is generalized to diff against the full generator list rather than a single generator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d3b9323 commit 6eb2139

2 files changed

Lines changed: 123 additions & 26 deletions

File tree

contentcuration/contentcuration/tests/test_exportchannel.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import string
55
import tempfile
66
import uuid
7+
import zipfile
78
from unittest import mock
89

910
import pytest
@@ -35,6 +36,7 @@
3536
from .testdata import slideshow
3637
from .testdata import thumbnail_bytes
3738
from .testdata import tree
39+
from .utils.qti.test_validation import _item_xml
3840
from .utils.qti.test_validation import VALID_CHOICE_ITEM
3941
from .utils.restricted_filesystemstorage import RestrictedFileSystemStorage
4042
from contentcuration import models as cc
@@ -56,6 +58,24 @@
5658

5759
pytestmark = pytest.mark.django_db
5860

61+
# A schema-valid native QTI item whose single interaction (order) Perseus cannot
62+
# express, so a node containing it must publish QTI only.
63+
UNSUPPORTED_QTI_ITEM = _item_xml(
64+
"item_unsupported",
65+
"Unsupported Item",
66+
'<qti-response-declaration identifier="RESPONSE" cardinality="ordered" base-type="identifier">'
67+
"<qti-correct-response>"
68+
"<qti-value>choice_0</qti-value>"
69+
"<qti-value>choice_1</qti-value>"
70+
"</qti-correct-response>"
71+
"</qti-response-declaration>",
72+
'<qti-order-interaction response-identifier="RESPONSE">'
73+
"<qti-prompt>Put these in order.</qti-prompt>"
74+
'<qti-simple-choice identifier="choice_0" fixed="false">First</qti-simple-choice>'
75+
'<qti-simple-choice identifier="choice_1" fixed="false">Second</qti-simple-choice>'
76+
"</qti-order-interaction>",
77+
)
78+
5979

6080
def description():
6181
return "".join(random.sample(string.printable, 20))
@@ -282,6 +302,29 @@ def setUp(self):
282302
randomize=False,
283303
)
284304

305+
# Native QTI item whose interaction Perseus cannot express -> QTI only
306+
native_qti_unsupported_exercise = create_node(
307+
{
308+
"kind_id": "exercise",
309+
"title": "Native QTI Unsupported Exercise",
310+
"extra_fields": qti_extra_fields,
311+
}
312+
)
313+
native_qti_unsupported_exercise.complete = True
314+
native_qti_unsupported_exercise.parent = current_exercise.parent
315+
native_qti_unsupported_exercise.save()
316+
cc.AssessmentItem.objects.create(
317+
contentnode=native_qti_unsupported_exercise,
318+
assessment_id=uuid.uuid4().hex,
319+
type=exercises.QTI,
320+
question="",
321+
answers="[]",
322+
hints="[]",
323+
raw_data=UNSUPPORTED_QTI_ITEM,
324+
order=1,
325+
randomize=False,
326+
)
327+
285328
# Only legacy structured-field items, no perseus_question -> must now route to QTI (was Perseus)
286329
legacy_no_perseus_exercise = create_node(
287330
{
@@ -779,8 +822,36 @@ def test_qti_exercise_generates_qti_archive(self):
779822
"QTI file should be a zip archive",
780823
)
781824

782-
def test_native_qti_item_routes_to_qti_packaging(self):
825+
def test_native_qti_choice_item_publishes_both_archives(self):
826+
node = cc.ContentNode.objects.get(title="Native QTI Exercise")
827+
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
828+
self.assertTrue(node.files.filter(preset_id=format_presets.EXERCISE).exists())
829+
830+
def test_native_qti_perseus_ids_match_assessment_metadata(self):
831+
"""The derived Perseus item JSON filenames must equal the ids recorded
832+
in the published node's ``AssessmentMetaData.assessment_item_ids`` (the
833+
QTI manifest ``K``-ids), so older Kolibri resolves the derived items."""
783834
node = cc.ContentNode.objects.get(title="Native QTI Exercise")
835+
exercise_file = node.files.get(preset_id=format_presets.EXERCISE)
836+
with exercise_file.file_on_disk.open("rb") as file_handle:
837+
item_stems = {
838+
name[: -len(".json")]
839+
for name in zipfile.ZipFile(file_handle).namelist()
840+
if name.endswith(".json") and name != "exercise.json"
841+
}
842+
843+
published_node = kolibri_models.ContentNode.objects.get(
844+
title="Native QTI Exercise"
845+
)
846+
assessment_item_ids = set(
847+
published_node.assessmentmetadata.first().assessment_item_ids
848+
)
849+
850+
self.assertTrue(item_stems)
851+
self.assertEqual(item_stems, assessment_item_ids)
852+
853+
def test_native_qti_unsupported_interaction_publishes_qti_only(self):
854+
node = cc.ContentNode.objects.get(title="Native QTI Unsupported Exercise")
784855
self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
785856
self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())
786857

contentcuration/contentcuration/utils/publish.py

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from contentcuration.utils.assessment.qti.imsmanifest import (
5151
get_assessment_ids_from_manifest,
5252
)
53+
from contentcuration.utils.assessment.qti.perseus_derive import is_perseus_derivable
5354
from contentcuration.utils.cache import delete_public_channel_cache_keys
5455
from contentcuration.utils.files import create_thumbnail_from_base64
5556
from contentcuration.utils.files import get_thumbnail_encoding
@@ -249,6 +250,27 @@ def assign_license_to_contentcuration_nodes(channel, license):
249250
]
250251

251252

253+
# Legacy structured-field item types Perseus can render, sourced from the
254+
# generator's own template map so the two never drift. ``perseus_question`` is
255+
# excluded: it takes the dedicated Perseus branch, never the derivation gate.
256+
PERSEUS_EXPRESSIBLE_LEGACY_TYPES = frozenset(PerseusExerciseGenerator.TEMPLATE_MAP) - {
257+
exercises.PERSEUS_QUESTION
258+
}
259+
260+
261+
def _node_is_perseus_derivable(node):
262+
"""True iff the node has >=1 native QTI item and every item is Perseus-expressible."""
263+
has_native_qti = False
264+
for item in node.assessment_items.all():
265+
if item.type == exercises.QTI:
266+
has_native_qti = True
267+
if not is_perseus_derivable(item.raw_data):
268+
return False
269+
elif item.type not in PERSEUS_EXPRESSIBLE_LEGACY_TYPES:
270+
return False
271+
return has_native_qti
272+
273+
252274
def has_assessments(node):
253275
"""Check if a node should have its assessment items published.
254276
@@ -367,36 +389,40 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901
367389
t == exercises.PERSEUS_QUESTION
368390
for t in exercise_data["assessment_mapping"].values()
369391
)
370-
generator_class = (
371-
PerseusExerciseGenerator
372-
if any_perseus_question
373-
else QTIExerciseGenerator
374-
)
375-
376-
# If this exercise previously had a file generated by a different
377-
# generator, make sure we clean it up here.
392+
if any_perseus_question:
393+
generator_classes = [PerseusExerciseGenerator]
394+
else:
395+
generator_classes = [QTIExerciseGenerator]
396+
# Also emit a Perseus archive when every item is a native QTI
397+
# interaction Perseus can express, so older Kolibri renders it.
398+
if _node_is_perseus_derivable(node):
399+
generator_classes.append(PerseusExerciseGenerator)
400+
401+
# If this exercise previously had files generated by generators no
402+
# longer in use, make sure we clean them up here.
403+
target_presets = {g.preset for g in generator_classes}
378404
stale_presets = {
379405
PerseusExerciseGenerator.preset,
380406
QTIExerciseGenerator.preset,
381-
} - {generator_class.preset}
382-
383-
# Remove archives produced by the previously-used generator
407+
} - target_presets
384408
node.files.filter(preset_id__in=stale_presets).delete()
385409

386-
if (
387-
self.force_exercises
388-
or node.changed
389-
or not node.files.filter(preset_id=generator_class.preset).exists()
390-
):
391-
392-
generator = generator_class(
393-
node,
394-
exercise_data,
395-
self.channel_id,
396-
self.default_language.lang_code,
397-
user_id=self.user_id,
398-
)
399-
generator.create_exercise_archive()
410+
for generator_class in generator_classes:
411+
if (
412+
self.force_exercises
413+
or node.changed
414+
or not node.files.filter(
415+
preset_id=generator_class.preset
416+
).exists()
417+
):
418+
generator = generator_class(
419+
node,
420+
exercise_data,
421+
self.channel_id,
422+
self.default_language.lang_code,
423+
user_id=self.user_id,
424+
)
425+
generator.create_exercise_archive()
400426

401427
# Only create assessment metadata for exercises, not UNIT topics
402428
# UNIT topics store their assessment config in options/completion_criteria

0 commit comments

Comments
 (0)