From d3d2d67c8e66a67bab80089753f0ff4c2597ed4b Mon Sep 17 00:00:00 2001 From: Mark Johnson Date: Mon, 9 Jun 2025 15:40:21 +0100 Subject: [PATCH] MDL-85721 qtype_multichoice: Generate default options on restore qtype_multichoice::get_question_options() will create a default options object if no qtype_multichoice_options record exists. This means if we restore a backup containing a multichoice question without any options (see previous commit), it will always create a duplicate. This change generates a default set of options for the backupdata if none exists, so the identity hash will match if the same question exists in the database without options, and we dont get duplicates. --- ...restore_qtype_multichoice_plugin.class.php | 15 ++++ question/type/multichoice/questiontype.php | 2 +- .../type/multichoice/tests/restore_test.php | 86 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 question/type/multichoice/tests/restore_test.php diff --git a/question/type/multichoice/backup/moodle2/restore_qtype_multichoice_plugin.class.php b/question/type/multichoice/backup/moodle2/restore_qtype_multichoice_plugin.class.php index 7ef6ab68ec7..71120c4fec0 100644 --- a/question/type/multichoice/backup/moodle2/restore_qtype_multichoice_plugin.class.php +++ b/question/type/multichoice/backup/moodle2/restore_qtype_multichoice_plugin.class.php @@ -160,4 +160,19 @@ class restore_qtype_multichoice_plugin extends restore_qtype_plugin { return $contents; } + + #[\Override] + public static function convert_backup_to_questiondata(array $backupdata): \stdClass { + global $CFG; + require_once($CFG->dirroot . '/question/type/multichoice/questiontype.php'); + $questiondata = parent::convert_backup_to_questiondata($backupdata); + if (count(get_object_vars($questiondata->options)) <= 1) { + // Historically, old versions of multichoice subquestions had their options record deleted. + // As qtype_multichoice::get_question_options() sets default options in this case, we need + // to do the same here. See MDL-85721. + $defaultoptions = (new qtype_multichoice())->create_default_options($questiondata); + $questiondata->options = (object) array_merge((array) $questiondata->options, (array) $defaultoptions); + } + return $questiondata; + } } diff --git a/question/type/multichoice/questiontype.php b/question/type/multichoice/questiontype.php index b1d1b4ff0cf..59627b2d73a 100644 --- a/question/type/multichoice/questiontype.php +++ b/question/type/multichoice/questiontype.php @@ -88,7 +88,7 @@ class qtype_multichoice extends question_type { * @param object $question The queston we are working with. * @return object The options object. */ - protected function create_default_options($question) { + public function create_default_options($question) { // Create a default question options record. $options = new stdClass(); $options->questionid = $question->id; diff --git a/question/type/multichoice/tests/restore_test.php b/question/type/multichoice/tests/restore_test.php new file mode 100644 index 00000000000..7c981323a25 --- /dev/null +++ b/question/type/multichoice/tests/restore_test.php @@ -0,0 +1,86 @@ +. + +namespace qtype_multichoice; + +/** + * Unit tests for restore_qtype_multichoice_plugin + * + * @package qtype_multichoice + * @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 \restore_qtype_multichoice_plugin + */ +final class restore_test extends \advanced_testcase { + /** + * Duplicate a quiz containing a multichoice question with no options record. + */ + public function test_restore_quiz_with_edited_questions(): void { + global $CFG, $DB, $USER; + require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + $this->resetAfterTest(); + $this->setAdminUser(); + + // Create a course and a user with editing teacher capabilities. + $generator = $this->getDataGenerator(); + $course1 = $generator->create_course(); + $context = \context_course::instance($course1->id); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $initialcount = $DB->count_records('question'); + + // Create a question category. + $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); + + // Create a quiz containing a multichoice question from the qbank. + $quiz = $this->getDataGenerator()->get_plugin_generator('mod_quiz')->create_instance(['course' => $course1->id]); + $question = $questiongenerator->create_question('multichoice', 'one_of_four', ['category' => $cat->id]); + quiz_add_quiz_question($question->id, $quiz); + + // Delete the multichoice_options record. + $DB->delete_records('qtype_multichoice_options', ['questionid' => $question->id]); + + // Confirm we have created 1 additional question. + $this->assertEquals($initialcount + 1, $DB->count_records('question')); + + // Backup quiz. + $bc = new \backup_controller(\backup::TYPE_1ACTIVITY, $quiz->cmid, \backup::FORMAT_MOODLE, + \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, $USER->id); + $backupid = $bc->get_backupid(); + $bc->execute_plan(); + $bc->destroy(); + + // Restore the backup into the same course. + $rc = new \restore_controller($backupid, $course1->id, \backup::INTERACTIVE_NO, \backup::MODE_IMPORT, + $USER->id, \backup::TARGET_CURRENT_ADDING); + $rc->execute_precheck(); + $rc->execute_plan(); + $rc->destroy(); + $this->assertDebuggingCalled(); + + // Both quizzes should refer to the same original question. + $quizzes = get_fast_modinfo($course1->id)->get_instances_of('quiz'); + $this->assertCount(2, $quizzes); + foreach ($quizzes as $quiz) { + $structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz->instance, $quiz->context); + $this->assertEquals($structure[1]->questionid, $question->id); + } + + // There should be no additional questions created during the restore. + $this->assertEquals($initialcount + 1, $DB->count_records('question')); + } +}