MDL-59562 upgrade: Plugins can declare supported moodle versions
author[Peter Burnett] <[peterburnett@catalyst-au.net]>
Wed, 25 Sep 2019 05:51:21 +0000 (15:51 +1000)
committerPeter Burnett <peterburnett@catalyst-au.net>
Wed, 15 Jan 2020 04:38:27 +0000 (14:38 +1000)
* Plugins can now explicitly declare supported and incompatible Moodle
  versions in version.php
  - $plugin->supported[37,39];
    supported takes an array of ascending numbers, that correspond to a
    range of branch numbers of supported versions, inclusive. Moodle
    versions that are outside of this range will produce a message
    notifying at install time, but will allow for installation.

  - $plugin->incompatible = 36;
    incompatible takes a single int corresponding to the first incompatible
    branch. Any Moodle versions including and below this will be prevented
    from installing the plugin, and a message will be given when
    attempting installation.

13 files changed:
admin/cli/upgrade.php
admin/index.php
admin/renderer.php
lang/en/error.php
lang/en/plugin.php
lib/classes/plugin_manager.php
lib/classes/plugininfo/base.php
lib/tests/fixtures/testable_plugin_manager.php
lib/tests/fixtures/testable_plugininfo_base.php
lib/tests/plugin_manager_test.php
lib/tests/plugininfo/base_test.php [new file with mode: 0644]
lib/upgrade.txt
lib/upgradelib.php

index d827e6d..12904e5 100644 (file)
@@ -137,7 +137,7 @@ if (!$envstatus) {
 
 // Test plugin dependencies.
 $failed = array();
-if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
+if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
     cli_problem(get_string('pluginscheckfailed', 'admin', array('pluginslist' => implode(', ', array_unique($failed)))));
     cli_error(get_string('pluginschecktodo', 'admin'));
 }
index c34a437..c95c73c 100644 (file)
@@ -241,7 +241,7 @@ if (!core_tables_exist()) {
 
     // check plugin dependencies
     $failed = array();
-    if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
+    if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
         $PAGE->navbar->add(get_string('pluginscheck', 'admin'));
         $PAGE->set_title($strinstallation);
         $PAGE->set_heading($strinstallation . ' - Moodle ' . $CFG->target_release);
@@ -508,7 +508,7 @@ if (!$cache and $version > $CFG->version) {  // upgrade
     } else {
         // Always verify plugin dependencies!
         $failed = array();
-        if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed)) {
+        if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
             echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
             die();
         }
