Skip to content

Return QTI for still-legacy assessment items on read - #6073

Open
rtibblesbot wants to merge 3 commits into
learningequality:unstablefrom
rtibblesbot:issue-6030-8ae902
Open

Return QTI for still-legacy assessment items on read#6073
rtibblesbot wants to merge 3 commits into
learningequality:unstablefrom
rtibblesbot:issue-6030-8ae902

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

The assessment item endpoint returned still-legacy rows as stored, so every client had to understand the five legacy question shapes alongside QTI. The read path now converts each still-legacy row to type='QTI' with the item XML in raw_data. QTI and Perseus rows pass through as stored, and a converted item is tagged with the bare lang_code of its content node's language, falling back to the channel's, matching publish. A row that cannot be converted is logged and left legacy, so one bad item does not cost the rest of the list.

Two converter fixes were needed to get there, and both also apply to publish and ricecooker upload:

  • A choice question with no answers — the shape the editor writes for every newly added question — raised. It now yields a choice interaction holding the question and one empty qti-simple-choice, the same shape the QTI editor opens a new interaction with.
  • Ordinary legacy markdown raised: li, td, th, caption, dt, dd and qti-simple-choice accepted no math, though the XSD admits it in all seven. Links and the inline marks (s, del, ins, u, mark, strike) are unwrapped in the rendered markup instead of modelled — an anchor has nothing to navigate to offline, and the QTI 3.0 HTML profile has no element for a mark. Their text survives.

References

Closes #6030. All four acceptance criteria are met; nothing is deferred. The AC-to-test mapping in this comment still holds except for its failure-handling row, which review superseded — see Deviations. The boxes on the issue are unticked only because rtibblesbot lacks UpdateIssue — please tick them on merge. Consumes the converter from #6003; removed again by the backfill in #6007.

Deviations from the issue spec

Criterion Why not
Conversion failures are surfaced, not silently dropped Met by logger.exception plus the row staying legacy, which the editor renders as unsupported — not by raising. Raising 500s a whole contentnode__in list on one bad row; log-and-skip per item was requested in review and matches the publish precedent on #6029.

Reviewer guidance

  • AssessmentTab.vue:21 still mounts the legacy AssessmentEditor, and a converted row now arrives with question="", answers="[]", hints="[]". Can this merge before the QTI editor replaces it, given that editor writes those fields back through updateAssessmentItems?
  • viewsets/assessmentitem.py adds two language keys to the viewset's values — one a join, one a Channel subquery — and pops both off in consolidate. Does anything else consuming self.values see them?
  • fixtures/single_selection_no_answers.xml is a golden fixture, not a test — test_single_selection_no_answers asserts converter output against it, verified failing on a one-character mutation.

AI usage

Used Claude Code to implement this from a plan agreed on the issue beforehand, writing the failing tests first. Verified with the assessment item viewset suite, the QTI utils suite, the exercise archive suite, the publish and channel creation suites, and pre-commit.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?
  • Ran pre-flight CI checks (lint, format, tests) and verified all pass
  • Rebased onto the target branch and resolved any conflicts
  • Reorganized commit history into clean, logical commits
  • Audited the diff to ensure only issue-relevant files are changed
  • Built PR body from the repository's PR template with evidence blocks
@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-08-19 22:29 UTC

continue
try:
# A new dict, so the language does not leak into the response.
result = convert_legacy_question_to_qti(dict(item, language=language))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Ported from rtibblesbot on #6095) convert_legacy_question_to_qti raises on math directly inside an <li>, which FlowContentElement does not accept.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current assessments can include links (a elements) in their markdown format, but when transformed into a QTI XML, the conversion fails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both are the same defect: the pydantic HTML models are narrower than the QTI XSD they encode.

Swept it by running every construct render_markdown can emit (the gfm-like preset, plus raw HTML passthrough) through the converter in all four slots it renders into — question, choice answer, hint, text-entry prompt. Eight places matched, all now fixed:

  • a@href rejected every absolute URL — external links, autolinks, mailto:. The schema types it xs:anyURI; only schemes that execute rather than navigate stay rejected.
  • li, td, th, caption, dt, dd and qti-simple-choice took no math, though every one of those content models admits m3:math.

Publish and the ricecooker upload reach the same converter, so both stop failing on these too.

One case is left, and it is not a model that can be widened: ~~text~~ renders <s>, which the QTI 3.0 HTML profile has no element for (no del/ins either) — same for any raw HTML tag an author typed. Either the mark is dropped during rendering or the item stays unconvertible. With log-and-skip it now degrades to an unsupported card instead of a 500. Tell me which you want and I will do it.

