Skip to content

Exercise: Import and export matching questions in QTI2, and export fill-in-the-blanks questions - #9281

Merged
ywarnier merged 9 commits into
chamilo:masterfrom
ybtbeez:fix/issue-1377-qti-import-matching
Oct 20, 2026
Merged

ywarnier merged 9 commits into
chamilo:masterfrom
ybtbeez:fix/issue-1377-qti-import-matching

Conversation

@ybtbeez

@ybtbeez ybtbeez commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1377

Problem

The issue asked for more question types in QTI exports, naming fill-in-the-blanks and matching.

  • Matching: the QTI2 import drops matching questions without saying much, and the QTI2 export
    never writes them. On current master (local portal, Oct 20), importing a zip with one matching
    question and one unique-answer question through the Vue "Import exercises Qti2" page gives
    Imported questions: 1 / Skipped questions: 1. The legacy qti2.php import keeps only the
    unique-answer question.
  • Fill-in-the-blanks: import was fixed in Exercise: Fix QTI export for Fill-in-the-Blanks questions - refs #3277 #7336 (refs Exercise Exports (QTI) (with fill blanks) #3277), but neither exporter writes these
    questions: ExerciseQti2ExportService skips them and the legacy export_question_qti() rejects them
    in isValid(). The legacy ImsAnswerFillInBlanks class exists but can't work: it treats the
    whole stored answer row (text plus the ::scores:sizes:separator part) as one blank word, so no blank
    is ever replaced, it declares the response as identifier and writes mapKey without escaping.

This PR adds matching import and export, and fill-in-the-blanks export, in both code paths, so a
Chamilo-to-Chamilo round trip works for both types. Since packages are untrusted input and the legacy
export entry points now export two more question types, it also cleans the imported HTML of both types
and checks that an exported question or exercise belongs to the current course.

Cause

  • Legacy importer (public/main/exercise/export/exercise_import.inc.php): it accepts a fixed list
    of question types. MATCHING is not on the list, so the parsed question is skipped.
  • ExerciseQti2ImportService: it maps matchInteraction to type 4, but type 4 is missing from
    SUPPORTED_TYPES, so the question is counted as skipped.
  • Export:
    • ExerciseQti2ExportService exports neither matching nor fill-in-the-blanks questions.
    • The legacy export_question_qti() (qti2_export.php) rejects both in isValid().
    • ImsAnswerMatching loops over leftList and rightList, which nothing fills, writes the raw
      question text inside the interaction and declares the response as single/identifier.

Fix

The two importers already duplicate their parsing logic (for example buildFibAnswerString), so the
matching import logic is duplicated the same way. The fill-in-the-blanks writer is shared
(Qti2FillBlanksWriter), because both exporters must write exactly the same XML.

Matching import

  • Both importers read the two simpleMatchSet blocks. Set 1 holds the items (left) and set 2 the
    options (right). Associations come from the correctResponse pairs <item> <option>. A reversed
    pair is swapped. Each item keeps its first pair. Scores come from mapping/mapEntry, falling back to
    the default value.
  • Two response declarations are accepted: the standard multiple/directedPair, and the
    single/identifier that the old Chamilo exporter wrote.
  • Answers are stored the way Chamilo's matching editor stores them: first the option rows
    (correct = 0), then the item rows, whose correct points to their option and whose ponderation
    is the pair's score. The question score is the sum of the item scores.
  • Item and option texts are sanitized with HTMLPurifier (QtiImportHtmlSanitizer) before they are
    stored. Matching texts are rendered as HTML in the exercise, so without this a package could store
    script in an answer (see "Security review").
  • QTI cannot tell Matching and Matching draggable apart, so both are imported as Matching.
  • A matching question with no usable association is skipped with a visible message.
  • The QTI2 import notes in ExerciseQuestionImportProvider now list matching questions.

Matching export

  • ExerciseQti2ExportService exports Matching and Matching draggable as a matchInteraction with a
    prompt, set 1 with the items left_<iid> (matchMax="1"), set 2 with the options right_<iid>
    (matchMax="0") and a multiple/directedPair declaration with one mapEntry per pair.
  • Legacy ImsAnswerMatching builds its lists from the stored rows and writes the same structure.

