MDL-86798 questions: Restore backups containing deleted versions

If the latest version of a question is deleted, and then a new one
created, the new one will currently re-use the version number of the
deleted version. If the original version was backed up before it was
deleted, then restored later, we end up with two questions in one
question bank entry that have the same version.

To resolve this, when restoring a question, we check if there is already
a version with the same version number, then bump it and any higher
versions up by 1. We also check for any references specifically using
these versions and bump those up by 1. Any references using "always
latest" will be fine, as the latest version will still be the latest.

Additionally, if deleting a version made it hidden, and the backup
contains the same version in "ready" state, restoring the backup will
restore it to "ready" state.
This commit is contained in:
Mark Johnson
2025-12-19 09:26:15 +00:00
parent 6e82b46a48
commit fb9f3ee58c
2 changed files with 172 additions and 1 deletions
+59 -1
View File
@@ -27,6 +27,8 @@
defined('MOODLE_INTERNAL') || die();
use core_question\local\bank\question_version_status;
/**
* delete old directories and conditionally create backup_temp_ids table
*/
@@ -5310,9 +5312,53 @@ class restore_create_categories_and_questions extends restore_structure_step {
$oldqvid = $this->latestversion->id;
$this->latestversion->questionbankentryid = $this->latestqbe->newid;
$this->latestversion->questionid = $newitemid;
// In case the backed up version was deleted and a new one created in its place, increase the version numbers of
// conflicting versions to make room for this one.
$transaction = $DB->start_delegated_transaction();
if (
$DB->record_exists(
'question_versions',
[
'questionbankentryid' => $this->latestversion->questionbankentryid,
'version' => $this->latestversion->version,
],
)
) {
// We'll update each higher version and any references one-at-a-time, starting with the highest, to avoid
// creating a duplicate questionbankentryid-version combination in question_versions.
$moveversions = $DB->get_records_select(
'question_versions',
'questionbankentryid = :questionbankentryid AND version >= :oldversion',
[
'questionbankentryid' => $this->latestversion->questionbankentryid,
'oldversion' => $this->latestversion->version,
],
'version DESC',
);
foreach ($moveversions as $moveversion) {
$DB->set_field(
'question_versions',
'version',
$moveversion->version + 1,
[
'questionbankentryid' => $moveversion->questionbankentryid,
'version' => $moveversion->version,
]
);
$DB->set_field(
'question_references',
'version',
$moveversion->version + 1,
[
'questionbankentryid' => $moveversion->questionbankentryid,
'version' => $moveversion->version,
]
);
}
}
$newqvid = $DB->insert_record('question_versions', $this->latestversion);
$this->set_mapping('question_versions', $oldqvid, $newqvid);
$transaction->allow_commit();
} else {
// By performing this set_mapping() we make get_old/new_parentid() to work for all the
// children elements of the 'question' one (so qtype plugins will know the question they belong to).
@@ -5320,6 +5366,18 @@ class restore_create_categories_and_questions extends restore_structure_step {
// Also create the question_bank_entry and version mappings, if required.
$newquestionversion = $DB->get_record('question_versions', ['questionid' => $questionmapping->newitemid]);
// Restore the version to ready state if it has been hidden.
if (
$newquestionversion->status == question_version_status::QUESTION_STATUS_HIDDEN
&& $this->latestversion->status == question_version_status::QUESTION_STATUS_READY
) {
$DB->set_field(
'question_versions',
'status',
question_version_status::QUESTION_STATUS_READY,
['questionid' => $questionmapping->newitemid],
);
}
$this->set_mapping('question_versions', $this->latestversion->id, $newquestionversion->id);
if (empty($this->latestqbe->newid)) {
$this->latestqbe->oldid = $this->latestqbe->id;
+113
View File
@@ -25,6 +25,7 @@ defined('MOODLE_INTERNAL') || die();
use backup;
use core_question\local\bank\question_bank_helper;
use core_question\local\bank\question_version_status;
use restore_controller;
use restore_dbops;
@@ -863,4 +864,116 @@ final class backup_test extends \advanced_testcase {
$quizcatq = reset($quizcatqs);
$this->assertEquals($expectedidentifiers[$i], $quizcatq->name);
}
/**
* Restore a backup containing question versions that were deleted, after new versions were created in their place.
*
* The new versions and any references to them should have their version numbers bumped up, and the original versions
* restored to their original numbers.
*/
public function test_restore_backup_containing_deleted_versions(): void {
global $DB;
self::setAdminUser();
$this->resetAfterTest();
$questiongenerator = self::getDataGenerator()->get_plugin_generator('core_question');
$testdata = $this->add_course_quiz_and_qbank();
$questionv1 = $testdata->qbankquestion;
$questionv2 = $questiongenerator->update_question($questionv1, null, ['name' => 'Version 2']);
$questionv3 = $questiongenerator->update_question($questionv2, null, ['name' => 'Version 3']);
$questionv4 = $questiongenerator->update_question($questionv3, null, ['name' => 'Version 4']);
$quizsettings = quiz_settings::create($testdata->quiz->id);
$structure1 = $quizsettings->get_structure();
// Set the usage of the question to specifically use version 2.
quiz_add_quiz_question($questionv2->id, $testdata->quiz);
$structure1->update_slot_version($structure1->get_slot_id_for_slot(1), 2);
$backupid = $this->backup_course($testdata->course);
question_delete_question($questionv4->id); // Actually deleted.
question_delete_question($questionv3->id); // Actually deleted.
question_delete_question($questionv2->id); // Hidden, it's being used explicitly.
$questionv5 = $questiongenerator->update_question($questionv1, null, ['name' => 'Version 5']);
$DB->set_field('question_versions', 'version', 3, ['questionid' => $questionv5->id]);
$questionv6 = $questiongenerator->update_question($questionv5, null, ['name' => 'Version 6']);
$DB->set_field('question_versions', 'version', 4, ['questionid' => $questionv6->id]);
// Add a quiz specifically using "version 5" (with version number 3).
$quiz2 = self::getDataGenerator()->create_module('quiz', ['course' => $testdata->course->id]);
$quiz2settings = quiz_settings::create($quiz2->id);
quiz_add_quiz_question($questionv5->id, $quiz2);
$structure2 = $quiz2settings->get_structure();
$structure2->update_slot_version($structure2->get_last_slot()->id, 3);
// Add another quiz using the "always latest" version of the question.
$quiz3 = self::getDataGenerator()->create_module('quiz', ['course' => $testdata->course->id]);
$quiz3settings = quiz_settings::create($quiz3->id);
quiz_add_quiz_question($questionv6->id, $quiz3);
$qbe = get_question_bank_entry($questionv1->id);
$versions = $DB->get_records(
'question_versions',
['questionbankentryid' => $qbe->id],
fields: 'version, questionid, status',
);
$this->assertCount(4, $versions);
$this->assertEquals($questionv1->id, $versions[1]->questionid);
$this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[1]->status);
$this->assertEquals($questionv2->id, $versions[2]->questionid);
$this->assertEquals(question_version_status::QUESTION_STATUS_HIDDEN, $versions[2]->status);
$this->assertEquals($questionv5->id, $versions[3]->questionid);
$this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[3]->status);
$this->assertEquals($questionv6->id, $versions[4]->questionid);
$this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[4]->status);
$structure1 = $quizsettings->get_structure();
$this->assertEquals($questionv2->id, $structure1->get_question_in_slot(1)->questionid);
$structure2 = $quiz2settings->get_structure();
$this->assertEquals($questionv5->id, $structure2->get_question_in_slot(1)->questionid);
$structure3 = $quiz3settings->get_structure();
$this->assertEquals($questionv6->id, $structure3->get_question_in_slot(1)->questionid);
$this->restore_to_course($backupid, $testdata->course->id);
$versions = $DB->get_records(
'question_versions',
['questionbankentryid' => $qbe->id],
fields: 'version, questionid, status',
);
$this->assertCount(6, $versions);
$this->assertEquals($questionv1->id, $versions[1]->questionid);
$this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[1]->status);
// Version 2 is restored to "ready" status.
$this->assertEquals($questionv2->id, $versions[2]->questionid);
$this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[2]->status);
// Version 3 is a new copy of $questionv3 with a different ID but the same content.
$this->assertEquals($questionv3->name, $DB->get_field('question', 'name', ['id' => $versions[3]->questionid]));
$this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[3]->status);
// Version 4 is a new copy of $questionv4 with a different ID but the same content.
$this->assertEquals($questionv4->name, $DB->get_field('question', 'name', ['id' => $versions[4]->questionid]));
$this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[4]->status);
// Versions 5 and 6 have had their version numbers bumped up by 1.
$this->assertEquals($questionv5->id, $versions[5]->questionid);
$this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[5]->status);
$this->assertEquals($questionv6->id, $versions[6]->questionid);
$this->assertEquals(question_version_status::QUESTION_STATUS_READY, $versions[6]->status);
// Questions referencing specific versions still point to the same question with its new version number.
$structure1 = $quizsettings->get_structure();
$this->assertEquals($questionv2->id, $structure1->get_question_in_slot(1)->questionid);
$structure2 = $quiz2settings->get_structure();
$this->assertEquals($questionv5->id, $structure2->get_question_in_slot(1)->questionid);
$structure3 = $quiz3settings->get_structure();
$this->assertEquals($questionv6->id, $structure3->get_question_in_slot(1)->questionid);
}
}