# serialize_object() turns these into a 404 (base.py), reporting
# a corrupt row as a missing one; re-raise as a type it does not
# catch. pydantic and json errors both subclass ValueError.
raise LegacyConversionError(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uncaught: one bad row 500s the whole contentnode__in list. Log and skip per item; isUnsupported renders it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — consolidate logs the failure with the assessment_id and leaves the row as the legacy row it is, so the rest of the contentnode__in list still returns and the editor renders it via isUnsupported. LegacyConversionError is gone; the two tests that asserted a raise now assert the sibling item still converts and that the detail route returns 200 rather than a 404.

Searched the branch for the same shape — a per-item failure that aborts a batch. consolidate was the only one; the hint loop in _create_catalog_info already logs and skips.

Outside the branch: publish's create_assessment_item has no per-item catch either, so one unconvertible item still fails the whole exercise archive. Pre-existing, not touched here — say if you want it in this PR.


def consolidate(self, items, queryset):
for item in items:
language = item.pop("contentnode__language__lang_code")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Ported from rtibblesbot on #6095) Publish uses the channel lang_code; convert_legacy_question_to_qti hardcodes "en" — frozen on first write.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the read path now tags each item with its content node's lang_code, falling back to the channel's — the same chain as _node_language() and default_language in archive.py, so the API hands out what publish writes. Both come off the queryset: the node join in values, the channel as a subquery on main_tree__tree_id. Tests cover a node language, the channel fallback, per-node languages across a contentnode__in read, and the en default when neither has one.

Searched for other hardcoded languages on this path: convert_legacy_question_to_qti's or "en" is the only one, and it is now reached only when node and channel are both unset.

# qti-item-body does not accept; empty P so an untyped question still
# renders as an editable paragraph.
body = _create_html_content_from_text(item.question) or [P()]
return Div(children=body), None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Ported from rtibblesbot on #6095) No interaction in the Div body: the card reports NO_INTERACTION forever, and feeds the wipe at QTIItemEditor/index.vue:140.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have a basic empty choice interaction if no questions or answers exist, similar to what we create for a new empty interaction on #6095

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. An answerless choice item now converts to a qti-choice-interaction carrying the question as its prompt and one empty qti-simple-choice, plus its response declaration and response processing — what _defaultState() opens a new choice interaction with in #6095. The Div body with no interaction is gone, so nothing reports NO_INTERACTION and the wipe at QTIItemEditor/index.vue:140 has no trigger. Golden fixture updated.

Checked the other converter paths that could produce a body without an interaction: text entry always emits one, and so does the Perseus custom interaction. This branch was the only one.

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rtibblesbot, I'm still getting errors for the s element. Yes, it should be supported. Also, a change for link elements: we won't provide support for links anymore. These items are intended to be rendered on devices without access to the internet, so please, instead of transforming them into links, strip them in the conversion to QTI (keep the word, but remove the link, i.e., [a](b.com) -> a), and revert support for links on the pydantic models.

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

s — added as an inline element, so it converts in all four slots (question, choice answer, hint, text-entry prompt) and nested inside li/td/qti-simple-choice.

Swept the class rather than the tag: rendered a corpus of every construct the gfm-like preset emits and diffed the tag set against the element registry. 35 tags, s the only unregistered one.

One consequence needs your call: s is absent from the QTI 3.0 HTML profile (as are del/ins/u/mark), so validate_qti_item rejects <s> against the vendored XSD. Converted output is not validated, so read and publish work — but strikethrough cannot be written back as raw_data, on a converted item or one authored in the #6095 editor. Either extend the schema locally or render it as a span; say which and I will do it.

Links — stripped in the conversion, text kept. Applied to the rendered markup rather than the markdown tokens, so a raw-HTML <a href="…"> typed into a legacy question is stripped too; autolinks and local ./file.html links likewise. Images untouched.

HyperlinkHref is gone — fields.py and test_fields.py are back to unstable byte for byte, and text.py differs from it only by S. A stays in the models: a native QTI item still links bundled files, which is what media.py dependency tracking reads.

_create_html_content_from_text is the only markdown→QTI entry point, and all four slots funnel through it. html_to_markdown keeps its a rule — it reads native QTI, not converted output.

@AlexVelezLl

Copy link
Copy Markdown
Member

@rtibblesbot, ah, I didn't know it was not supported by the QTI XSD. Let's not provide support for this either, and let's strip them out just like links, then (~~a~~ -> a).

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Strikethrough — stripped like links now: ~~a~~a. S is out of the pydantic models, so text.py is back to unstable byte for byte.

Swept the class rather than the tag: rendered every construct the gfm-like preset emits (plus raw HTML passthrough) and diffed the tag set against the element registry. Six unregistered tags matched — s, del, ins, u, mark, strike, every inline mark the QTI 3.0 HTML profile has no element for. All six are stripped in the same pass as a, on the rendered markup, so a raw-HTML <del>x</del> typed into a legacy question strips too.

_create_html_content_from_text is the only markdown→QTI entry point, so this lands in all four slots: question, choice answer, hint, text-entry prompt. Tests cover strikethrough in each slot and each of the six tags; converted output validates against the XSD again.

html_to_markdown keeps its ~~ rules for s/del/strike — it reads native QTI, not converted output.

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.
…ction

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.
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 (learningequality#6007).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QTI] Serve legacy assessment items as QTI through the API endpoint (dual-read)

2 participants