Fill-in-the-blanks export

Chamilo stores a FIB question as one answer row,
<html text with [blanks]>::<scores>:<input sizes>:<separator>@<switchable>. The new
Qti2FillBlanksWriter parses it with the same rules as FillBlanks::getAnswerInfo() and writes one
QTI 2.1 interaction per blank, with identifiers fill_1, fill_2, …:

Chamilo blank QTI 2.1 output
Typed word [PHP] textEntryInteraction; responseDeclaration single/string, correct value PHP, mapEntry mapKey="PHP" mappedValue="<score>"
Typed with alternatives [1.11||1.11.x] Same, one mapEntry per alternative with the blank's score; the correct value is 1.11||1.11.x (read back by Chamilo's importers)
Menu [Linux|Windows|macOS] (first option is correct) inlineChoiceInteraction shuffle="false" with one inlineChoice per option; single/identifier declaration, correct value and mapEntry on the first option
"Ignore case" question option (case:false) caseSensitive="false" on every typed mapEntry
  • Response processing is the standard map_response template, with an outcomeDeclaration SCORE.
  • The text between blanks is written as <span><![CDATA[…]]></span>, HTML-escaped inside the CDATA,
    like the other Chamilo QTI texts. Options are written the same way, mapKey and identifiers as
    XML-escaped attributes. A ]]> in the content can't close the CDATA because it is escaped first.
  • Any of the separators [] {} () ** ## %% $$ is read; scores are written as finite numbers only.
  • ExerciseQti2ExportService and the legacy ImsAnswerFillInBlanks / qti2_export.php
    (isValid(), map_response for FIB, extra copied for the case option) use the writer.

Fill-in-the-blanks import fixes needed for the round trip

Both importers already accepted textEntryInteraction and inlineChoiceInteraction, but a round trip
through them lost data. The fixes are small and shared by both importers:

  • Blanks are replaced one by one, so a question can mix typed blanks and menus (before, the whole
    question was handled as one kind of blank, and menus became [fill_2_1], see the Playwright run).
  • Each blank keeps its own score, from its mapEntry. Before, a menu got 1 point and two blanks with
    the same word shared one score.
  • A bare <span> wrapper around text is unwrapped, so the text doesn't gain extra tags on each round
    trip.
  • caseSensitive="false" on a blank sets the question's "Ignore case" option.
  • The question text is sanitized with the same QtiImportHtmlSanitizer (HTMLPurifier) as the
    matching texts: scripts, event handlers and javascript: URLs are removed, formatting is kept. The
    blanks are swapped for plain tokens before purifying and put back afterwards, so [word],
    [a||b], [a|b|c] and the other separators come out unchanged. Inside a blank, < and > are
    escaped (and quotes, when the blank sits inside an attribute), so a blank can't bring markup back.
    A blank that the purifier removes with its element (inside a <script>, for example) is dropped
    with its score, so the scores still match the blanks.

Legacy export entry points

  • admin.php?action=exportqti2&questionId= exported any question id, from any course, to a teacher of
    any course; exercise.php?action=exportqti2&exerciseId= did the same with exercises. Both now
    answer with the usual "not allowed" page unless the question/exercise has a live resource link in the
    current course (and session, or the base course in a session). The exercise uses the existing
    CQuizRepository::findInCourseContext(); the question uses a new
    CQuizQuestionRepository::findInCourseContext() written the same way. The API export
    (/api/exercise/{id}/qti2-export) already did this check.
  • Both entry points crashed on 3.x for every question type (HTTP 500,
    Call to undefined method Question::getPictureFilename() in ImsAssessmentItem::export()). The
    method was removed in 3.x and the value is only read by the hotspot exporter, which isn't reachable,
    so an empty string is passed instead. Without this, the legacy exporter changes above couldn't be
    used at all.

