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 63d5ec32e2..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, @@ -239,6 +290,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/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/utils/assessment/qti/convert.py b/contentcuration/contentcuration/utils/assessment/qti/convert.py index d363f3735f..c4442fcf4f 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) @@ -148,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", "")) 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): 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