Merge branch 'MDL-41924_500_STABLE' of https://github.com/marxjohnson/moodle into MOODLE_500_STABLE
This commit is contained in:
@@ -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
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user