What QTI 2.1 can't carry (by design)

  • Input sizes are not exported; imported blanks get the default width (200).
  • The separator is not kept: imported questions always use []. A question using another separator
    whose text contains literal [ ] may be read differently after import.
  • The "switchable answers" option (@1) is not exported.
  • "Ignore case" is per question in Chamilo; on import, one case-insensitive blank makes the whole
    question case-insensitive.
  • Other QTI tools score typed blanks with the mapEntry values; the a||b correct value is
    Chamilo-specific and other tools see it as one literal answer.

Files:

  • public/main/exercise/admin.php, public/main/exercise/exercise.php (course check)
  • public/main/exercise/export/exercise_import.inc.php
  • public/main/exercise/export/qti2/ImsAnswerFillInBlanks.php
  • public/main/exercise/export/qti2/ImsAnswerMatching.php
  • public/main/exercise/export/qti2/qti2_export.php
  • src/CoreBundle/Service/Exercise/ExerciseQti2ExportService.php
  • src/CoreBundle/Service/Exercise/ExerciseQti2ImportService.php
  • src/CoreBundle/Service/Exercise/Qti2FillBlanksWriter.php (new)
  • src/CoreBundle/Service/Exercise/QtiImportHtmlSanitizer.php (new)
  • src/CoreBundle/State/Exercise/ExerciseQuestionImportProvider.php (import notes only)
  • src/CourseBundle/Repository/CQuizQuestionRepository.php (findInCourseContext())

Tests:

  • tests/CoreBundle/Service/Exercise/ExerciseQti2ImportServiceTest.php
  • tests/CoreBundle/Service/Exercise/LegacyQti2ImportMatchingTest.php
  • tests/CoreBundle/Service/Exercise/ExerciseQti2FillBlanksExportTest.php (new)
  • tests/playwright/features/toolExerciseQti2Import.feature, its generated spec,
    tests/playwright/fixtures/qti2_matching.zip and tests/playwright/fixtures/qti2_fill_blanks.zip
  • New steps in common.steps.ts: the row for {string} should contain {string},
    I save the file linked by the {string} icon in the row for {string} as {string},
    the fill in blanks preview should show {string}, {string} and {string} and
    the QTI2 export of the question {string} should work in course {string} but be refused in course {string}

How it was tested

