diff --git a/contentcuration/contentcuration/tests/test_exportchannel.py b/contentcuration/contentcuration/tests/test_exportchannel.py index a296d08967..f867e6a734 100644 --- a/contentcuration/contentcuration/tests/test_exportchannel.py +++ b/contentcuration/contentcuration/tests/test_exportchannel.py @@ -1,3 +1,4 @@ +import io import json import os import random @@ -53,6 +54,7 @@ from contentcuration.utils.publish import convert_channel_thumbnail from contentcuration.utils.publish import create_content_database from contentcuration.utils.publish import create_draft_channel_version +from contentcuration.utils.publish import create_kolibri_assessment_metadata from contentcuration.utils.publish import create_slideshow_manifest from contentcuration.utils.publish import fill_published_fields from contentcuration.utils.publish import map_prerequisites @@ -455,6 +457,85 @@ def setUp(self): randomize=False, ) + # Perseus packaging with an input question that has no accepted answer + perseus_answerless_input_exercise = create_node( + { + "kind_id": "exercise", + "title": "Perseus Answerless Input Exercise", + "extra_fields": qti_extra_fields, + } + ) + perseus_answerless_input_exercise.complete = True + perseus_answerless_input_exercise.parent = current_exercise.parent + perseus_answerless_input_exercise.save() + cc.AssessmentItem.objects.create( + contentnode=perseus_answerless_input_exercise, + assessment_id=uuid.uuid4().hex, + type=exercises.PERSEUS_QUESTION, + question="", + answers="[]", + hints="[]", + raw_data="{}", + order=1, + randomize=False, + ) + cc.AssessmentItem.objects.create( + contentnode=perseus_answerless_input_exercise, + assessment_id=uuid.uuid4().hex, + type=exercises.INPUT_QUESTION, + question="What is 2+3?", + answers=json.dumps([{"answer": "", "correct": True, "order": 1}]), + hints="[]", + raw_data="{}", + order=2, + randomize=False, + ) + + # Dual-published: derivable native QTI item plus an answerless input question + native_qti_answerless_input_exercise = create_node( + { + "kind_id": "exercise", + "title": "Native QTI Answerless Input Exercise", + "extra_fields": qti_extra_fields, + } + ) + native_qti_answerless_input_exercise.complete = True + native_qti_answerless_input_exercise.parent = current_exercise.parent + native_qti_answerless_input_exercise.save() + cc.AssessmentItem.objects.create( + contentnode=native_qti_answerless_input_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=native_qti_answerless_input_exercise, + assessment_id=uuid.uuid4().hex, + type=exercises.INPUT_QUESTION, + question="What is 2+3?", + answers=json.dumps([{"answer": "", "correct": True, "order": 1}]), + hints="[]", + raw_data="{}", + order=2, + randomize=False, + ) + cc.AssessmentItem.objects.create( + contentnode=native_qti_answerless_input_exercise, + assessment_id=uuid.uuid4().hex, + type=exercises.INPUT_QUESTION, + question="What is 2+3?", + answers=json.dumps([{"answer": "5", "correct": True, "order": 1}]), + hints="[]", + raw_data="{}", + order=3, + 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. @@ -1104,18 +1185,24 @@ def item_contents(title): self.assertTrue(commented) self.assertEqual(commented, item_contents("Native QTI Exercise")) - def test_native_qti_perseus_ids_match_assessment_metadata(self): - """The derived Perseus item JSON filenames must equal the ids recorded - in the published node's ``AssessmentMetaData.assessment_item_ids`` (the - QTI manifest ``K``-ids), so older Kolibri resolves the derived items.""" - node = cc.ContentNode.objects.get(title="Native QTI Exercise") + def _read_perseus_archive(self, node): exercise_file = node.files.get(preset_id=format_presets.EXERCISE) - with exercise_file.file_on_disk.open("rb") as file_handle: + with exercise_file.file_on_disk.open("rb") as file_handle, zipfile.ZipFile( + file_handle + ) as archive: item_stems = { name[: -len(".json")] - for name in zipfile.ZipFile(file_handle).namelist() + for name in archive.namelist() if name.endswith(".json") and name != "exercise.json" } + return item_stems, json.loads(archive.read("exercise.json")) + + def test_native_qti_perseus_ids_match_assessment_metadata(self): + """The derived Perseus item JSON filenames must equal the ids recorded + in the published node's ``AssessmentMetaData.assessment_item_ids`` (the + QTI manifest ``K``-ids), so older Kolibri resolves the derived items.""" + node = cc.ContentNode.objects.get(title="Native QTI Exercise") + item_stems, _ = self._read_perseus_archive(node) published_node = kolibri_models.ContentNode.objects.get( title="Native QTI Exercise" @@ -1131,15 +1218,7 @@ def test_mixed_native_legacy_perseus_ids_match_assessment_metadata(self): """Older Kolibri looks up legacy items by the QTI manifest id too.""" title = "Native QTI + Legacy Mixed Exercise" node = cc.ContentNode.objects.get(title=title) - exercise_file = node.files.get(preset_id=format_presets.EXERCISE) - with exercise_file.file_on_disk.open("rb") as file_handle: - archive = zipfile.ZipFile(file_handle) - item_stems = { - name[: -len(".json")] - for name in archive.namelist() - if name.endswith(".json") and name != "exercise.json" - } - exercise_data = json.loads(archive.read("exercise.json")) + item_stems, exercise_data = self._read_perseus_archive(node) assessment_item_ids = ( kolibri_models.ContentNode.objects.get(title=title) @@ -1206,6 +1285,75 @@ 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_answerless_input_left_out_of_perseus_archive_and_metadata(self): + title = "Perseus Answerless Input Exercise" + node = cc.ContentNode.objects.get(title=title) + perseus_id = node.assessment_items.get( + type=exercises.PERSEUS_QUESTION + ).assessment_id + self.assertFalse(node.files.filter(preset_id=format_presets.QTI_ZIP).exists()) + item_stems, exercise_data = self._read_perseus_archive(node) + self.assertEqual(item_stems, {perseus_id}) + self.assertEqual(exercise_data["all_assessment_items"], [perseus_id]) + + published_node = kolibri_models.ContentNode.objects.get(title=title) + self.assertEqual( + published_node.assessmentmetadata.first().assessment_item_ids, + [perseus_id], + ) + + def test_unchanged_republish_metadata_leaves_out_answerless_input(self): + # An unchanged node keeps its archive, which may predate the skip. + title = "Perseus Answerless Input Exercise" + node = cc.ContentNode.objects.get(title=title) + all_ids = list( + node.assessment_items.order_by("order").values_list( + "assessment_id", flat=True + ) + ) + perseus_id = node.assessment_items.get( + type=exercises.PERSEUS_QUESTION + ).assessment_id + stale_archive = io.BytesIO() + with zipfile.ZipFile(stale_archive, "w") as archive: + archive.writestr( + "exercise.json", json.dumps({"all_assessment_items": all_ids}) + ) + for assessment_id in all_ids: + archive.writestr(f"{assessment_id}.json", "{}") + node.files.filter(preset_id=format_presets.EXERCISE).delete() + stale_file = create_studio_file( + stale_archive.getvalue(), preset=format_presets.EXERCISE, ext="perseus" + )["db_file"] + stale_file.contentnode = node + stale_file.save() + published_node = kolibri_models.ContentNode.objects.get(title=title) + published_node.assessmentmetadata.all().delete() + + create_kolibri_assessment_metadata(node, published_node) + + self.assertEqual( + published_node.assessmentmetadata.get().assessment_item_ids, + [perseus_id], + ) + + def test_answerless_input_left_out_of_dual_published_archives(self): + title = "Native QTI Answerless Input Exercise" + node = cc.ContentNode.objects.get(title=title) + answerless_id = node.assessment_items.get( + type=exercises.INPUT_QUESTION, order=2 + ).assessment_id + item_stems, exercise_data = self._read_perseus_archive(node) + self.assertEqual(len(item_stems), 2) + self.assertNotIn(hex_to_qti_id(answerless_id), item_stems) + self.assertEqual(set(exercise_data["all_assessment_items"]), item_stems) + + published_node = kolibri_models.ContentNode.objects.get(title=title) + self.assertEqual( + set(published_node.assessmentmetadata.first().assessment_item_ids), + item_stems, + ) + 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()) diff --git a/contentcuration/contentcuration/tests/test_restore_channel.py b/contentcuration/contentcuration/tests/test_restore_channel.py index faa1130689..6e2010d170 100644 --- a/contentcuration/contentcuration/tests/test_restore_channel.py +++ b/contentcuration/contentcuration/tests/test_restore_channel.py @@ -16,6 +16,7 @@ from le_utils.constants import content_kinds from le_utils.constants import exercises from le_utils.constants import format_presets +from lxml import etree from mixer.backend.django import mixer from mock import MagicMock from mock import patch @@ -219,6 +220,13 @@ def test_generate_assessment_item(self): PERSEUS_ID = "fedcba0987654321fedcba0987654321" +def _canonical_xml(data): + # Restore re-serializes items to rewrite media paths, so compare as XML. + if isinstance(data, str): + data = data.encode("utf-8") + return etree.tostring(etree.fromstring(data), method="c14n") + + class QTIRestoreTestCase(StudioTestCase): EDITOR_ITEM_XML = _item_xml( "{identifier}", @@ -370,7 +378,9 @@ def _stored_package(self, preset=format_presets.QTI_ZIP): def _package_contents(self, package): with zipfile.ZipFile(package) as zf: return { - name: zf.read(name) + name: _canonical_xml(zf.read(name)) + if name.endswith(".xml") + else zf.read(name) for name in zf.namelist() # Its manifest identifier is the node's content_id. if name != "imsmanifest.xml" @@ -412,7 +422,13 @@ def _assert_restores_source_rows(self, restored, assessment_ids): else: # The package names this item only by its editor slug. self.assertRegex(item.assessment_id, "^[0-9a-f]{32}$") - if source_item.type in (exercises.QTI, exercises.PERSEUS_QUESTION): + if source_item.type == exercises.QTI: + self.assertEqual(item.type, source_item.type) + self.assertEqual( + _canonical_xml(item.raw_data), + _canonical_xml(source_item.raw_data), + ) + elif source_item.type == exercises.PERSEUS_QUESTION: self.assertEqual(item.type, source_item.type) self.assertEqual(item.raw_data, source_item.raw_data) else: diff --git a/contentcuration/contentcuration/tests/utils/test_exercise_creation.py b/contentcuration/contentcuration/tests/utils/test_exercise_creation.py index 0da2360e56..1f7c041887 100644 --- a/contentcuration/contentcuration/tests/utils/test_exercise_creation.py +++ b/contentcuration/contentcuration/tests/utils/test_exercise_creation.py @@ -298,6 +298,17 @@ def test_play_once_gif_stays_play_once(self): self.assertNotIn("loop", img.info) +def _exercise_data(items): + return { + "mastery_model": exercises.M_OF_N, + "randomize": True, + "n": len(items), + "m": 1, + "all_assessment_items": [item.assessment_id for item in items], + "assessment_mapping": {item.assessment_id: item.type for item in items}, + } + + class TestPerseusExerciseCreation(StudioTestCase): """ Tests for the create_perseus_exercise function which handles exercise file generation. @@ -838,6 +849,70 @@ def test_exercise_with_graphie(self): processed_perseus_json, ) + def _assert_only_perseus_item_packaged(self, perseus_item, answerless): + with self.assertLogs(level="WARNING") as logs: + self._create_perseus_zip(_exercise_data([perseus_item, answerless])) + self.assertTrue(any("no correct answer" in message for message in logs.output)) + exercise_file = self.exercise_node.files.get(preset_id=format_presets.EXERCISE) + zip_file, exercise_data = self._validate_perseus_zip(exercise_file) + self.assertNotIn(f"{answerless.assessment_id}.json", zip_file.namelist()) + self.assertEqual( + exercise_data["all_assessment_items"], [perseus_item.assessment_id] + ) + self.assertEqual( + exercise_data["assessment_mapping"], + {perseus_item.assessment_id: exercises.PERSEUS_QUESTION}, + ) + return zip_file + + def test_answerless_input_question_is_left_out(self): + perseus_item, _ = self._create_perseus_item() + for answers in ( + [{"answer": "", "correct": True, "order": 1}], + [{"answer": False, "correct": True, "order": 1}], + [{"answer": None, "order": 1}], + [{"answer": "5", "correct": False, "order": 1}], + [], + ): + with self.subTest(answers=answers): + answerless = self._create_assessment_item( + exercises.INPUT_QUESTION, "What is 2+3?", answers + ) + self._assert_only_perseus_item_packaged(perseus_item, answerless) + answerless.delete() + + def test_answerless_input_question_images_are_not_packaged(self): + perseus_item, _ = self._create_perseus_item() + image_file = fileobj_exercise_image() + image_url = exercises.CONTENT_STORAGE_FORMAT.format(image_file.filename()) + answerless = self._create_assessment_item( + exercises.INPUT_QUESTION, + f"How many sides? ![shape]({image_url})", + [{"answer": "", "correct": True, "order": 1}], + ) + image_file.assessment_item = answerless + image_file.save() + zip_file = self._assert_only_perseus_item_packaged(perseus_item, answerless) + self.assertNotIn(f"images/{image_file.filename()}", zip_file.namelist()) + + def test_unit_keeps_answerless_input_question(self): + self.exercise_node.kind_id = content_kinds.TOPIC + self.exercise_node.save() + perseus_item, _ = self._create_perseus_item() + answerless = self._create_assessment_item( + exercises.INPUT_QUESTION, + "What is 2+3?", + [{"answer": "", "correct": True, "order": 1}], + ) + self._create_perseus_zip(_exercise_data([perseus_item, answerless])) + exercise_file = self.exercise_node.files.get(preset_id=format_presets.EXERCISE) + zip_file, exercise_data = self._validate_perseus_zip(exercise_file) + self.assertIn(f"{answerless.assessment_id}.json", zip_file.namelist()) + self.assertEqual( + exercise_data["all_assessment_items"], + [perseus_item.assessment_id, answerless.assessment_id], + ) + def test_formula_processing(self): """Test that formulas are properly processed in exercises""" # Create a question with LaTeX formulas @@ -2383,7 +2458,7 @@ def test_legacy_item_that_is_not_well_formed_xml_is_skipped(self): image_file.save() with self.assertLogs(level="ERROR") as logs: - self._create_qti_zip(self._exercise_data([item])) + self._create_qti_zip(_exercise_data([item])) self.assertTrue( any("is not well-formed XML" in message for message in logs.output) ) @@ -2408,7 +2483,7 @@ def _publish_legacy_sized_image(self, content, ext, width, height, ref_ext=None) image_file.assessment_item = item image_file.save() - self._create_qti_zip(self._exercise_data([item])) + self._create_qti_zip(_exercise_data([item])) exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP) zip_file = self._validate_qti_zip_structure(exercise_file) @@ -2830,24 +2905,14 @@ def test_native_qti_item_valid_only_in_its_declared_encoding_is_packaged(self): 'encoding="UTF-8"', 'encoding="ISO-8859-1"' ).replace("", "￿") item = self._create_native_qti_item(raw_data) - self._create_qti_zip(self._exercise_data([item])) + self._create_qti_zip(_exercise_data([item])) exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP) zip_file = self._validate_qti_zip_structure(exercise_file) self.assertIn("items/item_1.xml", zip_file.namelist()) - def _exercise_data(self, items): - return { - "mastery_model": exercises.M_OF_N, - "randomize": True, - "n": len(items), - "m": 1, - "all_assessment_items": [item.assessment_id for item in items], - "assessment_mapping": {item.assessment_id: item.type for item in items}, - } - def _assert_only_item_packaged(self, items, identifier): with self.assertLogs(level="WARNING") as logs: - self._create_qti_zip(self._exercise_data(items)) + self._create_qti_zip(_exercise_data(items)) self.assertTrue(any("no correct answer" in message for message in logs.output)) exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP) zip_file = self._validate_qti_zip_structure(exercise_file) @@ -2899,7 +2964,7 @@ def test_legacy_input_question_zero_answer_is_packaged(self): "What is 5 minus 5?", [{"answer": 0, "correct": True, "order": 1}], ) - self._create_qti_zip(self._exercise_data([item])) + self._create_qti_zip(_exercise_data([item])) exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP) zip_file = self._validate_qti_zip_structure(exercise_file) item_xml = zip_file.read( @@ -2914,7 +2979,7 @@ def test_native_answerless_float_entry_is_skipped(self): def test_native_custom_processed_float_entry_is_packaged(self): item = self._create_native_qti_item(TOLERANCE_ITEM) - self._create_qti_zip(self._exercise_data([item])) + self._create_qti_zip(_exercise_data([item])) exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP) zip_file = self._validate_qti_zip_structure(exercise_file) self.assertIn("items/item_text.xml", zip_file.namelist()) @@ -2922,7 +2987,7 @@ def test_native_custom_processed_float_entry_is_packaged(self): def _assert_missing_media_is_logged_and_omitted(self, raw_data): item = self._create_native_qti_item(raw_data) # no File row linked with self.assertLogs(level="ERROR") as logs: - self._create_qti_zip(self._exercise_data([item])) + self._create_qti_zip(_exercise_data([item])) self.assertTrue( any("no matching File record linked" in message for message in logs.output) ) @@ -2955,7 +3020,7 @@ def _create_native_qti_image_item(self, image_file, imgs): def _publish_native_qti_images(self, image_file, imgs): item = self._create_native_qti_image_item(image_file, imgs) - self._create_qti_zip(self._exercise_data([item])) + self._create_qti_zip(_exercise_data([item])) exercise_file = self.exercise_node.files.get(preset_id=format_presets.QTI_ZIP) zip_file = self._validate_qti_zip_structure(exercise_file) item_doc = parse_qti_xml(zip_file.read("items/native_item_1.xml")) @@ -3179,7 +3244,7 @@ def test_native_qti_perseus_derivation_resizes_sized_images(self): image_file, self.TWO_SIZE_IMGS.format(filename=filename) ) - self._create_perseus_zip(self._exercise_data([item])) + self._create_perseus_zip(_exercise_data([item])) exercise_file = self.exercise_node.files.get(preset_id=format_presets.EXERCISE) with storage.open(exercise_file.file_on_disk.name, "rb") as f: @@ -3201,7 +3266,7 @@ def test_native_qti_perseus_derivation_signed_numeric_answer(self): raw_data = _text_item("single", ["+5"]) item = self._create_native_qti_item(raw_data) - self._create_perseus_zip(self._exercise_data([item])) + self._create_perseus_zip(_exercise_data([item])) exercise_file = self.exercise_node.files.get(preset_id=format_presets.EXERCISE) with storage.open(exercise_file.file_on_disk.name, "rb") as f: diff --git a/contentcuration/contentcuration/utils/assessment/perseus.py b/contentcuration/contentcuration/utils/assessment/perseus.py index adf0c4fb25..5410fb17c0 100644 --- a/contentcuration/contentcuration/utils/assessment/perseus.py +++ b/contentcuration/contentcuration/utils/assessment/perseus.py @@ -1,23 +1,39 @@ import copy import json +import logging import math import re import zipfile from django.template.loader import render_to_string +from django.utils.functional import cached_property +from le_utils.constants import content_kinds from le_utils.constants import exercises from le_utils.constants import file_formats from le_utils.constants import format_presets from contentcuration.utils.assessment.base import ExerciseArchiveGenerator from contentcuration.utils.assessment.qti.convert import hex_to_qti_id +from contentcuration.utils.assessment.qti.convert import is_answerless_input from contentcuration.utils.assessment.qti.perseus_derive import derive_perseus_item from contentcuration.utils.parser import extract_value +logger = logging.getLogger(__name__) + _DOUBLE_DOLLAR_RE = re.compile(r"\$\$(.+?)\$\$", flags=re.DOTALL) +def answerless_input_ids(ccnode): + """Input questions an exercise's Perseus archive leaves out, judged from + stored answers, as exercise.json is written before any item is processed.""" + return { + item.assessment_id + for item in ccnode.assessment_items.filter(type=exercises.INPUT_QUESTION) + if is_answerless_input(item.type, json.loads(item.answers)) + } + + class PerseusExerciseGenerator(ExerciseArchiveGenerator): """ Exercise zip generator for Perseus format exercises. @@ -56,6 +72,19 @@ def _derived_items(self): } return self._derived_cache + @cached_property + def _answerless_input_ids(self): + """Units keep them: their pre/post test lists every id.""" + if self.ccnode.kind_id != content_kinds.EXERCISE: + return set() + answerless_ids = answerless_input_ids(self.ccnode) + for assessment_id in answerless_ids: + logger.warning( + f"Input question {assessment_id} on node {self.ccnode.pk} " + f"has no correct answer and will be excluded from the Perseus archive" + ) + return answerless_ids + def _process_formulas(self, content): return _DOUBLE_DOLLAR_RE.sub(r"$\1$", content) @@ -64,6 +93,8 @@ def _process_content(self, content): return super()._process_content(content) def process_assessment_item(self, assessment_item): + if assessment_item.assessment_id in self._answerless_input_ids: + return if assessment_item.type == exercises.QTI: derived = self._derived_items().get(assessment_item.assessment_id) if derived is None: @@ -118,14 +149,16 @@ def _exercise_data_for_archive(self): and raises ``FileNotFoundError``, and ``generate_assessment_item`` receives a ``qti`` type it cannot map. """ - derived = self._derived_items() - if not derived: - return self.exercise_data - + assessment_ids = [ + assessment_id + for assessment_id in self.exercise_data.get("all_assessment_items", []) + if assessment_id not in self._answerless_input_ids + ] original_mapping = self.exercise_data.get("assessment_mapping", {}) + derived = self._derived_items() new_ids = [] new_mapping = {} - for assessment_id in self.exercise_data.get("all_assessment_items", []): + for assessment_id in assessment_ids: if assessment_id in derived: proxy = derived[assessment_id] if proxy is None: @@ -135,10 +168,10 @@ def _exercise_data_for_archive(self): new_ids.append(proxy.assessment_id) new_mapping[proxy.assessment_id] = proxy.type else: - qti_id = hex_to_qti_id(assessment_id) - new_ids.append(qti_id) + item_id = hex_to_qti_id(assessment_id) if derived else assessment_id + new_ids.append(item_id) if assessment_id in original_mapping: - new_mapping[qti_id] = original_mapping[assessment_id] + new_mapping[item_id] = original_mapping[assessment_id] return { **self.exercise_data, "all_assessment_items": new_ids, diff --git a/contentcuration/contentcuration/utils/assessment/qti/archive.py b/contentcuration/contentcuration/utils/assessment/qti/archive.py index a979125658..2be2d0caa8 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/archive.py +++ b/contentcuration/contentcuration/utils/assessment/qti/archive.py @@ -14,7 +14,6 @@ from contentcuration.utils.assessment.base import ExerciseArchiveGenerator from contentcuration.utils.assessment.qti.constants import ResourceType -from contentcuration.utils.assessment.qti.convert import accepted_answers from contentcuration.utils.assessment.qti.convert import ( build_perseus_custom_interaction_item, ) @@ -22,6 +21,7 @@ convert_legacy_assessment_item_to_qti, ) from contentcuration.utils.assessment.qti.convert import hex_to_qti_id +from contentcuration.utils.assessment.qti.convert import is_answerless_input from contentcuration.utils.assessment.qti.convert import LegacyAssessmentItem from contentcuration.utils.assessment.qti.imsmanifest import File as ManifestFile from contentcuration.utils.assessment.qti.imsmanifest import Manifest @@ -103,9 +103,7 @@ def _warn_answerless(self, assessment_item): def _skip_answerless_input(self, assessment_item, answers) -> bool: # Conversion makes an input question with no accepted answer an # answerless float entry. - is_answerless = assessment_item.type == exercises.INPUT_QUESTION and not ( - accepted_answers(answers) - ) + is_answerless = is_answerless_input(assessment_item.type, answers) if is_answerless: self._warn_answerless(assessment_item) return is_answerless diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index 987211b9f7..f6e7c40b65 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/convert.py +++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py @@ -266,6 +266,10 @@ def accepted_answers(answers: List[Dict[str, Any]]) -> List[str]: return accepted +def is_answerless_input(item_type: str, answers: List[Dict[str, Any]]) -> bool: + return item_type == exercises.INPUT_QUESTION and not accepted_answers(answers) + + # Decimal literals Kolibri's Number() reads that extract_value does not, e.g. +3, 1E3. _JS_NUMBER = re.compile(r"[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?", re.ASCII) diff --git a/contentcuration/contentcuration/utils/publish.py b/contentcuration/contentcuration/utils/publish.py index 060bf63304..6f82b3c601 100644 --- a/contentcuration/contentcuration/utils/publish.py +++ b/contentcuration/contentcuration/utils/publish.py @@ -46,6 +46,7 @@ from contentcuration import models as ccmodels from contentcuration.decorators import delay_user_storage_calculation +from contentcuration.utils.assessment.perseus import answerless_input_ids from contentcuration.utils.assessment.perseus import PerseusExerciseGenerator from contentcuration.utils.assessment.qti.archive import QTIExerciseGenerator from contentcuration.utils.assessment.qti.imsmanifest import ( @@ -838,15 +839,21 @@ def process_assessment_metadata(ccnode): def create_kolibri_assessment_metadata(ccnode, kolibrinode): assessment_items = ccnode.assessment_items.all().order_by("order") - assessment_item_ids = [a.assessment_id for a in assessment_items] randomize, _, mastery_model = _get_exercise_data_from_ccnode( - ccnode, len(assessment_item_ids) + ccnode, len(assessment_items) ) qti_file = ccnode.files.filter(preset_id=format_presets.QTI_ZIP).first() if qti_file: # Open the zip file from Django storage with qti_file.file_on_disk.open("rb") as file_handle: assessment_item_ids = get_assessment_ids_from_manifest(file_handle) + else: + answerless_ids = answerless_input_ids(ccnode) + assessment_item_ids = [ + a.assessment_id + for a in assessment_items + if a.assessment_id not in answerless_ids + ] kolibrimodels.AssessmentMetaData.objects.create( id=uuid.uuid4(),