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

This commit is contained in:
Jake Dallimore
2026-03-05 11:56:09 +08:00
6 changed files with 89 additions and 8 deletions
+7 -7
View File
@@ -5715,20 +5715,20 @@ class restore_move_module_questions_categories extends restore_execution_step {
);
}
}
// Remove any remaining course-level question categories from the restored course.
// Remove any remaining course-level question categories and their questions from the restored course.
$coursecatsql = "
SELECT qc.id AS categoryid
SELECT qc.id AS id, qc.contextid AS contextid
FROM {question_categories} qc
JOIN {context} c ON c.id = qc.contextid
WHERE c.contextlevel = :courselevel AND c.instanceid = :courseid
";
$DB->delete_records_subquery(
'question_categories',
'id',
'categoryid',
$categories = $DB->get_records_sql(
$coursecatsql,
['courselevel' => context_course::LEVEL, 'courseid' => $this->task->get_courseid()]
['courselevel' => context_course::LEVEL, 'courseid' => $this->task->get_courseid()],
);
foreach ($categories as $category) {
question_category_delete_safe($category);
}
}
}
+9
View File
@@ -2008,5 +2008,14 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2025041406.01);
}
if ($oldversion < 2025041406.05) {
$orphanedquestions = core_question\category_manager::cleanup_questions_without_categories();
if ($orphanedquestions > 0) {
upgrade_log(UPGRADE_LOG_NORMAL, null, "Cleaned up {$orphanedquestions} questions left over from restores.");
}
upgrade_main_savepoint(true, 2025041406.05);
}
return true;
}
+27
View File
@@ -16,6 +16,7 @@
namespace core_question;
use core\attribute\deprecated;
use stdClass;
use core\exception\moodle_exception;
use core\context;
@@ -403,4 +404,30 @@ class category_manager {
$DB->update_record('question_categories', $categorytofix, true);
}
}
/**
* Upgrade step to find questions with no category and delete them.
*
* Due to MDL-86154, there may be questions left in the database after a restore, whose category has been deleted. This will
* find any questions like that and delete them. These questions will always be unused.
*
* Now that we have prevented this occurring, this function is used by the upgrade process to clean up these questions.
*
* @return int A count of deleted questions.
*/
public static function cleanup_questions_without_categories(): int {
global $DB;
$questionids = $DB->get_fieldset_sql("
SELECT q.id
FROM {question_bank_entries} qbe
JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
JOIN {question} q ON qv.questionid = q.id
LEFT JOIN {question_categories} qc ON qbe.questioncategoryid = qc.id
WHERE qc.id IS NULL
");
foreach ($questionids as $questionid) {
question_delete_question($questionid);
}
return count($questionids);
}
}
+2
View File
@@ -716,6 +716,8 @@ final class backup_test extends \advanced_testcase {
// Check we have the expected restored categories.
$this->assertEquals(2, $DB->count_records('question_categories', ['stamp' => $data->quizcategory->stamp]));
$this->assertEquals(1, $DB->count_records('question_categories', ['stamp' => $data->qbankcategory->stamp]));
// Check there is no additional copy of the referenced question bank question.
$this->assertEquals(1, $DB->count_records('question', ['name' => $data->qbankquestion->name]));
}
/**
+43
View File
@@ -664,4 +664,47 @@ final class category_manager_test extends \advanced_testcase {
$this->assertEquals($quiz2top->id, $DB->get_field('question_categories', 'parent', ['id' => $quiz2nontop->id]));
$this->assertEquals($qbank2top->id, $DB->get_field('question_categories', 'parent', ['id' => $qbank2nontop->id]));
}
/**
* A question with no category should be deleted, while other questions remain as-is.
*/
public function test_cleanup_questions_without_categories(): void {
global $DB;
$this->setAdminUser();
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
$context = \context_module::instance($quiz->cmid);
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$topcategory = question_get_top_category($context->id, true);
$defaultcategory = question_get_default_category($context->id);
$deletedcategory = $questiongenerator->create_question_category(
['contextid' => $context->id, 'parent' => $topcategory->id],
);
// Create 2 questions. One in the default category, and in the category being deleted.
$question = $questiongenerator->create_question('truefalse', overrides: ['category' => $defaultcategory->id]);
$orphan = $questiongenerator->create_question('truefalse', overrides: ['category' => $deletedcategory->id]);
$DB->delete_records('question_categories', ['id' => $deletedcategory->id]);
$this->assertEquals(1, category_manager::cleanup_questions_without_categories());
// The default category question is unchanged.
$this->assertTrue(
$DB->record_exists_sql(
"SELECT *
FROM {question} q
JOIN {question_versions} qv on qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qv.questionbankentryid = qbe.id
WHERE q.id = :questionid AND qbe.questioncategoryid = :categoryid",
[
'questionid' => $question->id,
'categoryid' => $defaultcategory->id,
],
),
);
// The orphaned question has been deleted.
$this->assertFalse($DB->record_exists('question', ['id' => $orphan->id]));
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2025041406.04; // 20250414 = branching date YYYYMMDD - do not modify!
$version = 2025041406.05; // 20250414 = branching date YYYYMMDD - do not modify!
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.
$release = '5.0.6+ (Build: 20260227)'; // Human-friendly version name