The branch is rebased on master f84ab3f. All runs below are from Oct 20.

  • PHPUnit, new tests (no DB: a mocked EntityManager assigns iids on flush):
    • php bin/phpunit tests/CoreBundle/Service/Exercise/ExerciseQti2ImportServiceTest.php: OK, 7 tests,
      59 assertions. Matching import with both declaration styles, rows, scores, positions, an export →
      import round trip, the skipped-question report and the HTML sanitizing of matching texts; the HTML
      sanitizing of a fill-in-the-blanks text (script, onclick/onerror, javascript: removed; bold,
      link, image, menu and alternatives kept; the blank inside the <script> dropped with its score),
      other separators, and a blank inside an attribute.
    • php bin/phpunit tests/CoreBundle/Service/Exercise/LegacyQti2ImportMatchingTest.php: OK, 5 tests,
      16 assertions. The same through the legacy qti_parse_file() + buildMatchingAnswerRows() /
      buildFibAnswerString().
    • Without the fill-in-the-blanks sanitizing, its two tests fail (1 failure in each file).
    • php bin/phpunit tests/CoreBundle/Service/Exercise/ExerciseQti2FillBlanksExportTest.php: OK,
      16 tests, 157 assertions. For typed, alternatives, menu and mixed blanks, the {} separator, HTML,
      entities and ]]> in the text, and two equal words with different scores, it exports with the
      service and with the legacy exporter (both must write the same item), imports the XML again through
      both importers and compares the stored answer string and the question score. It also checks the
      XML structure with XPath, the escaping, the "Ignore case" option and a question without blanks.
    • Without the change: the FIB export test gives 6 errors and 10 failures; with the export change but
      master's importers, 13 failures. With the production files reverted to master, the matching tests
      fail as well (3 errors + 1 failure, and 3 errors).
  • PHPUnit, whole tests/CoreBundle/Service/Exercise directory: 64 tests. The 44 tests that need no
    DB pass. The 20 DB-backed tests error with SQLSTATE[HY000] [1698] Access denied for user 'root'@'localhost', from the local test-DB setup, the same without this change.
  • Playwright (toolExerciseQti2Import.feature, admin in the seeded course TEMP):
    • Matching: imports the zip, checks Imported questions: 2, no skipped questions, total score 7, the
      distractor D: Lisbon and the item/option rows in the preview.
    • Fill-in-the-blanks: imports qti2_fill_blanks.zip (one question with a typed blank, a 3-option menu
      and a typed blank with alternatives, 1 + 2 + 3 points), checks the total score 6 and the three blanks
      in the question preview; then downloads the exercise with the list's "Export QTI2" link, imports
      that file and checks the same things again. Cleanup scenarios delete the exercises.
    • Course check: with the question and exercise ids of the imported question, the legacy
      admin.php?action=exportqti2 and exercise.php?action=exportqti2 URLs return a zip in the course
      TEMP and the "not allowed" page (no zip) in the course AIACT. With the course check reverted,
      admin.php in AIACT returns the zip of the TEMP question (the step fails).
    • Without the change: 4 failed, 1 passed (the matching import doesn't import 2 questions; the menu
      blank is shown as [fill_2_1]; the export → import gives no "File imported"; the cleanup can't find
      the second FIB exercise).
    • With the change: 5/5 passed.
    • Regression run with the change: toolExerciseAdmin.feature + toolExerciseQti2Import.feature
      27/27 passed (12:43 CEST).
    • Command:
      npx playwright test --config=tests/playwright/playwright.config.ts <spec> --retries=0 --workers=1
  • Static checks on the changed files:
    • vendor/bin/phpstan analyse on the changed src/ files: no errors.
    • vendor/bin/ecs check on the changed src/ and test files: clean. The legacy files have the same
      findings as on master, none on the changed lines.

Security review

An OWASP-style review of the branch was done (Oct 20):

  • XML parsing: unchanged parsers. External entities are not loaded (no LIBXML_NOENT, network
    disabled) and libxml rejects entity amplification ("billion laughs").
  • Zip handling: unchanged; entries are read in memory, nothing is extracted.
  • Stored XSS: matching item and option texts, and fill-in-the-blanks question texts, from a package
    were stored as raw HTML and rendered as HTML. This PR made matching importable and FIB round-trippable,
    so both are now sanitized with HTMLPurifier (QtiImportHtmlSanitizer), with regression tests in both
    importer test files.
  • Export injection: all exported text is HTML-escaped inside CDATA, attributes are XML-escaped,
    identifiers are generated and scores are written as finite numbers.
  • Access control: the legacy exportqti2 actions now check that the question/exercise belongs to
    the current course (see above); the API export already did. No new route, endpoint or form.
  • SQL: stored through the existing entity and Answer APIs only.

Found on master and not changed here (to be reported separately): the text of questions of other
types imported by the legacy importer and the description of imported questions are stored without
HTMLPurifier (the service uses a regex-based cleanup), qti2.php has no CSRF token on its upload
form, zip entries are read without a size limit, and a score such as 1e309 becomes INF.

