Merge branch 'MDL-41924_500_STABLE' of https://github.com/marxjohnson/moodle into MOODLE_500_STABLE

This commit is contained in:
Mihail Geshoski
2025-07-08 17:06:32 +08:00
8 changed files with 536 additions and 49 deletions
@@ -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
+98 -1
View File
@@ -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]);
+3
View File
@@ -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.
@@ -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.
*
@@ -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;
}
/**
@@ -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']);
@@ -0,0 +1,278 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
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 <[email protected]>
* @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();
}
}
+48
View File
@@ -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;
}
}