diff --git a/.upgradenotes/MDL-41924-2025022812015134.yml b/.upgradenotes/MDL-41924-2025022812015134.yml new file mode 100644 index 00000000000..73d06e0cf21 --- /dev/null +++ b/.upgradenotes/MDL-41924-2025022812015134.yml @@ -0,0 +1,17 @@ +issueNumber: MDL-41924 +notes: + core_question: + - message: > + The question backup API has been improved to only include questions that + are actually used or owned by backed up activities. + + Any activities that use question references should be supported + automatically. Activities that use *question set references* (for + example, random quiz questions) need to add a call to + `backup_question_set_reference_trait::annotate_set_reference_bank_entries()` + alongside the call to + `backup_question_set_reference_trait::add_question_set_references()` in + their backup step. See + `backup_quiz_activity_structure_step::define_structure()` for an + example. + type: improved diff --git a/backup/moodle2/backup_stepslib.php b/backup/moodle2/backup_stepslib.php index 2f620905da0..2042e585368 100644 --- a/backup/moodle2/backup_stepslib.php +++ b/backup/moodle2/backup_stepslib.php @@ -27,6 +27,8 @@ defined('MOODLE_INTERNAL') || die(); +use core_question\local\bank\random_question_loader; + /** * Create the temp dir where backup/restore will happen and create temp ids table. */ @@ -238,6 +240,8 @@ trait backup_question_reference_data_trait { 'questionarea' => backup_helper::is_sqlparam($questionarea), 'itemid' => backup::VAR_PARENTID ]); + + $reference->annotate_ids('question_bank_entry', 'questionbankentryid'); } } @@ -271,6 +275,56 @@ trait backup_question_set_reference_trait { 'itemid' => backup::VAR_PARENTID ]); } + + /** + * Find all questions that match set reference conditions used by the activity, and record the question bank entry IDs. + * + * @param int $contextid The context ID of the activity being backed up + * @param string $component The component of the activity + * @param string $questionarea The question area for finding set references + * @param string $backupid The backup ID to annotate question bank entries against + */ + protected function annotate_set_reference_bank_entries( + int $contextid, + string $component, + string $questionarea, + string $backupid, + ): void { + global $DB; + $setreferenceconditions = $DB->get_fieldset( + 'question_set_references', + 'filtercondition', + [ + 'usingcontextid' => $contextid, + 'component' => $component, + 'questionarea' => $questionarea, + ], + ); + if (empty($setreferenceconditions)) { + return; + } + $setreferencequestionids = []; + $randomloader = new random_question_loader(new qubaid_list([]), []); + + foreach ($setreferenceconditions as $setreferencecondition) { + $conditions = json_decode($setreferencecondition, true); + $setreferencequestionids += array_keys($randomloader->get_filtered_questions($conditions['filter'], 0)); + } + if (empty($setreferencequestionids)) { + return; + } + [$insql, $inparams] = $DB->get_in_or_equal($setreferencequestionids); + $qbeids = $DB->get_fieldset_select( + 'question_versions', + 'questionbankentryid', + "questionid {$insql}", + $inparams, + ); + + foreach ($qbeids as $qbeid) { + backup_structure_dbops::insert_backup_ids_record($backupid, 'question_bank_entry', $qbeid); + } + } } @@ -2543,6 +2597,23 @@ class backup_annotate_all_question_files extends backup_execution_step { */ class backup_questions_structure_step extends backup_structure_step { + #[\Override] + public function execute() { + global $DB; + backup_controller_dbops::create_question_category_temp_tables(); + $DB->execute("INSERT INTO {question_category_complete_temp} (backupid, itemid) + SELECT backupid, itemid + FROM {backup_ids_temp} + WHERE itemname = 'question_category_complete'"); + $DB->execute("INSERT INTO {question_category_partial_temp} (backupid, itemid) + SELECT backupid, itemid + FROM {backup_ids_temp} + WHERE itemname = 'question_category_partial'"); + $results = parent::execute(); + backup_controller_dbops::drop_question_category_temp_tables(); + return $results; + } + protected function define_structure() { // Define each element separately. @@ -2645,7 +2716,33 @@ class backup_questions_structure_step extends backup_structure_step { WHERE bi.backupid = ? AND bi.itemname = 'question_categoryfinal'", [backup::VAR_BACKUPID]); - $questionbankentry->set_source_table('question_bank_entries', ['questioncategoryid' => backup::VAR_PARENTID]); + // Add all question bank entries from "complete" categories, plus annotated question bank entires + // from "partial" categories. + $questionbankentry->set_source_sql( + " + SELECT qbe.* + FROM {question_bank_entries} qbe + JOIN {question_category_complete_temp} qcc ON qcc.itemid = qbe.questioncategoryid + WHERE qcc.itemid = ? + AND qcc.backupid = ? + UNION + SELECT qbe.* + FROM {question_bank_entries} qbe + JOIN {question_category_partial_temp} qcp ON qcp.itemid = qbe.questioncategoryid + JOIN {backup_ids_temp} biq ON biq.itemid = qbe.id + WHERE qcp.itemid = ? + AND qcp.backupid = ? + AND biq.backupid = ? + AND biq.itemname = 'question_bank_entry' + ", + [ + backup::VAR_PARENTID, + backup::VAR_BACKUPID, + backup::VAR_PARENTID, + backup::VAR_BACKUPID, + backup::VAR_BACKUPID, + ], + ); $questionverion->set_source_table('question_versions', ['questionbankentryid' => backup::VAR_PARENTID]); diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index 3a65aaf2724..09b2d036fbc 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -5494,6 +5494,9 @@ class restore_move_module_questions_categories extends restore_execution_step { 'question', $originalquestion->id, ); + if (!$backupids) { + continue; // This question was not included in the backup. + } // Restored question references will point to the restored copy of the question. Select question references // that point to that restored copy, only if they are within the target course's context, so we can update // them to point to the original question. diff --git a/backup/util/dbops/backup_controller_dbops.class.php b/backup/util/dbops/backup_controller_dbops.class.php index e4cf1954eb5..2a7c0bf8098 100644 --- a/backup/util/dbops/backup_controller_dbops.class.php +++ b/backup/util/dbops/backup_controller_dbops.class.php @@ -161,6 +161,28 @@ abstract class backup_controller_dbops extends backup_dbops { $dbman->create_temp_table($xmldb_table); // And create it } + /** + * Create temporary tables to store a partial copies of question category data from the backup_ids_temp table. + * + * This is to work around MySQL's restruction on joining the same temporary table multiple times in the same query. It isn't + * necessary on PostgreSQL (which can join temporary tables multiple times) or MSSQL (which doesn't really use temporary tables) + * but doing it this way keeps things database-agnostic. + */ + public static function create_question_category_temp_tables(): void { + global $DB; + $dbman = $DB->get_manager(); + + foreach (['question_category_complete_temp', 'question_category_partial_temp'] as $tablename) { + $xmldbtable = new xmldb_table($tablename); + $xmldbtable->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE); + $xmldbtable->add_field('backupid', XMLDB_TYPE_CHAR, 32, null, XMLDB_NOTNULL); + $xmldbtable->add_field('itemid', XMLDB_TYPE_INTEGER, 10, null, XMLDB_NOTNULL); + $xmldbtable->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + $dbman->create_temp_table($xmldbtable); + } + } + public static function drop_backup_ids_temp_table($backupid) { global $DB; $dbman = $DB->get_manager(); // We are going to use database_manager services @@ -172,6 +194,21 @@ abstract class backup_controller_dbops extends backup_dbops { } } + /** + * Dispose of the additional temporary tables. + */ + public static function drop_question_category_temp_tables(): void { + global $DB; + $dbman = $DB->get_manager(); + + foreach (['question_category_complete_temp', 'question_category_partial_temp'] as $tablename) { + if ($dbman->table_exists($tablename)) { + $xmldbtable = new xmldb_table($tablename); + $dbman->drop_table($xmldbtable); + } + } + } + /** * Decode the info field from backup_ids_temp or backup_files_temp. * diff --git a/backup/util/dbops/backup_question_dbops.class.php b/backup/util/dbops/backup_question_dbops.class.php index c4e5ce68816..cc80b07bf80 100644 --- a/backup/util/dbops/backup_question_dbops.class.php +++ b/backup/util/dbops/backup_question_dbops.class.php @@ -40,61 +40,66 @@ abstract class backup_question_dbops extends backup_dbops { public static function calculate_question_categories($backupid, $contextid) { global $DB; - // First step, annotate all the categories for the given context (course/module) + // First step, get all the categories for the given context (course/module) // i.e. the whole context questions bank - $DB->execute("INSERT INTO {backup_ids_temp} (backupid, itemname, itemid) - SELECT ?, 'question_category', id - FROM {question_categories} - WHERE contextid = ?", array($backupid, $contextid)); + $contextcategories = $DB->get_records_menu('question_categories', ['contextid' => $contextid], '', 'id, parent'); - // Now, based in the annotated questions, annotate all the categories they - // belong to (whole context question banks too) - // First, get all the contexts we are going to save their question bank (no matter - // where they are in the contexts hierarchy, transversals... whatever) - $contexts = $DB->get_fieldset_sql("SELECT DISTINCT qc2.contextid - FROM {question_categories} qc2 - JOIN {question_bank_entries} qbe ON qbe.questioncategoryid = qc2.id - JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id - JOIN {question} q ON q.id = qv.questionid - JOIN {backup_ids_temp} bi ON bi.itemid = q.id - WHERE bi.backupid = ? - AND bi.itemname = 'question' - AND qc2.contextid != ?", array($backupid, $contextid)); + // Now, based in the annotated question bank entries, get all the categories they + // belong to. + $questioncategories = $DB->get_records_sql_menu( + "SELECT DISTINCT qc2.id, qc2.parent + FROM {question_categories} qc2 + JOIN {question_bank_entries} qbe ON qbe.questioncategoryid = qc2.id + JOIN {backup_ids_temp} bi ON bi.itemid = qbe.id + WHERE bi.backupid = ? + AND bi.itemname = 'question_bank_entry' + AND qc2.contextid != ?", + [$backupid, $contextid] + ); - // Calculate and get the set reference records. - $setreferencecontexts = $DB->get_fieldset_sql(" - SELECT DISTINCT qc.contextid - FROM {question_categories} qc - JOIN {question_set_references} qsr ON qsr.questionscontextid = qc.contextid - WHERE qsr.usingcontextid = ?", [$contextid]); - foreach ($setreferencecontexts as $setreferencecontext) { - if (!in_array($setreferencecontext, $contexts) && (int)$setreferencecontext !== $contextid) { - $contexts [] = $setreferencecontext; + // These are all the question categories we want to include in the backup. + $categories = $contextcategories + $questioncategories; + // If we're not already including the parents of a category, add them in. + foreach ($categories as $parentid) { + if (!array_key_exists($parentid, $categories)) { + $categories += self::get_parent_categories($parentid); } } + // Insert annotations of the found categories. + foreach (array_keys($categories) as $categoryid) { + backup_structure_dbops::insert_backup_ids_record($backupid, 'question_category', $categoryid); + } + // For these categories, we want to include all questions. + foreach (array_keys($contextcategories) as $categoryid) { + backup_structure_dbops::insert_backup_ids_record($backupid, 'question_category_complete', $categoryid); + } + // For these categories, we only want to include the questions that have been annotated. + // Exclude those where we're already including all questions. + $partialcategories = array_diff( + array_keys($questioncategories), + array_keys($contextcategories), + ); + foreach ($partialcategories as $categoryid) { + backup_structure_dbops::insert_backup_ids_record($backupid, 'question_category_partial', $categoryid); + } + } - // Calculate the get the reference records. - $referencecontexts = $DB->get_fieldset_sql(" - SELECT DISTINCT qc.contextid - FROM {question_categories} qc - JOIN {question_bank_entries} qbe ON qbe.questioncategoryid = qc.id - JOIN {question_references} qr ON qr.questionbankentryid = qbe.id - WHERE qr.usingcontextid =?", [$contextid]); - foreach ($referencecontexts as $referencecontext) { - if (!in_array($referencecontext, $contexts) && (int)$referencecontext !== $contextid) { - $contexts [] = $referencecontext; - } - } - // And now, simply insert all the question categories (complete question bank) - // for those contexts if we have found any - if ($contexts) { - list($contextssql, $contextparams) = $DB->get_in_or_equal($contexts); - $params = array_merge(array($backupid), $contextparams); - $DB->execute("INSERT INTO {backup_ids_temp} (backupid, itemname, itemid) - SELECT ?, 'question_category', id - FROM {question_categories} - WHERE contextid $contextssql", $params); + /** + * Recursively find the parents and ancestors of the given category + * + * @param int $categoryid The category we want to find parents for. + * @return array id => parentid for each category + */ + protected static function get_parent_categories(int $categoryid): array { + global $DB; + $parentcategories = []; + $parentid = $DB->get_field('question_categories', 'parent', ['id' => $categoryid]); + $parentcategories[$categoryid] = $parentid; + // If this is not a top category, keep going. + if ($parentid > 0) { + array_merge($parentcategories, self::get_parent_categories($parentid)); } + return $parentcategories; } /** diff --git a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php index f7dd9ef4343..3a81746087e 100644 --- a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php +++ b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php @@ -59,6 +59,8 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru $this->add_question_set_references($qinstance, 'mod_quiz', 'slot'); + $this->annotate_set_reference_bank_entries($this->task->get_contextid(), 'mod_quiz', 'slot', $this->task->get_backupid()); + $sections = new backup_nested_element('sections'); $section = new backup_nested_element('section', ['id'], ['firstslot', 'heading', 'shufflequestions']); diff --git a/mod/quiz/tests/backup/backup_question_selection_test.php b/mod/quiz/tests/backup/backup_question_selection_test.php new file mode 100644 index 00000000000..1a2a9c31e4f --- /dev/null +++ b/mod/quiz/tests/backup/backup_question_selection_test.php @@ -0,0 +1,278 @@ +. + +namespace mod_quiz\backup; + +defined('MOODLE_INTERNAL') || die(); + +use mod_quiz\quiz_settings; +use mod_quiz\structure; + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); +require_once($CFG->dirroot . '/question/engine/lib.php'); +require_once($CFG->dirroot . '/mod/quiz/locallib.php'); +require_once($CFG->dirroot . '/course/lib.php'); +require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php'); + +/** + * Unit tests ensuring only required questions are included in backups. + * + * @package mod_quiz + * @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net} + * @author Mark Johnson + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \backup_questions_structure_step + * @covers \backup_question_dbops + * @covers \backup_quiz_activity_structure_step + */ +final class backup_question_selection_test extends \advanced_testcase { + use \quiz_question_helper_test_trait; + + /** + * Set up data to back up. + * + * A course contains a quiz and a qbank, and a second course contains a shared qbank. + * Each of these contains a some categories and some questions. + * The quiz uses 2 questions from its own question bank, plus 1 from the course qbank, 1 from the shared qbank, + * and a random question selecting questions from a separate category in the shared qbank. + * A user manages both courses. + * + * @return array The manager, quiz, questions and course records. + */ + protected function create_quiz_and_questions() { + $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 some question banks and a quiz with 2 categories each. + + $courseqbank = self::getDataGenerator()->create_module('qbank', ['course' => $course->id]); + $coursequestions = $questiongenerator->create_categories_and_questions( + \context_module::instance($courseqbank->cmid), + [ + 'courseparentcat' => [ + 'courseq1' => 'shortanswer', + 'courseq2' => 'shortanswer', + 'coursechildcat' => [ + 'courseq3' => 'shortanswer', + 'courseq4' => 'shortanswer', + ], + ], + ] + ); + $sharedqbank = self::getDataGenerator()->create_module('qbank', ['course' => $sharedcourse->id]); + $sharedquestions = $questiongenerator->create_categories_and_questions( + \context_module::instance($sharedqbank->cmid), + [ + 'sharedparentcat' => [ + 'sharedq1' => 'shortanswer', + 'sharedq2' => 'shortanswer', + 'sharedchildcat' => [ + 'sharedq3' => 'shortanswer', + 'sharedq4' => 'shortanswer', + ], + ], + 'tagcat' => [ + 'tagq1' => 'shortanswer', + 'tagq2' => 'shortanswer', + 'tagq3' => 'shortanswer', + ], + ] + ); + $quiz = $this->create_test_quiz($course); + $quizquestions = $questiongenerator->create_categories_and_questions( + \context_module::instance($quiz->cmid), + [ + 'quizparentcat' => [ + 'quizq1' => 'shortanswer', + 'quizq2' => 'shortanswer', + 'quizchildcat' => [ + 'quizq3' => 'shortanswer', + 'quizq4' => 'shortanswer', + ], + ], + + ] + ); + + $questiongenerator->create_question_tag(['questionid' => $sharedquestions['tagcat']['tagq1']->id, 'tag' => 'mytag']); + $questiongenerator->create_question_tag(['questionid' => $sharedquestions['tagcat']['tagq2']->id, 'tag' => 'mytag']); + + $tags = \core_tag_tag::get_item_tags('core_question', 'question', $sharedquestions['tagcat']['tagq1']->id); + $mytag = reset($tags); + + // Add a question from the shared bank child category. + quiz_add_quiz_question($sharedquestions['sharedparentcat']['sharedchildcat']['sharedq3']->id, $quiz); + // Add a question from the course bank parent category. + quiz_add_quiz_question($coursequestions['courseparentcat']['courseq2']->id, $quiz); + // Add a question from the quiz bank categories. + quiz_add_quiz_question($quizquestions['quizparentcat']['quizq1']->id, $quiz); + // Add a random question to select tagged questions. + $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' => [$sharedquestions['tagcat']['tagq1']->category], + 'filteroptions' => ['includesubcategories' => false], + ], + 'qtagids' => [ + 'jointype' => \core\output\datafilter::JOINTYPE_ANY, + 'values' => [$mytag->id], + ], + ], + ]); + + return [ + $manager, + $quiz, + $quizquestions, + $coursequestions, + $sharedquestions, + $course, + ]; + } + + /** + * Test that backing up a quiz only includes the questions owned or used by the quiz. + */ + public function test_quiz_backup_excludes_unused_questions(): void { + global $DB; + $this->resetAfterTest(); + + [ + $manager, + $quiz, + $quizquestions, + $coursequestions, + $sharedquestions, + ] = $this->create_quiz_and_questions(); + + // 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 used questions from shared qbanks. + $this->assertContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq3']->id, $backupquestions); + $this->assertContains((string) $coursequestions['courseparentcat']['courseq2']->id, $backupquestions); + // Backup should contain all questions from quiz's bank. + $this->assertContains((string) $quizquestions['quizparentcat']['quizq1']->id, $backupquestions); + $this->assertContains((string) $quizquestions['quizparentcat']['quizq2']->id, $backupquestions); + $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq3']->id, $backupquestions); + $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq4']->id, $backupquestions); + // Backup should contain questions matched by random question filter. + $this->assertContains((string) $sharedquestions['tagcat']['tagq1']->id, $backupquestions); + $this->assertContains((string) $sharedquestions['tagcat']['tagq2']->id, $backupquestions); + // All other questions should be excluded. + $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq1']->id, $backupquestions); + $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq2']->id, $backupquestions); + $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq4']->id, $backupquestions); + $this->assertNotContains((string) $coursequestions['courseparentcat']['courseq1']->id, $backupquestions); + $this->assertNotContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq3']->id, $backupquestions); + $this->assertNotContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq4']->id, $backupquestions); + $this->assertNotContains((string) $sharedquestions['tagcat']['tagq3']->id, $backupquestions); + $this->assertCount(8, $backupquestions); + // Clean up. + $rc->execute_plan(); + $rc->destroy(); + } + + /** + * Test that backing up a quiz only includes the questions used in or belonging to the course. + * + * This should include all questions in categories belonging to quizzes or qbanks on the course, plus questions from outside + * the course used by quizzes. + */ + public function test_course_backup_excludes_unused_questions(): void { + global $DB; + $this->resetAfterTest(); + + [ + $manager, + , + $quizquestions, + $coursequestions, + $sharedquestions, + $course, + ] = $this->create_quiz_and_questions(); + + // Backup the course. + $bc = new \backup_controller( + \backup::TYPE_1COURSE, + $course->id, + \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 used questions from shared qbanks. + $this->assertContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq3']->id, $backupquestions); + // Backup should contain all questions from course qbanks. + $this->assertContains((string) $coursequestions['courseparentcat']['courseq1']->id, $backupquestions); + $this->assertContains((string) $coursequestions['courseparentcat']['courseq2']->id, $backupquestions); + $this->assertContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq3']->id, $backupquestions); + $this->assertContains((string) $coursequestions['courseparentcat']['coursechildcat']['courseq4']->id, $backupquestions); + // Backup should contain all questions from quiz's bank. + $this->assertContains((string) $quizquestions['quizparentcat']['quizq1']->id, $backupquestions); + $this->assertContains((string) $quizquestions['quizparentcat']['quizq2']->id, $backupquestions); + $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq3']->id, $backupquestions); + $this->assertContains((string) $quizquestions['quizparentcat']['quizchildcat']['quizq4']->id, $backupquestions); + // Backup should contain questions matched by random question filter. + $this->assertContains((string) $sharedquestions['tagcat']['tagq1']->id, $backupquestions); + $this->assertContains((string) $sharedquestions['tagcat']['tagq2']->id, $backupquestions); + // All other questions should be excluded. + $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq1']->id, $backupquestions); + $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedq2']->id, $backupquestions); + $this->assertNotContains((string) $sharedquestions['sharedparentcat']['sharedchildcat']['sharedq4']->id, $backupquestions); + $this->assertNotContains((string) $sharedquestions['tagcat']['tagq3']->id, $backupquestions); + $this->assertCount(11, $backupquestions); + // Clean up. + $rc->execute_plan(); + $rc->destroy(); + } +} + diff --git a/question/tests/generator/lib.php b/question/tests/generator/lib.php index 3d1d0bccd9e..f955190cbaf 100644 --- a/question/tests/generator/lib.php +++ b/question/tests/generator/lib.php @@ -23,6 +23,7 @@ */ use core_question\local\bank\question_version_status; +use core\exception\coding_exception; /** * Class core_question_generator for generating question data. @@ -273,4 +274,51 @@ class core_question_generator extends component_generator_base { return $postdata; } + + /** + * Given a context and a structure of categories and questions, generate that structure. + * + * The $structure parameter takes a multi-dimensional array of categories and questions, like this: + * [ + * 'categoryname' => [ + * 'question1name' => 'questiontype', + * 'question2name' => 'questiontype', + * 'subcategoryname' => [ + * 'subquestion1name' => 'questiontype', + * ], + * ], + * ] + * Arrays are treated as categories, strings are treated as questions. The key in each case is used for the name of the category + * or question. For subcategories, the method is called recursively to create all descendants. + * The 'questiontype' string is the type of question to be generated, and will be passed to create_question. + * + * @param context $context The context to create the structure in. + * @param array $structure The array of categories and questions, see above. + * @param ?int $parentid The category to create the category or question within. + * @return array The input structure, with the generated questions in place of the question types. + */ + public function create_categories_and_questions(context $context, array $structure, ?int $parentid = null) { + $createdcategories = []; + foreach ($structure as $name => $item) { + if (is_array($item)) { + $categorydata = [ + 'name' => $name, + 'contextid' => $context->id, + ]; + if ($parentid) { + $categorydata['parent'] = $parentid; + } + $category = $this->create_question_category($categorydata); + $createdcategories[$name] = $this->create_categories_and_questions($context, $item, $category->id); + } else if (is_string($item)) { + if (!$parentid) { + throw new coding_exception('You cannot create questions in a top-level category.'); + } + $createdcategories[$name] = $this->create_question($item, null, ['name' => $name, 'category' => $parentid]); + } else { + throw new coding_exception('Structure items must be arrays or strings, ' . gettype($item) . ' found.'); + } + } + return $createdcategories; + } }