diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index c34d3490413..22fc462d393 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -5289,6 +5289,15 @@ class restore_create_categories_and_questions extends restore_structure_step { $this->set_mapping('question_bank_entry', $this->latestqbe->oldid, $this->latestqbe->newid); } + if ( + ($data->qtype === 'random') + && ($this->latestversion->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN) + ) { + // Ensure that this newly created question is considered by + // \qtype_random\task\remove_unused_questions. + $this->latestversion->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT; + } + // Now store the question. $newitemid = $DB->insert_record('question', $data); $this->set_mapping('question', $oldid, $newitemid); diff --git a/question/type/random/classes/task/remove_unused_questions.php b/question/type/random/classes/task/remove_unused_questions.php new file mode 100644 index 00000000000..eb059b4ecc6 --- /dev/null +++ b/question/type/random/classes/task/remove_unused_questions.php @@ -0,0 +1,98 @@ +. + +/** + * A scheduled task to remove unneeded random questions. + * + * @package qtype_random + * @category task + * @copyright 2018 Bo Pierce + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace qtype_random\task; + +use core\task\manager; + + +/** + * A scheduled task to remove unneeded random questions. + * + * @copyright 2018 Bo Pierce + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class remove_unused_questions extends \core\task\scheduled_task { + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name(): string { + return get_string('taskunusedrandomscleanup', 'qtype_random'); + } + + /** + * Do the job. + * + * @return void + */ + public function execute() { + global $DB, $CFG; + require_once($CFG->libdir . '/questionlib.php'); + + // Confirm, that there is no restore in progress to make sure we do not + // clean up questions that have their quiz slots not restored yet. + $restoretasks = [ + '\core\task\asynchronous_copy_task', + '\core\task\asynchronous_restore_task', + ]; + + $running = manager::get_running_tasks(); + foreach ($running as $task) { + if (in_array($task->classname, $restoretasks)) { + mtrace('Detected running async restore. Aborting the task.'); + return; + } + } + + // Find potentially unused random questions (up to 5000). + // Note, because we call question_delete_question below, + // the question will not actually be deleted if something else + // is using them, but nothing else in Moodle core uses qtype_random, + // and not many third-party plugins do. + $unusedrandomids = $DB->get_records_sql( + " SELECT DISTINCT q.id, 1 + FROM {question} q + JOIN {question_versions} qv on qv.questionid = q.id + JOIN {question_bank_entries} qbe on qbe.id = qv.questionbankentryid + LEFT JOIN {question_references} qr on qr.questionbankentryid = qbe.id + WHERE qr.questionbankentryid IS NULL + AND q.qtype = ? AND qv.status <> ?", + ['random', 'hidden'], + 0, + 5000 + ); + + $count = 0; + foreach ($unusedrandomids as $unusedrandomid => $notused) { + question_delete_question($unusedrandomid); + // In case the question was not actually deleted (because it was in use somehow), + // it will be marked as hidden, so the query above will not return it again. + $count += 1; + } + mtrace('Cleaned up ' . $count . ' unused random questions.'); + } +} diff --git a/question/type/random/db/tasks.php b/question/type/random/db/tasks.php new file mode 100644 index 00000000000..198dcba16c9 --- /dev/null +++ b/question/type/random/db/tasks.php @@ -0,0 +1,38 @@ +. + +/** + * Definition of question/type/random scheduled tasks. + * + * @package qtype_random + * @category task + * @copyright 2018 Bo Pierce + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => 'qtype_random\task\remove_unused_questions', + 'blocking' => 0, + 'minute' => 'R', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + ], +]; diff --git a/question/type/random/tests/cleanup_task_test.php b/question/type/random/tests/cleanup_task_test.php new file mode 100644 index 00000000000..4448aee648d --- /dev/null +++ b/question/type/random/tests/cleanup_task_test.php @@ -0,0 +1,144 @@ +. + +/** + * Tests of the scheduled task for cleaning up random questions. + * + * @package qtype_random + * @copyright 2018 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +namespace qtype_random; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + + +/** + * Tests of the scheduled task for cleaning up random questions. + * + * @copyright 2018 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \qtype_random\task\remove_unused_questions + */ +final class cleanup_task_test extends \advanced_testcase { + /** + * Test that remove_unused_questions deletes questions as appropriate. + * + * @covers ::execute + */ + public function test_cleanup_task_removes_unused_question(): void { + global $DB, $USER; + $this->resetAfterTest(); + $this->setAdminUser(); + + // To do the test, we will be restoring a backup that contains 3 questions: + // A non-hidden broken question, a hidden broken question, and a non-broken question. + // Only the non-hidden broken question should be deleted, + // because questions are hidden when a delete was attempted but failed, + // and this is used to indicate we should skip over them in future deletes, + // to avoid continuingly attempting to delete undeletable questions. + + // Extract backup file. + $backupid = 'test_cleanup_task_removes_unused_question'; + $backuppath = make_backup_temp_directory($backupid); + get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( + __DIR__ . '/fixtures/broken_question_course.mbz', + $backuppath + ); + + // Do restore to new course with default settings. + $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}"); + $newcourseid = \restore_dbops::create_new_course('Broken Question Course', 'BQC', $categoryid); + $rc = new \restore_controller( + $backupid, + $newcourseid, + \backup::INTERACTIVE_NO, + \backup::MODE_GENERAL, + $USER->id, + \backup::TARGET_NEW_COURSE + ); + $rc->execute_precheck(); + $rc->execute_plan(); + $rc->destroy(); + + // Check the hidden question was unhidden during the restore, + // to make it eligible for deletion. + $hiddenquestionid = $DB->get_field('question', 'id', ['name' => 'Random (BQC hidden broken question)']); + $this->assertNotEquals( + 'hidden', + $DB->get_field('question_versions', 'status', ['questionid' => $hiddenquestionid]) + ); + + // Revert the hidden question back to hidden, so we can check it isn't deleted. + $DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $hiddenquestionid]); + + // Run the scheduled task. + $task = new \qtype_random\task\remove_unused_questions(); + $this->expectOutputString("Cleaned up 1 unused random questions.\n"); + $task->execute(); + + // Verify. + $this->assertFalse( + $DB->record_exists('question', ['name' => 'Random (BQC non-hidden broken question)']) + ); + $this->assertTrue( + $DB->record_exists('question', ['name' => 'Random (BQC hidden broken question)']) + ); + $this->assertTrue( + $DB->record_exists('question', ['name' => 'BQC non-broken question']) + ); + } + + /** + * Test that remove_unused_questions aborts when there is a course restore in progress. + * + * @covers ::execute + */ + public function test_cleanup_task_checks_for_active_restores(): void { + $this->resetAfterTest(); + + // Get ready the tasks. + $cleanuptask = new \qtype_random\task\remove_unused_questions(); + $restoretask = new \core\task\asynchronous_restore_task(); + \core\task\manager::queue_adhoc_task($restoretask); + $copytask = new \core\task\asynchronous_copy_task(); + \core\task\manager::queue_adhoc_task($copytask); + + // Start the first adhoc task. This might be either restore or copy adhoc task. + $task1 = \core\task\manager::get_next_adhoc_task(time()); + \core\task\manager::adhoc_task_starting($task1); + $cleanuptask->execute(); + + // Complete the first task and start the second one. + \core\task\manager::adhoc_task_complete($task1); + $task2 = \core\task\manager::get_next_adhoc_task(time()); + \core\task\manager::adhoc_task_starting($task2); + $cleanuptask->execute(); + + // Complete the second adhoc task. + \core\task\manager::adhoc_task_complete($task2); + $cleanuptask->execute(); + + $aborted = 'Detected running async restore. Aborting the task.'; + $completed = 'Cleaned up 0 unused random questions.'; + $this->expectOutputRegex("/.*$aborted.*\s.*$aborted.*\s.*$completed.*/"); + } +} diff --git a/question/type/random/tests/fixtures/broken_question_course.mbz b/question/type/random/tests/fixtures/broken_question_course.mbz new file mode 100644 index 00000000000..204a62ddcaa Binary files /dev/null and b/question/type/random/tests/fixtures/broken_question_course.mbz differ diff --git a/question/type/random/version.php b/question/type/random/version.php index 1d00a5e47f1..b6285245068 100644 --- a/question/type/random/version.php +++ b/question/type/random/version.php @@ -26,7 +26,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_random'; -$plugin->version = 2025041400; +$plugin->version = 2025041401; $plugin->requires = 2025040800;