Skip to content

Commit 77b2664

Browse files
authored
avoid creating duplicates when restoring older backups (#197)
Backport of the same fix for the main branch (PR #196).
1 parent df81dbf commit 77b2664

File tree

4 files changed

+109
-9
lines changed

4 files changed

+109
-9
lines changed

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
### 5.3.6 (2025-04-29)
4+
5+
- bugfix: avoid creating duplicates when restoring older backups
6+
37
### 5.3.5 (2025-03-17)
48

59
- improvement: avoid possible precision problem with ncr()

backup/moodle2/restore_qtype_formulas_plugin.class.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ protected function define_question_plugin_structure() {
4444
// section. For every question, we will have <formulas_answers></formulas_answers> to enclose
4545
// all parts. For each part, there will then be a <formulas_answer id="..."></formulas_answer>
4646
// section containing the various data fields, e. g. <placeholder> or <numbox> etc. These
47-
// are the fields stored in the qtype_formulas_answers table, except for the partindex.
47+
// are the fields stored in the qtype_formulas_answers table.
4848
$paths[] = new restore_path_element('formulas_answer', $this->get_pathfor('/formulas_answers/formulas_answer'));
4949

5050
// Additionally, there will be <formulas id="..."></formulas> containing the custom data
@@ -188,8 +188,17 @@ public static function convert_backup_to_questiondata(array $backupdata): stdCla
188188
$questiondata = parent::convert_backup_to_questiondata($backupdata);
189189

190190
// As our parts are backed up in a separate XML key rather than just "answers", the parent
191-
// function did not add them to the questiondata.
192-
foreach ($backupdata['plugin_qtype_formulas_question']['formulas_answers']['formulas_answer'] as $answer) {
191+
// function did not add them to the questiondata. Old backups may lack the "answernotunique"
192+
// key, in which case we add it here with the default value. Also, backups might miss the
193+
// "partindex" key. In that case, we add the key and order the parts according to their appearance
194+
// in the file.
195+
foreach ($backupdata['plugin_qtype_formulas_question']['formulas_answers']['formulas_answer'] as $i => $answer) {
196+
if (!key_exists('answernotunique', $answer)) {
197+
$answer['answernotunique'] = '1';
198+
}
199+
if (!key_exists('partindex', $answer)) {
200+
$answer['partindex'] = $i;
201+
}
193202
$questiondata->options->answers[] = (object) $answer;
194203
}
195204

tests/backup_restore_test.php

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ public function test_restore_quiz_with_same_stamp_questions(string $questionname
240240
// Create a question category.
241241
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
242242

243-
// Create 2 quizzes with 2 questions multichoice.
243+
// Create 2 quizzes with 2 questions.
244244
$quiz1 = $this->create_test_quiz($course1);
245245
$question1 = $questiongenerator->create_question('formulas', $questionname, ['category' => $cat->id]);
246246
quiz_add_quiz_question($question1->id, $quiz1, 0);
@@ -288,6 +288,90 @@ public function test_restore_quiz_with_same_stamp_questions(string $questionname
288288
$this->assertEquals($quiz2structure[2]->questionid, $quiz2structure[2]->questionid);
289289
}
290290

291+
/**
292+
* Data provider.
293+
*
294+
* @return array
295+
*/
296+
public static function provide_xml_keys_to_remove(): array {
297+
return [
298+
['answernotunique'],
299+
['partindex'],
300+
];
301+
}
302+
303+
/**
304+
* Restore a quiz with a question where a field is missing in the backup.
305+
*
306+
* @param string $which the XML key to remove from the backup
307+
*
308+
* @dataProvider provide_xml_keys_to_remove
309+
*/
310+
public function test_restore_quiz_if_field_is_missing_in_backup(string $which): void {
311+
global $CFG, $USER;
312+
$this->resetAfterTest();
313+
$this->setAdminUser();
314+
315+
// The changes introduced while fixing MDL-83541 are only present in Moodle 4.4 and newer. It
316+
// does not make sense to perform this test with older versions.
317+
if ($CFG->branch < 404) {
318+
$this->markTestSkipped(
319+
'Not testing detection of duplicates while restoring in Moodle versions prior to 4.4.',
320+
);
321+
}
322+
323+
// Create a course and a user with editing teacher capabilities.
324+
$generator = $this->getDataGenerator();
325+
$course1 = $generator->create_course();
326+
$teacher = $USER;
327+
$generator->enrol_user($teacher->id, $course1->id, 'editingteacher');
328+
$coursecontext = \context_course::instance($course1->id);
329+
/** @var \core_question_generator $questiongenerator */
330+
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
331+
332+
// Create a question category.
333+
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
334+
335+
// Create a quiz with a multipart Formulas question.
336+
$quiz = $this->create_test_quiz($course1);
337+
$question = $questiongenerator->create_question('formulas', 'testmethodsinparts', ['category' => $cat->id]);
338+
quiz_add_quiz_question($question->id, $quiz, 0);
339+
340+
// Backup quiz1.
341+
$bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE,
342+
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id);
343+
$backupid = $bc->get_backupid();
344+
$bc->execute_plan();
345+
$bc->destroy();
346+
347+
// Delete requested entry from questions.xml file in the backup.
348+
$xmlfile = $bc->get_plan()->get_basepath() . '/questions.xml';
349+
$xml = file_get_contents($xmlfile);
350+
$xml = preg_replace("=<$which>[^<]+</$which>=", '', $xml);
351+
file_put_contents($xmlfile, $xml);
352+
353+
// Restore the (modified) backup into the same course.
354+
$rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
355+
$teacher->id, backup::TARGET_CURRENT_ADDING);
356+
$rc->execute_precheck();
357+
$rc->execute_plan();
358+
$rc->destroy();
359+
360+
// Verify that the newly-restored quiz uses the same question as quiz1.
361+
$modules = get_fast_modinfo($course1->id)->get_instances_of('quiz');
362+
$this->assertCount(2, $modules);
363+
$quizstructure = \mod_quiz\question\bank\qbank_helper::get_question_structure(
364+
$quiz->id,
365+
\context_module::instance($quiz->cmid),
366+
);
367+
$restoredquiz = end($modules);
368+
$restoredquizstructure = \mod_quiz\question\bank\qbank_helper::get_question_structure(
369+
$restoredquiz->instance,
370+
$restoredquiz->context,
371+
);
372+
$this->assertEquals($quizstructure[1]->questionid, $restoredquizstructure[1]->questionid);
373+
}
374+
291375
/**
292376
* Restore a quiz with duplicate questions (same stamp and questions) into the same course.
293377
* This is a contrived case, but this test serves as a control for the other tests in this class, proving
@@ -554,7 +638,7 @@ public function test_restore_quiz_with_same_stamp_questions_edited_hints(string
554638
// Create a question category.
555639
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
556640

557-
// Create 2 questions multichoice.
641+
// Create 2 questions.
558642
$quiz1 = $this->create_test_quiz($course1);
559643
$question1 = $questiongenerator->create_question('formulas', $questionname, ['category' => $cat->id]);
560644
quiz_add_quiz_question($question1->id, $quiz1, 0);
@@ -669,7 +753,7 @@ public function test_restore_quiz_with_same_stamp_questions_edited_options(strin
669753
// Create a question category.
670754
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
671755

672-
// A quiz with 2 multichoice questions.
756+
// A quiz with 2 questions.
673757
$quiz1 = $this->create_test_quiz($course1);
674758
$question1 = $questiongenerator->create_question('formulas', 'testsinglenum', ['category' => $cat->id]);
675759
quiz_add_quiz_question($question1->id, $quiz1, 0);
@@ -681,7 +765,10 @@ public function test_restore_quiz_with_same_stamp_questions_edited_options(strin
681765
$DB->set_field('question', 'stamp', $question1->stamp, ['id' => $question2->id]);
682766

683767
// Change the options of question2 to be different to question1.
684-
$optionfields = ['varsrandom', 'varsglobal', 'answernumbering', 'shownumcorrect', 'correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback'];
768+
$optionfields = [
769+
'varsrandom', 'varsglobal', 'answernumbering', 'shownumcorrect',
770+
'correctfeedback', 'partiallycorrectfeedback', 'incorrectfeedback',
771+
];
685772
if (in_array($field, $optionfields)) {
686773
$DB->set_field('qtype_formulas_options', $field, $value, ['questionid' => $question2->id]);
687774
} else {

version.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
defined('MOODLE_INTERNAL') || die();
2626

2727
$plugin->component = 'qtype_formulas';
28-
$plugin->version = 2025031700;
28+
$plugin->version = 2025042900;
2929

3030
$plugin->cron = 0;
3131
$plugin->requires = 2017111300;
@@ -35,6 +35,6 @@
3535
'qtype_multichoice' => 2015111600,
3636
);
3737
$plugin->supported = [39, 405];
38-
$plugin->release = '5.3.5 for Moodle 3.9 - 4.5';
38+
$plugin->release = '5.3.6 for Moodle 3.9 - 4.5';
3939

4040
$plugin->maturity = MATURITY_STABLE;

0 commit comments

Comments
 (0)