MDL-86856 courseformat: Add delete and delete_async to cmactions

This commit is contained in:
Sara Arjona
2025-10-22 16:06:14 +02:00
parent d33ecac29c
commit f81d3af2d2
3 changed files with 547 additions and 1 deletions
@@ -0,0 +1,5 @@
issueNumber: MDL-86856
notes:
core_courseformat:
- message: Add delete method to the core_courseformat\cmactions
type: improved
@@ -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.",
);
}
}
}
@@ -24,8 +24,8 @@ use core_courseformat\hook\after_cm_name_edited;
* @package core_courseformat
* @copyright 2024 Ferran Recio <[email protected]>
* @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]));
}
}