From f81d3af2d26da1cb2ffd23f7b9fd1c505518136f Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Tue, 14 Oct 2025 17:41:38 +0200 Subject: [PATCH] MDL-86856 courseformat: Add delete and delete_async to cmactions --- .upgradenotes/MDL-86856-2025101711015767.yml | 5 + .../course/format/classes/local/cmactions.php | 220 ++++++++++++ .../format/tests/local/cmactions_test.php | 323 +++++++++++++++++- 3 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 .upgradenotes/MDL-86856-2025101711015767.yml diff --git a/.upgradenotes/MDL-86856-2025101711015767.yml b/.upgradenotes/MDL-86856-2025101711015767.yml new file mode 100644 index 00000000000..6037a80c861 --- /dev/null +++ b/.upgradenotes/MDL-86856-2025101711015767.yml @@ -0,0 +1,5 @@ +issueNumber: MDL-86856 +notes: + core_courseformat: + - message: Add delete method to the core_courseformat\cmactions + type: improved diff --git a/public/course/format/classes/local/cmactions.php b/public/course/format/classes/local/cmactions.php index 4458fa44384..6ca773894b6 100644 --- a/public/course/format/classes/local/cmactions.php +++ b/public/course/format/classes/local/cmactions.php @@ -20,6 +20,7 @@ use core\exception\moodle_exception; use core_courseformat\sectiondelegatemodule; use core_text; use course_modinfo; +use stdClass; /** * Course module course format actions. @@ -227,4 +228,223 @@ class cmactions extends baseactions { return true; } + + /** + * Handles the whole deletion process of a module. + * This includes calling the modules delete_instance function, deleting files, events, grades, conditional data, + * the data in the course_module and course_sections table and adding a module deletion event to the DB. + * + * @param int $cmid The course module id + * @param bool $async Whether or not to try to delete the module using an adhoc task. Async also depends on a plugin hook. + */ + public function delete(int $cmid, bool $async = false): void { + // Check the 'course_module_background_deletion_recommended' hook first. + // Only use asynchronous deletion if at least one plugin returns true and if async deletion has been requested. + // Both are checked because plugins should not be allowed to dictate the deletion behaviour, only support/decline it. + // It's up to plugins to handle things like whether or not they are enabled. + if ($async && $pluginsfunction = get_plugins_with_function('course_module_background_deletion_recommended')) { + foreach ($pluginsfunction as $plugintype => $plugins) { + foreach ($plugins as $pluginfunction) { + if ($pluginfunction()) { + $this->delete_async($cmid); + return; + } + } + } + } + + global $CFG, $DB; + + require_once($CFG->libdir . '/gradelib.php'); + require_once($CFG->libdir . '/questionlib.php'); + require_once($CFG->dirroot . '/blog/lib.php'); + require_once($CFG->dirroot . '/calendar/lib.php'); + + if (!$cm = $DB->get_record('course_modules', ['id' => $cmid])) { + return; + } + $modulename = $DB->get_field('modules', 'name', ['id' => $cm->module], MUST_EXIST); + + $this->check_deletion($cm, $modulename); + + // Allow plugins to use this course module before we completely delete it. + if ($pluginsfunction = get_plugins_with_function('pre_course_module_delete')) { + foreach ($pluginsfunction as $plugintype => $plugins) { + foreach ($plugins as $pluginfunction) { + $pluginfunction($cm); + } + } + } + + if (empty($cm->instance)) { + throw new moodle_exception( + errorcode: 'cannotdeletemodulemissinginstance', + debuginfo: "Cannot delete module with ID $cm->id because it does not have a valid activity instance.", + ); + } + + // Call the delete_instance function, if it returns false throw an exception. + $deleteinstancefunction = $modulename . '_delete_instance'; + if (!$deleteinstancefunction($cm->instance)) { + throw new moodle_exception( + errorcode: 'cannotdeletemoduleinstance', + debuginfo: "Cannot delete module $modulename (instance).", + ); + } + + // We delete the questions after the activity database is removed, + // because questions are referenced via question reference tables + // and cannot be deleted while the activities that use them still exist. + question_delete_activity($cm); + + // Remove all module files in case modules forget to do that. + $modcontext = \context_module::instance($cm->id); + $fs = get_file_storage(); + $fs->delete_area_files($modcontext->id); + + // Delete events from calendar. + if ($events = $DB->get_records('event', ['instance' => $cm->instance, 'modulename' => $modulename])) { + $coursecontext = \context_course::instance($cm->course); + foreach ($events as $event) { + $event->context = $coursecontext; + $calendarevent = \calendar_event::load($event); + $calendarevent->delete(); + } + } + + // Delete grade items, outcome items and grades attached to modules. + $gradeitems = \grade_item::fetch_all(['itemtype' => 'mod', + 'itemmodule' => $modulename, + 'iteminstance' => $cm->instance, + 'courseid' => $cm->course, + ]); + if ($gradeitems) { + foreach ($gradeitems as $gradeitem) { + $gradeitem->delete('moddelete'); + } + } + + // Delete associated blogs and blog tag instances. + blog_remove_associations_for_module($modcontext->id); + + // Delete completion and availability data; it is better to do this even if the + // features are not turned on, in case they were turned on previously (these will be + // very quick on an empty table). + $DB->delete_records('course_modules_completion', ['coursemoduleid' => $cm->id]); + $DB->delete_records('course_modules_viewed', ['coursemoduleid' => $cm->id]); + $DB->delete_records('course_completion_criteria', [ + 'moduleinstance' => $cm->id, + 'course' => $cm->course, + 'criteriatype' => COMPLETION_CRITERIA_TYPE_ACTIVITY, + ]); + + // Delete all tag instances associated with the instance of this module. + \core_tag_tag::delete_instances('mod_' . $modulename, null, $modcontext->id); + \core_tag_tag::remove_all_item_tags('core', 'course_modules', $cm->id); + + // Notify the competency subsystem. + \core_competency\api::hook_course_module_deleted($cm); + + // Delete the context. + \context_helper::delete_instance(CONTEXT_MODULE, $cm->id); + + // Delete the module from the course_modules table. + $DB->delete_records('course_modules', ['id' => $cm->id]); + + // Delete module from that section. + if (!delete_mod_from_section($cm->id, $cm->section)) { + throw new moodle_exception( + errorcode: 'cannotdeletemodulefromsection', + debuginfo: "Cannot delete the module $modulename (instance) from section.", + ); + } + + // Trigger event for course module delete action. + $event = \core\event\course_module_deleted::create([ + 'courseid' => $cm->course, + 'context' => $modcontext, + 'objectid' => $cm->id, + 'other' => [ + 'modulename' => $modulename, + 'instanceid' => $cm->instance, + ], + ]); + $event->add_record_snapshot('course_modules', $cm); + $event->trigger(); + course_modinfo::purge_course_module_cache($cm->course, $cm->id); + rebuild_course_cache($cm->course, false, true); + } + + /** + * Schedule a course module for deletion in the background using an adhoc task. + * + * @param int $cmid the course module id. + */ + protected function delete_async(int $cmid): void { + global $DB, $USER; + + if (!$cm = $DB->get_record('course_modules', ['id' => $cmid])) { + return; + } + $modulename = $DB->get_field('modules', 'name', ['id' => $cm->module], MUST_EXIST); + + // We need to be reasonably certain the deletion is going to succeed before we background the process. + // Make the necessary delete_instance checks, etc. before proceeding further. Throw exceptions if required. + $this->check_deletion($cm, $modulename); + + // Defer the deletion as we can't be sure how long the module's pre_delete code will run for. + $DB->set_field( + 'course_modules', + 'deletioninprogress', + '1', + ['id' => $cmid], + ); + + // Create an adhoc task for the deletion of the course module. + $removaltask = new \core_course\task\course_delete_modules(); + $removaltask->set_custom_data([ + 'cms' => [$cm], + 'userid' => $USER->id, + 'realuserid' => \core\session\manager::get_realuser()->id, + ]); + + // Queue the task for the next run. + \core\task\manager::queue_adhoc_task($removaltask); + + // Reset the course cache to hide the module. + rebuild_course_cache($cm->course, true); + } + + /** + * Make the necessary delete_instance checks. Throw exceptions if required. + * + * @param stdClass $cm The course module object. + * @param string $modulename The module name. + * @throws \moodle_exception + */ + private function check_deletion(stdClass $cm, string $modulename) { + global $CFG; + + // Get the file location of the delete_instance function for this module. + $modlib = "$CFG->dirroot/mod/$modulename/lib.php"; + + // Include the file required to call the delete_instance function for this module. + if (file_exists($modlib)) { + require_once($modlib); + } else { + throw new \moodle_exception( + errorcode: 'cannotdeletemodulemissinglib', + debuginfo: "Cannot delete module: Missing file mod/$modulename/lib.php.", + ); + } + + // Ensure the delete_instance function exists for this module. + $deleteinstancefunction = $modulename . '_delete_instance'; + if (!function_exists($deleteinstancefunction)) { + throw new \moodle_exception( + errorcode: 'cannotdeletemodulemissingfunc', + debuginfo: "Cannot delete module: Missing function {$modulename}_delete_instance in mod/$modulename/lib.php.", + ); + } + } } diff --git a/public/course/format/tests/local/cmactions_test.php b/public/course/format/tests/local/cmactions_test.php index 8d0a3a65377..3cef7993fed 100644 --- a/public/course/format/tests/local/cmactions_test.php +++ b/public/course/format/tests/local/cmactions_test.php @@ -24,8 +24,8 @@ use core_courseformat\hook\after_cm_name_edited; * @package core_courseformat * @copyright 2024 Ferran Recio * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @coversDefaultClass \core_courseformat\local\cmactions */ +#[\PHPUnit\Framework\Attributes\CoversClass(\core_courseformat\local\cmactions::class)] final class cmactions_test extends \advanced_testcase { /** * Setup to ensure that fixtures are loaded. @@ -227,4 +227,325 @@ final class cmactions_test extends \advanced_testcase { $this->assertEquals($activity->cmid, $executedhook->get_cm()->id); $this->assertEquals('New name', $executedhook->get_newname()); } + + + /** + * Tests the function that deletes a course module. + */ + public function test_delete(): void { + global $DB, $USER; + + $this->resetAfterTest(); + $this->setAdminUser(); + + // Generate an assignment with due date (will generate a course event). + $course = $this->getDataGenerator()->create_course(['enablecompletion' => COMPLETION_ENABLED]); + $module = $this->getDataGenerator()->create_module( + 'assign', + ['course' => $course->id, 'duedate' => time()], + ['completion' => COMPLETION_TRACKING_MANUAL], + ); + $modcontext = \context_module::instance($module->cmid); + $cm = $DB->get_record('course_modules', ['id' => $module->cmid]); + $this->assertInstanceOf('context_module', $modcontext); + $this->assertEquals(1, $DB->count_records('event', ['instance' => $module->id, 'modulename' => 'assign'])); + + // Create blog entry associated to the module. + /** @var \core_blog_generator $blogsgenerator */ + $blogsgenerator = $this->getDataGenerator()->get_plugin_generator('core_blog'); + $user = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $blogentry = $blogsgenerator->create_entry([ + 'publishstate' => 'site', + 'userid' => $user->id, + 'subject' => 'My blog', + 'summary' => 'Animals', + 'tags' => ['cat', 'fish'], + 'courseid' => $course->id, + ]); + $blogentry->add_association($modcontext->id); + $this->assertEquals(1, $DB->count_records('post', ['id' => $blogentry->id])); + $this->assertEquals(1, $DB->count_records('blog_association', ['contextid' => $modcontext->id])); + $this->assertEquals(2, $DB->count_records('tag_instance', ['itemid' => $blogentry->id])); + + // Completion data. + $completion = new \completion_info($course); + $completion->update_state($cm, COMPLETION_COMPLETE); + $this->assertEquals(1, $DB->count_records('course_modules_completion', ['coursemoduleid' => $cm->id])); + + // Add some tags to this module. + \core_tag_tag::set_item_tags('mod_assign', 'assign', $module->id, $modcontext, ['Tag 1', 'Tag 2', 'Tag 3']); + \core_tag_tag::set_item_tags('core', 'course_modules', $module->cmid, $modcontext, ['Tag 3', 'Tag 4', 'Tag 5']); + $criteria = ['component' => 'mod_assign', 'itemtype' => 'assign', 'contextid' => $modcontext->id]; + $this->assertEquals(3, $DB->count_records('tag_instance', $criteria)); + $criteria = ['component' => 'core', 'itemtype' => 'course_modules', 'contextid' => $modcontext->id]; + $this->assertEquals(3, $DB->count_records('tag_instance', $criteria)); + + // To capture the event. + $sink = $this->redirectEvents(); + + // Run delete. + $cmactions = new cmactions($course); + $cmactions->delete($module->cmid); + + // Verify the context has been removed. + $this->assertFalse(\context_module::instance($module->cmid, IGNORE_MISSING)); + + // Verify the course_module record has been deleted. + $this->assertEmpty($DB->count_records('course_modules', ['id' => $module->cmid])); + + // Verify the course_modules_completion record has been deleted. + $this->assertEmpty($DB->count_records('course_modules_completion', ['coursemoduleid' => $module->cmid])); + + // Verify the blog_association record has been deleted. + $this->assertEmpty($DB->count_records('blog_association', ['contextid' => $modcontext->id])); + + // Verify the blog post record has been deleted. + $this->assertEmpty($DB->count_records('post', ['id' => $blogentry->id])); + + // Verify the tag instance record has been deleted. + $this->assertEmpty($DB->count_records('tag_instance', ['itemid' => $blogentry->id])); + + // Verify events have been removed. + $this->assertEmpty($DB->count_records('event', ['instance' => $module->id, 'modulename' => 'assign'])); + + // Verify the tag instances were deleted. + $criteria = ['component' => 'mod_assign', 'contextid' => $modcontext->id]; + $this->assertEmpty($DB->count_records('tag_instance', $criteria)); + $criteria = ['component' => 'core', 'itemtype' => 'course_modules', 'contextid' => $modcontext->id]; + $this->assertEmpty($DB->count_records('tag_instance', $criteria)); + + // Check the event is triggered. + $events = $sink->get_events(); + $sink->close(); + $count = 0; + while (!empty($events)) { + $event = array_pop($events); + if ($event instanceof \core\event\course_module_deleted) { + $count++; + // Check that the event data is valid. + $this->assertInstanceOf('\core\event\course_module_deleted', $event); + $this->assertEquals($module->cmid, $event->objectid); + $this->assertEquals($USER->id, $event->userid); + $this->assertEquals('course_modules', $event->objecttable); + $this->assertEquals(null, $event->get_url()); + $this->assertEquals($cm, $event->get_record_snapshot('course_modules', $module->cmid)); + } + } + $this->assertEquals(1, $count); + } + + /** + * Tests the function that deletes a course module. + */ + public function test_delete_module_with_questions(): void { + global $DB, $USER; + + $this->resetAfterTest(); + $this->setAdminUser(); + + // Generate a quiz. + $course = $this->getDataGenerator()->create_course(['enablecompletion' => COMPLETION_ENABLED]); + $module = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); + $modcontext = \context_module::instance($module->cmid); + + // Add some questions to this module. + /** @var \core_question_generator $qgen */ + $qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); + $qcat = $qgen->create_question_category(['contextid' => $modcontext->id]); + $qgen->create_question('shortanswer', null, ['category' => $qcat->id]); + $qgen->create_question('shortanswer', null, ['category' => $qcat->id]); + $this->assertEquals(2, $DB->count_records('question')); + + // Run delete. + $cmactions = new cmactions($course); + $cmactions->delete($module->cmid); + + // Verify the context has been removed. + $this->assertFalse(\context_module::instance($module->cmid, IGNORE_MISSING)); + + // Verify the course_module record has been deleted. + $this->assertEmpty($DB->count_records('course_modules', ['id' => $module->cmid])); + + // Verify events have been removed. + $this->assertEmpty($DB->count_records('event', ['instance' => $module->id, 'modulename' => 'quiz'])); + + // Verify the category and questions were deleted. + $this->assertEquals(0, $DB->count_records('question_categories', ['contextid' => $modcontext->id])); + $this->assertEquals(0, $DB->count_records('question')); + } + + /** + * Tests the function that deletes a course module with wrong cmid. + */ + public function test_delete_wrong_cmid(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $this->getDataGenerator()->create_module('assign', ['course' => $course->id, 'duedate' => time()]); + $this->assertEquals(1, $DB->count_records('course_modules')); + + $cmactions = new cmactions($course); + $cmactions->delete(99999); // Non existing cmid. + + // Verify the course_module record has not been deleted. + $this->assertEquals(1, $DB->count_records('course_modules')); + } + + /** + * Tests the function that deletes a course module with missing lib.php file. + */ + public function test_delete_missinglib(): void { + global $DB; + $this->resetAfterTest(); + + // Generate test data. + $course = $this->getDataGenerator()->create_course(); + $module = $this->getDataGenerator()->create_module('assign', ['course' => $course->id, 'duedate' => time()]); + $cm = $DB->get_record('course_modules', ['id' => $module->cmid]); + $this->assertEquals(1, $DB->count_records('course_modules')); + + // Modify module name to make an exception when deleting. + $module = $DB->get_record('modules', ['id' => $cm->module], 'id, name', MUST_EXIST); + $module->name = 'TestModuleToDelete'; + $DB->update_record('modules', $module); + + // Delete the module. + $cmactions = new cmactions($course); + $this->expectException(\moodle_exception::class); + $this->expectExceptionMessage('Missing file mod/TestModuleToDelete/lib.php'); + $cmactions->delete($cm->id); + + // Verify the course_module record has not been deleted. + $this->assertEquals(1, $DB->count_records('course_modules')); + } + + + /** + * Tests the function that deletes a course module async way. + */ + public function test_async_module_deletion_hook_implemented(): void { + // Async module deletion depends on the 'true' being returned by at least one plugin implementing the hook, + // 'course_module_adhoc_deletion_recommended'. In core, is implemented by the course recyclebin, which will only return + // true if the recyclebin plugin is enabled. To make sure async deletion occurs, this test force-enables the recyclebin. + global $DB, $USER; + $this->resetAfterTest(); + $this->setAdminUser(); + + // Ensure recyclebin is enabled. + set_config('coursebinenable', true, 'tool_recyclebin'); + + // Create course, module and context. + $course = $this->getDataGenerator()->create_course(); + $module = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]); + $modcontext = \context_module::instance($module->cmid); + + // Check events generated when deleting module. + $sink = $this->redirectEvents(); + + // Try to delete the module asynchronously. + $cmactions = new cmactions($course); + $cmactions->delete($module->cmid, true); + + // Verify that no event has been generated yet. + $events = $sink->get_events(); + $event = array_pop($events); + $sink->close(); + $this->assertEmpty($event); + + // Verify the course_module hasn't been deleted yet. + $this->assertEquals(1, $DB->count_records('course_modules', ['id' => $module->cmid])); + + // Grab the record, in it's final state before hard deletion, for comparison with the event snapshot. + // We need to do this because the 'deletioninprogress' flag has changed from '0' to '1'. + $cm = $DB->get_record('course_modules', ['id' => $module->cmid], '*', MUST_EXIST); + + // Verify the course_module is marked as 'deletioninprogress'. + $this->assertNotEquals($cm, false); + $this->assertEquals($cm->deletioninprogress, '1'); + + // Verify the context has not yet been removed. + $this->assertEquals($modcontext, \context_module::instance($module->cmid, IGNORE_MISSING)); + + // Set up a sink to catch the 'course_module_deleted' event. + $sink = $this->redirectEvents(); + + // Now, run the adhoc task which performs the hard deletion. + \phpunit_util::run_all_adhoc_tasks(); + + // Fetch and validate the event data. + $events = $sink->get_events(); + $event = array_pop($events); + $sink->close(); + $this->assertInstanceOf('\core\event\course_module_deleted', $event); + $this->assertEquals($module->cmid, $event->objectid); + $this->assertEquals($USER->id, $event->userid); + $this->assertEquals('course_modules', $event->objecttable); + $this->assertEquals(null, $event->get_url()); + $this->assertEquals($cm, $event->get_record_snapshot('course_modules', $module->cmid)); + + // Verify the context has been removed. + $this->assertFalse(\context_module::instance($module->cmid, IGNORE_MISSING)); + + // Verify the course_module record has been deleted. + $this->assertEquals(0, $DB->count_records('course_modules', ['id' => $module->cmid])); + } + + /** + * Tests the function that deletes a course module async way when no plugin implements the hook. + */ + public function test_async_module_deletion_hook_not_implemented(): void { + // Only proceed if we are sure that no plugin is going to advocate async removal of a module. I.e. no plugin returns + // 'true' from the 'course_module_adhoc_deletion_recommended' hook. + // In the case of core, only recyclebin implements this hook, and it will only return true if enabled, so disable it. + global $DB, $USER; + $this->resetAfterTest(); + $this->setAdminUser(); + + // Ensure recyclebin is disabled. + set_config('coursebinenable', false, 'tool_recyclebin'); + + // Non-core plugins might implement the 'course_module_adhoc_deletion_recommended' hook and spoil this test. + // If at least one plugin still returns true, then skip this test. + if ($pluginsfunction = get_plugins_with_function('course_module_background_deletion_recommended')) { + foreach ($pluginsfunction as $plugintype => $plugins) { + foreach ($plugins as $pluginfunction) { + if ($pluginfunction()) { + $this->markTestSkipped(); + } + } + } + } + + // Create course, module and context. + $course = $this->getDataGenerator()->create_course(); + $module = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]); + $cm = $DB->get_record('course_modules', ['id' => $module->cmid], '*', MUST_EXIST); + + // Check events generated when deleting module. + $sink = $this->redirectEvents(); + + // Try to delete the module asynchronously. + $cmactions = new cmactions($course); + $cmactions->delete($module->cmid, true); + + // Fetch and validate the event data. + $events = $sink->get_events(); + $event = array_pop($events); + $sink->close(); + $this->assertInstanceOf('\core\event\course_module_deleted', $event); + $this->assertEquals($module->cmid, $event->objectid); + $this->assertEquals($USER->id, $event->userid); + $this->assertEquals('course_modules', $event->objecttable); + $this->assertEquals(null, $event->get_url()); + $this->assertEquals($cm, $event->get_record_snapshot('course_modules', $module->cmid)); + + // Verify the context has been removed. + $this->assertFalse(\context_module::instance($module->cmid, IGNORE_MISSING)); + + // Verify the course_module record has been deleted. + $this->assertEquals(0, $DB->count_records('course_modules', ['id' => $module->cmid])); + } }