Not covered / notes for reviewers

  • The 2016 report also mentioned that images were not imported and that JPG images were visible only to
    teachers (on 1.10.x). This was not checked on 3.x.
  • The import was not run with a teacher account. Teachers use the same import pages as admins.
  • A learner attempt on an imported matching or FIB question (scoring) was not tested end to end. The
    stored rows are the same as those the question editors create.
  • Comments on individual matching items are not exported or imported. Matching items with no
    association are dropped on import (Chamilo can't store them); distractor options are kept.
  • The new warning and error texts follow the surrounding untranslated style.
  • Documentation (Rule 14): a page for 3.x/en/ on the docs repository's all branch is attached to
    this PR (qti2-import-matching.md), to be merged into the docs manually.

…o#1377

QTI 2.1 packages containing a matchInteraction were imported without
their matching questions: the legacy importer (qti2.php) only accepted
a fixed list of types and the API importer (ExerciseQti2ImportService)
detected type 4 but did not support it, so both skipped the question.

Import:
- Legacy importer: accept MATCHING, build option rows (correct = 0)
  followed by item rows whose correct value is the option position,
  with the score from the mapping (or the default), so Answer::save()
  links them to the option ids. Questions without associations are
  skipped with a warning.
- ExerciseQti2ImportService: add Matching to the supported types,
  create the option answers first, flush to get their ids and then
  create the item answers pointing to them; question score is the sum
  of the item scores. Questions without associations are reported as
  skipped.
- Both standard (multiple/directedPair) and older Chamilo
  (single/identifier) response declarations are read. QTI cannot tell
  Matching and Matching draggable apart, so both import as Matching.

Export (needed for the round trip, matching was not exported):
- ExerciseQti2ExportService: export Matching and Matching draggable
  as a matchInteraction with a directedPair response declaration and
  per-pair scores.
- Legacy ImsAnswerMatching/qti2_export: list options and items from
  the stored rows, emit the prompt and a multiple/directedPair
  declaration, and accept matching questions as exportable.

Tests: PHPUnit for the service (incl. export/import round trip) and
the legacy parser, plus a Playwright scenario importing a QTI2 zip
with a matching question.
Matching options and items are displayed without any HTML filtering in
the exercise views (unique and multiple answers go through
Security::remove_XSS() when displayed), so importing them as-is from an
uploaded QTI2 package let the package plant scripts, event handlers or
javascript: URLs that run for students and teachers.

Both QTI2 importers now clean the matching texts with HTMLPurifier
(QtiImportHtmlSanitizer) before storing them; formatting is kept.
Fill in blanks questions were not exported to QTI2 by either exporter.

Export (ExerciseQti2ExportService and legacy ImsAnswerFillInBlanks, both
through the new Qti2FillBlanksWriter so they write the same markup):
- typed blanks ([word], [word||alternative]) as textEntryInteraction
  with a string response, menus ([correct|wrong1|wrong2]) as
  inlineChoiceInteraction, each blank in its own fill_N declaration
  with its score in the mapping (caseSensitive="false" for
  case-insensitive questions), processed with map_response;
- the text around the blanks is kept in spans, as escaped HTML in CDATA
  like the other texts of Chamilo QTI2 exports; any blank separator is
  read.

Import (both QTI2 importers), so that the export round-trips:
- each blank is rebuilt from its own interaction, so a question can mix
  menus and typed blanks (one kind used to be turned into "[]" or into
  the choice identifier);
- each blank takes its score from its own response mapping (menu
  scores were lost and identical words shared one score);
- spans without attributes are read as plain text;
- caseSensitive="false" sets the question as case insensitive.

Input sizes, the blank separator and "allow answers order switches"
have no QTI equivalent and are imported with the defaults.
@codecov

codecov Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.61850% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 17.56%. Comparing base (f84ab3f) to head (96bd6ef).
⚠️ Report is 12 commits behind head on master.

Files with missing lines Patch % Lines
...ourseBundle/Repository/CQuizQuestionRepository.php 0.00% 15 Missing ⚠️
...dle/Service/Exercise/ExerciseQti2ImportService.php 92.17% 9 Missing ⚠️
...reBundle/Service/Exercise/Qti2FillBlanksWriter.php 97.16% 3 Missing ⚠️
...dle/Service/Exercise/ExerciseQti2ExportService.php 98.11% 1 Missing ⚠️
...Bundle/Service/Exercise/QtiImportHtmlSanitizer.php 98.24% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #9281      +/-   ##
============================================
+ Coverage     17.20%   17.56%   +0.35%     
- Complexity    62176    62303     +127     
============================================
  Files          2522     2527       +5     
  Lines        233319   233745     +426     
============================================
+ Hits          40150    41053     +903     
+ Misses       193169   192692     -477     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ywarnier
ywarnier merged commit 874c677 into chamilo:master Oct 20, 2026
7 of 9 checks passed
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.

Exportar más tipos de preguntas en QTI

2 participants