Merge branch 'MDL-54751-master-v5' of https://github.com/snake/moodle

This commit is contained in:
David Monllao 2016-11-07 08:55:44 +08:00
commit 90abff01b3
25 changed files with 725 additions and 25 deletions

View file

@ -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.
*

View file

@ -58,6 +58,7 @@ Feature: Backup user data
And I follow "Course 1"
And I turn editing mode on
And I delete "Quiz 1" activity
And I run all adhoc tasks
And I navigate to "Recycle bin" node in "Course administration"
And I should see "Quiz 1"
And I click on "Restore" "link" in the "region-main" "region"

View file

@ -69,6 +69,7 @@ Feature: Basic recycle bin functionality
| Assignment name | Test assign |
| Description | Test |
And I delete "Test assign" activity
And I run all adhoc tasks
And I navigate to "Recycle bin" node in "Course administration"
When I click on "Delete" "link"
Then I should see "Are you sure you want to delete the selected item from the recycle bin?"
@ -92,6 +93,7 @@ Feature: Basic recycle bin functionality
| Description | Test 2 |
And I delete "Test assign 1" activity
And I delete "Test assign 2" activity
And I run all adhoc tasks
And I navigate to "Recycle bin" node in "Course administration"
And I should see "Test assign 1"
And I should see "Test assign 2"

View file

