diff --git a/admin/courseformats.php b/admin/courseformats.php index a8377dec7a7..fddcdda750f 100644 --- a/admin/courseformats.php +++ b/admin/courseformats.php @@ -40,8 +40,7 @@ require_sesskey(); $return = new moodle_url('/admin/settings.php', array('section' => 'manageformats')); -$allplugins = plugin_manager::instance()->get_plugins(); -$formatplugins = $allplugins['format']; +$formatplugins = plugin_manager::instance()->get_plugins_of_type('format'); $sortorder = array_flip(array_keys($formatplugins)); if (!isset($formatplugins[$formatname])) { diff --git a/admin/plugins.php b/admin/plugins.php index c6738dfb2f7..972bf518d5a 100644 --- a/admin/plugins.php +++ b/admin/plugins.php @@ -18,6 +18,15 @@ /** * UI for general plugins management * + * Supported HTTP parameters: + * + * ?fetchremote=1 - check for available updates + * ?updatesonly=1 - display plugins with available update only + * ?contribonly=1 - display non-standard add-ons only + * ?uninstall=foo_bar - uninstall the given plugin + * ?delete=foo_bar - delete the plugin folder (it must not be installed) + * &confirm=1 - confirm the uninstall or delete action + * * @package core * @subpackage admin * @copyright 2011 David Mudrak @@ -27,6 +36,7 @@ require_once(dirname(dirname(__FILE__)) . '/config.php'); require_once($CFG->libdir . '/adminlib.php'); require_once($CFG->libdir . '/pluginlib.php'); +require_once($CFG->libdir . '/filelib.php'); admin_externalpage_setup('pluginsoverview'); require_capability('moodle/site:config', context_system::instance()); @@ -34,8 +44,97 @@ require_capability('moodle/site:config', context_system::instance()); $fetchremote = optional_param('fetchremote', false, PARAM_BOOL); $updatesonly = optional_param('updatesonly', false, PARAM_BOOL); $contribonly = optional_param('contribonly', false, PARAM_BOOL); +$uninstall = optional_param('uninstall', '', PARAM_COMPONENT); +$delete = optional_param('delete', '', PARAM_COMPONENT); +$confirmed = optional_param('confirm', false, PARAM_BOOL); + +$output = $PAGE->get_renderer('core', 'admin'); $pluginman = plugin_manager::instance(); + +if ($uninstall) { + require_sesskey(); + $pluginfo = $pluginman->get_plugin_info($uninstall); + + // Make sure we know the plugin. + if (is_null($pluginfo)) { + throw new moodle_exception('err_uninstalling_unknown_plugin', 'core_plugin', '', array('plugin' => $uninstall), + 'plugin_manager::get_plugin_info() returned null for the plugin to be uninstalled'); + } + + $pluginname = $pluginman->plugin_name($pluginfo->component); + $PAGE->set_title($pluginname); + $PAGE->navbar->add(get_string('uninstalling', 'core_plugin', array('name' => $pluginname))); + + if (!$pluginman->can_uninstall_plugin($pluginfo->component)) { + throw new moodle_exception('err_cannot_uninstall_plugin', 'core_plugin', '', + array('plugin' => $pluginfo->component), + 'plugin_manager::can_uninstall_plugin() returned false'); + } + + if (!$confirmed) { + $continueurl = new moodle_url($PAGE->url, array('uninstall' => $pluginfo->component, 'sesskey' => sesskey(), 'confirm' => 1)); + echo $output->plugin_uninstall_confirm_page($pluginman, $pluginfo, $continueurl); + exit(); + + } else { + $progress = new progress_trace_buffer(new text_progress_trace(), false); + $pluginman->uninstall_plugin($pluginfo->component, $progress); + $progress->finished(); + + if ($pluginman->is_plugin_folder_removable($pluginfo->component)) { + $continueurl = new moodle_url($PAGE->url, array('delete' => $pluginfo->component, 'sesskey' => sesskey(), 'confirm' => 1)); + echo $output->plugin_uninstall_results_removable_page($pluginman, $pluginfo, $progress, $continueurl); + exit(); + + } else { + echo $output->plugin_uninstall_results_page($pluginman, $pluginfo, $progress); + exit(); + } + } +} + +if ($delete and $confirmed) { + require_sesskey(); + $pluginfo = $pluginman->get_plugin_info($delete); + + // Make sure we know the plugin. + if (is_null($pluginfo)) { + throw new moodle_exception('err_removing_unknown_plugin', 'core_plugin', '', array('plugin' => $delete), + 'plugin_manager::get_plugin_info() returned null for the plugin to be deleted'); + } + + $pluginname = $pluginman->plugin_name($pluginfo->component); + $PAGE->set_title($pluginname); + $PAGE->navbar->add(get_string('uninstalling', 'core_plugin', array('name' => $pluginname))); + + // Make sure it is not installed. + if (!is_null($pluginfo->versiondb)) { + throw new moodle_exception('err_removing_installed_plugin', 'core_plugin', '', + array('plugin' => $pluginfo->component, 'versiondb' => $pluginfo->versiondb), + 'plugin_manager::get_plugin_info() returned not-null versiondb for the plugin to be deleted'); + } + + // Make sure the folder is removable. + if (!$pluginman->is_plugin_folder_removable($pluginfo->component)) { + throw new moodle_exception('err_removing_unremovable_folder', 'core_plugin', '', + array('plugin' => $pluginfo->component, 'rootdir' => $pluginfo->rootdir), + 'plugin root folder is not removable as expected'); + } + + // Make sure the folder is within Moodle installation tree. + if (strpos($pluginfo->rootdir, $CFG->dirroot) !== 0) { + throw new moodle_exception('err_unexpected_plugin_rootdir', 'core_plugin', '', + array('plugin' => $pluginfo->component, 'rootdir' => $pluginfo->rootdir, 'dirroot' => $CFG->dirroot), + 'plugin root folder not in the moodle dirroot'); + } + + // So long, and thanks for all the bugs. + fulldelete($pluginfo->rootdir); + cache::make('core', 'pluginlist')->purge(); + redirect($PAGE->url); +} + $checker = available_update_checker::instance(); // Filtering options. @@ -50,8 +149,6 @@ if ($fetchremote) { redirect(new moodle_url($PAGE->url, $options)); } -$output = $PAGE->get_renderer('core', 'admin'); - $deployer = available_update_deployer::instance(); if ($deployer->enabled()) { $myurl = new moodle_url($PAGE->url, array('updatesonly' => $updatesonly, 'contribonly' => $contribonly)); diff --git a/admin/renderer.php b/admin/renderer.php index b39976d2220..5f96325f6e4 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -371,6 +371,88 @@ class core_admin_renderer extends plugin_renderer_base { return $output; } + /** + * Display a page to confirm the plugin uninstallation. + * + * @param plugin_manager $pluginman + * @param plugin_info $pluginfo + * @param moodle_url $continueurl URL to continue after confirmation + * @return string + */ + public function plugin_uninstall_confirm_page(plugin_manager $pluginman, plugininfo_base $pluginfo, moodle_url $continueurl) { + $output = ''; + + $pluginname = $pluginman->plugin_name($pluginfo->component); + + $output .= $this->output->header(); + $output .= $this->output->heading(get_string('uninstalling', 'core_plugin', array('name' => $pluginname))); + $output .= $this->output->confirm(get_string('uninstallconfirm', 'core_plugin', array('name' => $pluginname)), + $continueurl, $this->page->url); + $output .= $this->output->footer(); + + return $output; + } + + /** + * Display a page with results of plugin uninstallation and offer removal of plugin files. + * + * @param plugin_manager $pluginman + * @param plugin_info $pluginfo + * @param progress_trace_buffer $progress + * @param moodle_url $continueurl URL to continue to remove the plugin folder + * @return string + */ + public function plugin_uninstall_results_removable_page(plugin_manager $pluginman, plugininfo_base $pluginfo, + progress_trace_buffer $progress, moodle_url $continueurl) { + $output = ''; + + $pluginname = $pluginman->plugin_name($pluginfo->component); + + $output .= $this->output->header(); + $output .= $this->output->heading(get_string('uninstalling', 'core_plugin', array('name' => $pluginname))); + + $output .= $this->output->box($progress->get_buffer(), 'generalbox uninstallresultmessage'); + + $confirm = $this->output->container(get_string('uninstalldeleteconfirm', 'core_plugin', + array('name' => $pluginname, 'rootdir' => $pluginfo->rootdir)), 'uninstalldeleteconfirm'); + + if ($repotype = $pluginman->plugin_external_source($pluginfo->component)) { + $confirm .= $this->output->container(get_string('uninstalldeleteconfirmexternal', 'core_plugin', $repotype), + 'uninstalldeleteconfirmexternal'); + } + + $output .= $this->output->confirm($confirm, $continueurl, $this->page->url); + $output .= $this->output->footer(); + + return $output; + } + + /** + * Display a page with results of plugin uninstallation and inform about the need to remove plugin files manually. + * + * @param plugin_manager $pluginman + * @param plugin_info $pluginfo + * @param progress_trace_buffer $progress + * @return string + */ + public function plugin_uninstall_results_page(plugin_manager $pluginman, plugininfo_base $pluginfo, progress_trace_buffer $progress) { + $output = ''; + + $pluginname = $pluginman->plugin_name($pluginfo->component); + + $output .= $this->output->header(); + $output .= $this->output->heading(get_string('uninstalling', 'core_plugin', array('name' => $pluginname))); + + $output .= $this->output->box($progress->get_buffer(), 'generalbox uninstallresultmessage'); + + $output .= $this->output->box(get_string('uninstalldelete', 'core_plugin', + array('name' => $pluginname, 'rootdir' => $pluginfo->rootdir)), 'generalbox uninstalldelete'); + $output .= $this->output->continue_button($this->page->url); + $output .= $this->output->footer(); + + return $output; + } + /** * Display the plugin management page (admin/environment.php). * @param array $versions @@ -1065,22 +1147,23 @@ class core_admin_renderer extends plugin_renderer_base { get_string('actions', 'core_plugin'), get_string('notes','core_plugin'), ); + $table->headspan = array(1, 1, 1, 1, 2, 1); $table->colclasses = array( - 'pluginname', 'source', 'version', 'availability', 'actions', 'notes' + 'pluginname', 'source', 'version', 'availability', 'settings', 'uninstall', 'notes' ); foreach ($plugininfo as $type => $plugins) { $header = new html_table_cell($pluginman->plugintype_name_plural($type)); $header->header = true; - $header->colspan = count($table->head); + $header->colspan = array_sum($table->headspan); $header = new html_table_row(array($header)); $header->attributes['class'] = 'plugintypeheader type-' . $type; $table->data[] = $header; if (empty($plugins)) { $msg = new html_table_cell(get_string('noneinstalled', 'core_plugin')); - $msg->colspan = count($table->head); + $msg->colspan = array_sum($table->headspan); $row = new html_table_row(array($msg)); $row->attributes['class'] .= 'msg msg-noneinstalled'; $table->data[] = $row; @@ -1127,19 +1210,21 @@ class core_admin_renderer extends plugin_renderer_base { $availability = new html_table_cell(get_string('plugindisabled', 'core_plugin')); } - $actions = array(); - $settingsurl = $plugin->get_settings_url(); if (!is_null($settingsurl)) { - $actions[] = html_writer::link($settingsurl, get_string('settings', 'core_plugin'), array('class' => 'settings')); + $settings = html_writer::link($settingsurl, get_string('settings', 'core_plugin'), array('class' => 'settings')); + } else { + $settings = ''; } + $settings = new html_table_cell($settings); - $uninstallurl = $plugin->get_uninstall_url(); - if (!is_null($uninstallurl)) { - $actions[] = html_writer::link($uninstallurl, get_string('uninstall', 'core_plugin'), array('class' => 'uninstall')); + if ($pluginman->can_uninstall_plugin($plugin->component)) { + $uninstallurl = $plugin->get_uninstall_url(); + $uninstall = html_writer::link($uninstallurl, get_string('uninstall', 'core_plugin')); + } else { + $uninstall = ''; } - - $actions = new html_table_cell(implode(html_writer::tag('span', ' ', array('class' => 'separator')), $actions)); + $uninstall = new html_table_cell($uninstall); $requriedby = $pluginman->other_plugins_that_require($plugin->component); if ($requriedby) { @@ -1159,7 +1244,7 @@ class core_admin_renderer extends plugin_renderer_base { $notes = new html_table_cell($requiredby.$updateinfo); $row->cells = array( - $pluginname, $source, $version, $availability, $actions, $notes + $pluginname, $source, $version, $availability, $settings, $uninstall, $notes ); $table->data[] = $row; } diff --git a/lang/en/plugin.php b/lang/en/plugin.php index 18c5b7f5d24..69754cd022d 100644 --- a/lang/en/plugin.php +++ b/lang/en/plugin.php @@ -147,6 +147,11 @@ $string['updatepluginconfirminfo'] = 'You are about to install a new version of $string['updatepluginconfirmexternal'] = 'It appears that the current version of the plugin has been obtained via source code management system ({$a}) checkout. If you install this update, you will no longer be able to obtain plugin updates from the source code management system. Please ensure that you definitely want to update the plugin before continuing.'; $string['updatepluginconfirmwarning'] = 'Please note that Moodle will not automatically make a backup of your database before the upgrade. We strongly recommend that you make a full snapshot backup now, to cope with the rare case that the new code has bugs that make your site unavailable or even corrupts your database. Proceed at your own risk.'; $string['uninstall'] = 'Uninstall'; +$string['uninstallconfirm'] = 'You are about to uninstall the plugin {$a->name}. This will completely delete everything in the database associated with this plugin, including its configuration, log records, user files managed by the plugin etc. There is no way back and Moodle itself does not create any recovery backup. Are you SURE you want to continue?'; +$string['uninstalldelete'] = 'All data associated with the plugin {$a->name} has been deleted from the database. To prevent the plugin re-installing itself, its folder {$a->rootdir} must be manually removed from your server now. Moodle itself cannot remove the folder due to write permissions.'; +$string['uninstalldeleteconfirm'] = 'All data associated with the plugin {$a->name} has been deleted from the database. To prevent the plugin re-installing itself, its folder {$a->rootdir} must be removed from your server. Do you want to remove the plugin folder now?'; +$string['uninstalldeleteconfirmexternal'] = 'It appears that the current version of the plugin has been obtained via source code management system ({$a}) checkout. If you remove the plugin folder, you may loose important local modifications of the code. Please ensure that you definitely want to remove the plugin folder before continuing.'; +$string['uninstalling'] = 'Uninstalling {$a->name}'; $string['version'] = 'Version'; $string['versiondb'] = 'Current version'; $string['versiondisk'] = 'New version'; diff --git a/lib/adminlib.php b/lib/adminlib.php index c6e35799986..be26db438df 100644 --- a/lib/adminlib.php +++ b/lib/adminlib.php @@ -321,6 +321,13 @@ function uninstall_plugin($type, $name) { // remove event handlers and dequeue pending events events_uninstall($component); + // Delete all remaining files in the filepool owned by the component. + $fs = get_file_storage(); + $fs->delete_component_files($component); + + // Finally purge all caches. + purge_all_caches(); + echo $OUTPUT->notification(get_string('success'), 'notifysuccess'); } @@ -6083,8 +6090,7 @@ class admin_setting_manageformats extends admin_setting { if (parent::is_related($query)) { return true; } - $allplugins = plugin_manager::instance()->get_plugins(); - $formats = $allplugins['format']; + $formats = plugin_manager::instance()->get_plugins_of_type('format'); foreach ($formats as $format) { if (strpos($format->component, $query) !== false || strpos(textlib::strtolower($format->displayname), $query) !== false) { @@ -6107,8 +6113,7 @@ class admin_setting_manageformats extends admin_setting { $return = $OUTPUT->heading(new lang_string('courseformats'), 3, 'main'); $return .= $OUTPUT->box_start('generalbox formatsui'); - $allplugins = plugin_manager::instance()->get_plugins(); - $formats = $allplugins['format']; + $formats = plugin_manager::instance()->get_plugins_of_type('format'); // display strings $txt = get_strings(array('settings', 'name', 'enable', 'disable', 'up', 'down', 'default', 'delete')); diff --git a/lib/editor/tinymce/adminlib.php b/lib/editor/tinymce/adminlib.php index 36eb494e803..6b4b23242e8 100644 --- a/lib/editor/tinymce/adminlib.php +++ b/lib/editor/tinymce/adminlib.php @@ -35,6 +35,11 @@ require_once("$CFG->libdir/pluginlib.php"); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugininfo_tinymce extends plugininfo_base { + + public function is_uninstall_allowed() { + return true; + } + public function get_uninstall_url() { return new moodle_url('/lib/editor/tinymce/subplugins.php', array('delete' => $this->name, 'sesskey' => sesskey())); } diff --git a/lib/editor/tinymce/settings.php b/lib/editor/tinymce/settings.php index 1e8d3f4a004..6fbe2671bd8 100644 --- a/lib/editor/tinymce/settings.php +++ b/lib/editor/tinymce/settings.php @@ -48,8 +48,7 @@ $ADMIN->add('editortinymce', $settings); unset($settings); require_once("$CFG->libdir/pluginlib.php"); -$allplugins = plugin_manager::instance()->get_plugins(); -foreach ($allplugins['tinymce'] as $plugin) { +foreach (plugin_manager::instance()->get_plugins_of_type('tinymce') as $plugin) { $plugin->load_settings($ADMIN, 'editortinymce', $hassiteconfig); } diff --git a/lib/filestorage/file_storage.php b/lib/filestorage/file_storage.php index de8be240dfe..ee29b75f6a1 100644 --- a/lib/filestorage/file_storage.php +++ b/lib/filestorage/file_storage.php @@ -743,6 +743,21 @@ class file_storage { $filerecords->close(); } + /** + * Delete all files associated with the given component. + * + * @param string $component the component owning the file + */ + public function delete_component_files($component) { + global $DB; + + $filerecords = $DB->get_recordset('files', array('component' => $component)); + foreach ($filerecords as $filerecord) { + $this->get_file_instance($filerecord)->delete(); + } + $filerecords->close(); + } + /** * Move all the files in a file area from one context to another. * @@ -2000,6 +2015,7 @@ class file_storage { */ public function cron() { global $CFG, $DB; + require_once($CFG->libdir.'/cronlib.php'); // find out all stale draft areas (older than 4 days) and purge them // those are identified by time stamp of the /. root dir diff --git a/lib/filestorage/tests/file_storage_test.php b/lib/filestorage/tests/file_storage_test.php index 38e0eb1f59f..b895e578bb5 100644 --- a/lib/filestorage/tests/file_storage_test.php +++ b/lib/filestorage/tests/file_storage_test.php @@ -688,6 +688,17 @@ class filestoragelib_testcase extends advanced_testcase { $this->assertEquals(0, count($areafiles)); } + public function test_delete_component_files() { + $user = $this->setup_three_private_files(); + $fs = get_file_storage(); + + $areafiles = $fs->get_area_files($user->ctxid, 'user', 'private'); + $this->assertEquals(4, count($areafiles)); + $fs->delete_component_files('user'); + $areafiles = $fs->get_area_files($user->ctxid, 'user', 'private'); + $this->assertEquals(0, count($areafiles)); + } + public function test_create_file_from_url() { $this->resetAfterTest(true); diff --git a/lib/pluginlib.php b/lib/pluginlib.php index d1aabf7328f..12970aa8863 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -107,6 +107,27 @@ class plugin_manager { return $this->reorder_plugin_types(get_plugin_types($fullpaths)); } + /** + * Returns list of known plugins of the given type + * + * This method returns the subset of the tree returned by {@link self::get_plugins()}. + * If the given type is not known, empty array is returned. + * + * @param string $type plugin type, e.g. 'mod' or 'workshopallocation' + * @param bool $disablecache force reload, cache can be used otherwise + * @return array (string)plugin name (e.g. 'workshop') => corresponding subclass of {@link plugininfo_base} + */ + public function get_plugins_of_type($type, $disablecache=false) { + + $plugins = $this->get_plugins($disablecache); + + if (!isset($plugins[$type])) { + return array(); + } + + return $plugins[$type]; + } + /** * Returns a tree of known plugins and information about them * @@ -161,6 +182,41 @@ class plugin_manager { return $this->pluginsinfo; } + /** + * Returns list of all known subplugins of the given plugin + * + * For plugins that do not provide subplugins (i.e. there is no support for it), + * empty array is returned. + * + * @param string $component full component name, e.g. 'mod_workshop' + * @param bool $disablecache force reload, cache can be used otherwise + * @return array (string) component name (e.g. 'workshopallocation_random') => subclass of {@link plugininfo_base} + */ + public function get_subplugins_of_plugin($component, $disablecache=false) { + + $pluginfo = $this->get_plugin_info($component, $disablecache); + + if (is_null($pluginfo)) { + return array(); + } + + $subplugins = $this->get_subplugins($disablecache); + + if (!isset($subplugins[$pluginfo->component])) { + return array(); + } + + $list = array(); + + foreach ($subplugins[$pluginfo->component] as $subdata) { + foreach ($this->get_plugins_of_type($subdata->type) as $subpluginfo) { + $list[$subpluginfo->component] = $subpluginfo; + } + } + + return $list; + } + /** * Returns list of plugins that define their subplugins and the information * about them from the db/subplugins.php file. @@ -221,12 +277,18 @@ class plugin_manager { /** * Returns a localized name of a given plugin * - * @param string $plugin name of the plugin, eg mod_workshop or auth_ldap + * @param string $component name of the plugin, eg mod_workshop or auth_ldap * @return string */ - public function plugin_name($plugin) { - list($type, $name) = normalize_component($plugin); - return $this->pluginsinfo[$type][$name]->displayname; + public function plugin_name($component) { + + $pluginfo = $this->get_plugin_info($component); + + if (is_null($pluginfo)) { + throw new moodle_exception('err_unknown_plugin', 'core_plugin', '', array('plugin' => $component)); + } + + return $pluginfo->displayname; } /** @@ -288,12 +350,15 @@ class plugin_manager { } /** + * Returns information about the known plugin, or null + * * @param string $component frankenstyle component name. + * @param bool $disablecache force reload, cache can be used otherwise * @return plugininfo_base|null the corresponding plugin information. */ - public function get_plugin_info($component) { - list($type, $name) = normalize_component($component); - $plugins = $this->get_plugins(); + public function get_plugin_info($component, $disablecache=false) { + list($type, $name) = $this->normalize_component($component); + $plugins = $this->get_plugins($disablecache); if (isset($plugins[$type][$name])) { return $plugins[$type][$name]; } else { @@ -301,6 +366,38 @@ class plugin_manager { } } + /** + * Check to see if the current version of the plugin seems to be a checkout of an external repository. + * + * @see available_update_deployer::plugin_external_source() + * @param string $component frankenstyle component name + * @return false|string + */ + public function plugin_external_source($component) { + + $plugininfo = $this->get_plugin_info($component); + + if (is_null($plugininfo)) { + return false; + } + + $pluginroot = $plugininfo->rootdir; + + if (is_dir($pluginroot.'/.git')) { + return 'git'; + } + + if (is_dir($pluginroot.'/CVS')) { + return 'cvs'; + } + + if (is_dir($pluginroot.'/.svn')) { + return 'svn'; + } + + return false; + } + /** * Get a list of any other plugins that require this one. * @param string $component frankenstyle component name. @@ -371,6 +468,94 @@ class plugin_manager { return $return; } + /** + * Is it possible to uninstall the given plugin? + * + * False is returned if the plugininfo subclass declares the uninstall should + * not be allowed via {@link plugininfo_base::is_uninstall_allowed()} or if the + * core vetoes it (e.g. becase the plugin or some of its subplugins is required + * by some other installed plugin). + * + * @param string $component full frankenstyle name, e.g. mod_foobar + * @return bool + */ + public function can_uninstall_plugin($component) { + + $pluginfo = $this->get_plugin_info($component); + + if (is_null($pluginfo)) { + return false; + } + + if (!$this->common_uninstall_check($pluginfo)) { + return false; + } + + // If it has subplugins, check they can be uninstalled too. + $subplugins = $this->get_subplugins_of_plugin($pluginfo->component); + foreach ($subplugins as $subpluginfo) { + if (!$this->common_uninstall_check($subpluginfo)) { + return false; + } + // Check if there are some other plugins requiring this subplugin + // (but the parent and siblings). + foreach ($this->other_plugins_that_require($subpluginfo->component) as $requiresme) { + $ismyparent = ($pluginfo->component === $requiresme); + $ismysibling = in_array($requiresme, array_keys($subplugins)); + if (!$ismyparent and !$ismysibling) { + return false; + } + } + } + + // Check if there are some other plugins requiring this plugin + // (but its subplugins). + foreach ($this->other_plugins_that_require($pluginfo->component) as $requiresme) { + $ismysubplugin = in_array($requiresme, array_keys($subplugins)); + if (!$ismysubplugin) { + return false; + } + } + + return true; + } + + /** + * Uninstall the given plugin. + * + * Automatically cleans-up all remaining configuration data, log records, events, + * files from the file pool etc. + * + * In the future, the functionality of {@link uninstall_plugin()} function may be moved + * into this method and all the code should be refactored to use it. At the moment, we + * mimic this future behaviour by wrapping that function call. + * + * @param string $component + * @param progress_trace $progress traces the process + * @return bool true on success, false on errors/problems + */ + public function uninstall_plugin($component, progress_trace $progress) { + + $pluginfo = $this->get_plugin_info($component); + + if (is_null($pluginfo)) { + return false; + } + + // Give the pluginfo class a chance to execute some steps. + $result = $pluginfo->uninstall($progress); + if (!$result) { + return false; + } + + // Call the legacy core function to uninstall the plugin. + ob_start(); + uninstall_plugin($pluginfo->type, $pluginfo->name); + $progress->output(ob_get_clean()); + + return true; + } + /** * Checks if there are some plugins with a known available update * @@ -388,6 +573,29 @@ class plugin_manager { return false; } + /** + * Check to see if the given plugin folder can be removed by the web server process. + * + * @param string $component full frankenstyle component + * @return bool + */ + public function is_plugin_folder_removable($component) { + + $pluginfo = $this->get_plugin_info($component); + + if (is_null($pluginfo)) { + return false; + } + + // To be able to remove the plugin folder, its parent must be writable, too. + if (!is_writable(dirname($pluginfo->rootdir))) { + return false; + } + + // Check that the folder and all its content is writable (thence removable). + return $this->is_directory_removable($pluginfo->rootdir); + } + /** * Defines a list of all plugins that were originally shipped in the standard Moodle distribution, * but are not anymore and are deleted during upgrades. @@ -640,6 +848,18 @@ class plugin_manager { } } + /** + * Wrapper for the core function {@link normalize_component()}. + * + * This is here just to make it possible to mock it in unit tests. + * + * @param string $component + * @return array + */ + protected function normalize_component($component) { + return normalize_component($component); + } + /** * Reorders plugin types into a sequence to be displayed * @@ -670,6 +890,73 @@ class plugin_manager { } return $fix; } + + /** + * Check if the given directory can be removed by the web server process. + * + * This recursively checks that the given directory and all its contents + * it writable. + * + * @param string $fullpath + * @return boolean + */ + protected function is_directory_removable($fullpath) { + + if (!is_writable($fullpath)) { + return false; + } + + if (is_dir($fullpath)) { + $handle = opendir($fullpath); + } else { + return false; + } + + $result = true; + + while ($filename = readdir($handle)) { + + if ($filename === '.' or $filename === '..') { + continue; + } + + $subfilepath = $fullpath.'/'.$filename; + + if (is_dir($subfilepath)) { + $result = $result && $this->is_directory_removable($subfilepath); + + } else { + $result = $result && is_writable($subfilepath); + } + } + + closedir($handle); + + return $result; + } + + /** + * Helper method that implements common uninstall prerequisities + * + * @param plugininfo_base $pluginfo + * @return bool + */ + protected function common_uninstall_check(plugininfo_base $pluginfo) { + + if (!$pluginfo->is_uninstall_allowed()) { + // The plugin's plugininfo class declares it should not be uninstalled. + return false; + } + + if (is_null($pluginfo->get_uninstall_url())) { + // Backwards compatibility. + debugging('plugininfo_base subclasses should use is_uninstall_allowed() instead of returning null in get_uninstall_url()', + DEBUG_DEVELOPER); + return false; + } + + return true; + } } @@ -1662,6 +1949,7 @@ class available_update_deployer { /** * Check to see if the current version of the plugin seems to be a checkout of an external repository. * + * @see plugin_manager::plugin_external_source() * @param available_update_info $info * @return false|string */ @@ -2287,6 +2575,24 @@ abstract class plugininfo_base { return $this->dependencies; } + /** + * Is this is a subplugin? + * + * @return boolean + */ + public function is_subplugin() { + return ($this->get_parent_plugin() !== false); + } + + /** + * If I am a subplugin, return the name of my parent plugin. + * + * @return string|bool false if not a subplugin, name of the parent otherwise + */ + public function get_parent_plugin() { + return $this->get_plugin_manager()->get_parent_of_subplugin($this->type); + } + /** * Sets {@link $versiondb} property to a numerical value representing the * currently installed version of the plugin. @@ -2496,19 +2802,43 @@ abstract class plugininfo_base { public function load_settings(part_of_admin_tree $adminroot, $parentnodename, $hassiteconfig) { } + /** + * Should there be a way to uninstall the plugin via the administration UI + * + * By default, uninstallation is allowed for all non-standard add-ons. Subclasses + * may want to override this to allow uninstallation of all plugins (simply by + * returning true unconditionally). Subplugins follow their parent plugin's + * decision by default. + * + * Note that even if true is returned, the core may still prohibit the uninstallation, + * e.g. in case there are other plugins that depend on this one. + * + * @return boolean + */ + public function is_uninstall_allowed() { + + if ($this->is_subplugin()) { + return $this->get_plugin_manager()->get_plugin_info($this->get_parent_plugin())->is_uninstall_allowed(); + } + + if ($this->is_standard()) { + return false; + } + + return true; + } + /** * Returns the URL of the screen where this plugin can be uninstalled * * Visiting that URL must be safe, that is a manual confirmation is needed - * for actual uninstallation of the plugin. Null value means that the - * plugin either does not support uninstallation, or does not require any - * database cleanup or the location of the screen is not available via this - * library. + * for actual uninstallation of the plugin. By default, URL to a common + * uninstalling tool is returned. * - * @return null|moodle_url + * @return moodle_url */ public function get_uninstall_url() { - return null; + return $this->get_default_uninstall_url(); } /** @@ -2522,6 +2852,36 @@ abstract class plugininfo_base { return substr($this->rootdir, strlen($CFG->dirroot)); } + /** + * Hook method to implement certain steps when uninstalling the plugin. + * + * This hook is called by {@link plugin_manager::uninstall_plugin()} so + * it is basically usable only for those plugin types that use the default + * uninstall tool provided by {@link self::get_default_uninstall_url()}. + * + * @param progress_trace $progress traces the process + * @return bool true on success, false on failure + */ + public function uninstall(progress_trace $progress) { + return true; + } + + /** + * Returns URL to a script that handles common plugin uninstall procedure. + * + * This URL is suitable for plugins that do not have their own UI + * for uninstalling. + * + * @return moodle_url + */ + protected final function get_default_uninstall_url() { + return new moodle_url('/admin/plugins.php', array( + 'sesskey' => sesskey(), + 'uninstall' => $this->component, + 'confirm' => 0, + )); + } + /** * Provides access to plugin versions from the {config_plugins} table * @@ -2552,6 +2912,15 @@ abstract class plugininfo_base { return false; } } + + /** + * Provides access to the plugin_manager singleton. + * + * @return plugin_manmager + */ + protected function get_plugin_manager() { + return plugin_manager::instance(); + } } @@ -2678,8 +3047,11 @@ class plugininfo_block extends plugininfo_base { } } - public function get_uninstall_url() { + public function is_uninstall_allowed() { + return true; + } + public function get_uninstall_url() { $blocksinfo = self::get_blocks_info(); return new moodle_url('/admin/blocks.php', array('delete' => $blocksinfo[$this->name]->id, 'sesskey' => sesskey())); } @@ -2814,6 +3186,10 @@ class plugininfo_filter extends plugininfo_base { } } + public function is_uninstall_allowed() { + return true; + } + public function get_uninstall_url() { return new moodle_url('/admin/filters.php', array('sesskey' => sesskey(), 'filterpath' => $this->name, 'action' => 'delete')); } @@ -2997,15 +3373,24 @@ class plugininfo_mod extends plugininfo_base { } } - public function get_uninstall_url() { + /** + * Allow all activity modules but Forum to be uninstalled. - if ($this->name !== 'forum') { - return new moodle_url('/admin/modules.php', array('delete' => $this->name, 'sesskey' => sesskey())); + * This exception for the Forum has been hard-coded in Moodle since ages, + * we may want to re-think it one day. + */ + public function is_uninstall_allowed() { + if ($this->name === 'forum') { + return false; } else { - return null; + return true; } } + public function get_uninstall_url() { + return new moodle_url('/admin/modules.php', array('delete' => $this->name, 'sesskey' => sesskey())); + } + /** * Provides access to the records in {modules} table * @@ -3039,6 +3424,10 @@ class plugininfo_mod extends plugininfo_base { */ class plugininfo_qbehaviour extends plugininfo_base { + public function is_uninstall_allowed() { + return true; + } + public function get_uninstall_url() { return new moodle_url('/admin/qbehaviours.php', array('delete' => $this->name, 'sesskey' => sesskey())); @@ -3051,6 +3440,10 @@ class plugininfo_qbehaviour extends plugininfo_base { */ class plugininfo_qtype extends plugininfo_base { + public function is_uninstall_allowed() { + return true; + } + public function get_uninstall_url() { return new moodle_url('/admin/qtypes.php', array('delete' => $this->name, 'sesskey' => sesskey())); @@ -3175,6 +3568,10 @@ class plugininfo_enrol extends plugininfo_base { } } + public function is_uninstall_allowed() { + return true; + } + public function get_uninstall_url() { return new moodle_url('/admin/enrol.php', array('action' => 'uninstall', 'enrol' => $this->name, 'sesskey' => sesskey())); } @@ -3225,16 +3622,21 @@ class plugininfo_message extends plugininfo_base { } } + public function is_uninstall_allowed() { + $processors = get_message_processors(); + if (isset($processors[$this->name])) { + return true; + } else { + return false; + } + } + /** * @see plugintype_interface::get_uninstall_url() */ public function get_uninstall_url() { $processors = get_message_processors(); - if (isset($processors[$this->name])) { - return new moodle_url('/admin/message.php', array('uninstall' => $processors[$this->name]->id, 'sesskey' => sesskey())); - } else { - return parent::get_uninstall_url(); - } + return new moodle_url('/admin/message.php', array('uninstall' => $processors[$this->name]->id, 'sesskey' => sesskey())); } } @@ -3379,6 +3781,10 @@ class plugininfo_mnetservice extends plugininfo_base { */ class plugininfo_tool extends plugininfo_base { + public function is_uninstall_allowed() { + return true; + } + public function get_uninstall_url() { return new moodle_url('/admin/tools.php', array('delete' => $this->name, 'sesskey' => sesskey())); } @@ -3390,6 +3796,10 @@ class plugininfo_tool extends plugininfo_base { */ class plugininfo_report extends plugininfo_base { + public function is_uninstall_allowed() { + return true; + } + public function get_uninstall_url() { return new moodle_url('/admin/reports.php', array('delete' => $this->name, 'sesskey' => sesskey())); } @@ -3513,6 +3923,10 @@ class plugininfo_webservice extends plugininfo_base { return false; } + public function is_uninstall_allowed() { + return true; + } + public function get_uninstall_url() { return new moodle_url('/admin/webservice/protocols.php', array('sesskey' => sesskey(), 'action' => 'uninstall', 'webservice' => $this->name)); @@ -3568,11 +3982,16 @@ class plugininfo_format extends plugininfo_base { return !get_config($this->component, 'disabled'); } - public function get_uninstall_url() { + public function is_uninstall_allowed() { if ($this->name !== get_config('moodlecourse', 'format') && $this->name !== 'site') { - return new moodle_url('/admin/courseformats.php', - array('sesskey' => sesskey(), 'action' => 'uninstall', 'format' => $this->name)); + return true; + } else { + return false; } - return parent::get_uninstall_url(); + } + + public function get_uninstall_url() { + return new moodle_url('/admin/courseformats.php', + array('sesskey' => sesskey(), 'action' => 'uninstall', 'format' => $this->name)); } } diff --git a/lib/tests/fixtures/mockplugins/mod/bar/version.php b/lib/tests/fixtures/mockplugins/mod/bar/version.php new file mode 100644 index 00000000000..47613f65a4c --- /dev/null +++ b/lib/tests/fixtures/mockplugins/mod/bar/version.php @@ -0,0 +1,4 @@ +version = 2012030500; +$module->requires = 2012010100; diff --git a/lib/tests/fixtures/mockplugins/mod/baz/meg/one/version.php b/lib/tests/fixtures/mockplugins/mod/baz/meg/one/version.php new file mode 100644 index 00000000000..b099a779bbf --- /dev/null +++ b/lib/tests/fixtures/mockplugins/mod/baz/meg/one/version.php @@ -0,0 +1,5 @@ +version = 2013041103; +$plugin->requires = 2013010100; +$plugin->component = 'bazmeg_one'; diff --git a/lib/tests/fixtures/mockplugins/mod/baz/version.php b/lib/tests/fixtures/mockplugins/mod/baz/version.php new file mode 100644 index 00000000000..47613f65a4c --- /dev/null +++ b/lib/tests/fixtures/mockplugins/mod/baz/version.php @@ -0,0 +1,4 @@ +version = 2012030500; +$module->requires = 2012010100; diff --git a/lib/tests/fixtures/mockplugins/mod/foo/lish/frog/version.php b/lib/tests/fixtures/mockplugins/mod/foo/lish/frog/version.php new file mode 100644 index 00000000000..b5dd0eeba04 --- /dev/null +++ b/lib/tests/fixtures/mockplugins/mod/foo/lish/frog/version.php @@ -0,0 +1,6 @@ +version = 2013041103; +$plugin->requires = 2013010100; +$plugin->component = 'foolish_frog'; +$plugin->dependencies = array('mod_foo' => 2012030500); diff --git a/lib/tests/fixtures/mockplugins/mod/foo/lish/hippo/version.php b/lib/tests/fixtures/mockplugins/mod/foo/lish/hippo/version.php new file mode 100644 index 00000000000..f64b02d59ff --- /dev/null +++ b/lib/tests/fixtures/mockplugins/mod/foo/lish/hippo/version.php @@ -0,0 +1,6 @@ +version = 2013041103; +$plugin->requires = 2012010100; +$plugin->component = 'foolish_hippo'; +$plugin->dependencies = array('foolish_frog' => ANY_VERSION); diff --git a/lib/tests/fixtures/mockplugins/mod/foo/version.php b/lib/tests/fixtures/mockplugins/mod/foo/version.php new file mode 100644 index 00000000000..a12a76a40dc --- /dev/null +++ b/lib/tests/fixtures/mockplugins/mod/foo/version.php @@ -0,0 +1,10 @@ +version = 2012030500; +$module->requires = 2012010100; +$module->component = 'mod_foo'; +$module->dependencies = array( + 'mod_bar' => 2012030500, + 'mod_missing' => ANY_VERSION, + 'foolish_frog' => ANY_VERSION, +); diff --git a/lib/tests/fixtures/mockplugins/mod/qux/cat/one/version.php b/lib/tests/fixtures/mockplugins/mod/qux/cat/one/version.php new file mode 100644 index 00000000000..4c9e9781270 --- /dev/null +++ b/lib/tests/fixtures/mockplugins/mod/qux/cat/one/version.php @@ -0,0 +1,6 @@ +version = 2013041103; +$plugin->requires = 2013010100; +$plugin->component = 'quxcat_one'; +$plugin->dependencies = array('bazmeg_one' => 2013010100); diff --git a/lib/tests/fixtures/mockplugins/mod/qux/version.php b/lib/tests/fixtures/mockplugins/mod/qux/version.php new file mode 100644 index 00000000000..9c1c5f01a28 --- /dev/null +++ b/lib/tests/fixtures/mockplugins/mod/qux/version.php @@ -0,0 +1,5 @@ +version = 2013041103; +$plugin->requires = 2013010100; +$plugin->component = 'mod_qux'; diff --git a/lib/tests/pluginlib_test.php b/lib/tests/pluginlib_test.php index 141191386a3..06abf584075 100644 --- a/lib/tests/pluginlib_test.php +++ b/lib/tests/pluginlib_test.php @@ -17,6 +17,10 @@ /** * Unit tests for the lib/pluginlib.php library * + * Execute the core_plugin group to run all tests in this file: + * + * $ phpunit --group core_plugin + * * @package core * @category phpunit * @copyright 2012 David Mudrak @@ -31,26 +35,190 @@ require_once($CFG->libdir.'/pluginlib.php'); /** * Tests of the basic API of the plugin manager + * + * @group core_plugin */ class plugin_manager_test extends advanced_testcase { + public function setUp() { + $this->resetAfterTest(); + } + public function test_plugin_manager_instance() { $pluginman = testable_plugin_manager::instance(); $this->assertTrue($pluginman instanceof testable_plugin_manager); } + public function test_get_plugins_of_type() { + $pluginman = testable_plugin_manager::instance(); + $mods = $pluginman->get_plugins_of_type('mod'); + $this->assertEquals('array', gettype($mods)); + $this->assertEquals(4, count($mods)); + $this->assertTrue($mods['foo'] instanceof testable_plugininfo_mod); + $this->assertTrue($mods['bar'] instanceof testable_plugininfo_mod); + $this->assertTrue($mods['baz'] instanceof testable_plugininfo_mod); + $this->assertTrue($mods['qux'] instanceof testable_plugininfo_mod); + $foolishes = $pluginman->get_plugins_of_type('foolish'); + $this->assertEquals(2, count($foolishes)); + $this->assertTrue($foolishes['frog'] instanceof testable_pluginfo_foolish); + $this->assertTrue($foolishes['hippo'] instanceof testable_pluginfo_foolish); + $bazmegs = $pluginman->get_plugins_of_type('bazmeg'); + $this->assertEquals(1, count($bazmegs)); + $this->assertTrue($bazmegs['one'] instanceof testable_pluginfo_bazmeg); + $quxcats = $pluginman->get_plugins_of_type('quxcat'); + $this->assertEquals(1, count($quxcats)); + $this->assertTrue($quxcats['one'] instanceof testable_pluginfo_quxcat); + $unknown = $pluginman->get_plugins_of_type('muhehe'); + $this->assertSame(array(), $unknown); + } + public function test_get_plugins() { $pluginman = testable_plugin_manager::instance(); $plugins = $pluginman->get_plugins(); + $this->assertEquals('array', gettype($plugins)); $this->assertTrue(isset($plugins['mod']['foo'])); + $this->assertTrue(isset($plugins['mod']['bar'])); + $this->assertTrue(isset($plugins['mod']['baz'])); + $this->assertTrue(isset($plugins['foolish']['frog'])); + $this->assertTrue(isset($plugins['foolish']['hippo'])); $this->assertTrue($plugins['mod']['foo'] instanceof testable_plugininfo_mod); + $this->assertTrue($plugins['mod']['bar'] instanceof testable_plugininfo_mod); + $this->assertTrue($plugins['mod']['baz'] instanceof testable_plugininfo_mod); + $this->assertTrue($plugins['foolish']['frog'] instanceof testable_pluginfo_foolish); + $this->assertTrue($plugins['foolish']['hippo'] instanceof testable_pluginfo_foolish); + $this->assertTrue($plugins['bazmeg']['one'] instanceof testable_pluginfo_bazmeg); + $this->assertTrue($plugins['quxcat']['one'] instanceof testable_pluginfo_quxcat); + } + + public function test_get_subplugins_of_plugin() { + $pluginman = testable_plugin_manager::instance(); + $this->assertSame(array(), $pluginman->get_subplugins_of_plugin('mod_missing')); + $this->assertSame(array(), $pluginman->get_subplugins_of_plugin('mod_bar')); + $foosubs = $pluginman->get_subplugins_of_plugin('mod_foo'); + $this->assertEquals('array', gettype($foosubs)); + $this->assertEquals(2, count($foosubs)); + $this->assertTrue($foosubs['foolish_frog'] instanceof testable_pluginfo_foolish); + $this->assertTrue($foosubs['foolish_hippo'] instanceof testable_pluginfo_foolish); + $bazsubs = $pluginman->get_subplugins_of_plugin('mod_baz'); + $this->assertEquals('array', gettype($bazsubs)); + $this->assertEquals(1, count($bazsubs)); + $this->assertTrue($bazsubs['bazmeg_one'] instanceof testable_pluginfo_bazmeg); + $quxsubs = $pluginman->get_subplugins_of_plugin('mod_qux'); + $this->assertEquals('array', gettype($quxsubs)); + $this->assertEquals(1, count($quxsubs)); + $this->assertTrue($quxsubs['quxcat_one'] instanceof testable_pluginfo_quxcat); + } + + public function test_get_subplugins() { + $pluginman = testable_plugin_manager::instance(); + $subplugins = $pluginman->get_subplugins(); + $this->assertTrue(isset($subplugins['mod_foo']['foolish'])); + $this->assertTrue(isset($subplugins['mod_baz']['bazmeg'])); + $this->assertTrue(isset($subplugins['mod_qux']['quxcat'])); + } + + public function test_get_parent_of_subplugin() { + $pluginman = testable_plugin_manager::instance(); + $this->assertEquals('mod_foo', $pluginman->get_parent_of_subplugin('foolish')); + $this->assertEquals('mod_baz', $pluginman->get_parent_of_subplugin('bazmeg')); + $this->assertEquals('mod_qux', $pluginman->get_parent_of_subplugin('quxcat')); + $this->assertSame(false, $pluginman->get_parent_of_subplugin('mod')); + $this->assertSame(false, $pluginman->get_parent_of_subplugin('unknown')); + $plugins = $pluginman->get_plugins(); + $this->assertFalse($plugins['mod']['foo']->is_subplugin()); + $this->assertSame(false, $plugins['mod']['foo']->get_parent_plugin()); + $this->assertTrue($plugins['foolish']['frog']->is_subplugin()); + $this->assertEquals('mod_foo', $plugins['foolish']['frog']->get_parent_plugin()); + } + + public function test_plugin_name() { + $pluginman = testable_plugin_manager::instance(); + $this->assertEquals('Foo', $pluginman->plugin_name('mod_foo')); + $this->assertEquals('Bar', $pluginman->plugin_name('mod_bar')); + $this->assertEquals('Frog', $pluginman->plugin_name('foolish_frog')); + $this->assertEquals('Hippo', $pluginman->plugin_name('foolish_hippo')); + $this->assertEquals('One', $pluginman->plugin_name('bazmeg_one')); + $this->assertEquals('One', $pluginman->plugin_name('quxcat_one')); + } + + public function test_get_plugin_info() { + $pluginman = testable_plugin_manager::instance(); + $this->assertTrue($pluginman->get_plugin_info('mod_foo') instanceof testable_plugininfo_mod); + $this->assertTrue($pluginman->get_plugin_info('foolish_frog') instanceof testable_pluginfo_foolish); + } + + public function test_other_plugins_that_require() { + $pluginman = testable_plugin_manager::instance(); + $this->assertEquals(array('foolish_frog'), $pluginman->other_plugins_that_require('mod_foo')); + $this->assertEquals(2, count($pluginman->other_plugins_that_require('foolish_frog'))); + $this->assertTrue(in_array('foolish_hippo', $pluginman->other_plugins_that_require('foolish_frog'))); + $this->assertTrue(in_array('mod_foo', $pluginman->other_plugins_that_require('foolish_frog'))); + $this->assertEquals(array(), $pluginman->other_plugins_that_require('foolish_hippo')); + $this->assertEquals(array('mod_foo'), $pluginman->other_plugins_that_require('mod_bar')); + $this->assertEquals(array('mod_foo'), $pluginman->other_plugins_that_require('mod_missing')); + $this->assertEquals(array('quxcat_one'), $pluginman->other_plugins_that_require('bazmeg_one')); + } + + public function test_are_dependencies_satisfied() { + $pluginman = testable_plugin_manager::instance(); + $this->assertTrue($pluginman->are_dependencies_satisfied(array())); + $this->assertTrue($pluginman->are_dependencies_satisfied(array( + 'mod_bar' => 2012030500, + ))); + $this->assertTrue($pluginman->are_dependencies_satisfied(array( + 'mod_bar' => ANY_VERSION, + ))); + $this->assertFalse($pluginman->are_dependencies_satisfied(array( + 'mod_bar' => 2099010000, + ))); + $this->assertFalse($pluginman->are_dependencies_satisfied(array( + 'mod_bar' => 2012030500, + 'mod_missing' => ANY_VERSION, + ))); + } + + public function test_all_plugins_ok() { + $pluginman = testable_plugin_manager::instance(); + $failedplugins = array(); + $this->assertFalse($pluginman->all_plugins_ok(2013010100, $failedplugins)); + $this->assertTrue(in_array('mod_foo', $failedplugins)); // Requires mod_missing + $this->assertFalse(in_array('mod_bar', $failedplugins)); + $this->assertFalse(in_array('foolish_frog', $failedplugins)); + $this->assertFalse(in_array('foolish_hippo', $failedplugins)); + + $failedplugins = array(); + $this->assertFalse($pluginman->all_plugins_ok(2012010100, $failedplugins)); + $this->assertTrue(in_array('mod_foo', $failedplugins)); // Requires mod_missing + $this->assertFalse(in_array('mod_bar', $failedplugins)); + $this->assertTrue(in_array('foolish_frog', $failedplugins)); // Requires Moodle 2013010100 + $this->assertFalse(in_array('foolish_hippo', $failedplugins)); + + $failedplugins = array(); + $this->assertFalse($pluginman->all_plugins_ok(2011010100, $failedplugins)); + $this->assertTrue(in_array('mod_foo', $failedplugins)); // Requires mod_missing and Moodle 2012010100 + $this->assertTrue(in_array('mod_bar', $failedplugins)); // Requires Moodle 2012010100 + $this->assertTrue(in_array('foolish_frog', $failedplugins)); // Requires Moodle 2013010100 + $this->assertTrue(in_array('foolish_hippo', $failedplugins)); // Requires Moodle 2012010100 + } + + public function test_some_plugins_updatable() { + $pluginman = testable_plugin_manager::instance(); + $this->assertTrue($pluginman->some_plugins_updatable()); // We have available update for mod_foo. + } + + public function test_is_standard() { + $pluginman = testable_plugin_manager::instance(); + $this->assertTrue($pluginman->get_plugin_info('mod_bar')->is_standard()); + $this->assertFalse($pluginman->get_plugin_info('mod_foo')->is_standard()); + $this->assertFalse($pluginman->get_plugin_info('foolish_frog')->is_standard()); } public function test_get_status() { $pluginman = testable_plugin_manager::instance(); $plugins = $pluginman->get_plugins(); - $modfoo = $plugins['mod']['foo']; - $this->assertEquals($modfoo->get_status(), plugin_manager::PLUGIN_STATUS_UPGRADE); + $this->assertEquals(plugin_manager::PLUGIN_STATUS_UPGRADE, $plugins['mod']['foo']->get_status()); + $this->assertEquals(plugin_manager::PLUGIN_STATUS_NEW, $plugins['bazmeg']['one']->get_status()); + $this->assertEquals(plugin_manager::PLUGIN_STATUS_UPTODATE, $plugins['quxcat']['one']->get_status()); } public function test_available_update() { @@ -62,11 +230,35 @@ class plugin_manager_test extends advanced_testcase { $this->assertInstanceOf('available_update_info', $availableupdate); } } + + public function test_can_uninstall_plugin() { + $pluginman = testable_plugin_manager::instance(); + $this->assertFalse($pluginman->can_uninstall_plugin('mod_missing')); + $this->assertTrue($pluginman->can_uninstall_plugin('mod_foo')); // Because mod_foo is required by foolish_frog only + // and foolish_frog is required by mod_foo and foolish_hippo only. + $this->assertFalse($pluginman->can_uninstall_plugin('mod_bar')); // Because mod_bar is required by mod_foo. + $this->assertFalse($pluginman->can_uninstall_plugin('mod_qux')); // Because even if no plugin (not even subplugins) declare + // dependency on it, but its subplugin can't be uninstalled. + $this->assertFalse($pluginman->can_uninstall_plugin('mod_baz')); // Because it's subplugin bazmeg_one is required by quxcat_one. + $this->assertFalse($pluginman->can_uninstall_plugin('quxcat_one')); // Because of testable_pluginfo_quxcat::is_uninstall_allowed(). + $this->assertFalse($pluginman->can_uninstall_plugin('foolish_frog')); // Because foolish_hippo requires it. + } + + public function test_get_uninstall_url() { + $pluginman = testable_plugin_manager::instance(); + foreach ($pluginman->get_plugins() as $plugintype => $plugininfos) { + foreach ($plugininfos as $plugininfo) { + $this->assertTrue($plugininfo->get_uninstall_url() instanceof moodle_url); + } + } + } } /** * Tests of the basic API of the available update checker + * + * @group core_plugin */ class available_update_checker_test extends advanced_testcase { @@ -325,6 +517,17 @@ class available_update_checker_test extends advanced_testcase { } +/** + * Base class for testable plugininfo classes. + */ +class testable_plugininfo_base extends plugininfo_base { + + protected function get_plugin_manager() { + return testable_plugin_manager::instance(); + } +} + + /** * Modified {@link plugininfo_mod} suitable for testing purposes */ @@ -334,20 +537,86 @@ class testable_plugininfo_mod extends plugininfo_mod { $this->displayname = ucfirst($this->name); } - public function load_disk_version() { - $this->versiondisk = 2012030500; - } - - protected function load_version_php($disablecache=false) { - return (object)array( - 'version' => 2012030500, - 'requires' => 2012010100, - 'component' => $this->type.'_'.$this->name); + public function is_standard() { + if ($this->component === 'mod_foo') { + return false; + } else { + return true; + } } public function load_db_version() { $this->versiondb = 2012022900; } + + public function is_uninstall_allowed() { + return true; // Allow uninstall for standard plugins too. + } + + protected function get_plugin_manager() { + return testable_plugin_manager::instance(); + } +} + + +/** + * Testable class representing subplugins of testable mod_foo + */ +class testable_pluginfo_foolish extends testable_plugininfo_base { + + public function init_display_name() { + $this->displayname = ucfirst($this->name); + } + + public function is_standard() { + return false; + } + + public function load_db_version() { + $this->versiondb = 2012022900; + } +} + + +/** + * Testable class representing subplugins of testable mod_baz + */ +class testable_pluginfo_bazmeg extends testable_plugininfo_base { + + public function init_display_name() { + $this->displayname = ucfirst($this->name); + } + + public function is_standard() { + return false; + } + + public function load_db_version() { + $this->versiondb = null; + } +} + + +/** + * Testable class representing subplugins of testable mod_qux + */ +class testable_pluginfo_quxcat extends testable_plugininfo_base { + + public function init_display_name() { + $this->displayname = ucfirst($this->name); + } + + public function is_standard() { + return false; + } + + public function load_db_version() { + $this->versiondb = 2013041103; + } + + public function is_uninstall_allowed() { + return false; + } } @@ -378,23 +647,113 @@ class testable_plugin_manager extends plugin_manager { * @return array */ public function get_plugins($disablecache=false) { - global $CFG; + + $dirroot = dirname(__FILE__).'/fixtures/mockplugins'; $this->pluginsinfo = array( 'mod' => array( - 'foo' => plugininfo_default_factory::make('mod', $CFG->dirroot.'/mod', 'foo', - $CFG->dirroot.'/mod/foo', 'testable_plugininfo_mod'), - 'bar' => plugininfo_default_factory::make('mod', $CFG->dirroot.'/bar', 'bar', - $CFG->dirroot.'/mod/bar', 'testable_plugininfo_mod'), - ) + 'foo' => plugininfo_default_factory::make('mod', $dirroot.'/mod', 'foo', + $dirroot.'/mod/foo', 'testable_plugininfo_mod'), + 'bar' => plugininfo_default_factory::make('mod', $dirroot.'/bar', 'bar', + $dirroot.'/mod/bar', 'testable_plugininfo_mod'), + 'baz' => plugininfo_default_factory::make('mod', $dirroot.'/baz', 'baz', + $dirroot.'/mod/baz', 'testable_plugininfo_mod'), + 'qux' => plugininfo_default_factory::make('mod', $dirroot.'/qux', 'qux', + $dirroot.'/mod/qux', 'testable_plugininfo_mod'), + ), + 'foolish' => array( + 'frog' => plugininfo_default_factory::make('foolish', $dirroot.'/mod/foo/lish', 'frog', + $dirroot.'/mod/foo/lish/frog', 'testable_pluginfo_foolish'), + 'hippo' => plugininfo_default_factory::make('foolish', $dirroot.'/mod/foo/lish', 'hippo', + $dirroot.'/mod/foo/lish/hippo', 'testable_pluginfo_foolish'), + ), + 'bazmeg' => array( + 'one' => plugininfo_default_factory::make('bazmeg', $dirroot.'/mod/baz/meg', 'one', + $dirroot.'/mod/baz/meg/one', 'testable_pluginfo_bazmeg'), + ), + 'quxcat' => array( + 'one' => plugininfo_default_factory::make('quxcat', $dirroot.'/mod/qux/cat', 'one', + $dirroot.'/mod/qux/cat/one', 'testable_pluginfo_quxcat'), + ), ); $checker = testable_available_update_checker::instance(); $this->pluginsinfo['mod']['foo']->check_available_updates($checker); $this->pluginsinfo['mod']['bar']->check_available_updates($checker); + $this->pluginsinfo['mod']['baz']->check_available_updates($checker); + $this->pluginsinfo['bazmeg']['one']->check_available_updates($checker); + $this->pluginsinfo['quxcat']['one']->check_available_updates($checker); return $this->pluginsinfo; } + + /** + * Testable version of {@link plugin_manager::get_subplugins()} that works with + * the simulated environment. + * + * In this case, the mod_foo fake module provides subplugins of type 'foolish', + * mod_baz provides subplugins of type 'bazmeg' and mod_qux has 'quxcat'. + * + * @param bool $disablecache ignored in this class + * @return array + */ + public function get_subplugins($disablecache=false) { + + $this->subpluginsinfo = array( + 'mod_foo' => array( + 'foolish' => (object)array( + 'type' => 'foolish', + 'typerootdir' => 'mod/foo/lish', + ), + ), + 'mod_baz' => array( + 'bazmeg' => (object)array( + 'type' => 'bazmeg', + 'typerootdir' => 'mod/baz/meg', + ), + ), + 'mod_qux' => array( + 'quxcat' => (object)array( + 'type' => 'quxcat', + 'typerootdir' => 'mod/qux/cat', + ), + ), + ); + + return $this->subpluginsinfo; + } + + /** + * Adds support for mock plugin types. + */ + protected function normalize_component($component) { + + // List of mock plugin types used in these unit tests. + $faketypes = array('foolish', 'bazmeg', 'quxcat'); + + foreach ($faketypes as $faketype) { + if (strpos($component, $faketype.'_') === 0) { + return explode('_', $component, 2); + } + } + + return parent::normalize_component($component); + } + + public function plugintype_name($type) { + return ucfirst($type); + } + + public function plugintype_name_plural($type) { + return ucfirst($type).'s'; // Simple, isn't it? ;-) + } + + public function plugin_external_source($component) { + if ($component === 'foolish_frog') { + return true; + } + return false; + } } @@ -555,6 +914,8 @@ class testable_available_update_deployer extends available_update_deployer { /** * Test cases for {@link available_update_deployer} class + * + * @group core_plugin */ class available_update_deployer_test extends advanced_testcase { diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 02c84c653ee..19db0251d3a 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -52,6 +52,15 @@ information provided here is intended especially for developers. * Additional (optional) param $onlyactive has been added to get_enrolled_users, count_enrolled_users functions to get information for only active (excluding suspended enrolments) users. Included two helper functions extract_suspended_users, get_suspended_userids to extract suspended user information. +* The plugin_manager class now provides two new helper methods for getting information + about known plugins: get_plugins_of_type() and get_subplugins_of_plugin(). +* The get_uninstall_url() method of all subclasses of plugininfo_base class is now expected + to always return moodle_url. Subclasses can use the new method is_uninstall_allowed() + to control the availability of the 'Uninstall' link at the Plugins overview page (previously + they would do it by get_uninstall_url() returning null). By default, URL to a new general plugin + uninstall tool is returned. Unless the plugin type needs extra steps that can't be handled by + plugininfo_xxx::uninstall() method or xmldb_xxx_uninstall() function, this default URL should + satisfy all plugin types. Database (DML) layer: * $DB->sql_empty() is deprecated, you have to use sql parameters with empty values instead, diff --git a/theme/base/style/admin.css b/theme/base/style/admin.css index a43b6e5d42a..979f14179ce 100644 --- a/theme/base/style/admin.css +++ b/theme/base/style/admin.css @@ -152,6 +152,8 @@ #page-admin-index .updateplugin .updatepluginconfirmexternal, #page-admin-plugins .updateplugin .updatepluginconfirmexternal {padding:1em;background-color:#ffd3d9;border:1px solid #EEAAAA} +#page-admin-plugins .uninstalldeleteconfirmexternal {margin:1em auto;padding:1em;background-color:#ffd3d9;border:1px solid #EEAAAA} + #page-admin-user-user_bulk #users .fgroup {white-space: nowrap;} #page-admin-report-stats-index .graph {text-align: center;margin-bottom: 1em;} #page-admin-report-courseoverview-index .graph {text-align: center;margin-bottom: 1em;} @@ -292,8 +294,6 @@ #page-admin-plugins #plugins-control-panel .pluginname .componentname {font-size:0.8em;color:#999;margin-left:26px;} #page-admin-plugins #plugins-control-panel .missingfromdisk .pluginname {background-color:#ffd3d9;} #page-admin-plugins #plugins-control-panel .disabled .availability {background-color:#eee;} -#page-admin-plugins #plugins-control-panel .actions a {padding:0 10px;} -#page-admin-plugins #plugins-control-panel .actions .separator {border-left:1px dotted #999;} #page-admin-plugins #plugins-control-panel .extension .source {background-color:#f3f2aa;} #page-admin-plugins #plugins-control-panel .msg td {text-align:center;} #page-admin-plugins #plugins-control-panel .requiredby {font-size:0.8em;color:#999;} @@ -306,7 +306,7 @@ #page-admin-plugins #plugins-overview-filter .filter-item.active {font-weight:bold;} #page-admin-plugins #plugins-overview-filter .separator {border-left:1px dotted #999;} #page-admin-plugins #plugins-control-panel .displayname img.icon { padding-top:0; padding-bottom: 0; } -#page-admin-plugins #plugins-control-panel .actions .uninstall {color:#900;} +#page-admin-plugins #plugins-control-panel .uninstall a {color:#900;} #page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo {padding:5px 10px;margin:10px;background-color:#d2ebff;-moz-border-radius: 10px;-webkit-border-radius: 10px;border-radius: 10px;} #page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo.maturity50 {background-color:#ffd3d9;} #page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo.maturity100, diff --git a/theme/bootstrap/less/moodle/admin.less b/theme/bootstrap/less/moodle/admin.less index 80a4bfd4a4c..abef8f2963d 100644 --- a/theme/bootstrap/less/moodle/admin.less +++ b/theme/bootstrap/less/moodle/admin.less @@ -601,8 +601,7 @@ img.iconsmall { } #plugins-overview-filter .filter-item, -#plugins-overview-panel .info, -#plugins-control-panel .actions a { +#plugins-overview-panel .info { padding: 0 10px; } @@ -638,7 +637,7 @@ img.iconsmall { padding-bottom: 0; } -#plugins-control-panel .actions .uninstall { +#plugins-control-panel .uninstall a { color: @errorText; }