Skip to content

Show QTI questions read-only when an edit would drop item-level content - #6306

Open
rtibblesbot wants to merge 1 commit into
learningequality:unstablefrom
rtibblesbot:issue-6290-74ac01
Open

rtibblesbot wants to merge 1 commit into
learningequality:unstablefrom
rtibblesbot:issue-6290-74ac01

Conversation

@rtibblesbot

@rtibblesbot rtibblesbot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • QTI questions open read-only when the root holds content the editor doesn't write, e.g. <qti-stylesheet>, <qti-modal-feedback>, a non-hint catalog, adaptive="true", or an outcome reference.
  • Custom scoring stays editable; save regenerates it.
  • Saves write xsi:schemaLocation, tool-name, tool-version, xml:lang for legacy language, and keep label.

References

Fixes #6290.

Reviewer guidance

  • Allowlist of root attributes and children: validateItem.js; <qti-catalog-info> may hold only hint cards parseHints reads in full.
  • Read-only items breaking an editor rule show Incomplete with the delete prompt.
  • Open risk: an edit drops a non-zero SCORE default or custom partial credit.
  • Known gap, QTI editor drops prefixed MathML from a question prompt on save #6308: MathML in a prompt saves as plain text.

QA: import "Skeletal Structure - Practice" (Kolibri QA Channel > Courses > Hummingbird Biology > Anatomy & Physiology > Skeletal Structure) into an editable channel; open Questions.

  1. Edit an answer in question 1 twice: both changes show, each once.
  2. Mark another answer correct in question 2, reopen: it stays correct.
  3. Add a choice and a Text entry question; each reopens as set.
  4. Check the read-only messages in Evidence are clear.

Evidence

Outcome references open read-only

Step Screenshot
Before: printed-SCORE question editable; edit dropped the variable Before the fix: printed-SCORE question 3 editable

After: read-only, covered by Jest.

Read-only items that break a rule

Step Screenshot
Two rule-breaking questions Incomplete; valid one read-only Two incomplete questions and one plain read-only question
Both deleted: complete After deleting both: exercise complete
readonly-rules-delete-flagged.webm

Scoring extras stay editable

Step Screenshot
All editable One scoring extra each: all editable
s1x-edit-correct-answer-save-reopen.webm

Root extras open read-only

Step Screenshot
Questions 1–7 read-only Questions 1–7 read-only, Edit disabled
adaptive="false", time-dependent="0": editable adaptive="false", time-dependent="0" question editable

Legacy root language

s3-legacy-language-edit-save-reopen.webm

Converted legacy hints

s4-edit-delete-hint-save-reopen.webm

New questions

Step Screenshot
Choice question reopened Choice question reopened with hints
s5-text-entry-create-reopen.webm
More captures (17)
Step Screenshot
Before the fix: printed-SCORE question in the editor, Beta marked Before the fix: printed-SCORE question in the editor, Beta marked
Before the fix: printed-SCORE question after save Before the fix: printed-SCORE question after save
One flagged question deleted: one incomplete left One flagged question deleted: one incomplete left
Scoring extras: Bravo correct after reload Scoring extras: Bravo correct after reload
MAXSCORE question reopened MAXSCORE question reopened
Root extras exercise: details filled Root extras exercise: details filled
false/0 question in the editor false/0 question in the editor
Legacy language question: Edit enabled Legacy language question: Edit enabled
Legacy language question in the editor Legacy language question in the editor
Legacy language question reopened Legacy language question reopened
Converted question with two hints Converted question with two hints
One hint left One hint left
Reopened: one hint with image Reopened: one hint with image
Empty question list Empty question list
New choice question New choice question
New text entry question New text entry question
Text entry reopened Text entry reopened

Deviations from the issue spec

Criterion Why not
Edits, including removing every hint, keep <qti-stylesheet>, <qti-modal-feedback>, non-hint catalogs Read-only instead, as asked

AI usage

Claude Code wrote and self-reviewed it; Jest and pre-commit pass.

🤖 Generated with Claude Code


Written by rtibblesbot, an LLM-based coding agent.

@rtibblesbot

🟡 Waiting for feedback

Last updated: 2026-10-11 13:51 UTC

@rtibblesbot
rtibblesbot marked this pull request as ready for review October 8, 2026 06:20
@rtibblesbot
rtibblesbot requested review from a team and leq-bot October 8, 2026 06:20
@rtibblesbot
rtibblesbot force-pushed the issue-6290-74ac01 branch 2 times, most recently from 90c5b61 to dbe8a96 Compare October 8, 2026 18:58
@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Reworked per @AlexVelezLl's direction: items with item-level content the editor doesn't write now open read-only instead of carrying it. The PR description above still describes the carry-over approach.

  • Read-only: <qti-stylesheet>, <qti-modal-feedback>, outcomes other than SCORE/RAW_SCORE, catalogs or cards other than hints, response declarations no interaction uses, any other root child, unknown root attributes.
  • Still editable, kept through an edit: xsi:schemaLocation (with xmlns:xsi), label, tool-name, tool-version. The converter writes these on every legacy question, so making them read-only would lock all converted items.
  • keepsItemContent in assembleItem.js decides; QTIItemEditor and validateQtiItem both call it.