@ -71,6 +71,9 @@ class tool_recyclebin_course_bin_tests extends advanced_testcase {
// Delete the course module.
course_delete_module($this->quiz->cmid);
// Now, run the course module deletion adhoc task.
phpunit_util::run_all_adhoc_tasks();
// Check the course module is now in the recycle bin.
$this->assertEquals(1, $DB->count_records('tool_recyclebin_course'));
@ -112,6 +115,9 @@ class tool_recyclebin_course_bin_tests extends advanced_testcase {
// Delete the course module.
course_delete_module($this->quiz->cmid);
// Now, run the course module deletion adhoc task.
phpunit_util::run_all_adhoc_tasks();
// Try purging.
$recyclebin = new \tool_recyclebin\course_bin($this->course->id);
foreach ($recyclebin->get_items() as $item) {
@ -134,6 +140,9 @@ class tool_recyclebin_course_bin_tests extends advanced_testcase {
// Delete the quiz.
course_delete_module($this->quiz->cmid);
// Now, run the course module deletion adhoc task.
phpunit_util::run_all_adhoc_tasks();
// Set deleted date to the distant past.
$recyclebin = new \tool_recyclebin\course_bin($this->course->id);
foreach ($recyclebin->get_items() as $item) {
@ -147,6 +156,9 @@ class tool_recyclebin_course_bin_tests extends advanced_testcase {
course_delete_module($book->cmid);
// Now, run the course module deletion adhoc task.
phpunit_util::run_all_adhoc_tasks();
// Should have 2 items now.
$this->assertEquals(2, count($recyclebin->get_items()));

View file

@ -64,7 +64,7 @@ class frontend extends \core_availability\frontend {
foreach ($modinfo->cms as $id => $othercm) {
// Add each course-module if it has completion turned on and is not
// the one currently being edited.
if ($othercm->completion && (empty($cm) || $cm->id != $id)) {
if ($othercm->completion && (empty($cm) || $cm->id != $id) && !$othercm->deletioninprogress) {
$cms[] = (object)array('id' => $id, 'name' =>
format_string($othercm->name, true, array('context' => $context)));
}

View file

@ -42,6 +42,7 @@ class frontend extends \core_availability\frontend {
\section_info $section = null) {
global $DB, $CFG;
require_once($CFG->libdir . '/gradelib.php');
require_once($CFG->dirroot . '/course/lib.php');
// Get grades as basic associative array.
$gradeoptions = array();
@ -49,6 +50,10 @@ class frontend extends \core_availability\frontend {
// For some reason the fetch_all things return null if none.
$items = $items ? $items : array();
foreach ($items as $id => $item) {
// Don't include the grade item if it's linked with a module that is being deleted.
if (course_module_instance_pending_deletion($item->courseid, $item->itemmodule, $item->iteminstance)) {
continue;
}
// Do not include grades for current item.
if ($cm && $cm->instance == $item->iteminstance
&& $cm->modname == $item->itemmodule

View file

@ -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);

View file

@ -114,7 +114,8 @@ abstract class base_logger implements checksumable {
public final function process($message, $level, $options = null) {
$result = true;
if ($this->level != backup::LOG_NONE && $this->level >= $level) { // Perform action conditionally
if ($this->level != backup::LOG_NONE && $this->level >= $level
&& !(defined('BEHAT_TEST') && BEHAT_TEST)) { // Perform action conditionally.
$result = $this->action($message, $level, $options);
}
if ($result === false) { // Something was wrong, stop the chain

View file

@ -194,6 +194,7 @@ Feature: View structural changes in recent activity block
And I follow "Course 1"
And I turn editing mode on
And I delete "ForumUpdated" activity
And I run all adhoc tasks
And I log out
And I wait "1" seconds
# Students 1 and 2 see that forum was deleted

View file

@ -0,0 +1,62 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Adhoc task handling course module deletion.
*
* @package core_course
* @copyright 2016 Jake Dallimore <jrhdallimore@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_course\task;
defined('MOODLE_INTERNAL') || die();
/**
* Class handling course module deletion.
*
* This task supports an array of course module object as custom_data, and calls course_delete_module() in synchronous deletion
* mode for each of them.
* This will:
* 1. call any 'mod_xxx_pre_course_module_deleted' functions (e.g. Recycle bin)
* 2. delete the module
* 3. fire the deletion event
*
* @package core_course
* @copyright 2016 Jake Dallimore <jrhdallimore@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class course_delete_modules extends \core\task\adhoc_task {
/**
* Run the deletion task.
*
* @throws \coding_exception if the module could not be removed.
*/
public function execute() {
global $CFG;
require_once($CFG->dirroot. '/course/lib.php');
$cms = $this->get_custom_data()->cms;
foreach ($cms as $cm) {
try {
course_delete_module($cm->id);
} catch (\Exception $e) {
throw new \coding_exception("The course module {$cm->id} could not be deleted. $e->getTraceAsString()");
}
}
}
}

View file

@ -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 {

View file

@ -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
*

View file

@ -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;

View file

@ -3379,4 +3379,288 @@ class core_course_courselib_testcase extends advanced_testcase {
$this->assertFalse($updates->introfiles->updated);
$this->assertFalse($updates->outcomes->updated);
}
public function test_async_module_deletion_hook_implemented() {
// 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(true);
$this->setAdminUser();
// Ensure recyclebin is enabled.
set_config('coursebinenable', true, 'tool_recyclebin');
// Create course, module and context.
$course = $this->getDataGenerator()->create_course(['numsections' => 5]);
$module = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]);
$modcontext = context_module::instance($module->cmid);
// Verify context exists.
$this->assertInstanceOf('context_module', $modcontext);
// Check events generated on the course_delete_module call.
$sink = $this->redirectEvents();
// Try to delete the module using the async flag.
course_delete_module($module->cmid, true); // Try to delete the module asynchronously.
// Verify that no event has been generated yet.
$events = $sink->get_events();
$event = array_pop($events);
$sink->close();
$this->assertEmpty($event);
// 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.
$cmcount = $DB->count_records('course_modules', ['id' => $module->cmid]);
$this->assertEmpty($cmcount);
}
public function test_async_module_deletion_hook_not_implemented() {
// 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(true);
$this->setAdminUser();
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(['numsections' => 5]);
$module = $this->getDataGenerator()->create_module('assign', ['course' => $course->id]);
$modcontext = context_module::instance($module->cmid);
$cm = $DB->get_record('course_modules', ['id' => $module->cmid], '*', MUST_EXIST);
// Verify context exists.
$this->assertInstanceOf('context_module', $modcontext);
// Check events generated on the course_delete_module call.
$sink = $this->redirectEvents();
// Try to delete the module using the async flag.
course_delete_module($module->cmid, true); // Try to delete the module asynchronously.
// 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.
$cmcount = $DB->count_records('course_modules', ['id' => $module->cmid]);
$this->assertEmpty($cmcount);
}
public function test_async_section_deletion_hook_implemented() {
// Async section deletion (provided section contains modules), depends on the 'true' being returned by at least one plugin
// implementing the 'course_module_adhoc_deletion_recommended' hook. In core, is implemented by the course recyclebin,
// which will only return true if the plugin is enabled. To make sure async deletion occurs, this test enables recyclebin.
global $DB, $USER;
$this->resetAfterTest(true);
$this->setAdminUser();
// Ensure recyclebin is enabled.
set_config('coursebinenable', true, 'tool_recyclebin');
// Create course, module and context.
$generator = $this->getDataGenerator();
$course = $generator->create_course(['numsections' => 4, 'format' => 'topics'], ['createsections' => true]);
$assign0 = $generator->create_module('assign', ['course' => $course, 'section' => 2]);
$assign1 = $generator->create_module('assign', ['course' => $course, 'section' => 2]);
$assign2 = $generator->create_module('assign', ['course' => $course, 'section' => 2]);
$assign3 = $generator->create_module('assign', ['course' => $course, 'section' => 0]);
// Delete empty section. No difference from normal, synchronous behaviour.
$this->assertTrue(course_delete_section($course, 4, false, true));
$this->assertEquals(3, course_get_format($course)->get_course()->numsections);
// Delete a module in section 2 (using async). Need to verify this doesn't generate two tasks when we delete
// the section in the next step.
course_delete_module($assign2->cmid, true);
// Confirm that the module is pending deletion in its current section.
$section = $DB->get_record('course_sections', ['course' => $course->id, 'section' => '2']); // For event comparison.
$this->assertEquals(true, $DB->record_exists('course_modules', ['id' => $assign2->cmid, 'deletioninprogress' => 1,
'section' => $section->id]));
// Now, delete section 2.
$this->assertFalse(course_delete_section($course, 2, false, true)); // Non-empty section, no forcedelete, so no change.
$sink = $this->redirectEvents(); // To capture the event.
$this->assertTrue(course_delete_section($course, 2, true, true));
// Now, confirm that:
// a) the section's modules have been flagged for deletion and moved to section 0 and;
// b) the section has been deleted and;
// c) course_section_deleted event has been fired. The course_module_deleted events will only fire once they have been
// removed from section 0 via the adhoc task.
// Modules should have been flagged for deletion and moved to section 0.
$sectionid = $DB->get_field('course_sections', 'id', ['course' => $course->id, 'section' => 0]);
$this->assertEquals(3, $DB->count_records('course_modules', ['section' => $sectionid, 'deletioninprogress' => 1]));
// Confirm the section has been deleted.
$this->assertEquals(2, course_get_format($course)->get_course()->numsections);
// Check event fired.
$events = $sink->get_events();
$event = array_pop($events);
$sink->close();
$this->assertInstanceOf('\core\event\course_section_deleted', $event);
$this->assertEquals($section->id, $event->objectid);
$this->assertEquals($USER->id, $event->userid);
$this->assertEquals('course_sections', $event->objecttable);
$this->assertEquals(null, $event->get_url());
$this->assertEquals($section, $event->get_record_snapshot('course_sections', $section->id));
// Now, run the adhoc task to delete the modules from section 0.
$sink = $this->redirectEvents(); // To capture the events.
phpunit_util::run_all_adhoc_tasks();
// Confirm the modules have been deleted.
list($insql, $assignids) = $DB->get_in_or_equal([$assign0->cmid, $assign1->cmid, $assign2->cmid]);
$cmcount = $DB->count_records_select('course_modules', 'id ' . $insql, $assignids);
$this->assertEmpty($cmcount);
// Confirm other modules in section 0 still remain.
$this->assertEquals(1, $DB->count_records('course_modules', ['id' => $assign3->cmid]));
// Confirm that events were generated for all 3 of the modules.
$events = $sink->get_events();
$sink->close();
$count = 0;
while (!empty($events)) {
$event = array_pop($events);
if (in_array($event->objectid, [$assign0->cmid, $assign1->cmid, $assign2->cmid])) {
$count++;
}
}
$this->assertEquals(3, $count);
}
public function test_async_section_deletion_hook_not_implemented() {
// If no plugins advocate async removal, then normal synchronous removal will take place.
// 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(true);
$this->setAdminUser();
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.
$generator = $this->getDataGenerator();
$course = $generator->create_course(['numsections' => 4, 'format' => 'topics'], ['createsections' => true]);
$assign0 = $generator->create_module('assign', ['course' => $course, 'section' => 2]);
$assign1 = $generator->create_module('assign', ['course' => $course, 'section' => 2]);
// Delete empty section. No difference from normal, synchronous behaviour.
$this->assertTrue(course_delete_section($course, 4, false, true));
$this->assertEquals(3, course_get_format($course)->get_course()->numsections);
// Delete section in the middle (2).
$section = $DB->get_record('course_sections', ['course' => $course->id, 'section' => '2']); // For event comparison.
$this->assertFalse(course_delete_section($course, 2, false, true)); // Non-empty section, no forcedelete, so no change.
$sink = $this->redirectEvents(); // To capture the event.
$this->assertTrue(course_delete_section($course, 2, true, true));
// Now, confirm that:
// a) The section's modules have deleted and;
// b) the section has been deleted and;
// c) course_section_deleted event has been fired and;
// d) course_module_deleted events have both been fired.
// Confirm modules have been deleted.
list($insql, $assignids) = $DB->get_in_or_equal([$assign0->cmid, $assign1->cmid]);
$cmcount = $DB->count_records_select('course_modules', 'id ' . $insql, $assignids);
$this->assertEmpty($cmcount);
// Confirm the section has been deleted.
$this->assertEquals(2, course_get_format($course)->get_course()->numsections);
// Confirm the course_section_deleted event has been generated.
$events = $sink->get_events();
$event = array_pop($events);
$sink->close();
$this->assertInstanceOf('\core\event\course_section_deleted', $event);
$this->assertEquals($section->id, $event->objectid);
$this->assertEquals($USER->id, $event->userid);
$this->assertEquals('course_sections', $event->objecttable);
$this->assertEquals(null, $event->get_url());
$this->assertEquals($section, $event->get_record_snapshot('course_sections', $section->id));
// Confirm that the course_module_deleted events have both been generated.
$count = 0;
while (!empty($events)) {
$event = array_pop($events);
if (in_array($event->objectid, [$assign0->cmid, $assign1->cmid])) {
$count++;
}
}
$this->assertEquals(2, $count);
}
}

View file

@ -970,6 +970,13 @@ function print_grade_page_head($courseid, $active_type, $active_plugin=null,
$user = null) {
global $CFG, $OUTPUT, $PAGE;
// Put a warning on all gradebook pages if the course has modules currently scheduled for background deletion.
require_once($CFG->dirroot . '/course/lib.php');
if (course_modules_pending_deletion($courseid)) {
\core\notification::add(get_string('gradesmoduledeletionpendingwarning', 'grades'),
\core\output\notification::NOTIFY_WARNING);
}
if ($active_type === 'preferences') {
// In Moodle 2.8 report preferences were moved under 'settings'. Allow backward compatibility for 3rd party grade reports.
$active_type = 'settings';

View file

@ -326,6 +326,8 @@ $string['grades'] = 'Grades';
$string['gradesforuser'] = 'Grades for {$a->user}';
$string['singleview'] = 'Single view for {$a}';
$string['gradesonly'] = 'Change to grades only';
$string['gradesmoduledeletionpendingwarning'] = 'Warning: Activity deletion in progress! Some grades are about to be removed.';
$string['gradesmoduledeletionprefix'] = '[Deletion in progress]';
$string['gradessettings'] = 'Grade settings';
$string['gradetype'] = 'Grade type';
$string['gradetype_help'] = 'There are 4 grade types:

View file

@ -1063,7 +1063,7 @@ class completion_info {
$modinfo = get_fast_modinfo($this->course);
$result = array();
foreach ($modinfo->get_cms() as $cm) {
if ($cm->completion != COMPLETION_TRACKING_NONE) {
if ($cm->completion != COMPLETION_TRACKING_NONE && !$cm->deletioninprogress) {
$result[$cm->id] = $cm;
}
}

View file

@ -300,6 +300,7 @@
<FIELD NAME="completionexpected" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Date at which students are expected to complete this activity. This field is used when displaying student progress."/>
<FIELD NAME="showdescription" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Some module types support a 'description' which shows within the module pages. This option controls whether it also displays on the course main page. 0 = does not display (default), 1 = displays"/>
<FIELD NAME="availability" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Availability restrictions for viewing this activity, in JSON format. Null if no restrictions."/>
<FIELD NAME="deletioninprogress" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>

View file

@ -2392,5 +2392,19 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2016110500.00);
}
if ($oldversion < 2016110600.00) {
// Define a field 'deletioninprogress' in the 'course_modules' table, to background deletion tasks.
$table = new xmldb_table('course_modules');
$field = new xmldb_field('deletioninprogress', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'availability');
// Conditionally launch add field 'deletioninprogress'.
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
// Main savepoint reached.
upgrade_main_savepoint(true, 2016110600.00);
}
return true;
}

View file

@ -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);
}
}
/**

View file

@ -524,6 +524,14 @@ class grade_item extends grade_object {
* @return bool Locked state
*/
public function is_locked($userid=NULL) {
global $CFG;
// Override for any grade items belonging to activities which are in the process of being deleted.
require_once($CFG->dirroot . '/course/lib.php');
if (course_module_instance_pending_deletion($this->courseid, $this->itemmodule, $this->iteminstance)) {
return true;
}
if (!empty($this->locked)) {
return true;
}
@ -1393,7 +1401,12 @@ class grade_item extends grade_object {
public function get_name($fulltotal=false) {
if (strval($this->itemname) !== '') {
// MDL-10557
return format_string($this->itemname);
// Make it obvious to users if the course module to which this grade item relates, is currently being removed.
$deletionpending = course_module_instance_pending_deletion($this->courseid, $this->itemmodule, $this->iteminstance);
$deletionnotice = get_string('gradesmoduledeletionprefix', 'grades');
return $deletionpending ? format_string($deletionnotice . ' ' . $this->itemname) : format_string($this->itemname);
} else if ($this->is_course_item()) {
return get_string('coursetotal', 'grades');

View file

@ -554,8 +554,7 @@ class course_modinfo {
// Get section data
$sections = $DB->get_records('course_sections', array('course' => $course->id), 'section',
'section, id, course, name, summary, summaryformat, sequence, visible, ' .
'availability');
'section, id, course, name, summary, summaryformat, sequence, visible, availability');
$compressedsections = array();
$formatoptionsdef = course_get_format($course)->section_format_options();
@ -753,6 +752,7 @@ class course_modinfo {
* @property-read mixed $customdata Optional custom data stored in modinfo cache for this activity, or null if none
* @property-read string $afterlink Extra HTML code to display after link - calculated on request
* @property-read string $afterediticons Extra HTML code to display after editing icons (e.g. more icons) - calculated on request
* @property-read bool $deletioninprogress True if this course module is scheduled for deletion, false otherwise.
*/
class cm_info implements IteratorAggregate {
/**
@ -1038,6 +1038,11 @@ class cm_info implements IteratorAggregate {
*/
private $afterediticons;
/**
* @var bool representing the deletion state of the module. True if the mod is scheduled for deletion.
*/
private $deletioninprogress;
/**
* List of class read-only properties and their getter methods.
* Used by magic functions __get(), __isset(), __empty()
@ -1089,6 +1094,7 @@ class cm_info implements IteratorAggregate {
'uservisible' => 'get_user_visible',
'visible' => false,
'visibleold' => false,
'deletioninprogress' => false
);
/**
@ -1505,7 +1511,7 @@ class cm_info implements IteratorAggregate {
static $cmfields = array('id', 'course', 'module', 'instance', 'section', 'idnumber', 'added',
'score', 'indent', 'visible', 'visibleold', 'groupmode', 'groupingid',
'completion', 'completiongradeitemnumber', 'completionview', 'completionexpected',
'showdescription', 'availability');
'showdescription', 'availability', 'deletioninprogress');
foreach ($cmfields as $key) {
$cmrecord->$key = $this->$key;
}
@ -1700,6 +1706,7 @@ class cm_info implements IteratorAggregate {
$this->added = isset($mod->added) ? $mod->added : 0;
$this->score = isset($mod->score) ? $mod->score : 0;
$this->visibleold = isset($mod->visibleold) ? $mod->visibleold : 0;
$this->deletioninprogress = isset($mod->deletioninprogress) ? $mod->deletioninprogress : 0;
// Note: it saves effort and database space to always include the
// availability and completion fields, even if availability or completion
@ -1861,6 +1868,12 @@ class cm_info implements IteratorAggregate {
}
$this->uservisible = true;
// If the module is being deleted, set the uservisible state to false and return.
if ($this->deletioninprogress) {
$this->uservisible = false;
return null;
}
// If the user cannot access the activity set the uservisible flag to false.
// Additional checks are required to determine whether the activity is entirely hidden or just greyed out.
if ((!$this->visible or !$this->get_available()) and
@ -2465,7 +2478,7 @@ class section_info implements IteratorAggregate {
'summary' => '',
'summaryformat' => '1', // FORMAT_HTML, but must be a string
'visible' => '1',
'availability' => null,
'availability' => null
);
/**

View file

@ -822,4 +822,21 @@ class phpunit_util extends testing_util {
return 'en_AU.UTF-8';
}
}
/**
* Executes all adhoc tasks in the queue. Useful for testing asynchronous behaviour.
*
* @return void
*/
public static function run_all_adhoc_tasks() {
$now = time();
while (($task = \core\task\manager::get_next_adhoc_task($now)) !== null) {
try {
$task->execute();
\core\task\manager::adhoc_task_complete($task);
} catch (Exception $e) {
\core\task\manager::adhoc_task_failed($task);
}
}
}
}

View file

@ -1001,6 +1001,43 @@ class behat_general extends behat_base {
}
}
/**
* Runs all ad-hoc tasks in the queue.
*
* This is faster and more reliable than running cron (running cron won't
* work more than once in the same test, for instance). However it is
* a little less 'realistic'.
*
* While the task is running, we suppress mtrace output because it makes
* the Behat result look ugly.
*
* @Given /^I run all adhoc tasks$/
* @throws DriverException
*/
public function i_run_all_adhoc_tasks() {
// Do setup for cron task.
cron_setup_user();
// Run tasks. Locking is handled by get_next_adhoc_task.
$now = time();
ob_start(); // Discard task output as not appropriate for Behat output!
while (($task = \core\task\manager::get_next_adhoc_task($now)) !== null) {
try {
$task->execute();
// Mark task complete.
\core\task\manager::adhoc_task_complete($task);
} catch (Exception $e) {
// Mark task failed and throw exception.
\core\task\manager::adhoc_task_failed($task);
ob_end_clean();
throw new DriverException('An adhoc task failed', 0, $e);
}
}
ob_end_clean();
}
/**
* Checks that an element and selector type exists in another element and selector type on the current page.
*

View file

@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
$version = 2016110500.00; // YYYYMMDD = weekly release date of this DEV branch.
$version = 2016110600.00; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.