From 846fc98e6b64534e0e48852efc3d928ef1c4f135 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Wed, 19 Aug 2026 14:03:36 -0700 Subject: [PATCH 1/3] fix: convert the legacy markdown the QTI models rejected Inline maths: li, td, th, caption, dt, dd and qti-simple-choice took no math, though the XSD admits m3:math in all seven. Links and inline marks (a, s, del, ins, u, mark, strike): unwrapped in the rendered markup, keeping their text. An anchor has nothing to navigate to on a device with no internet access, and the QTI 3.0 HTML profile has no element for the marks. Publish and ricecooker upload reach the same converter, so both stop failing on these items too. --- .../tests/utils/qti/test_convert.py | 106 ++++++++++++++++++ .../utils/assessment/qti/convert.py | 18 ++- .../utils/assessment/qti/html/__init__.py | 2 + .../utils/assessment/qti/html/sequence.py | 8 +- .../utils/assessment/qti/html/table.py | 8 +- .../qti/interaction_types/simple.py | 4 +- 6 files changed, 135 insertions(+), 11 deletions(-) diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py index 63d5ec32e2..526b1aed95 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py @@ -239,6 +239,112 @@ def test_free_response_with_maths(self): ) +class MarkdownContentConversionTests(unittest.TestCase): + """Markdown a legacy question can hold that the QTI models rejected.""" + + def _convert(self, question="Question", answers=None, hints=None): + item = _make_item( + type=exercises.SINGLE_SELECTION, + question=question, + answers=answers + if answers is not None + else [{"answer": "4", "correct": True, "order": 1}], + assessment_id="abcdef1234567890abcdef1234567890", + hints=hints, + ) + return convert_legacy_assessment_item_to_qti(item) + + def test_links_are_stripped(self): + # A QTI item is delivered offline, so the link text survives and the + # anchor does not. + cases = ( + ("Read [the docs](https://learningequality.org).", "

Read the docs.

"), + ("Read [the docs](./docs.html).", "

Read the docs.

"), + ( + "See for more.", + "

See https://learningequality.org for more.

", + ), + ( + 'Read this.', + "

Read this.

", + ), + ) + for markdown, expected in cases: + with self.subTest(markdown=markdown): + result = self._convert(markdown) + + self.assertIn(expected, result.xml) + self.assertNotIn("Read the docs.

