From 4d89422fc86907aab4bd2c58778df25e4705d26d Mon Sep 17 00:00:00 2001 From: Mark Johnson Date: Thu, 8 Jan 2026 13:48:51 +0000 Subject: [PATCH] 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. --- backup/moodle2/backup_stepslib.php | 7 +- .../backup/backup_question_selection_test.php | 137 +++++++++++++++++- 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/backup/moodle2/backup_stepslib.php b/backup/moodle2/backup_stepslib.php index 76b64a40fdd..c3912662b9d 100644 --- a/backup/moodle2/backup_stepslib.php +++ b/backup/moodle2/backup_stepslib.php @@ -2722,7 +2722,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( " @@ -2734,8 +2734,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 = ? diff --git a/mod/quiz/tests/backup/backup_question_selection_test.php b/mod/quiz/tests/backup/backup_question_selection_test.php index a333bd703e0..730c5442e38 100644 --- a/mod/quiz/tests/backup/backup_question_selection_test.php +++ b/mod/quiz/tests/backup/backup_question_selection_test.php @@ -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(); + } +}