Stylesheet item read-only; xsi:schemaLocation item editable

Deleting a hint on the xsi:schemaLocation item, then closing it. The stored raw_data keeps xsi:schemaLocation and has one hint left:

edit-schemalocation.webm

@rtibblesbot
rtibblesbot force-pushed the issue-6290-74ac01 branch 2 times, most recently from f6778b6 to 0f4e0d1 Compare October 9, 2026 02:39
@rtibblesbot rtibblesbot changed the title Keep QTI item content the editor doesn't write through an edit Show QTI questions read-only when an edit would drop item-level content Oct 9, 2026

@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.

The high level strategy proposed for this issue was too risky and prone to errors, where new introducedn editor capabilities could potentially drop support for questions created with older versions of it. Let's allow some flexibility in favor of a safer strategy for 99.99999% of cases.

}

/** Root metadata the converter writes and nothing in the item points at. */
const METADATA_ROOT_ATTRIBUTES = ['label', 'tool-name', 'tool-version', '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.

suggestion: With an allowlist, root attributes need one too: identifier, title, xml:lang, label, tool-name, tool-version, xsi:schemaLocation, and adaptive/time-dependent only when false. Any other attribute locks the item.

To write them back, could assembleItemXml write the converter's root itself instead of copying it from the source? The Python AssessmentItem (utils/assessment/qti/assessment_item.py) always writes the same xmlns:xsi and xsi:schemaLocation. That removes the sourceXml parameter, isCarriedRootAttribute, and the prefix-declaration filter with its tests. label/tool-name/tool-version can either be written as the converter writes them (kolibri/0.1) or carried as plain attributes, without namespace handling.

Legacy language: items converted before c51c5e0 have a root language instead of xml:lang, and parseItem reads only xml:lang (parseItem.js:40). The PR currently carries language over, so an edited legacy item never gets xml:lang. I reproduced that with a converted fixture. Could parseItem fall back to language, and the allowlist accept it, so an edit writes it as xml:lang?

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 in a3f4945.

  • isEditorRootAttribute (validateItem.js): your list plus legacy language. Any other attribute, namespaced ones included, locks the item.
  • assembleItemXml writes xmlns:xsi, xsi:schemaLocation, tool-name="kolibri" and tool-version="0.1" as the converter does. sourceXml, isCarriedRootAttribute and the prefix filter are gone.
  • label is carried as a plain attribute via parseItem → useQtiItem; the converter never sets one, so there is no fixed value to write.
  • parseItem falls back to language, so an edit writes it as xml:lang (test in convertedItem.spec.js).
  • Blank items now get the same root metadata.

Searched QTIEditor for other root-language reads: 1 (parseItem.js:43), changed.

* @returns {boolean} Whether assembling the item unedited writes back each source root
* attribute and child as it was
*/
function writesBackItemContent(sourceXml) {

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.

suggestion: Could the check be an allowlist of the root children the editor writes, instead of reassembling the item and comparing? qti-assessment-item has a closed set of 12 children (AssessmentItem.Type in utils/assessment/qti/schema/xsd/imsqti_itemv3p0p1_v1p0.xsd), and the editor writes 5 of them:

// The item children the editor writes; any other child makes the item read-only.
const EDITOR_CHILDREN = [
  'qti-response-declaration',
  'qti-outcome-declaration',
  'qti-item-body',
  'qti-catalog-info',
  'qti-response-processing',
];

export function keepsItemContent(sourceXml) {
  const root = parseXML(sourceXml).documentElement;
  return (
    attributesOf(root).every(isEditorRootAttribute) &&
    [...root.children].every(
      el =>
        el.namespaceURI === root.namespaceURI &&
        EDITOR_CHILDREN.includes(el.localName) &&
        (el.localName !== 'qti-catalog-info' || holdsOnlyHints(el)),
    )
  );
}

(A sketch; I haven't run it. isEditorRootAttribute and holdsOnlyHints are described in the other comments.)

The comparison makes the editor's current output the reference, so anything that differs locks the item, even when an edit would lose nothing. I ran these through isEditableItem on an otherwise editable choice item, and each opens read-only:

  • explicit match_correct rules instead of the template;
  • a SCORE default of 1, which the template overwrites anyway;
  • normal-maximum on SCORE, or a MAXSCORE outcome, as other QTI tools export;
  • a multi-blank item with one blank unanswered. The editor then writes no processing, so the stored rules no longer match. Not reachable until a multi-blank editor ships, but then it is.

It also ties stored content to today's writer. If assembleItemXml later adds an attribute to SCORE, or changes the rules it writes for several declarations, every item saved before that change locks. The same goes for items stored by the Python converter. That has already happened once: STANDARD_TEMPLATE_URIS exists because the converter's template spelling changed in 90b0a08.

With the allowlist, response processing and outcome declarations belong to the editor, as buildScoringNodes already says ("Regenerated on every save rather than carried over"). The trade-off: a non-zero SCORE default or custom partial-credit processing gets replaced on edit instead of locking the item. That matches the interaction builders, which already rewrite mappings on edit. An allowlist rather than a list of unsupported children fails closed on content that isn't valid QTI, which matters because ricecooker raw_data isn't validated on upload.

writesBackItemContent, isSameContent, the outcome and default-value cases of holdsNothing, STANDARD_TEMPLATE_URIS and probably the cache would go. Most read-only cases in assembleItem.spec.js still apply. The scoring ones (a score maximum, non-zero defaults, processing the editor doesn't write) and the unused response declaration would become editable.

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 in a3f4945: keepsItemContent in validateItem.js, as sketched.

  • Children: your five, in the root namespace. Non-whitespace text at the root also locks.
  • Gone: writesBackItemContent, isSameContent, the outcome/default cases, STANDARD_TEMPLATE_URIS, the cache.
  • Now editable, with tests: explicit match_correct rules, SCORE default 1, normal-maximum plus MAXSCORE, an unused response declaration. An edit replaces their scoring.
  • Still read-only: stylesheet, modal feedback, glossary catalog, non-hint cards, adaptive/time-dependent="true".

Searched QTIEditor for other checks that reassemble and compare: 0. isSupportedItem matches body structure per descriptor, so it is unchanged.

The PR description's reviewer guidance still describes the comparison; this supersedes it.

* @param {string} sourceXml
* @returns {boolean}
*/
export function keepsItemContent(sourceXml) {

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.

nitpick: The check judges an item rather than assembling one. Could it live in validateItem.js, next to isEditableItem, with the hint rule in hints.js (see that comment)? assembleItem.js would then stay the write path only and wouldn't need to import parseItem. As a scan over the root's attributes and children, it probably doesn't need the cache either.

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 in a3f4945: keepsItemContent sits beside isEditableItem in validateItem.js, uncached. assembleItem.js no longer imports parseItem.

* @param {Element} card
* @returns {boolean}
*/
function isHintCard(card) {

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.

praise: isHintCard is strict in the right way. It accepts only the card shape the hint editor reads in full, so a card with xml:lang, a second content element or a qti-file-href locks the item instead of being half-read.

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.

Kept as is, now in hints.js. A non-card child of the hint catalog also locks the item.

* @param {Node} node
* @returns {boolean}
*/
function holdsNothing(node) {

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.

suggestion: Keep this hint rule (isHintCard plus the qti-catalog and qti-catalog-info cases here), but move it to hints.js, e.g. as holdsOnlyHints(catalogInfo) next to parseHints. It decides what parseHints can read in full, so the two should change together. parseHints finds cards by support in any catalog and reads only the first qti-html-content; if it grows, for example to hints in several languages, this rule has to grow with it. With the allowlist, the outcome and default-value cases here can go.

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 in a3f4945: holdsOnlyHints(catalogInfo) with isHintCard in hints.js, under parseHints. An attribute on qti-catalog-info also locks the item. The outcome and default-value cases are gone.

- An item opens read-only when its root has an attribute or child the
  editor doesn't write; scoring outcomes and processing are regenerated.
- An item that reads an outcome (feedback, printed variable) opens
  read-only: the rich-text editors drop those elements.
- holdsOnlyHints (hints.js) accepts only hint cards parseHints reads in
  full.
- assembleItemXml writes the converter's root metadata, including
  xsi:schemaLocation.
- parseItem reads a pre-c51c5e035 root `language`, so an edit writes it
  as xml:lang.
- validateQtiItem still applies the editor's rules to these items, so an
  unanswerable one stays incomplete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Python unit tests red: test_restore_channel.py QTIRestoreTestCase, 7 failures — #6305. Same 7 fail on base afc8296ac locally; fix is in #6262. test_convert.py passes.

@rtibblesbot

Copy link
Copy Markdown
Contributor Author

Python unit tests: the 7 QTIRestoreTestCase failures in test_restore_channel.py reproduce on base afc8296ac too (#6305, fixed by #6262). This branch's Python change, test_convert.py, passes (49 tests).

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] Editing a question keeps the item-level content the editor doesn't write

2 participants