@@ -682,7 +682,7 @@ if (!$cache and moodle_needs_upgrading()) {
 
         // Make sure plugin dependencies are always checked.
         $failed = array();
-        if (!$pluginman->all_plugins_ok($version, $failed)) {
+        if (!$pluginman->all_plugins_ok($version, $failed, $CFG->branch)) {
             $output = $PAGE->get_renderer('core', 'admin');
             echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
             die();
index 5ba612e..4dbe8c0 100644 (file)
@@ -983,7 +983,7 @@ class core_admin_renderer extends plugin_renderer_base {
      * @return string HTML code
      */
     public function plugins_check_table(core_plugin_manager $pluginman, $version, array $options = array()) {
-
+        global $CFG;
         $plugininfo = $pluginman->get_plugins();
 
         if (empty($plugininfo)) {
@@ -1069,8 +1069,10 @@ class core_admin_renderer extends plugin_renderer_base {
                 }
 
                 $coredependency = $plugin->is_core_dependency_satisfied($version);
+                $incompatibledependency = $plugin->is_core_compatible_satisfied($CFG->branch);
+
                 $otherpluginsdependencies = $pluginman->are_dependencies_satisfied($plugin->get_other_required_plugins());
-                $dependenciesok = $coredependency && $otherpluginsdependencies;
+                $dependenciesok = $coredependency && $otherpluginsdependencies && $incompatibledependency;
 
                 $statuscode = $plugin->get_status();
                 $row->attributes['class'] .= ' status-' . $statuscode;
@@ -1120,8 +1122,11 @@ class core_admin_renderer extends plugin_renderer_base {
                 }
 
                 $status = new html_table_cell($sourcelabel.' '.$status);
-
-                $requires = new html_table_cell($this->required_column($plugin, $pluginman, $version));
+                if ($plugin->pluginsupported != null) {
+                    $requires = new html_table_cell($this->required_column($plugin, $pluginman, $version, $CFG->branch));
+                } else {
+                    $requires = new html_table_cell($this->required_column($plugin, $pluginman, $version));
+                }
 
                 $statusisboring = in_array($statuscode, array(
                         core_plugin_manager::PLUGIN_STATUS_NODB, core_plugin_manager::PLUGIN_STATUS_UPTODATE));
@@ -1452,15 +1457,17 @@ class core_admin_renderer extends plugin_renderer_base {
      * @param \core\plugininfo\base $plugin the plugin we are rendering the row for.
      * @param core_plugin_manager $pluginman provides data on all the plugins.
      * @param string $version
+     * @param int $branch the current Moodle branch
      * @return string HTML code
      */
-    protected function required_column(\core\plugininfo\base $plugin, core_plugin_manager $pluginman, $version) {
+    protected function required_column(\core\plugininfo\base $plugin, core_plugin_manager $pluginman, $version, $branch = null) {
 
         $requires = array();
         $displayuploadlink = false;
         $displayupdateslink = false;
 
-        foreach ($pluginman->resolve_requirements($plugin, $version) as $reqname => $reqinfo) {
+        $requirements = $pluginman->resolve_requirements($plugin, $version, $branch);
+        foreach ($requirements as $reqname => $reqinfo) {
             if ($reqname === 'core') {
                 if ($reqinfo->status == $pluginman::REQUIREMENT_STATUS_OK) {
                     $class = 'requires-ok';
@@ -1469,7 +1476,19 @@ class core_admin_renderer extends plugin_renderer_base {
                     $class = 'requires-failed';
                     $label = html_writer::span(get_string('dependencyfails', 'core_plugin'), 'badge badge-danger');
                 }
-                if ($reqinfo->reqver != ANY_VERSION) {
+
+                if ($branch != null && !$plugin->is_core_compatible_satisfied($branch)) {
+                    $requires[] = html_writer::tag('li',
+                    html_writer::span(get_string('incompatibleversion', 'core_plugin', $branch), 'dep dep-core').
+                    ' '.$label, array('class' => $class));
+
+                } else if ($branch != null && $plugin->pluginsupported != null) {
+                    $requires[] = html_writer::tag('li',
+                        html_writer::span(get_string('moodlebranch', 'core_plugin',
+                        array('min' => $plugin->pluginsupported[0], 'max' => $plugin->pluginsupported[1])), 'dep dep-core').
+                        ' '.$label, array('class' => $class));
+
+                } else if ($reqinfo->reqver != ANY_VERSION) {
                     $requires[] = html_writer::tag('li',
                         html_writer::span(get_string('moodleversion', 'core_plugin', $plugin->versionrequires), 'dep dep-core').
                         ' '.$label, array('class' => $class));
@@ -1556,6 +1575,13 @@ class core_admin_renderer extends plugin_renderer_base {
             );
         }
 
+        // Check if supports is present, and $branch is not in, only if $incompatible check was ok.
+        if ($plugin->pluginsupported != null && $class == 'requires-ok' && $branch != null) {
+            if ($pluginman->check_explicitly_supported($plugin, $branch) == $pluginman::VERSION_NOT_SUPPORTED) {
+                $out .= html_writer::div(get_string('notsupported', 'core_plugin', $branch));
+            }
+        }
+
         return $out;
 
     }
index ef92a53..61674dd 100644 (file)
@@ -467,6 +467,7 @@ $string['pagenotexist'] = 'An unusual error occurred (tried to reach a page that
 $string['pathdoesnotstartslash'] = 'No valid arguments supplied, path does not start with slash!';
 $string['pleasereport'] = 'If you have time, please let us know what you were trying to do when the error occurred:';
 $string['pluginrequirementsnotmet'] = 'Plugin "{$a->pluginname}" ({$a->pluginversion}) could not be installed.  It requires a newer version of Moodle (currently you are using {$a->currentmoodle}, you need {$a->requiremoodle}).';
+$string['pluginunsupported'] = 'Plugin "{$a->pluginname}" {$a->pluginversion} does not support this version of Moodle {$a->moodleversion}. Seek plugin information to find supported versions.';
 $string['prefixcannotbeempty'] = '<p>Error: database table prefix cannot be empty ({$a})</p>
 <p>The site administrator must fix this problem.</p>';
 $string['prefixtoolong'] = '<p>Error: database table prefix is too long ({$a->dbfamily})</p>
@@ -604,4 +605,3 @@ $string['xmldberror'] = 'XMLDB error!';
 $string['alreadyloggedin'] = 'You are already logged in as {$a}, you need to log out before logging in as different user.';
 $string['youcannotdeletecategory'] = 'You cannot delete category \'{$a}\' because you can neither delete the contents, nor move them elsewhere.';
 $string['protected_cc_not_supported'] = 'Protected cartridges not supported.';
-
index c0d4da1..94c5616 100644 (file)
@@ -54,6 +54,7 @@ $string['err_response_http_code'] = 'Unable to fetch available updates data - un
 $string['filterall'] = 'Show all';
 $string['filtercontribonly'] = 'Show additional plugins only';
 $string['filterupdatesonly'] = 'Show updateable only';
+$string['incompatibleversion'] = 'Incompatible Moodle version: {$a}';
 $string['isenabled'] = 'Enabled?';
 $string['misdepinfoplugin'] = 'Plugin info';
 $string['misdepinfoversion'] = 'Version info';
@@ -61,12 +62,14 @@ $string['misdepsavail'] = 'Available missing dependencies';
 $string['misdepsunavail'] = 'Unavailable missing dependencies';
 $string['misdepsunavaillist'] = 'No version found to fulfill the dependency requirements: {$a}.';
 $string['misdepsunknownlist'] = 'Not in the Plugins directory: <strong>{$a}</strong>.';
+$string['moodlebranch'] = 'Moodle {$a->min} - {$a->max}';
 $string['moodleversion'] = 'Moodle {$a}';
 $string['noneinstalled'] = 'No plugins of this type are installed';
 $string['notes'] = 'Notes';
 $string['notdownloadable'] = 'Can not download the package';
 $string['notdownloadable_help'] = 'ZIP package with the update can not be downloaded automatically. Please refer to the documentation page for more help.';
 $string['notdownloadable_link'] = 'admin/mdeploy/notdownloadable';
+$string['notsupported'] = 'Plugin may not be compatible with Moodle version {$a}';
 $string['notwritable'] = 'Plugin files not writable';
 $string['notwritable_help'] = 'Plugin files are not writable by the web server. The web server process must have write access to the plugin folder and all its contents. Write access to the root folder of the given plugin type may also be required.';
 $string['otherplugin'] = '{$a->component}';
index 33e4a28..c666c38 100644 (file)
@@ -59,12 +59,21 @@ class core_plugin_manager {
     const REQUIREMENT_STATUS_OUTDATED = 'outdated';
     /** the required dependency is not installed */
     const REQUIREMENT_STATUS_MISSING = 'missing';
+    /** the current Moodle version is too high for plugin. */
+    const REQUIREMENT_STATUS_NEWER = 'newer';
 
     /** the required dependency is available in the plugins directory */
     const REQUIREMENT_AVAILABLE = 'available';
     /** the required dependency is available in the plugins directory */
     const REQUIREMENT_UNAVAILABLE = 'unavailable';
 
+    /** the moodle version is explicitly supported */
+    const VERSION_SUPPORTED = 'supported';
+    /** the moodle version is not explicitly supported */
+    const VERSION_NOT_SUPPORTED = 'notsupported';
+    /** the plugin does not specify supports */
+    const VERSION_NO_SUPPORTS = 'nosupports';
+
     /** @var core_plugin_manager holds the singleton instance */
     protected static $singletoninstance;
     /** @var array of raw plugins information */
@@ -737,10 +746,14 @@ class core_plugin_manager {
      *
      * @param int $moodleversion the version from version.php.
      * @param array $failedplugins to return the list of plugins with non-satisfied dependencies
+     * @param int $branch the current moodle branch, null if not provided
      * @return bool true if all the dependencies are satisfied for all plugins.
      */
-    public function all_plugins_ok($moodleversion, &$failedplugins = array()) {
-
+    public function all_plugins_ok($moodleversion, &$failedplugins = array(), $branch = null) {
+        global $CFG;
+        if (empty($branch)) {
+            $branch = $CFG->branch;
+        }
         $return = true;
         foreach ($this->get_plugins() as $type => $plugins) {
             foreach ($plugins as $plugin) {
@@ -754,6 +767,11 @@ class core_plugin_manager {
                     $return = false;
                     $failedplugins[] = $plugin->component;
                 }
+
+                if (!$plugin->is_core_compatible_satisfied($branch)) {
+                    $return = false;
+                    $failedplugins[] = $plugin->component;
+                }
             }
         }
 
@@ -794,7 +812,7 @@ class core_plugin_manager {
         }
 
         $reqs = array();
-        $reqcore = $this->resolve_core_requirements($plugin, $moodleversion);
+        $reqcore = $this->resolve_core_requirements($plugin, $moodleversion, $moodlebranch);
 
         if (!empty($reqcore)) {
             $reqs['core'] = $reqcore;
@@ -814,7 +832,7 @@ class core_plugin_manager {
      * @param string|int|double $moodleversion moodle core branch to check against
      * @return stdObject
      */
-    protected function resolve_core_requirements(\core\plugininfo\base $plugin, $moodleversion) {
+    protected function resolve_core_requirements(\core\plugininfo\base $plugin, $moodleversion, $moodlebranch) {
 
         $reqs = (object)array(
             'hasver' => null,
@@ -822,7 +840,6 @@ class core_plugin_manager {
             'status' => null,
             'availability' => null,
         );
-
         $reqs->hasver = $moodleversion;
 
         if (empty($plugin->versionrequires)) {
@@ -837,6 +854,14 @@ class core_plugin_manager {
             $reqs->status = self::REQUIREMENT_STATUS_OUTDATED;
         }
 
+        // Now check if there is an explicit incompatible, supersedes requires.
+        if (isset($plugin->pluginincompatible) && $plugin->pluginincompatible != null) {
+            if (!$plugin->is_core_compatible_satisfied($moodlebranch)) {
+
+                $reqs->status = self::REQUIREMENT_STATUS_NEWER;
+            }
+        }
+
         return $reqs;
     }
 
@@ -890,6 +915,49 @@ class core_plugin_manager {
         return $reqs;
     }
 
+    /**
+     * Helper method to determine whether a moodle version is explicitly supported.
+     *
+     * @param \core\plugininfo\base $plugin the plugin we are checking
+     * @param int $branch the moodle branch to check support for
+     * @return bool
+     */
+    public function check_explicitly_supported($plugin, $branch) : bool {
+        // Check for correctly formed supported.
+        if (isset($plugin->pluginsupported)) {
+            // Broken apart for readability.
+            $error = false;
+            if (!is_array($plugin->pluginsupported)) {
+                $error = true;
+            }
+            if (!is_int($plugin->pluginsupported[0]) || !is_int($plugin->pluginsupported[1])) {
+                $error = true;
+            }
+            if (count($plugin->pluginsupported) != 2) {
+                $error = true;
+            }
+            if ($error) {
+                throw new coding_exception(get_string('err_supported_syntax', 'core_plugin'));
+            }
+        }
+
+        if (isset($plugin->pluginsupported) && $plugin->pluginsupported != null) {
+            if ($plugin->pluginsupported[0] <= $branch && $branch <= $plugin->pluginsupported[1]) {
+                return self::VERSION_SUPPORTED;
+            } else {
+                return self::VERSION_NOT_SUPPORTED;
+            }
+        } else {
+            // If supports aren't specified, but incompatible is, return not supported if not incompatible.
+            if (!isset($plugin->pluginsupported) && isset($plugin->pluginincompatible) && !empty($plugin->pluginincompatible)) {
+                if (!$plugin->is_core_compatible_satisfied($branch)) {
+                    return self::VERSION_NOT_SUPPORTED;
+                }
+            }
+            return self::VERSION_NO_SUPPORTS;
+        }
+    }
+
     /**
      * Is the given plugin version available in the plugins directory?
      *
index cb80f29..71e5a70 100644 (file)
@@ -53,6 +53,10 @@ abstract class base {
     public $versiondb;
     /** @var int|float|string required version of Moodle core  */
     public $versionrequires;
+    /** @var array explicitly supported branches of Moodle core  */
+    public $pluginsupported;
+    /** @var int first incompatible branch of Moodle core  */
+    public $pluginincompatible;
     /** @var mixed human-readable release information */
     public $release;
     /** @var array other plugins that this one depends on, lazy-loaded by {@link get_other_required_plugins()} */
@@ -218,6 +222,8 @@ abstract class base {
 
         $this->versiondisk = null;
         $this->versionrequires = null;
+        $this->pluginsupported = null;
+        $this->pluginincompatible = null;
         $this->dependencies = array();
 
         if (!isset($versions[$this->name])) {
@@ -238,6 +244,28 @@ abstract class base {
         if (isset($plugin->dependencies)) {
             $this->dependencies = $plugin->dependencies;
         }
+
+        // Check that supports and incompatible are wellformed, exception otherwise.
+        if (isset($plugin->supported)) {
+            // Checks for structure of supported.
+            $isint = (is_int($plugin->supported[0]) && is_int($plugin->supported[1]));
+            $isrange = ($plugin->supported[0] <= $plugin->supported[1] && count($plugin->supported) == 2);
+
+            if (is_array($plugin->supported) && $isint && $isrange) {
+                $this->pluginsupported = $plugin->supported;
+            } else {
+                throw new coding_exception('Incorrect syntax in $plugin->supported in '."$this->name");
+            }
+        }
+
+        if (isset($plugin->incompatible) && $plugin->incompatible !== null) {
+            if ((ctype_digit($plugin->incompatible) || is_int($plugin->incompatible)) && (int) $plugin->incompatible > 0) {
+                $this->pluginincompatible = intval($plugin->incompatible);
+            } else {
+                throw new coding_exception('Incorrect syntax in $plugin->incompatible in '."$this->name");
+            }
+        }
+
     }
 
     /**
@@ -341,6 +369,20 @@ abstract class base {
         }
     }
 
+    /**
+     * Returns true if the the given moodle branch is not stated incompatible with the plugin
+     *
+     * @param int $branch the moodle branch number
+     * @return bool true if not incompatible with moodle branch
+     */
+    public function is_core_compatible_satisfied(int $branch) : bool {
+        if (!empty($this->pluginincompatible) && ($branch >= $this->pluginincompatible)) {
+            return false;
+        } else {
+            return true;
+        }
+    }
+
     /**
      * Returns the status of the plugin
      *
index 4a298f9..507bd00 100644 (file)
@@ -109,4 +109,16 @@ class testable_core_plugin_manager extends core_plugin_manager {
 
         return null;
     }
+
+    /**
+     * Adds fake plugin information from record.
+     *
+     * @param stdClass $record
+     * @return void
+     */
+    public function add_fake_plugin_info($record): void {
+        $this->load_present_plugins();
+
+        $this->presentplugins[$record->type][$record->name] = $record;
+    }
 }
index 783d285..7976b02 100644 (file)
@@ -41,12 +41,6 @@ class testable_plugininfo_base extends \core\plugininfo\base {
         $this->displayname = 'Testable fake pluginfo instance';
     }
 
-    public function load_disk_version() {
-        $this->versiondisk = null;
-        $this->versionrequires = null;
-        $this->dependencies = array();
-    }
-
     public function load_db_version() {
         $this->versiondb = null;
     }
index cd4c505..009c57e 100644 (file)
@@ -458,6 +458,19 @@ class core_plugin_manager_testcase extends advanced_testcase {
             'testable_plugininfo_base', $pluginman);
         $pluginfo->versiondisk = null;
         $this->assertEmpty($pluginman->resolve_requirements($pluginfo, 2015110900, 30));
+
+        // Test plugin fails for incompatible version.
+        $pluginfo = testable_plugininfo_base::fake_plugin_instance('fake', '/dev/null', 'two', '/dev/null/fake',
+            'testable_plugininfo_base', $pluginman);
+        $pluginfo->versiondisk = 2015060600;
+        $pluginfo->pluginincompatible = 30;
+        $reqs = $pluginman->resolve_requirements($pluginfo, 2015110900, 30);
+        $this->assertEquals($pluginman::REQUIREMENT_STATUS_NEWER, $reqs['core']->status);
+
+        // Test no failure for no incompatible version.
+        $pluginfo->pluginincompatible = 30;
+        $reqs = $pluginman->resolve_requirements($pluginfo, 2015110900, 29);
+        $this->assertEquals($pluginman::REQUIREMENT_STATUS_OK, $reqs['core']->status);
     }
 
     public function test_missing_dependencies() {
@@ -486,4 +499,118 @@ class core_plugin_manager_testcase extends advanced_testcase {
         $this->assertInstanceOf('\core\update\remote_info', $misdeps['foo_bar']);
         $this->assertEquals(2015100500, $misdeps['foo_bar']->version->version);
     }
+
+    /**
+     * Tests for check_explicitly_supported function to ensure that versions are correctly reported.
+     *
+     * @dataProvider check_explicitly_supported_provider
+     * @param array|null $supported Supported versions to inject
+     * @param string|int|null $incompatible Incompatible version to inject.
+     * @param int $version Version to test
+     * @param int $expected
+     * @return void
+     */
+    public function test_explicitly_supported($supported, $incompatible, $version, $expected): void {
+        $pluginman = testable_core_plugin_manager::instance();
+
+        // Prepare a fake pluginfo instance.
+        $plugininfo = new testable_plugininfo_base();
+        $plugininfo->type = 'fake';
+        $plugininfo->typerootdir = '/dev/null';
+        $plugininfo->name = 'example';
+        $plugininfo->rootdir = '/dev/null/fake';
+        $plugininfo->pluginman = $pluginman;
+        $plugininfo->versiondisk = 2015060600;
+        $plugininfo->supported = $supported;
+        $plugininfo->incompatible = $incompatible;
+
+        $pluginman->add_fake_plugin_info($plugininfo);
+
+        $plugininfo->load_disk_version();
+
+        $this->assertEquals($expected, $pluginman->check_explicitly_supported($plugininfo, $version));
+    }
+
+    /**
+     * Data provider for check_explicitly_supported with a range of correctly defined version support values.
+     *
+     * @return array
+     */
+    public function check_explicitly_supported_provider(): array {
+        return [
+            'Range, branch in support, lowest' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 29,
+                'expected' => core_plugin_manager::VERSION_SUPPORTED,
+            ],
+            'Range, branch in support, mid' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 30,
+                'expected' => core_plugin_manager::VERSION_SUPPORTED,
+            ],
+            'Range, branch in support, highest' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 31,
+                'expected' => core_plugin_manager::VERSION_SUPPORTED,
+            ],
+
+            'Range, branch not in support, high' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 32,
+                'expected' => core_plugin_manager::VERSION_NOT_SUPPORTED,
+            ],
+            'Range, branch not in support, low' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 28,
+                'expected' => core_plugin_manager::VERSION_NOT_SUPPORTED,
+            ],
+            'Range, incompatible, high.' => [
+                'supported' => [29, 31],
+                'incompatible' => 32,
+                'version' => 33,
+                'expected' => core_plugin_manager::VERSION_NOT_SUPPORTED,
+            ],
+            'Range, incompatible, low.' => [
+                'supported' => [29, 31],
+                'incompatible' => 32,
+                'version' => 31,
+                'expected' => core_plugin_manager::VERSION_SUPPORTED,
+            ],
+            'Range, incompatible, equal.' => [
+                'supported' => [29, 31],
+                'incompatible' => 32,
+                'version' => 32,
+                'expected' => core_plugin_manager::VERSION_NOT_SUPPORTED,
+            ],
+            'No supports' => [
+                'supported' => null,
+                'incompatible' => null,
+                'version' => 32,
+                'expected' => core_plugin_manager::VERSION_NO_SUPPORTS,
+            ],
+            'No supports, but incompatible, older' => [
+                'supported' => null,
+                'incompatible' => 30,
+                'version' => 32,
+                'expected' => core_plugin_manager::VERSION_NOT_SUPPORTED,
+            ],
+            'No supports, but incompatible, equal' => [
+                'supported' => null,
+                'incompatible' => 32,
+                'version' => 32,
+                'expected' => core_plugin_manager::VERSION_NOT_SUPPORTED,
+            ],
+            'No supports, but incompatible, newer' => [
+                'supported' => null,
+                'incompatible' => 34,
+                'version' => 32,
+                'expected' => core_plugin_manager::VERSION_NO_SUPPORTS,
+            ],
+        ];
+    }
 }
diff --git a/lib/tests/plugininfo/base_test.php b/lib/tests/plugininfo/base_test.php
new file mode 100644 (file)
index 0000000..508c14d
--- /dev/null
@@ -0,0 +1,282 @@
+<?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/>.
+
+/**
+ * Unit tests for plugin base class.
+ *
+ * @package   core
+ * @copyright 2019 Andrew Nicols
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+namespace core\plugininfo;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_plugin_manager;
+use testable_core_plugin_manager;
+use testable_plugininfo_base;
+
+
+/**
+ * Tests of the basic API of the plugin manager.
+ */
+class base_testcase extends \advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setUpBeforeClass(): void {
+        global $CFG;
+
+        require_once($CFG->dirroot.'/lib/tests/fixtures/testable_plugin_manager.php');
+        require_once($CFG->dirroot.'/lib/tests/fixtures/testable_plugininfo_base.php');
+    }
+
+    /**
+     * Tear down the testable plugin manager singleton between tests.
+     */
+    public function tearDown() {
+        // The caches of the testable singleton must be reset explicitly. It is
+        // safer to kill the whole testable singleton at the end of every test.
+        testable_core_plugin_manager::reset_caches();
+    }
+
+    /**
+     * Test the load_disk_version function to check that it handles a variety of invalid supported fields.
+     *
+     * @dataProvider load_disk_version_invalid_supported_version_provider
+     * @param array|null $supported Supported versions to inject
+     * @param string|int|null $incompatible Incompatible version to inject.
+     * @param int $version Version to test
+     */
+    public function test_load_disk_version_invalid_supported_version($supported, $incompatible, $version): void {
+        $pluginman = testable_core_plugin_manager::instance();
+
+        // Prepare a fake plugininfo instance.
+        $plugininfo = new testable_plugininfo_base();
+        $plugininfo->type = 'fake';
+        $plugininfo->typerootdir = '/dev/null';
+        $plugininfo->name = 'example';
+        $plugininfo->rootdir = '/dev/null/fake';
+        $plugininfo->pluginman = $pluginman;
+        $plugininfo->versiondisk = 2015060600;
+        $plugininfo->supported = $supported;
+        $plugininfo->incompatible = $incompatible;
+
+        $pluginman->add_fake_plugin_info($plugininfo);
+
+        $this->expectException(\coding_exception::class);
+        $this->expectExceptionMessage('Incorrect syntax in $plugin->supported in example');
+        $plugininfo->load_disk_version();
+    }
+
+    /**
+     * Data provider for the load_disk_version tests for testing with invalid supported fields.
+     *
+     * @return array
+     */
+    public function load_disk_version_invalid_supported_version_provider(): array {
+        return [
+            'Invalid supported range.' => [
+                'supported' => [31, 29],
+                'incompatible' => null,
+                'version' => 32,
+            ],
+            'Explicit list, low' => [
+                'supported' => [29, 30, 31, 32],
+                'incompatible' => null,
+                'version' => 28,
+            ],
+            'Explicit list, high' => [
+                'supported' => [29, 30, 31, 32],
+                'incompatible' => null,
+                'version' => 33,
+            ],
+            'Explicit list, in list' => [
+                'supported' => [29, 30, 31, 32, 33],
+                'incompatible' => null,
+                'version' => 31,
+            ],
+            'Explicit list, missing value, unsupported' => [
+                'supported' => [29, 30, 32],
+                'incompatible' => null,
+                'version' => 31,
+            ],
+            'Explicit list, missing value, supported' => [
+                'supported' => [29, 30, 32],
+                'incompatible' => null,
+                'version' => 30,
+            ],
+        ];
+    }
+
+    /**
+     * Test the load_disk_version function to check that it handles a variety of invalid incompatible fields.
+     *
+     * @dataProvider load_disk_version_invalid_incompatible_version_provider
+     * @param mixed $incompatible
+     */
+    public function test_load_disk_version_invalid_incompatible_version($incompatible): void {
+        $pluginman = testable_core_plugin_manager::instance();
+
+        // Prepare a fake plugininfo instance.
+        $plugininfo = new testable_plugininfo_base();
+        $plugininfo->type = 'fake';
+        $plugininfo->typerootdir = '/dev/null';
+        $plugininfo->name = 'example';
+        $plugininfo->rootdir = '/dev/null/fake';
+        $plugininfo->pluginman = $pluginman;
+        $plugininfo->versiondisk = 2015060600;
+        $plugininfo->incompatible = $incompatible;
+
+        $pluginman->add_fake_plugin_info($plugininfo);
+
+        $this->expectException(\coding_exception::class);
+        $this->expectExceptionMessage('Incorrect syntax in $plugin->incompatible in example');
+        $plugininfo->load_disk_version();
+    }
+
+    /**
+     * Data provider for the load_disk_version tests for testing with invalid incompatible fields.
+     *
+     * @return array
+     */
+    public function load_disk_version_invalid_incompatible_version_provider(): array {
+        return [
+            [[38]],
+            [['38']],
+            [3.8],
+            ['3.8'],
+            [''],
+            ['somestring'],
+        ];
+
+    }
+
+    /**
+     * Test the load_disk_version function to check that it handles a range of correct supported and incompatible field
+     * definitions.
+     *
+     * @dataProvider test_load_disk_version_branch_supports_provider
+     * @param array|null $supported Supported versions to inject
+     * @param string|int|null $incompatible Incompatible version to inject.
+     * @param int $version Version to test
+     */
+    public function test_load_disk_version_branch_supports($supported, $incompatible, $version): void {
+        $pluginman = testable_core_plugin_manager::instance();
+
+        // Prepare a fake plugininfo instance.
+        $plugininfo = new testable_plugininfo_base();
+        $plugininfo->type = 'fake';
+        $plugininfo->typerootdir = '/dev/null';
+        $plugininfo->name = 'example';
+        $plugininfo->rootdir = '/dev/null/fake';
+        $plugininfo->pluginman = $pluginman;
+        $plugininfo->versiondisk = 2015060600;
+        $plugininfo->supported = $supported;
+        $plugininfo->incompatible = $incompatible;
+
+        $pluginman->add_fake_plugin_info($plugininfo);
+
+        $plugininfo->load_disk_version();
+
+        $this->assertEquals($supported, $plugininfo->supported);
+        $this->assertEquals($incompatible, $plugininfo->incompatible);
+    }
+
+    /**
+     * Test cases for tests of load_disk_version for testing the supported/incompatible fields.
+     *
+     * @return array
+     */
+    public function test_load_disk_version_branch_supports_provider(): array {
+        return [
+            'Range, branch in support, lowest' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 29,
+            ],
+            'Range, branch in support, mid' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 30,
+            ],
+            'Range, branch in support, highest' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 31,
+            ],
+
+            'Range, branch not in support, high' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 32,
+            ],
+            'Range, branch not in support, low' => [
+                'supported' => [29, 31],
+                'incompatible' => null,
+                'version' => 28,
+            ],
+            'Range, incompatible, high.' => [
+                'supported' => [29, 31],
+                'incompatible' => 32,
+                'version' => 33,
+            ],
+            'Range, incompatible, low.' => [
+                'supported' => [29, 31],
+                'incompatible' => 32,
+                'version' => 31,
+            ],
+            'Range, incompatible, equal.' => [
+                'supported' => [29, 31],
+                'incompatible' => 32,
+                'version' => 32,
+            ],
+            'No supports' => [
+                'supported' => null,
+                'incompatible' => null,
+                'version' => 32,
+            ],
+            'No supports, but incompatible, older' => [
+                'supported' => null,
+                'incompatible' => 30,
+                'version' => 32,
+            ],
+            'No supports, but incompatible, equal' => [
+                'supported' => null,
+                'incompatible' => 32,
+                'version' => 32,
+            ],
+            'No supports, but incompatible, newer' => [
+                'supported' => null,
+                'incompatible' => 34,
+                'version' => 32,
+            ],
+            'String incompatible' => [
+                'supported' => null,
+                'incompatible' => '34',
+                'version' => 32,
+            ],
+            'Empty incompatible' => [
+                'supported' => null,
+                'incompatible' => null,
+                'version' => 32,
+            ],
+        ];
+    }
+}
index 65cac74..5e6d472 100644 (file)
@@ -11,6 +11,13 @@ information provided here is intended especially for developers.
   which means auto-detecting number of decimal points.
 * plagiarism_save_form_elements() has been deprecated. Please use {plugin name}_coursemodule_edit_post_actions() instead.
 * plagiarism_get_form_elements_module() has been deprecated. Please use {plugin name}_coursemodule_standard_elements() instead.
+* Plugins can now explicitly declare supported and incompatible Moodle versions in version.php
+  - $plugin->supported = [37,39];
+    supported takes an array of ascending numbers, that correspond to a range of branch numbers of supported versions, inclusive.
+    Moodle versions that are outside of this range will produce a message notifying at install time, but will allow for installation.
+  - $plugin->incompatible = 36;
+    incompatible takes a single int corresponding to the first incompatible branch. Any Moodle versions including and
+    above this will be prevented from installing the plugin, and a message will be given when attempting installation.
 
 === 3.8 ===
 * Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop
index ee2e4da..a50ac01 100644 (file)
@@ -84,6 +84,32 @@ class upgrade_requires_exception extends moodle_exception {
     }
 }
 
+/**
+ * Exception thrown when attempting to install a plugin that declares incompatibility with moodle version
+ *
+ * @package    core
+ * @subpackage upgrade
+ * @copyright  2019 Peter Burnett <peterburnett@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class plugin_incompatible_exception extends moodle_exception {
+    /**
+     * Constructor function for exception
+     *
+     * @param \core\plugininfo\base $plugin The plugin causing the exception
+     * @param int $pluginversion The version of the plugin causing the exception
+     */
+    public function __construct($plugin, $pluginversion) {
+        global $CFG;
+        $a = new stdClass();
+        $a->pluginname      = $plugin;
+        $a->pluginversion   = $pluginversion;
+        $a->moodleversion   = $CFG->branch;
+
+        parent::__construct('pluginunsupported', 'error', "$CFG->wwwroot/$CFG->admin/index.php", $a);
+    }
+}
+
 /**
  * @package    core
  * @subpackage upgrade
@@ -579,6 +605,13 @@ function upgrade_plugins($type, $startcallback, $endcallback, $verbose) {
             }
         }
 
+        // Throw exception if plugin is incompatible with moodle version.
+        if (!empty($plugin->incompatible)) {
+            if ($CFG->branch <= $plugin->incompatible) {
+                throw new plugin_incompatible_exception($component, $plugin->version);
+            }
+        }
+
         // try to recover from interrupted install.php if needed
         if (file_exists($fullplug.'/db/install.php')) {
             if (get_config($plugin->fullname, 'installrunning')) {