diff --git a/contentcuration/contentcuration/tests/test_exportchannel.py b/contentcuration/contentcuration/tests/test_exportchannel.py
index 6da5afc62f..d4ff340e6f 100644
--- a/contentcuration/contentcuration/tests/test_exportchannel.py
+++ b/contentcuration/contentcuration/tests/test_exportchannel.py
@@ -377,6 +377,42 @@ def setUp(self):
randomize=False,
)
+ # A node mixing a native QTI item with a raw perseus_question item ->
+ # must route to a single QTI package (Perseus embedded as custom
+ # interactions), not a separate Perseus archive.
+ mixed_perseus_qti_exercise = create_node(
+ {
+ "kind_id": "exercise",
+ "title": "Perseus + Native QTI Mixed Exercise",
+ "extra_fields": qti_extra_fields,
+ }
+ )
+ mixed_perseus_qti_exercise.complete = True
+ mixed_perseus_qti_exercise.parent = current_exercise.parent
+ mixed_perseus_qti_exercise.save()
+ cc.AssessmentItem.objects.create(
+ contentnode=mixed_perseus_qti_exercise,
+ assessment_id=uuid.uuid4().hex,
+ type=exercises.QTI,
+ question="",
+ answers="[]",
+ hints="[]",
+ raw_data=VALID_CHOICE_ITEM,
+ order=1,
+ randomize=False,
+ )
+ cc.AssessmentItem.objects.create(
+ contentnode=mixed_perseus_qti_exercise,
+ assessment_id=uuid.uuid4().hex,
+ type=exercises.PERSEUS_QUESTION,
+ question="",
+ answers="[]",
+ hints="[]",
+ raw_data="{}",
+ order=2,
+ randomize=False,
+ )
+
first_topic = self.content_channel.main_tree.get_descendants().first()
# 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):
self.assertIsNone(local_file.file_size)
def test_file_included_presets_renderable(self):
- # Every non-supplementary (renderable) exported file carries its own preset bit.
+ # Every non-supplementary (renderable) exported file carries its own
+ # preset bit. A mixed Perseus + native QTI package additionally sets the
+ # exercise bit (see test_mixed_qti_file_included_presets); no other file
+ # is augmented.
files = kolibri_models.File.objects.filter(supplementary=False)
assert files.count() > 0
+ exercise_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(format_presets.EXERCISE)
for file in files:
- expected = 2 ** RENDERABLE_PRESETS_ORDER.index(file.preset)
- self.assertEqual(file.included_presets, expected)
+ own_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(file.preset)
+ if file.preset == format_presets.QTI_ZIP:
+ self.assertIn(file.included_presets, (own_bit, own_bit | exercise_bit))
+ else:
+ self.assertEqual(file.included_presets, own_bit)
def test_file_included_presets_supplementary_null(self):
# Supplementary files (e.g. thumbnails) leave included_presets NULL.
@@ -685,6 +728,31 @@ def test_file_included_presets_supplementary_null(self):
for file in files:
self.assertIsNone(file.included_presets)
+ def test_mixed_qti_file_included_presets(self):
+ # A mixed Perseus + native QTI package embeds raw Perseus questions as
+ # custom interactions, so its qti File must also flag the exercise
+ # (Perseus) renderer via included_presets = qti | exercise.
+ qti_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(format_presets.QTI_ZIP)
+ exercise_bit = 2 ** RENDERABLE_PRESETS_ORDER.index(format_presets.EXERCISE)
+
+ mixed_node = kolibri_models.ContentNode.objects.get(
+ title="Perseus + Native QTI Mixed Exercise"
+ )
+ mixed_qti_file = kolibri_models.File.objects.get(
+ contentnode=mixed_node, preset=format_presets.QTI_ZIP
+ )
+ self.assertEqual(mixed_qti_file.included_presets, qti_bit | exercise_bit)
+
+ # A native-QTI-only node embeds no Perseus questions, so its qti File
+ # keeps only the qti bit (guards against over-tagging).
+ native_node = kolibri_models.ContentNode.objects.get(
+ title="Native QTI Exercise"
+ )
+ native_qti_file = kolibri_models.File.objects.get(
+ contentnode=native_node, preset=format_presets.QTI_ZIP
+ )
+ self.assertEqual(native_qti_file.included_presets, qti_bit)
+
def test_channel_icon_encoding(self):
self.assertIsNotNone(self.content_channel.icon_encoding)
@@ -932,6 +1000,11 @@ def test_perseus_question_item_routes_to_perseus_packaging(self):
self.assertTrue(node.files.filter(preset_id=format_presets.EXERCISE).exists())
self.assertFalse(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
+ def test_mixed_perseus_and_native_qti_routes_to_qti(self):
+ node = cc.ContentNode.objects.get(title="Perseus + Native QTI Mixed Exercise")
+ self.assertTrue(node.files.filter(preset_id=format_presets.QTI_ZIP).exists())
+ self.assertFalse(node.files.filter(preset_id=format_presets.EXERCISE).exists())
+
def test_qti_archive_contains_manifest_and_assessment_ids(self):
published_qti_exercise = kolibri_models.ContentNode.objects.get(
diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py
index 2e3c2f7961..63d5ec32e2 100644
--- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py
+++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py
@@ -3,10 +3,17 @@
from le_utils.constants import exercises
+from contentcuration.utils.assessment.qti.convert import (
+ build_perseus_custom_interaction_item,
+)
from contentcuration.utils.assessment.qti.convert import (
convert_legacy_assessment_item_to_qti,
)
+from contentcuration.utils.assessment.qti.convert import hex_to_qti_id
from contentcuration.utils.assessment.qti.convert import LegacyAssessmentItem
+from contentcuration.utils.assessment.qti.interaction_types.custom import (
+ CustomInteraction,
+)
from contentcuration.utils.assessment.qti.validation import validate_qti_item
@@ -232,6 +239,65 @@ def test_free_response_with_maths(self):
)
+class CustomInteractionTests(unittest.TestCase):
+ ASSESSMENT_ID = "2b1c3d4e5f60718293a4b5c6d7e8f900"
+
+ def _build_item(self):
+ return build_perseus_custom_interaction_item(
+ self.ASSESSMENT_ID,
+ f"perseus/{self.ASSESSMENT_ID}.json",
+ "Q 1",
+ "en",
+ )
+
+ def test_custom_interaction_element_and_attributes(self):
+ interaction = CustomInteraction(
+ response_identifier="RESPONSE",
+ data_type="perseus",
+ data_perseus_path="perseus/abc.json",
+ )
+
+ xml = interaction.to_xml_string()
+
+ self.assertEqual(
+ _normalize_xml(
+ ''
+ ),
+ _normalize_xml(xml),
+ )
+
+ def test_builder_identifier_and_validity(self):
+ result = self._build_item()
+
+ self.assertEqual(result.identifier, hex_to_qti_id(self.ASSESSMENT_ID))
+ self.assertEqual(result.file_dependencies, [])
+ self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
+ self.assertIn('data-type="perseus"', result.xml)
+ self.assertIn(
+ f'data-perseus-path="perseus/{self.ASSESSMENT_ID}.json"', result.xml
+ )
+
+ def test_builder_grades_from_record_correct_field(self):
+ """
+ The Perseus renderer reports its result through a record RESPONSE, and
+ the item grades itself off that record's ``correct`` field.
+ """
+ result = self._build_item()
+
+ normalized = _normalize_xml(result.xml)
+ # RESPONSE is a record so it can carry correct/simpleAnswer/answerState.
+ self.assertIn(
+ '', normalized)
+ self.assertIn('', normalized)
+
+
class UnsupportedTypeConversionTests(unittest.TestCase):
def test_unsupported_type_raises(self):
item = _make_item(
diff --git a/contentcuration/contentcuration/tests/utils/test_exercise_creation.py b/contentcuration/contentcuration/tests/utils/test_exercise_creation.py
index a3785b6507..17541d8ba3 100644
--- a/contentcuration/contentcuration/tests/utils/test_exercise_creation.py
+++ b/contentcuration/contentcuration/tests/utils/test_exercise_creation.py
@@ -7,6 +7,7 @@
import re
import zipfile
from io import BytesIO
+from tempfile import TemporaryDirectory
from uuid import uuid4
from django.core.files.storage import default_storage as storage
@@ -27,6 +28,7 @@
from contentcuration.utils.assessment.qti.archive import hex_to_qti_id
from contentcuration.utils.assessment.qti.archive import QTIExerciseGenerator
from contentcuration.utils.assessment.qti.validation import parse_qti_xml
+from contentcuration.utils.assessment.qti.validation import validate_qti_item
class TestPerseusExerciseCreation(StudioTestCase):
@@ -480,6 +482,45 @@ def _create_perseus_item(self):
return item, graphie_files
+ def test_write_raw_perseus_assets_returns_paths_and_writes_files(self):
+ """`_write_raw_perseus_assets` writes an item's images/graphie assets into
+ the given directory and returns their package-relative paths."""
+ image_file = fileobj_exercise_image()
+ graphie_file = fileobj_exercise_graphie(original_filename="mygraphie")
+
+ item = AssessmentItem.objects.create(
+ contentnode=self.exercise_node,
+ assessment_id="fedcba0987654321fedcba0987654321",
+ type=exercises.PERSEUS_QUESTION,
+ raw_data="{}",
+ order=1,
+ randomize=True,
+ )
+ image_file.assessment_item = item
+ image_file.save()
+ graphie_file.assessment_item = item
+ graphie_file.save()
+
+ generator = PerseusExerciseGenerator(
+ self.exercise_node, {}, self.channel.id, "en-US", user_id=self.user.id
+ )
+ with TemporaryDirectory() as tempdir:
+ generator.tempdir = tempdir
+ written = generator._write_raw_perseus_assets(item, "perseus/images")
+
+ image_path = (
+ f"perseus/images/{image_file.checksum}.{image_file.file_format_id}"
+ )
+ svg_path = f"perseus/images/{graphie_file.original_filename}.svg"
+ json_path = f"perseus/images/{graphie_file.original_filename}-data.json"
+
+ self.assertIn(image_path, written)
+ self.assertIn(svg_path, written)
+ self.assertIn(json_path, written)
+
+ for path in (image_path, svg_path, json_path):
+ self.assertTrue(os.path.exists(os.path.join(tempdir, path)))
+
def test_exercise_with_graphie(self):
"""Test creating an exercise with graphie files (SVG+JSON pairs)"""
@@ -1454,32 +1495,83 @@ def test_qti_exercise_without_hints_produces_no_catalog_info(self):
item_xml = self._render_single_item_xml("abcdef1234567890abcdef1234567890", [])
self.assertNotIn("', manifest_xml)
+ self.assertIn(f'', manifest_xml)
def test_exercise_with_image(self):
"""Test QTI exercise generation with images"""
diff --git a/contentcuration/contentcuration/utils/assessment/base.py b/contentcuration/contentcuration/utils/assessment/base.py
index ead266e6ba..501b990d7f 100644
--- a/contentcuration/contentcuration/utils/assessment/base.py
+++ b/contentcuration/contentcuration/utils/assessment/base.py
@@ -13,6 +13,7 @@
from django.core.files import File
from django.core.files.storage import default_storage as storage
from le_utils.constants import exercises
+from le_utils.constants import format_presets
from PIL import Image
from contentcuration import models
@@ -136,6 +137,67 @@ def add_file_to_write(self, filepath, content):
f.write(content)
self.files_to_write.append(full_path)
+ def _write_raw_perseus_assets(self, assessment_item, images_dir):
+ """Write a raw Perseus item's image and graphie assets into ``images_dir``
+ and return the sorted list of package-relative paths written.
+
+ Graphie files are stored as a single delimited blob and are split into
+ their ``.svg`` and ``-data.json`` parts here.
+ """
+ # For raw perseus JSON questions, the files must be
+ # specified in advance.
+
+ # Files have been prefetched when the assessment item was
+ # queried, so take advantage of that.
+ files = sorted(assessment_item.files.all(), key=lambda x: x.checksum)
+ image_files = filter(
+ lambda x: x.preset_id == format_presets.EXERCISE_IMAGE, files
+ )
+ graphie_files = filter(
+ lambda x: x.preset_id == format_presets.EXERCISE_GRAPHIE, files
+ )
+ written_paths = []
+ for image in image_files:
+ image_name = "{}/{}.{}".format(
+ images_dir, image.checksum, image.file_format_id
+ )
+ with storage.open(
+ models.generate_object_storage_name(image.checksum, str(image)),
+ "rb",
+ ) as content:
+ self.add_file_to_write(image_name, content.read())
+ written_paths.append(image_name)
+
+ for image in graphie_files:
+ svg_name = "{}/{}.svg".format(images_dir, image.original_filename)
+ json_name = "{}/{}-data.json".format(images_dir, image.original_filename)
+ with storage.open(
+ models.generate_object_storage_name(image.checksum, str(image)),
+ "rb",
+ ) as content:
+ content = content.read()
+ # in Python 3, delimiter needs to be in bytes format
+ content = content.split(exercises.GRAPHIE_DELIMITER.encode("ascii"))
+ if len(content) != 2:
+ raise ValueError(
+ f"Graphie file '{image.original_filename}' "
+ f"missing delimiter {exercises.GRAPHIE_DELIMITER!r}"
+ )
+ self.add_file_to_write(svg_name, content[0])
+ self.add_file_to_write(json_name, content[1])
+ written_paths.append(svg_name)
+ written_paths.append(json_name)
+
+ return sorted(written_paths)
+
+ def _rewrite_content_storage_refs(self, raw_data, images_dir):
+ """Rewrite a raw item's content-storage references to the packaged
+ ``images_dir`` (under the ``IMG_PLACEHOLDER`` the renderer resolves)."""
+ return raw_data.replace(
+ exercises.CONTENT_STORAGE_PLACEHOLDER,
+ f"{exercises.IMG_PLACEHOLDER}/{images_dir}",
+ )
+
def _add_original_image(self, checksum, filename, new_file_path):
"""Extract original image handling"""
with storage.open(
@@ -314,15 +376,13 @@ def process_assessment_item(self, assessment_item):
processed_answers = self._process_answers(assessment_item)
processed_hints = self._process_hints(assessment_item)
- new_file_path = self.get_image_file_path()
- new_image_path = f"{exercises.IMG_PLACEHOLDER}/{new_file_path}"
context = {
"question": question,
"question_images": question_images,
"answers": processed_answers,
"multiple_select": assessment_item.type == exercises.MULTIPLE_SELECTION,
- "raw_data": assessment_item.raw_data.replace(
- exercises.CONTENT_STORAGE_PLACEHOLDER, new_image_path
+ "raw_data": self._rewrite_content_storage_refs(
+ assessment_item.raw_data, self.get_image_file_path()
),
"hints": processed_hints,
"randomize": assessment_item.randomize,
diff --git a/contentcuration/contentcuration/utils/assessment/perseus.py b/contentcuration/contentcuration/utils/assessment/perseus.py
index 8ca2836940..20e20a0bfb 100644
--- a/contentcuration/contentcuration/utils/assessment/perseus.py
+++ b/contentcuration/contentcuration/utils/assessment/perseus.py
@@ -2,13 +2,11 @@
import re
import zipfile
-from django.core.files.storage import default_storage as storage
from django.template.loader import render_to_string
from le_utils.constants import exercises
from le_utils.constants import file_formats
from le_utils.constants import format_presets
-from contentcuration import models
from contentcuration.utils.assessment.base import ExerciseArchiveGenerator
from contentcuration.utils.assessment.qti.perseus_derive import derive_perseus_item
from contentcuration.utils.parser import extract_value
@@ -55,48 +53,6 @@ def _derived_items(self):
}
return self._derived_cache
- def _write_raw_perseus_image_files(self, assessment_item):
- # For raw perseus JSON questions, the files must be
- # specified in advance.
-
- # Files have been prefetched when the assessment item was
- # queried, so take advantage of that.
- files = sorted(assessment_item.files.all(), key=lambda x: x.checksum)
- image_files = filter(
- lambda x: x.preset_id == format_presets.EXERCISE_IMAGE, files
- )
- graphie_files = filter(
- lambda x: x.preset_id == format_presets.EXERCISE_GRAPHIE, files
- )
- images_path = self.get_image_file_path()
- for image in image_files:
- image_name = "{}/{}.{}".format(
- images_path, image.checksum, image.file_format_id
- )
- with storage.open(
- models.generate_object_storage_name(image.checksum, str(image)),
- "rb",
- ) as content:
- self.add_file_to_write(image_name, content.read())
-
- for image in graphie_files:
- svg_name = "{}/{}.svg".format(images_path, image.original_filename)
- json_name = "{}/{}-data.json".format(images_path, image.original_filename)
- with storage.open(
- models.generate_object_storage_name(image.checksum, str(image)),
- "rb",
- ) as content:
- content = content.read()
- # in Python 3, delimiter needs to be in bytes format
- content = content.split(exercises.GRAPHIE_DELIMITER.encode("ascii"))
- if len(content) != 2:
- raise ValueError(
- f"Graphie file '{image.original_filename}' "
- f"missing delimiter {exercises.GRAPHIE_DELIMITER!r}"
- )
- self.add_file_to_write(svg_name, content[0])
- self.add_file_to_write(json_name, content[1])
-
def _process_formulas(self, content):
return _DOUBLE_DOLLAR_RE.sub(r"$\1$", content)
@@ -112,7 +68,7 @@ def process_assessment_item(self, assessment_item):
return
return super().process_assessment_item(derived)
if assessment_item.type == exercises.PERSEUS_QUESTION:
- self._write_raw_perseus_image_files(assessment_item)
+ self._write_raw_perseus_assets(assessment_item, self.get_image_file_path())
return super().process_assessment_item(assessment_item)
def _process_input_answers(self, processed_data):
diff --git a/contentcuration/contentcuration/utils/assessment/qti/archive.py b/contentcuration/contentcuration/utils/assessment/qti/archive.py
index dbbce7f65a..a420953e77 100644
--- a/contentcuration/contentcuration/utils/assessment/qti/archive.py
+++ b/contentcuration/contentcuration/utils/assessment/qti/archive.py
@@ -11,6 +11,9 @@
from contentcuration.utils.assessment.base import ExerciseArchiveGenerator
from contentcuration.utils.assessment.qti.constants import ResourceType
+from contentcuration.utils.assessment.qti.convert import (
+ build_perseus_custom_interaction_item,
+)
from contentcuration.utils.assessment.qti.convert import (
convert_legacy_assessment_item_to_qti,
)
@@ -43,6 +46,8 @@ class QTIExerciseGenerator(ExerciseArchiveGenerator):
file_format = "zip"
preset = format_presets.QTI_ZIP
+ PERSEUS_IMAGE_DIR = "perseus/images"
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.qti_resources: List[QTIResource] = []
@@ -61,6 +66,16 @@ def get_image_ref_prefix(self):
def _qti_item_filepath(self, assessment_id):
return f"items/{assessment_id}.xml"
+ def _node_language(self):
+ return (
+ self.ccnode.language.lang_code
+ if self.ccnode.language
+ else self.default_language
+ )
+
+ def _next_item_title(self):
+ return f"{self.ccnode.title} {len(self.qti_resources) + 1}"
+
def _add_resource(self, resource: QTIResource) -> None:
if any(r.identifier == resource.identifier for r in self.qti_resources):
raise ValueError(
@@ -136,6 +151,44 @@ def _write_qti_media_files(self, assessment_item) -> Tuple[str, List[str]]:
item_xml = rewrite_qti_media_paths(raw_data, path_by_filename)
return item_xml, sorted(path_by_filename.values())
+ def process_assessment_item(self, assessment_item):
+ if assessment_item.type == exercises.PERSEUS_QUESTION:
+ return self._create_perseus_custom_interaction(assessment_item)
+ return super().process_assessment_item(assessment_item)
+
+ def _create_perseus_custom_interaction(self, assessment_item) -> None:
+ """Embed a raw Perseus question as a ``qti-custom-interaction``.
+
+ Writes the Perseus JSON (with its content-storage references rewritten to
+ the packaged asset paths) and its image/graphie assets into the package,
+ and declares them as dependencies of the wrapper item's manifest resource.
+ """
+ asset_paths = self._write_raw_perseus_assets(
+ assessment_item, self.PERSEUS_IMAGE_DIR
+ )
+ perseus_json = self._rewrite_content_storage_refs(
+ assessment_item.raw_data, self.PERSEUS_IMAGE_DIR
+ )
+ perseus_path = f"perseus/{assessment_item.assessment_id}.json"
+ self.add_file_to_write(perseus_path, perseus_json.encode("utf-8"))
+
+ result = build_perseus_custom_interaction_item(
+ assessment_item.assessment_id,
+ perseus_path,
+ self._next_item_title(),
+ self._node_language(),
+ )
+
+ item_path = self._qti_item_filepath(result.identifier)
+ self.add_file_to_write(item_path, result.xml.encode("utf-8"))
+ self._add_resource(
+ QTIResource(
+ identifier=result.identifier,
+ filepath=item_path,
+ file_dependencies=[perseus_path, *asset_paths],
+ )
+ )
+
def create_assessment_item(
self, assessment_item, processed_data: Dict[str, Any]
) -> Optional[Tuple[str, bytes]]:
@@ -144,17 +197,6 @@ def create_assessment_item(
if assessment_item.type == exercises.QTI:
return self._create_native_qti_item(assessment_item)
- # Skip Perseus questions as they can't be easily converted
- if assessment_item.type == exercises.PERSEUS_QUESTION:
- raise ValueError(
- f"Perseus questions are not supported in QTI format: {assessment_item.assessment_id}"
- )
-
- language = (
- self.ccnode.language.lang_code
- if self.ccnode.language
- else self.default_language
- )
legacy_item = LegacyAssessmentItem(
type=assessment_item.type,
question=processed_data["question"],
@@ -162,8 +204,8 @@ def create_assessment_item(
hints=processed_data.get("hints", []),
randomize=processed_data.get("randomize", False),
assessment_id=assessment_item.assessment_id,
- title=f"{self.ccnode.title} {len(self.qti_resources) + 1}",
- language=language,
+ title=self._next_item_title(),
+ language=self._node_language(),
)
result = convert_legacy_assessment_item_to_qti(legacy_item)
diff --git a/contentcuration/contentcuration/utils/assessment/qti/assessment_item.py b/contentcuration/contentcuration/utils/assessment/qti/assessment_item.py
index 54f52e5375..87d29cc268 100644
--- a/contentcuration/contentcuration/utils/assessment/qti/assessment_item.py
+++ b/contentcuration/contentcuration/utils/assessment/qti/assessment_item.py
@@ -194,7 +194,9 @@ class ResponseDeclaration(QTIBase):
identifier: QTIIdentifier
cardinality: Cardinality
- base_type: BaseType
+ # base-type is optional in the XSD and is omitted for record cardinality,
+ # whose fields each carry their own base-type at runtime.
+ base_type: Optional[BaseType] = None
correct_response: Optional[CorrectResponse] = None
mapping: Optional[Mapping] = None
area_mapping: Optional[AreaMapping] = None
@@ -205,6 +207,57 @@ def validate_cardinality_compatibility(self):
return self
+class BaseValue(QTIBase):
+ """A literal value of a given base-type (``qti-base-value`` expression)."""
+
+ base_type: BaseType
+ value: TextType
+
+
+class Variable(QTIBase):
+ """Look up the value of an item variable (``qti-variable`` expression)."""
+
+ identifier: QTIIdentifier
+
+
+class FieldValue(QTIBase):
+ """
+ Extract a named field from a record-cardinality expression
+ (``qti-field-value``). Used to read a single field, e.g. ``correct``, out of
+ a record response variable.
+ """
+
+ field_identifier: QTIIdentifier
+ variable: Variable
+
+
+class SetOutcomeValue(QTIBase):
+ """Assign an expression's value to an outcome variable."""
+
+ identifier: QTIIdentifier
+ base_value: BaseValue
+
+
+class ResponseIf(QTIBase):
+ """The 'if' branch of a response condition: a boolean expression plus a rule."""
+
+ field_value: FieldValue
+ set_outcome_value: SetOutcomeValue
+
+
+class ResponseElse(QTIBase):
+ """The 'else' branch of a response condition."""
+
+ set_outcome_value: SetOutcomeValue
+
+
+class ResponseCondition(QTIBase):
+ """An if/else response-processing rule (``qti-response-condition``)."""
+
+ response_if: ResponseIf
+ response_else: Optional[ResponseElse] = None
+
+
class ResponseProcessing(QTIBase):
"""Represents response processing rules or template reference"""
@@ -213,7 +266,9 @@ class ResponseProcessing(QTIBase):
# Optional URL that resolves to the template - we additionally enforce that this be local
# although this is not required by the QTI spec
template_location: Optional[LocalHrefPath] = None
- # rules deliberately not implemented yet
+ # Inline response-processing rules, used when no standard template can express
+ # the grading (e.g. reading the correct/incorrect result out of a record).
+ children: List[ResponseCondition] = Field(default_factory=list)
class AssessmentItem(QTIBase):
diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py
index e6906b2696..2a64cd251f 100644
--- a/contentcuration/contentcuration/utils/assessment/qti/convert.py
+++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py
@@ -12,12 +12,19 @@
from contentcuration.utils.assessment.markdown import render_markdown
from contentcuration.utils.assessment.qti.assessment_item import AssessmentItem
+from contentcuration.utils.assessment.qti.assessment_item import BaseValue
from contentcuration.utils.assessment.qti.assessment_item import CorrectResponse
+from contentcuration.utils.assessment.qti.assessment_item import FieldValue
from contentcuration.utils.assessment.qti.assessment_item import ItemBody
from contentcuration.utils.assessment.qti.assessment_item import OutcomeDeclaration
+from contentcuration.utils.assessment.qti.assessment_item import ResponseCondition
from contentcuration.utils.assessment.qti.assessment_item import ResponseDeclaration
+from contentcuration.utils.assessment.qti.assessment_item import ResponseElse
+from contentcuration.utils.assessment.qti.assessment_item import ResponseIf
from contentcuration.utils.assessment.qti.assessment_item import ResponseProcessing
+from contentcuration.utils.assessment.qti.assessment_item import SetOutcomeValue
from contentcuration.utils.assessment.qti.assessment_item import Value
+from contentcuration.utils.assessment.qti.assessment_item import Variable
from contentcuration.utils.assessment.qti.base import ElementTreeBase
from contentcuration.utils.assessment.qti.catalog import Card
from contentcuration.utils.assessment.qti.catalog import Catalog
@@ -30,6 +37,9 @@
from contentcuration.utils.assessment.qti.html import Div
from contentcuration.utils.assessment.qti.html import FlowContentList
from contentcuration.utils.assessment.qti.html import P
+from contentcuration.utils.assessment.qti.interaction_types.custom import (
+ CustomInteraction,
+)
from contentcuration.utils.assessment.qti.interaction_types.simple import (
ChoiceInteraction,
)
@@ -208,6 +218,86 @@ def _create_text_entry_interaction_and_response(
return interaction, response_declaration
+def build_perseus_custom_interaction_item(
+ assessment_id: str, perseus_path: str, title: str, language: str
+) -> QTIConversionResult:
+ """
+ Wrap a raw Perseus question in a schema-valid ``qti-assessment-item`` whose
+ body is a single ``qti-custom-interaction`` (``data-type="perseus"``).
+
+ The host's Perseus renderer owns rendering and grading, but the result is
+ handled as a complete QTI question. The renderer reports its outcome through
+ the ``RESPONSE`` variable as a record with fields ``correct`` (boolean),
+ ``simpleAnswer`` (string) and ``answerState`` (an arbitrary object). The
+ record declaration specifies no schema, so each field carries its own
+ base-type at runtime and ``answerState`` need not be stringified.
+ Response processing reads the ``correct`` field and sets
+ the ``SCORE`` outcome to 1 (correct) or 0 (incorrect) - the standard
+ correct/incorrect grading, expressed inline because no standard response
+ processing template can inspect a record field.
+
+ The Perseus JSON and its assets are declared as package files by the
+ generator, not tracked from this XML, so ``file_dependencies`` is empty.
+ """
+ identifier = hex_to_qti_id(assessment_id)
+
+ item = AssessmentItem(
+ identifier=identifier,
+ title=title,
+ language=language,
+ adaptive=False,
+ time_dependent=False,
+ response_declaration=[
+ ResponseDeclaration(
+ identifier="RESPONSE",
+ cardinality=Cardinality.RECORD,
+ )
+ ],
+ outcome_declaration=[
+ OutcomeDeclaration(
+ identifier="SCORE",
+ cardinality=Cardinality.SINGLE,
+ base_type=BaseType.FLOAT,
+ )
+ ],
+ item_body=ItemBody(
+ children=[
+ CustomInteraction(
+ response_identifier="RESPONSE",
+ data_type="perseus",
+ data_perseus_path=perseus_path,
+ )
+ ]
+ ),
+ response_processing=ResponseProcessing(
+ children=[
+ ResponseCondition(
+ response_if=ResponseIf(
+ field_value=FieldValue(
+ field_identifier="correct",
+ variable=Variable(identifier="RESPONSE"),
+ ),
+ set_outcome_value=SetOutcomeValue(
+ identifier="SCORE",
+ base_value=BaseValue(base_type=BaseType.FLOAT, value="1"),
+ ),
+ ),
+ response_else=ResponseElse(
+ set_outcome_value=SetOutcomeValue(
+ identifier="SCORE",
+ base_value=BaseValue(base_type=BaseType.FLOAT, value="0"),
+ ),
+ ),
+ )
+ ]
+ ),
+ )
+
+ xml = f'\n{item.to_xml_string()}'
+
+ return QTIConversionResult(identifier=identifier, xml=xml, file_dependencies=[])
+
+
def convert_legacy_assessment_item_to_qti(
item: LegacyAssessmentItem,
) -> QTIConversionResult:
diff --git a/contentcuration/contentcuration/utils/assessment/qti/interaction_types/custom.py b/contentcuration/contentcuration/utils/assessment/qti/interaction_types/custom.py
new file mode 100644
index 0000000000..dac2485d55
--- /dev/null
+++ b/contentcuration/contentcuration/utils/assessment/qti/interaction_types/custom.py
@@ -0,0 +1,14 @@
+from contentcuration.utils.assessment.qti.interaction_types.base import BlockInteraction
+
+
+class CustomInteraction(BlockInteraction):
+ """
+ A delivery-engine-specific interaction (``qti-custom-interaction``).
+
+ Used to embed a raw Perseus question in a QTI package: the host's Perseus
+ renderer, keyed off ``data-type="perseus"``, owns rendering and grading and
+ reports correctness back through the QTI response.
+ """
+
+ data_type: str
+ data_perseus_path: str
diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py
index 722ee06155..060bf63304 100644
--- a/contentcuration/contentcuration/utils/publish.py
+++ b/contentcuration/contentcuration/utils/publish.py
@@ -386,13 +386,26 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901
metadata,
)
+ # A mixed node's QTI package embeds its raw Perseus questions as
+ # custom interactions, so that file also needs the Perseus/exercise
+ # renderer. Discovered here alongside generator selection and carried
+ # to create_associated_file_objects, so the included_presets bit is
+ # grounded in the same decision that drives the embedding.
+ qti_embeds_perseus = False
+
if has_assessments(node):
exercise_data = process_assessment_metadata(node)
+ mapping_values = exercise_data["assessment_mapping"].values()
any_perseus_question = any(
- t == exercises.PERSEUS_QUESTION
- for t in exercise_data["assessment_mapping"].values()
+ t == exercises.PERSEUS_QUESTION for t in mapping_values
)
- if any_perseus_question:
+ any_native_qti = any(t == exercises.QTI for t in mapping_values)
+ qti_embeds_perseus = any_perseus_question and any_native_qti
+ if qti_embeds_perseus:
+ # Mixed node: one QTI package, raw Perseus embedded as
+ # custom interactions.
+ generator_classes = [QTIExerciseGenerator]
+ elif any_perseus_question:
generator_classes = [PerseusExerciseGenerator]
else:
generator_classes = [QTIExerciseGenerator]
@@ -439,7 +452,7 @@ def recurse_nodes(self, node, inherited_fields): # noqa C901
if node.kind_id == content_kinds.TOPIC:
for child in node.children.all():
self.recurse_nodes(child, metadata)
- create_associated_file_objects(kolibrinode, node)
+ create_associated_file_objects(kolibrinode, node, qti_embeds_perseus)
map_tags_to_node(kolibrinode, node)
self._node_completed()
@@ -669,7 +682,7 @@ def create_associated_thumbnail(ccnode, ccfilemodel):
)
-def create_associated_file_objects(kolibrinode, ccnode):
+def create_associated_file_objects(kolibrinode, ccnode, qti_embeds_perseus=False):
logging.debug(
"Creating LocalFile and File objects for Node {}".format(kolibrinode.id)
)
@@ -722,6 +735,17 @@ def create_associated_file_objects(kolibrinode, ccnode):
ccfilemodel.pk,
)
+ # EXERCISE bit: a mixed QTI package embeds raw Perseus custom
+ # interactions, so it also needs the Perseus renderer (see recurse_nodes).
+ if (
+ included_presets is not None
+ and preset.pk == format_presets.QTI_ZIP
+ and qti_embeds_perseus
+ ):
+ included_presets |= 2 ** RENDERABLE_PRESETS_ORDER.index(
+ format_presets.EXERCISE
+ )
+
kolibrimodels.File.objects.create(
pk=ccfilemodel.pk,
checksum=ccfilemodel.checksum,