Skip to content

Commit 203e25e

Browse files
rtibblesbotclaude
andcommitted
Publish mixed Perseus + native QTI nodes as one QTI package
Route a node holding both raw Perseus and native QTI questions to QTIExerciseGenerator alone (one QTI package, no separate Perseus archive), capturing the mixed fact as qti_embeds_perseus during generator selection. Thread that flag into create_associated_file_objects to OR the exercise bit into the QTI file's included_presets (qti | exercise), so the flag is grounded in the same routing decision that embeds the Perseus custom interactions and cannot diverge from the packaged contents. Tests cover the routing, the included_presets bit, and the end-to-end mixed package contents. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3ce5e6b commit 203e25e

2 files changed

Lines changed: 105 additions & 8 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/utils/publish.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -386,13 +386,26 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901
386386
metadata,
387387
)
388388

389+
# A mixed node's QTI package embeds its raw Perseus questions as
390+
# custom interactions, so that file also needs the Perseus/exercise
391+
# renderer. Discovered here alongside generator selection and carried
392+
# to create_associated_file_objects, so the included_presets bit is
393+
# grounded in the same decision that drives the embedding.
394+
qti_embeds_perseus = False
395+
389396
if has_assessments(node):
390397
exercise_data = process_assessment_metadata(node)
398+
mapping_values = exercise_data["assessment_mapping"].values()
391399
any_perseus_question = any(
392-
t == exercises.PERSEUS_QUESTION
393-
for t in exercise_data["assessment_mapping"].values()
400+
t == exercises.PERSEUS_QUESTION for t in mapping_values
394401
)
395-
if any_perseus_question:
402+
any_native_qti = any(t == exercises.QTI for t in mapping_values)
403+
qti_embeds_perseus = any_perseus_question and any_native_qti
404+
if qti_embeds_perseus:
405+
# Mixed node: one QTI package, raw Perseus embedded as
406+
# custom interactions.
407+
generator_classes = [QTIExerciseGenerator]
408+
elif any_perseus_question:
396409
generator_classes = [PerseusExerciseGenerator]
397410
else:
398411
generator_classes = [QTIExerciseGenerator]
@@ -439,7 +452,7 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901
439452
if node.kind_id == content_kinds.TOPIC:
440453
for child in node.children.all():
441454
self.recurse_nodes(child, metadata)
442-
create_associated_file_objects(kolibrinode, node)
455+
create_associated_file_objects(kolibrinode, node, qti_embeds_perseus)
443456
map_tags_to_node(kolibrinode, node)
444457

445458
self._node_completed()
@@ -669,7 +682,7 @@ def create_associated_thumbnail(ccnode, ccfilemodel):
669682
)
670683

671684

672-
def create_associated_file_objects(kolibrinode, ccnode):
685+
def create_associated_file_objects(kolibrinode, ccnode, qti_embeds_perseus=False):
673686
logging.debug(
674687
"Creating LocalFile and File objects for Node {}".format(kolibrinode.id)
675688
)
@@ -722,6 +735,17 @@ def create_associated_file_objects(kolibrinode, ccnode):
722735
ccfilemodel.pk,
723736
)
724737

738+
# EXERCISE bit: a mixed QTI package embeds raw Perseus custom
739+
# interactions, so it also needs the Perseus renderer (see recurse_nodes).
740+
if (
741+
included_presets is not None
742+
and preset.pk == format_presets.QTI_ZIP
743+
and qti_embeds_perseus
744+
):
745+
included_presets |= 2 ** RENDERABLE_PRESETS_ORDER.index(
746+
format_presets.EXERCISE
747+
)
748+
725749
kolibrimodels.File.objects.create(
726750
pk=ccfilemodel.pk,
727751
checksum=ccfilemodel.checksum,

0 commit comments

Comments
 (0)