MDL-87606 backup: Include child questions in backups

Since MDL-41924, because we are not always including the whole category
of each question being used in a backup, we have been missing some child
questions.

This expands the query selecting questions belonging to categories
where only annotated questions are being included, to also select any
children of those questions.
This commit is contained in:
Mark Johnson
2026-01-27 14:52:13 +00:00
parent 96dd901bea
commit 3f9e294f21
2 changed files with 140 additions and 4 deletions
+5 -2
View File
@@ -2726,7 +2726,7 @@ class backup_questions_structure_step extends backup_structure_step {
WHERE bi.backupid = ?
AND bi.itemname = 'question_categoryfinal'", [backup::VAR_BACKUPID]);
// Add all question bank entries from "complete" categories, plus annotated question bank entires
// Add all question bank entries from "complete" categories, plus annotated question bank entires and their children
// from "partial" categories.
$questionbankentry->set_source_sql(
"
@@ -2738,8 +2738,11 @@ class backup_questions_structure_step extends backup_structure_step {
UNION
SELECT qbe.*
FROM {question_bank_entries} qbe
JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
JOIN {question} q ON q.id = qv.questionid
LEFT JOIN {question_versions} parentqv ON parentqv.questionid = q.parent
JOIN {question_category_partial_temp} qcp ON qcp.itemid = qbe.questioncategoryid
JOIN {backup_ids_temp} biq ON biq.itemid = qbe.id
JOIN {backup_ids_temp} biq ON biq.itemid = qbe.id OR biq.itemid = parentqv.questionbankentryid
WHERE qcp.itemid = ?
AND qcp.backupid = ?
AND biq.backupid = ?
@@ -250,7 +250,8 @@ final class backup_question_selection_test extends \advanced_testcase {
] = $this->create_quiz_and_questions();
// Revert the filtercondition to the legacy JSON format.
$questionsetreference = $DB->get_record('question_set_references', []);
$questionsetreferences = $DB->get_records('question_set_references');
$questionsetreference = reset($questionsetreferences);
$filtercondition = json_decode($questionsetreference->filtercondition);
$tag = \core_tag_tag::get($filtercondition->filter->qtagids->values[0]);
$questionsetreference->filtercondition = json_encode([
@@ -379,5 +380,137 @@ final class backup_question_selection_test extends \advanced_testcase {
$rc->execute_plan();
$rc->destroy();
}
}
/**
* Backing up a quiz using questions with children includes those children in the backup.
*/
public function test_quiz_backup_includes_child_questions(): void {
global $DB;
$this->resetAfterTest();
$manager = $this->getDataGenerator()->create_user();
$this->setUser($manager);
$course = $this->getDataGenerator()->create_course();
$sharedcourse = $this->getDataGenerator()->create_course();
$this->getDataGenerator()->enrol_user($manager->id, $course->id, 'manager');
$this->getDataGenerator()->enrol_user($manager->id, $sharedcourse->id, 'manager');
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
// Create a question bank with 3 shared questions, each with 2 children.
$courseqbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]);
$coursequestions = $questiongenerator->create_categories_and_questions(
\context_module::instance($courseqbank->cmid),
[
'courseparentcat' => [
'courseq1' => 'multianswer',
'courseq2' => 'multianswer',
],
'randomcat' => [
'courseq3' => 'multianswer',
],
]
);
$coursechildquestions = [
'courseq1' => $DB->get_records(
'question',
['parent' => $coursequestions['courseparentcat']['courseq1']->id],
),
'courseq2' => $DB->get_records(
'question',
['parent' => $coursequestions['courseparentcat']['courseq2']->id],
),
'courseq3' => $DB->get_records(
'question',
['parent' => $coursequestions['randomcat']['courseq3']->id],
),
];
$quiz = $this->create_test_quiz($course);
$quizquestions = $questiongenerator->create_categories_and_questions(
\context_module::instance($quiz->cmid),
[
'quizcat' => [
'quizq1' => 'multianswer',
'quizq2' => 'multianswer',
],
]
);
$quizchildquestions = [
'quizq1' => $DB->get_records(
'question',
['parent' => $quizquestions['quizcat']['quizq1']->id],
),
'quizq2' => $DB->get_records(
'question',
['parent' => $quizquestions['quizcat']['quizq2']->id],
),
];
// Add a question from the course bank parent category.
quiz_add_quiz_question($coursequestions['courseparentcat']['courseq2']->id, $quiz);
// A question from quiz bank.
quiz_add_quiz_question($quizquestions['quizcat']['quizq2']->id, $quiz);
// Add a random question from the course bank.
$settings = quiz_settings::create($quiz->id);
$structure = structure::create_for_quiz($settings);
$structure->add_random_questions(1, 1, [
'filter' => [
'category' => [
'jointype' => \core\output\datafilter::JOINTYPE_ANY,
'values' => [$coursequestions['randomcat']['courseq3']->category],
'filteroptions' => ['includesubcategories' => false],
],
],
]);
// Backup the quiz.
$bc = new \backup_controller(
\backup::TYPE_1ACTIVITY,
$quiz->cmid,
\backup::FORMAT_MOODLE,
\backup::INTERACTIVE_NO,
\backup::MODE_IMPORT,
$manager->id,
);
$backupid = $bc->get_backupid();
$bc->execute_plan();
$bc->destroy();
$course2 = $this->getDataGenerator()->create_course();
$this->getDataGenerator()->enrol_user($manager->id, $course2->id, 'manager');
$rc = new \restore_controller(
$backupid,
$course2->id,
\backup::INTERACTIVE_NO,
\backup::MODE_IMPORT,
$manager->id,
\backup::TARGET_CURRENT_ADDING,
);
$rc->execute_precheck();
$backupquestions = $DB->get_records_menu('backup_ids_temp', ['itemname' => 'question'], '', 'id, itemid');
// Backup should contain the used parent category question and its children.
$this->assertContains((string) $coursequestions['courseparentcat']['courseq2']->id, $backupquestions);
foreach ($coursechildquestions['courseq2'] as $childquestion) {
$this->assertContains((string) $childquestion->id, $backupquestions);
}
// Backup should not contain the parent category question or its children.
$this->assertNotContains((string) $coursequestions['courseparentcat']['courseq1']->id, $backupquestions);
foreach ($coursechildquestions['courseq1'] as $childquestion) {
$this->assertNotContains((string) $childquestion->id, $backupquestions);
}
// Backup should contain the random category question and its children.
$this->assertContains((string) $coursequestions['randomcat']['courseq3']->id, $backupquestions);
foreach ($coursechildquestions['courseq3'] as $childquestion) {
$this->assertContains((string) $childquestion->id, $backupquestions);
}
// Backup should contain all quiz category questions and their children, used or unused.
$this->assertContains((string) $quizquestions['quizcat']['quizq1']->id, $backupquestions);
foreach ($quizchildquestions['quizq1'] as $childquestion) {
$this->assertContains((string) $childquestion->id, $backupquestions);
}
$this->assertContains((string) $quizquestions['quizcat']['quizq2']->id, $backupquestions);
foreach ($quizchildquestions['quizq2'] as $childquestion) {
$this->assertContains((string) $childquestion->id, $backupquestions);
}
// Clean up.
$rc->execute_plan();
$rc->destroy();
}
}