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,