diff --git a/admin/tool/recyclebin/lib.php b/admin/tool/recyclebin/lib.php index ee2eb748633..d4e748bd1b6 100644 --- a/admin/tool/recyclebin/lib.php +++ b/admin/tool/recyclebin/lib.php @@ -152,6 +152,17 @@ function tool_recyclebin_pre_course_module_delete($cm) { } } +/** + * Hook called to check whether async course module deletion should be performed or not. + * + * @return true if background deletion is required (is the recyclebin is enabled), false otherwise. + */ +function tool_recyclebin_course_module_background_deletion_recommended() { + if (\tool_recyclebin\course_bin::is_enabled()) { + return true; + } +} + /** * Hook called before we delete a course. * diff --git a/backup/util/dbops/backup_plan_dbops.class.php b/backup/util/dbops/backup_plan_dbops.class.php index 530763752be..615f802fc6c 100644 --- a/backup/util/dbops/backup_plan_dbops.class.php +++ b/backup/util/dbops/backup_plan_dbops.class.php @@ -87,7 +87,8 @@ abstract class backup_plan_dbops extends backup_dbops { FROM {course_modules} cm JOIN {modules} m ON m.id = cm.module WHERE cm.course = ? - AND cm.section = ?", array($courseid, $sectionid)); + AND cm.section = ? + AND cm.deletioninprogress <> 1", array($courseid, $sectionid)); foreach (explode(',', $sequence) as $moduleid) { if (isset($modules[$moduleid])) { $module = array('id' => $modules[$moduleid]->id, 'modname' => $modules[$moduleid]->modname); diff --git a/course/editsection.php b/course/editsection.php index 14b74932488..231935f31be 100644 --- a/course/editsection.php +++ b/course/editsection.php @@ -50,7 +50,7 @@ if ($deletesection) { if (course_can_delete_section($course, $sectioninfo)) { $confirm = optional_param('confirm', false, PARAM_BOOL) && confirm_sesskey(); if ($confirm) { - course_delete_section($course, $sectioninfo, true); + course_delete_section($course, $sectioninfo, true, true); $courseurl = course_get_url($course, 0, array('sr' => $sectionreturn)); redirect($courseurl); } else { diff --git a/course/lib.php b/course/lib.php index 553632602e9..353845d3c99 100644 --- a/course/lib.php +++ b/course/lib.php @@ -430,6 +430,7 @@ function get_array_of_activities($courseid) { $mod[$seq]->completionexpected = $rawmods[$seq]->completionexpected; $mod[$seq]->showdescription = $rawmods[$seq]->showdescription; $mod[$seq]->availability = $rawmods[$seq]->availability; + $mod[$seq]->deletioninprogress = $rawmods[$seq]->deletioninprogress; $modname = $mod[$seq]->mod; $functionname = $modname."_get_coursemodule_info"; @@ -504,7 +505,7 @@ function get_array_of_activities($courseid) { foreach (array('idnumber', 'groupmode', 'groupingid', 'indent', 'completion', 'extra', 'extraclasses', 'iconurl', 'onclick', 'content', 'icon', 'iconcomponent', 'customdata', 'availability', 'completionview', - 'completionexpected', 'score', 'showdescription') as $property) { + 'completionexpected', 'score', 'showdescription', 'deletioninprogress') as $property) { if (property_exists($mod[$seq], $property) && empty($mod[$seq]->{$property})) { unset($mod[$seq]->{$property}); @@ -1072,9 +1073,25 @@ function set_coursemodule_name($id, $name) { * 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. + * @throws moodle_exception * @since Moodle 2.5 */ -function course_delete_module($cmid) { +function course_delete_module($cmid, $async = false) { + // 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()) { + return course_module_flag_for_async_deletion($cmid); + } + } + } + } + global $CFG, $DB; require_once($CFG->libdir.'/gradelib.php'); @@ -1192,6 +1209,104 @@ function course_delete_module($cmid) { rebuild_course_cache($cm->course, true); } +/** + * Schedule a course module for deletion in the background using an adhoc task. + * + * This method should not be called directly. Instead, please use course_delete_module($cmid, true), to denote async deletion. + * The real deletion of the module is handled by the task, which calls 'course_delete_module($cmid)'. + * + * @param int $cmid the course module id. + * @return bool whether the module was successfully scheduled for deletion. + * @throws \moodle_exception + */ +function course_module_flag_for_async_deletion($cmid) { + 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'); + + // Get the course module. + if (!$cm = $DB->get_record('course_modules', array('id' => $cmid))) { + return true; + } + + // 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. + + // Get the course module name. + $modulename = $DB->get_field('modules', 'name', array('id' => $cm->module), MUST_EXIST); + + // 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('cannotdeletemodulemissinglib', '', '', null, + "Cannot delete this module as the file mod/$modulename/lib.php is missing."); + } + + $deleteinstancefunction = $modulename . '_delete_instance'; + + // Ensure the delete_instance function exists for this module. + if (!function_exists($deleteinstancefunction)) { + throw new \moodle_exception('cannotdeletemodulemissingfunc', '', '', null, + "Cannot delete this module as the function {$modulename}_delete_instance is missing in mod/$modulename/lib.php."); + } + + // We are going to defer the deletion as we can't be sure how long the module's pre_delete code will run for. + $cm->deletioninprogress = '1'; + $DB->update_record('course_modules', $cm); + + // Create an adhoc task for the deletion of the course module. The task takes an array of course modules for removal. + $removaltask = new \core_course\task\course_delete_modules(); + $removaltask->set_custom_data(array('cms' => array($cm))); + + // 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); +} + +/** + * Checks whether the given course has any course modules scheduled for adhoc deletion. + * + * @param int $courseid the id of the course. + * @return bool true if the course contains any modules pending deletion, false otherwise. + */ +function course_modules_pending_deletion($courseid) { + if (empty($courseid)) { + return false; + } + $modinfo = get_fast_modinfo($courseid); + foreach ($modinfo->get_cms() as $module) { + if ($module->deletioninprogress == '1') { + return true; + } + } + return false; +} + +/** + * Checks whether the course module, as defined by modulename and instanceid, is scheduled for deletion within the given course. + * + * @param int $courseid the course id. + * @param string $modulename the module name. E.g. 'assign', 'book', etc. + * @param int $instanceid the module instance id. + * @return bool true if the course module is pending deletion, false otherwise. + */ +function course_module_instance_pending_deletion($courseid, $modulename, $instanceid) { + if (empty($courseid) || empty($modulename) || empty($instanceid)) { + return false; + } + $modinfo = get_fast_modinfo($courseid); + $instances = $modinfo->get_instances_of($modulename); + return isset($instances[$instanceid]) && $instances[$instanceid]->deletioninprogress; +} + function delete_mod_from_section($modid, $sectionid) { global $DB; @@ -1285,9 +1400,10 @@ function move_section_to($course, $section, $destination, $ignorenumsections = f * @param int|stdClass $course * @param int|stdClass|section_info $section * @param bool $forcedeleteifnotempty if set to false section will not be deleted if it has modules in it. + * @param bool $async whether or not to try to delete the section using an adhoc task. Async also depends on a plugin hook. * @return bool whether section was deleted */ -function course_delete_section($course, $section, $forcedeleteifnotempty = true) { +function course_delete_section($course, $section, $forcedeleteifnotempty = true, $async = false) { global $DB; // Prepare variables. @@ -1298,6 +1414,21 @@ function course_delete_section($course, $section, $forcedeleteifnotempty = true) // No section exists, can't proceed. return false; } + + // 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()) { + return course_delete_section_async($section, $forcedeleteifnotempty); + } + } + } + } + $format = course_get_format($course); $sectionname = $format->get_section_name($section); @@ -1308,22 +1439,102 @@ function course_delete_section($course, $section, $forcedeleteifnotempty = true) if ($result) { $context = context_course::instance($courseid); $event = \core\event\course_section_deleted::create( - array( - 'objectid' => $section->id, - 'courseid' => $courseid, - 'context' => $context, - 'other' => array( - 'sectionnum' => $section->section, - 'sectionname' => $sectionname, - ) + array( + 'objectid' => $section->id, + 'courseid' => $courseid, + 'context' => $context, + 'other' => array( + 'sectionnum' => $section->section, + 'sectionname' => $sectionname, ) - ); + ) + ); $event->add_record_snapshot('course_sections', $section); $event->trigger(); } return $result; } +/** + * Course section deletion, using an adhoc task for deletion of the modules it contains. + * 1. Schedule all modules within the section for adhoc removal. + * 2. Move all modules to course section 0. + * 3. Delete the resulting empty section. + * + * @param \stdClass $section the section to schedule for deletion. + * @param bool $forcedeleteifnotempty whether to force section deletion if it contains modules. + * @return bool true if the section was scheduled for deletion, false otherwise. + */ +function course_delete_section_async($section, $forcedeleteifnotempty = true) { + global $DB; + + // Objects only, and only valid ones. + if (!is_object($section) || empty($section->id)) { + return false; + } + + // Does the object currently exist in the DB for removal (check for stale objects). + $section = $DB->get_record('course_sections', array('id' => $section->id)); + if (!$section || !$section->section) { + // No section exists, or the section is 0. Can't proceed. + return false; + } + + // Check whether the section can be removed. + if (!$forcedeleteifnotempty && (!empty($section->sequence) || !empty($section->summary))) { + return false; + } + + $format = course_get_format($section->course); + $sectionname = $format->get_section_name($section); + + // Flag those modules having no existing deletion flag. Some modules may have been scheduled for deletion manually, and we don't + // want to create additional adhoc deletion tasks for these. Moving them to section 0 will suffice. + $affectedmods = $DB->get_records_select('course_modules', 'course = ? AND section = ? AND deletioninprogress <> ?', + [$section->course, $section->id, 1], '', 'id'); + $DB->set_field('course_modules', 'deletioninprogress', '1', ['course' => $section->course, 'section' => $section->id]); + + // Move all modules to section 0. + $modules = $DB->get_records('course_modules', ['section' => $section->id], ''); + $sectionzero = $DB->get_record('course_sections', ['course' => $section->course, 'section' => '0']); + foreach ($modules as $mod) { + moveto_module($mod, $sectionzero); + } + + // Create and queue an adhoc task for the deletion of the modules. + $removaltask = new \core_course\task\course_delete_modules(); + $data = array( + 'cms' => $affectedmods + ); + $removaltask->set_custom_data($data); + \core\task\manager::queue_adhoc_task($removaltask); + + // Delete the now empty section, passing in only the section number, which forces the function to fetch a new object. + // The refresh is needed because the section->sequence is now stale. + $result = $format->delete_section($section->section, $forcedeleteifnotempty); + + // Trigger an event for course section deletion. + if ($result) { + $context = \context_course::instance($section->course); + $event = \core\event\course_section_deleted::create( + array( + 'objectid' => $section->id, + 'courseid' => $section->course, + 'context' => $context, + 'other' => array( + 'sectionnum' => $section->section, + 'sectionname' => $sectionname, + ) + ) + ); + $event->add_record_snapshot('course_sections', $section); + $event->trigger(); + } + rebuild_course_cache($section->course, true); + + return $result; +} + /** * Updates the course section * diff --git a/course/rest.php b/course/rest.php index 31064e105d6..a63f6cef128 100644 --- a/course/rest.php +++ b/course/rest.php @@ -168,7 +168,7 @@ switch($requestmethod) { switch ($class) { case 'resource': require_capability('moodle/course:manageactivities', $modcontext); - course_delete_module($cm->id); + course_delete_module($cm->id, true); break; } break; diff --git a/lib/filestorage/file_storage.php b/lib/filestorage/file_storage.php index c3abedda152..c709c5f0dc2 100644 --- a/lib/filestorage/file_storage.php +++ b/lib/filestorage/file_storage.php @@ -2215,7 +2215,12 @@ class file_storage { mkdir($trashpath, $this->dirpermissions, true); } rename($contentfile, $trashfile); - chmod($trashfile, $this->filepermissions); // fix permissions if needed + + // Fix permissions, only if needed. + $currentperms = octdec(substr(decoct(fileperms($trashfile)), -4)); + if ((int)$this->filepermissions !== $currentperms) { + chmod($trashfile, $this->filepermissions); + } } /**