", result.xml) + + def test_link_in_an_answer_is_stripped(self): + result = self._convert( + answers=[ + {"answer": "See [here](https://learningequality.org)", "correct": True} + ] + ) + + self.assertIn("

See here

", result.xml) + self.assertNotIn("
Look at the docs

", result.xml) + self.assertNotIn("
It is not four.

", result.xml) + self.assertNotIn("", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_raw_html_marks_are_stripped(self): + # Every inline mark the renderer passes through as raw HTML that the QTI + # 3.0 HTML profile has no element for. + for tag in ("s", "del", "ins", "u", "mark", "strike"): + with self.subTest(tag=tag): + result = self._convert(f"It is <{tag}>not four.") + + self.assertIn("

It is not four.

", result.xml) + self.assertNotIn(f"<{tag}>", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_maths_in_a_list_item(self): + # Validity is not asserted here or below: rendered MathML carries no + # namespace, the same gap test_free_response_with_maths lives with. + result = self._convert("- $$x^2$$\n- two") + + self.assertIn('
  • ', result.xml) + + def test_maths_in_a_table_cell(self): + result = self._convert("| a |\n|---|\n| $$x^2$$ |") + + self.assertIn('', result.xml) + + def test_maths_in_an_answer(self): + result = self._convert(answers=[{"answer": "$$x^2$$", "correct": True}]) + + self.assertIn('>', result.xml) + + class CustomInteractionTests(unittest.TestCase): ASSESSMENT_ID = "2b1c3d4e5f60718293a4b5c6d7e8f900" diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index d363f3735f..127c845e2a 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/convert.py +++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py @@ -9,6 +9,7 @@ from typing import Tuple from le_utils.constants import exercises +from lxml import etree from contentcuration.utils.assessment.markdown import render_markdown from contentcuration.utils.assessment.qti.assessment_item import AssessmentItem @@ -85,11 +86,26 @@ class QTIConversionResult: file_dependencies: List[str] +def _strip_unsupported_markup(markup: str) -> str: + """ + Unwrap every tag a QTI item body cannot carry, keeping its content. + + An anchor has nothing to navigate to on a device with no internet access. + The QTI 3.0 HTML profile has no element for the inline marks. + Runs on the rendered markup, so tags typed as raw HTML are stripped too. + """ + root = etree.fromstring(f"{markup}") + etree.strip_tags(root, "a", "s", "del", "ins", "u", "mark", "strike") + return (root.text or "") + "".join( + etree.tostring(child, encoding="unicode") for child in root + ) + + def _create_html_content_from_text(text: str) -> FlowContentList: """Convert text content to QTI HTML flow content.""" if not text.strip(): return [] - markup = render_markdown(text) + markup = _strip_unsupported_markup(render_markdown(text)) return ElementTreeBase.from_string(markup) diff --git a/contentcuration/contentcuration/utils/assessment/qti/html/__init__.py b/contentcuration/contentcuration/utils/assessment/qti/html/__init__.py index f28fea09f0..25b919a2cd 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/html/__init__.py +++ b/contentcuration/contentcuration/utils/assessment/qti/html/__init__.py @@ -7,6 +7,7 @@ from contentcuration.utils.assessment.qti.html.breaks import Hr from contentcuration.utils.assessment.qti.html.content_types import FlowContent from contentcuration.utils.assessment.qti.html.content_types import FlowContentList +from contentcuration.utils.assessment.qti.html.content_types import FlowGroupList from contentcuration.utils.assessment.qti.html.content_types import InlineContent from contentcuration.utils.assessment.qti.html.content_types import InlineContentList from contentcuration.utils.assessment.qti.html.content_types import InlineGroup @@ -97,6 +98,7 @@ # Content type aliases "FlowContent", "FlowContentList", + "FlowGroupList", "InlineContent", "InlineContentList", "InlineGroup", diff --git a/contentcuration/contentcuration/utils/assessment/qti/html/sequence.py b/contentcuration/contentcuration/utils/assessment/qti/html/sequence.py index ef81c17d00..2d33291f73 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/html/sequence.py +++ b/contentcuration/contentcuration/utils/assessment/qti/html/sequence.py @@ -8,12 +8,12 @@ from contentcuration.utils.assessment.qti.html.base import BlockContentElement from contentcuration.utils.assessment.qti.html.base import HTMLElement from contentcuration.utils.assessment.qti.html.content_types import FlowContent -from contentcuration.utils.assessment.qti.html.content_types import FlowContentList +from contentcuration.utils.assessment.qti.html.content_types import FlowGroupList class Li(HTMLElement): value: Optional[int] = None - children: FlowContentList = Field(default_factory=list) + children: FlowGroupList = Field(default_factory=list) class OlType(Enum): @@ -37,11 +37,11 @@ class Ul(BlockContentElement): class Dt(HTMLElement): # There are restrictions on allowed descendants - children: FlowContentList = Field(default_factory=list) + children: FlowGroupList = Field(default_factory=list) class Dd(HTMLElement): - children: FlowContentList = Field(default_factory=list) + children: FlowGroupList = Field(default_factory=list) class Dl(BlockContentElement): diff --git a/contentcuration/contentcuration/utils/assessment/qti/html/table.py b/contentcuration/contentcuration/utils/assessment/qti/html/table.py index fe5be0e584..1b07f097ce 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/html/table.py +++ b/contentcuration/contentcuration/utils/assessment/qti/html/table.py @@ -7,11 +7,11 @@ from contentcuration.utils.assessment.qti.html.base import BlockContentElement from contentcuration.utils.assessment.qti.html.base import HTMLElement -from contentcuration.utils.assessment.qti.html.content_types import FlowContentList +from contentcuration.utils.assessment.qti.html.content_types import FlowGroupList class Caption(HTMLElement): - children: FlowContentList = Field(default_factory=list) + children: FlowGroupList = Field(default_factory=list) class Col(HTMLElement): # Void element @@ -27,7 +27,7 @@ class Td(HTMLElement): colspan: Optional[int] = None rowspan: Optional[int] = None headers: Optional[str] = None - children: FlowContentList = Field(default_factory=list) + children: FlowGroupList = Field(default_factory=list) class ThScope(Enum): @@ -44,7 +44,7 @@ class Th(HTMLElement): headers: Optional[str] = None scope: Optional[ThScope] = None abbr: Optional[str] = None - children: FlowContentList = Field(default_factory=list) + children: FlowGroupList = Field(default_factory=list) class Tr(HTMLElement): diff --git a/contentcuration/contentcuration/utils/assessment/qti/interaction_types/simple.py b/contentcuration/contentcuration/utils/assessment/qti/interaction_types/simple.py index a4dcec2251..0ef6d9c201 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/interaction_types/simple.py +++ b/contentcuration/contentcuration/utils/assessment/qti/interaction_types/simple.py @@ -13,7 +13,7 @@ from contentcuration.utils.assessment.qti.constants import Orientation from contentcuration.utils.assessment.qti.constants import ShowHide from contentcuration.utils.assessment.qti.fields import QTIIdentifier -from contentcuration.utils.assessment.qti.html import FlowContentList +from contentcuration.utils.assessment.qti.html import FlowGroupList from contentcuration.utils.assessment.qti.interaction_types.base import BlockInteraction from contentcuration.utils.assessment.qti.prompt import Prompt @@ -29,7 +29,7 @@ class SimpleChoice(QTIBase, BaseSequence): template_identifier: Optional[str] = None show_hide: ShowHide = ShowHide.SHOW fixed: bool = False - children: FlowContentList = Field(default_factory=list) + children: FlowGroupList = Field(default_factory=list) class ChoiceInteraction(BlockInteraction): From 37a1da67a5583b4bbc4f2b5dd0519ebf079c9286 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Wed, 19 Aug 2026 14:03:44 -0700 Subject: [PATCH 2/3] fix: give an answerless legacy choice question an empty choice interaction Every newly added question is one: the editor writes type single_selection with no answers, and the XSD requires at least one qti-simple-choice, so conversion raised. Stand in the single empty choice the QTI editor opens a new choice interaction with. --- .../fixtures/single_selection_no_answers.xml | 14 +++++ .../tests/utils/qti/test_convert.py | 51 +++++++++++++++++++ .../utils/assessment/qti/convert.py | 7 ++- 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml diff --git a/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml b/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml new file mode 100644 index 0000000000..1e1ca2458d --- /dev/null +++ b/contentcuration/contentcuration/tests/utils/qti/fixtures/single_selection_no_answers.xml @@ -0,0 +1,14 @@ + + + + + + + +

    What is 2+2?

    + + + + + + diff --git a/contentcuration/contentcuration/tests/utils/qti/test_convert.py b/contentcuration/contentcuration/tests/utils/qti/test_convert.py index 526b1aed95..3de6532f61 100644 --- a/contentcuration/contentcuration/tests/utils/qti/test_convert.py +++ b/contentcuration/contentcuration/tests/utils/qti/test_convert.py @@ -117,6 +117,57 @@ def test_true_false(self): ) self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + def test_single_selection_no_answers(self): + item = _make_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=[], + randomize=True, + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertEqual(result.identifier, "Kq83vEjRWeJCrze8SNFZ4kA") + self.assertEqual( + _normalize_xml(_load_fixture("single_selection_no_answers.xml")), + _normalize_xml(result.xml), + ) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_choice_types_with_no_answers_get_one_empty_choice(self): + # test_single_selection_no_answers pins SINGLE_SELECTION against the fixture. + for question_type in (exercises.MULTIPLE_SELECTION, "true_false"): + with self.subTest(question_type=question_type): + item = _make_item( + type=question_type, + question="What is 2+2?", + answers=[], + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertIn( + '', + result.xml, + ) + self.assertIn("

    What is 2+2?

    ", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + + def test_choice_type_with_no_answers_and_no_question(self): + item = _make_item( + type=exercises.MULTIPLE_SELECTION, + question="", + answers=[], + assessment_id="abcdef1234567890abcdef1234567890", + ) + + result = convert_legacy_assessment_item_to_qti(item) + + self.assertIn("", result.xml) + self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid) + def test_media_reference_survives(self): item = _make_item( type=exercises.SINGLE_SELECTION, diff --git a/contentcuration/contentcuration/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index 127c845e2a..c4442fcf4f 100644 --- a/contentcuration/contentcuration/utils/assessment/qti/convert.py +++ b/contentcuration/contentcuration/utils/assessment/qti/convert.py @@ -164,9 +164,14 @@ def _create_choice_interaction_and_response( prompt = Prompt(children=_create_html_content_from_text(item.question)) + # Every newly added question starts answerless, but the XSD requires at least one + # qti-simple-choice. Stand in the single empty choice the QTI editor opens a new + # choice interaction with. + answers = item.answers or [{}] + choices = [] correct_values = [] - for i, answer in enumerate(item.answers): + for i, answer in enumerate(answers): choice_id = f"choice_{i}" choice_content = _create_html_content_from_text(answer.get("answer", "")) From 255bd5f4f3036caf2be5eb721b042692abc6bd6b Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Wed, 19 Aug 2026 14:03:52 -0700 Subject: [PATCH 3/3] feat: convert still-legacy assessment items to QTI on read consolidate() replaces each legacy row's type and raw_data with the converter's output, so the client only ever sees QTI. QTI and perseus_question rows pass through. Items are tagged with the content node's language, falling back to the channel's, matching what publish writes. An item that cannot be converted is logged and left as the legacy row it is, so one bad row does not cost the whole contentnode__in list; the editor renders it as unsupported. Goes away with the global backfill (#6007). --- .../tests/viewsets/test_assessmentitem.py | 223 ++++++++++++++++++ .../viewsets/assessmentitem.py | 52 ++++ 2 files changed, 275 insertions(+) diff --git a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py index 1f3d1330f8..2023bab9c8 100644 --- a/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py +++ b/contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py @@ -14,6 +14,7 @@ from contentcuration.tests.viewsets.base import generate_delete_event from contentcuration.tests.viewsets.base import generate_update_event from contentcuration.tests.viewsets.base import SyncTestMixin +from contentcuration.utils.assessment.qti.validation import validate_qti_item from contentcuration.viewsets.sync.constants import ASSESSMENTITEM @@ -35,6 +36,14 @@ "", ) +CHOICE_ANSWERS = json.dumps( + [ + {"answer": "4", "correct": True, "order": 1}, + {"answer": "5", "correct": False, "order": 2}, + ] +) +TEXT_ANSWERS = json.dumps([{"answer": "4", "correct": True, "order": 1}]) + class SyncTestCase(SyncTestMixin, StudioAPITestCase): @property @@ -1177,6 +1186,220 @@ def test_delete_assessmentitem(self): self.assertEqual(response.status_code, 405, response.content) +class DualReadTestCase(StudioAPITestCase): + def setUp(self): + super(DualReadTestCase, self).setUp() + self.channel = testdata.channel() + self.user = testdata.user() + self.channel.editors.add(self.user) + self.node = ( + self.channel.main_tree.get_descendants() + .filter(kind_id=content_kinds.EXERCISE) + .first() + ) + self.client.force_authenticate(user=self.user) + + def _create_item(self, node=None, **kwargs): + return models.AssessmentItem.objects.create( + contentnode=node or self.node, assessment_id=uuid.uuid4().hex, **kwargs + ) + + def _list_items(self, **query): + # The fixture node carries its own assessment items, so key the response + # by assessment_id rather than indexing it. + response = self.client.get(reverse("assessmentitem-list"), query) + self.assertEqual(response.status_code, 200, response.content) + return {item["assessment_id"]: item for item in response.json()} + + def _get_item(self, assessment_id): + return self._list_items(contentnode=self.node.id)[assessment_id] + + def test_supported_legacy_types_returned_as_qti(self): + cases = [ + (exercises.SINGLE_SELECTION, CHOICE_ANSWERS, "qti-choice-interaction"), + (exercises.MULTIPLE_SELECTION, CHOICE_ANSWERS, "qti-choice-interaction"), + ("true_false", CHOICE_ANSWERS, "qti-choice-interaction"), + (exercises.INPUT_QUESTION, TEXT_ANSWERS, "qti-text-entry-interaction"), + (exercises.FREE_RESPONSE, TEXT_ANSWERS, "qti-text-entry-interaction"), + ] + created = { + item_type: self._create_item( + type=item_type, + question="What is 2+2?", + answers=answers, + hints=json.dumps([{"hint": "Count.", "order": 1}]), + ).assessment_id + for item_type, answers, _ in cases + } + + items = self._list_items(contentnode=self.node.id) + + for item_type, _, interaction in cases: + with self.subTest(type=item_type): + item = items[created[item_type]] + self.assertEqual(item["type"], exercises.QTI) + self.assertTrue(validate_qti_item(item["raw_data"]).is_valid) + self.assertIn(interaction, item["raw_data"]) + + def test_answerless_choice_item_is_returned_as_valid_qti(self): + # The shape the editor writes for every newly added question. + assessment_id = self._create_item(type=exercises.SINGLE_SELECTION).assessment_id + + item = self._get_item(assessment_id) + + self.assertEqual(item["type"], exercises.QTI) + self.assertTrue(validate_qti_item(item["raw_data"]).is_valid) + + def test_converted_item_has_no_legacy_field_content(self): + assessment_id = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=CHOICE_ANSWERS, + hints=json.dumps([{"hint": "Count.", "order": 1}]), + ).assessment_id + + item = self._get_item(assessment_id) + + self.assertEqual(item["question"], "") + self.assertEqual(item["answers"], "[]") + self.assertEqual(item["hints"], "[]") + # The node language is only in the values tuple to feed the conversion. + self.assertNotIn("contentnode__language__lang_code", item) + + def test_converted_items_are_tagged_with_their_own_node_language(self): + # A contentnode__in read spans several nodes, so each item must pick up its own + # node's language. pt-BR's bare lang_code differs from its primary key, so the + # assertions also pin which of the two publish tags an item with. + self.node.language = models.Language.objects.get(id="pt-BR") + self.node.save() + other_node = models.ContentNode.objects.create( + id=uuid.uuid4().hex, + title="Exercise 2", + kind_id=content_kinds.EXERCISE, + parent=self.node.parent, + language=models.Language.objects.get(id="fr"), + ) + first = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=CHOICE_ANSWERS, + ) + second = self._create_item( + node=other_node, + type=exercises.SINGLE_SELECTION, + question="What is 3+3?", + answers=CHOICE_ANSWERS, + ) + + items = self._list_items( + contentnode__in=f"{self.node.id},{other_node.id}", + ) + + self.assertIn('xml:lang="pt"', items[first.assessment_id]["raw_data"]) + self.assertNotIn("pt-BR", items[first.assessment_id]["raw_data"]) + self.assertIn('xml:lang="fr"', items[second.assessment_id]["raw_data"]) + + def test_converted_item_falls_back_to_the_channel_language(self): + # What publish does for a node with no language of its own. + self.channel.language = models.Language.objects.get(id="pt-BR") + self.channel.save() + assessment_id = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=CHOICE_ANSWERS, + ).assessment_id + + item = self._get_item(assessment_id) + + self.assertIn('xml:lang="pt"', item["raw_data"]) + self.assertNotIn("pt-BR", item["raw_data"]) + + def test_converted_item_defaults_to_english_without_any_language(self): + self.channel.language = None + self.channel.save() + assessment_id = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=CHOICE_ANSWERS, + ).assessment_id + + item = self._get_item(assessment_id) + + self.assertIn('xml:lang="en"', item["raw_data"]) + + def test_perseus_question_returned_unchanged(self): + raw_data = '{"question": {"content": "raw perseus"}}' + assessment_id = self._create_item( + type=exercises.PERSEUS_QUESTION, raw_data=raw_data + ).assessment_id + + item = self._get_item(assessment_id) + + self.assertEqual(item["type"], exercises.PERSEUS_QUESTION) + self.assertEqual(item["raw_data"], raw_data) + # A passed-through row must shed the node language too. + self.assertNotIn("contentnode__language__lang_code", item) + + def test_native_qti_item_returned_unchanged(self): + assessment_id = self._create_item( + type=exercises.QTI, raw_data=VALID_CHOICE_ITEM + ).assessment_id + + item = self._get_item(assessment_id) + + self.assertEqual(item["type"], exercises.QTI) + self.assertEqual(item["raw_data"], VALID_CHOICE_ITEM) + + def test_detail_route_converts(self): + assessmentitem = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 2+2?", + answers=CHOICE_ANSWERS, + ) + + response = self.client.get( + reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}) + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["type"], exercises.QTI) + self.assertTrue(validate_qti_item(response.json()["raw_data"]).is_valid) + + def test_unconvertible_item_is_left_legacy_and_the_rest_convert(self): + unconvertible = self._create_item( + type="not_a_real_type", question="What is 2+2?" + ) + convertible = self._create_item( + type=exercises.SINGLE_SELECTION, + question="What is 3+3?", + answers=CHOICE_ANSWERS, + ) + + with self.assertLogs( + "contentcuration.viewsets.assessmentitem", level="ERROR" + ) as logs: + items = self._list_items(contentnode=self.node.id) + + self.assertIn(unconvertible.assessment_id, logs.output[0]) + self.assertEqual(items[unconvertible.assessment_id]["type"], "not_a_real_type") + self.assertEqual(items[unconvertible.assessment_id]["question"], "What is 2+2?") + self.assertEqual(items[convertible.assessment_id]["type"], exercises.QTI) + + def test_unconvertible_item_is_not_a_404_on_detail_route(self): + # Only this route goes through serialize_object(), which turns an error raised + # here into "No AssessmentItem matches the given query". + assessmentitem = self._create_item( + type="not_a_real_type", question="What is 2+2?" + ) + + response = self.client.get( + reverse("assessmentitem-detail", kwargs={"pk": assessmentitem.id}) + ) + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["type"], "not_a_real_type") + + class ContentIDTestCase(SyncTestMixin, StudioAPITestCase): def setUp(self): super(ContentIDTestCase, self).setUp() diff --git a/contentcuration/contentcuration/viewsets/assessmentitem.py b/contentcuration/contentcuration/viewsets/assessmentitem.py index e000c67371..9634d81d2b 100644 --- a/contentcuration/contentcuration/viewsets/assessmentitem.py +++ b/contentcuration/contentcuration/viewsets/assessmentitem.py @@ -1,7 +1,10 @@ import json +import logging import re from django.db import transaction +from django.db.models import OuterRef +from django.db.models import Subquery from le_utils.constants import exercises from le_utils.constants import format_presets from rest_framework import serializers @@ -9,9 +12,11 @@ from rest_framework.serializers import ValidationError from contentcuration.models import AssessmentItem +from contentcuration.models import Channel from contentcuration.models import ContentNode from contentcuration.models import File from contentcuration.models import generate_object_storage_name +from contentcuration.utils.assessment.qti.ingest import convert_legacy_question_to_qti from contentcuration.utils.assessment.qti.media import get_qti_media_references from contentcuration.utils.assessment.qti.validation import validate_qti_item from contentcuration.viewsets.base import BulkCreateMixin @@ -25,12 +30,18 @@ from contentcuration.viewsets.common import UUIDRegexField +logger = logging.getLogger(__name__) + exercise_image_filename_regex = re.compile( r"\!\[[^]]*\]\(\${placeholder}/([a-f0-9]{{32}}\.[0-9a-z]+)\)".format( placeholder=exercises.CONTENT_STORAGE_PLACEHOLDER ) ) +# Everything else is a legacy type, converted to QTI on read until the global +# backfill (#6007) makes that permanent and consolidate() goes away. +PASSTHROUGH_TYPES = (exercises.QTI, exercises.PERSEUS_QUESTION) + class AssessmentItemFilter(RequiredFilterSet): contentnode__in = UUIDInFilter(field_name="contentnode") @@ -332,8 +343,49 @@ class AssessmentItemViewSet(BulkCreateMixin, BulkUpdateMixin, ValuesViewset): "source_url", "randomize", "deleted", + # Only consumed by consolidate(), which pops them back off. Publish tags an + # item with the bare lang_code of its content node's language, falling back to + # the channel's (utils/assessment/qti/archive.py), so the read path matches. + "contentnode__language__lang_code", + "channel_lang_code", ) field_map = { "contentnode": "contentnode_id", } + + def annotate_queryset(self, queryset): + return queryset.annotate( + channel_lang_code=Subquery( + Channel.objects.filter( + main_tree__tree_id=OuterRef("contentnode__tree_id") + ).values("language__lang_code")[:1] + ) + ) + + def consolidate(self, items, queryset): + for item in items: + node_language = item.pop("contentnode__language__lang_code") + channel_language = item.pop("channel_lang_code") + language = node_language or channel_language + if item["type"] in PASSTHROUGH_TYPES: + continue + try: + # A new dict, so the language does not leak into the response. + result = convert_legacy_question_to_qti(dict(item, language=language)) + except Exception: + # One item Studio cannot represent in QTI must not take out every other + # item on the node, so leave it as the legacy row it is; the editor + # renders a non-QTI item as unsupported. + logger.exception( + "Could not convert assessment item %s to QTI", item["assessment_id"] + ) + continue + item.update( + type=exercises.QTI, + raw_data=result.xml, + question="", + answers="[]", + hints="[]", + ) + return items