Skip to content

Commit 4131050

Browse files
committed
refactor: build the ungraded body in the choice converter
The answerless-choice case returned (None, None) and left convert_legacy_assessment_item_to_qti to branch on it and rebuild the body, splitting one decision across two functions. Return the Div directly instead, so the caller always has an item body and only the response declaration is optional. Drop the defensive default on the contentnode__language__lang_code pop: the key is in values, so a missing one is a bug, not a case to absorb. Trim comments that restated the code they sat above.
1 parent bfd94b5 commit 4131050

4 files changed

Lines changed: 39 additions & 53 deletions

File tree

contentcuration/contentcuration/tests/utils/qti/test_convert.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,8 @@ def test_single_selection_no_answers(self):
136136
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
137137

138138
def test_choice_types_with_no_answers_omit_the_interaction(self):
139-
# The guard is on the choice types as a group, not just SINGLE_SELECTION,
140-
# which test_single_selection_no_answers already pins against the fixture.
139+
# The guard covers the choice types as a group; test_single_selection_no_answers
140+
# pins SINGLE_SELECTION against the fixture.
141141
for question_type in (exercises.MULTIPLE_SELECTION, "true_false"):
142142
with self.subTest(question_type=question_type):
143143
item = _make_item(
@@ -156,8 +156,6 @@ def test_choice_types_with_no_answers_omit_the_interaction(self):
156156
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
157157

158158
def test_choice_type_with_no_answers_and_no_question(self):
159-
# The model's own defaults - a question with nothing typed into it yet
160-
# still carries an empty paragraph to render and edit.
161159
item = _make_item(
162160
type=exercises.MULTIPLE_SELECTION,
163161
question="",
@@ -171,10 +169,8 @@ def test_choice_type_with_no_answers_and_no_question(self):
171169
self.assertTrue(validate_qti_item(result.xml.encode("utf-8")).is_valid)
172170

173171
def test_choice_type_with_no_answers_and_block_maths(self):
174-
# Block maths renders as a top level <math>, which qti-item-body does not
175-
# accept directly - hence the wrapping div. XSD validity is not asserted
176-
# here: rendered MathML does not carry its namespace, the same gap
177-
# test_free_response_with_maths lives with.
172+
# Validity is not asserted: rendered MathML carries no namespace, the same
173+
# gap test_free_response_with_maths lives with.
178174
item = _make_item(
179175
type=exercises.SINGLE_SELECTION,
180176
question="$$\\sum_n^sxa^n$$",

contentcuration/contentcuration/tests/viewsets/test_assessmentitem.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,8 +1243,7 @@ def test_supported_legacy_types_returned_as_qti(self):
12431243
self.assertIn(interaction, item["raw_data"])
12441244

12451245
def test_answerless_choice_item_is_returned_as_valid_qti(self):
1246-
# The shape the editor writes for every newly added question: a choice
1247-
# type with no answers and nothing typed into it yet.
1246+
# The shape the editor writes for every newly added question.
12481247
assessment_id = self._create_item(type=exercises.SINGLE_SELECTION).assessment_id
12491248

12501249
item = self._get_item(assessment_id)
@@ -1269,10 +1268,9 @@ def test_converted_item_has_no_legacy_field_content(self):
12691268
self.assertNotIn("contentnode__language__lang_code", item)
12701269

12711270
def test_converted_items_are_tagged_with_their_own_node_language(self):
1272-
# A contentnode__in read spans several nodes, so each item has to pick
1273-
# up its own node's language rather than one language for the batch.
1274-
# pt-BR has a subcode, so the bare lang_code publish tags items with is
1275-
# distinguishable from the Language primary key.
1271+
# A contentnode__in read spans several nodes, so each item must pick up its
1272+
# own node's language; pt-BR has a subcode, so the bare lang_code publish
1273+
# tags items with is distinguishable from the Language primary key.
12761274
self.node.language = models.Language.objects.get(id="pt-BR")
12771275
self.node.save()
12781276
other_node = models.ContentNode.objects.create(
@@ -1360,8 +1358,8 @@ def test_unconvertible_type_raises_on_list_route(self):
13601358
)
13611359

13621360
def test_unconvertible_type_is_not_a_404_on_detail_route(self):
1363-
# serialize_object() turns ValueError into a 404, which would report a
1364-
# corrupt row as a missing one - the failure must surface instead.
1361+
# Only this route goes through serialize_object(), which is what would
1362+
# otherwise swallow the failure into a 404.
13651363
assessmentitem = self._create_item(
13661364
type="not_a_real_type", question="What is 2+2?"
13671365
)

contentcuration/contentcuration/utils/assessment/qti/convert.py

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import List
88
from typing import Optional
99
from typing import Tuple
10+
from typing import Union
1011

1112
from le_utils.constants import exercises
1213

@@ -142,17 +143,17 @@ def _response_declaration(
142143

143144
def _create_choice_interaction_and_response(
144145
item: LegacyAssessmentItem,
145-
) -> Tuple[Optional[ChoiceInteraction], Optional[ResponseDeclaration]]:
146-
"""
147-
Create a QTI choice interaction for multiple choice questions, or
148-
``(None, None)`` if the question has no answers to choose between.
149-
"""
146+
) -> Tuple[Union[ChoiceInteraction, Div], Optional[ResponseDeclaration]]:
147+
"""Create a QTI choice interaction for multiple choice questions."""
150148
if not item.answers:
151-
# An answerless choice question is ordinary in-progress authoring state -
152-
# it is what the editor writes for every newly added question - but the
153-
# XSD requires a qti-choice-interaction to carry at least one
154-
# qti-simple-choice, and there is nothing to bind a response to.
155-
return None, None
149+
# An answerless choice question is ordinary in-progress authoring state,
150+
# but the XSD requires at least one qti-simple-choice and there is no
151+
# response to bind, so emit the question alone, ungraded. Div because
152+
# rendered markdown can start with a top level <math>, which
153+
# qti-item-body does not accept; empty P so an untyped question still
154+
# renders as an editable paragraph.
155+
body = _create_html_content_from_text(item.question) or [P()]
156+
return Div(children=body), None
156157

157158
multiple_select = item.type == exercises.MULTIPLE_SELECTION
158159

@@ -322,25 +323,18 @@ def convert_legacy_assessment_item_to_qti(
322323
else:
323324
raise ValueError(f"Unsupported question type: {item.type}")
324325

325-
if interaction is None:
326-
# Emit the question text alone, ungraded. Div because rendered markdown
327-
# can start with a top level <math>, which qti-item-body does not accept
328-
# directly; the empty P stands in for the text a newly added question
329-
# does not have yet, so the body is a paragraph to render and edit
330-
# rather than a bare empty div.
331-
item_body = ItemBody(
332-
children=[
333-
Div(children=_create_html_content_from_text(item.question) or [P()])
334-
]
335-
)
336-
response_declarations = []
337-
response_processing = None
338-
else:
339-
item_body = ItemBody(children=[interaction])
340-
response_declarations = [response_declaration]
341-
response_processing = ResponseProcessing(
326+
item_body = ItemBody(children=[interaction])
327+
328+
response_declarations = (
329+
[response_declaration] if response_declaration is not None else []
330+
)
331+
response_processing = (
332+
ResponseProcessing(
342333
template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct"
343334
)
335+
if response_declarations
336+
else None
337+
)
344338

345339
outcome_declaration = OutcomeDeclaration(
346340
identifier="SCORE", cardinality=Cardinality.SINGLE, base_type=BaseType.FLOAT

contentcuration/contentcuration/viewsets/assessmentitem.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,8 @@
3232
)
3333
)
3434

35-
# Types the read path returns as stored. Everything else is a legacy type that is
36-
# converted to QTI on read until the global backfill (#6007) makes the conversion
37-
# permanent, at which point AssessmentItemViewSet.consolidate goes away.
35+
# Everything else is a legacy type, converted to QTI on read until the global
36+
# backfill (#6007) makes that permanent and consolidate() goes away.
3837
PASSTHROUGH_TYPES = (exercises.QTI, exercises.PERSEUS_QUESTION)
3938

4039

@@ -342,8 +341,8 @@ class AssessmentItemViewSet(BulkCreateMixin, BulkUpdateMixin, ValuesViewset):
342341
"source_url",
343342
"randomize",
344343
"deleted",
345-
# Only consumed by consolidate(), which pops it back off - publish tags
346-
# an item with the bare lang_code of its content node's language
344+
# Only consumed by consolidate(), which pops it back off. Publish tags an
345+
# item with the bare lang_code of its content node's language
347346
# (utils/assessment/qti/archive.py), so the read path matches.
348347
"contentnode__language__lang_code",
349348
)
@@ -354,17 +353,16 @@ class AssessmentItemViewSet(BulkCreateMixin, BulkUpdateMixin, ValuesViewset):
354353

355354
def consolidate(self, items, queryset):
356355
for item in items:
357-
language = item.pop("contentnode__language__lang_code", None)
356+
language = item.pop("contentnode__language__lang_code")
358357
if item["type"] in PASSTHROUGH_TYPES:
359358
continue
360359
try:
361360
# A new dict, so the language does not leak into the response.
362361
result = convert_legacy_question_to_qti(dict(item, language=language))
363362
except (IndexError, ValueError, TypeError) as e:
364-
# serialize_object() turns IndexError/ValueError/TypeError into
365-
# a 404 (base.py), reporting a corrupt row as a missing one;
366-
# re-raise as a type it does not catch (pydantic and json errors
367-
# both subclass ValueError).
363+
# serialize_object() turns these into a 404 (base.py), reporting
364+
# a corrupt row as a missing one; re-raise as a type it does not
365+
# catch. pydantic and json errors both subclass ValueError.
368366
raise LegacyConversionError(
369367
f"Could not convert assessment item {item['assessment_id']} to QTI"
370368
) from e

0 commit comments

Comments
 (0)