diff --git a/admin/renderer.php b/admin/renderer.php index 78d4ebbaade..43760c59344 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -651,7 +651,7 @@ class core_admin_renderer extends plugin_renderer_base { if (!$plugin->is_standard()) { $numextension++; } - if ($plugin->available_update()) { + if ($plugin->available_updates()) { $numupdatable++; } } @@ -779,10 +779,11 @@ class core_admin_renderer extends plugin_renderer_base { $requiredby = ''; } - if ($updateinfo = $plugin->available_update()) { - $updateinfo = $this->plugin_available_update_info($updateinfo); - } else { - $updateinfo = ''; + $updateinfo = ''; + if (is_array($plugin->available_updates())) { + foreach ($plugin->available_updates() as $availableupdate) { + $updateinfo .= $this->plugin_available_update_info($availableupdate); + } } $notes = new html_table_cell($requiredby.$updateinfo); @@ -801,12 +802,11 @@ class core_admin_renderer extends plugin_renderer_base { * Helper method to render the information about the available update * * The passed objects always provides at least the 'version' property containing - * the (higher) version of the plugin available. Other properties may be provided, see - * the specification of the protocol used by {@link available_update_checker}. + * the (higher) version of the plugin available. * - * @param stdClass $updateinfo information about the available update for the plugin + * @param available_update_info $updateinfo information about the available update for the plugin */ - protected function plugin_available_update_info(stdClass $updateinfo) { + protected function plugin_available_update_info(available_update_info $updateinfo) { $box = $this->output->box_start('pluginupdateinfo'); $box .= html_writer::tag('div', get_string('updateavailable', 'core_plugin', $updateinfo->version), array('class' => 'version')); diff --git a/lib/pluginlib.php b/lib/pluginlib.php index d8a878defc1..7638392aa61 100644 --- a/lib/pluginlib.php +++ b/lib/pluginlib.php @@ -123,7 +123,7 @@ class plugin_manager { $provider = available_update_checker::instance(); foreach ($this->pluginsinfo as $plugintype => $plugins) { foreach ($plugins as $plugininfoholder) { - $plugininfoholder->check_available_update($provider); + $plugininfoholder->check_available_updates($provider); } } } @@ -572,20 +572,15 @@ class available_update_checker { /** @var available_update_checker holds the singleton instance */ protected static $singletoninstance; - /** @var null|string numerical version of the local Moodle site */ - protected $localversion = null; - /** @var null|string release signature of the local Moodle site */ - protected $localrelease = null; - /** @var array of (string)frankestyle => (string)version list of plugins installed at the local Moodle site */ - protected $localplugins = array(); - /** @var null|stdClass */ - protected $config = null; + /** @var null|int the timestamp of when the most recent response was fetched */ + protected $recentfetch = null; + /** @var null|array the recent response from the update notification provider */ + protected $recentresponse = null; /** * Direct initiation not allowed, use the factory method {@link self::instance()} */ protected function __construct() { - $this->load_config(); } /** @@ -658,10 +653,14 @@ class available_update_checker { * @return int|null null if it has never been executed or we don't known */ public function get_last_timefetched() { - if (isset($this->config->timelastfetched)) { - return $this->config->timelastfetched; + + $this->restore_response(); + + if (!empty($this->recentfetch)) { + return $this->recentfetch; + } else { - return false; + return null; } } @@ -671,126 +670,133 @@ class available_update_checker { * @throws available_update_checker_exception */ public function fetch() { - $response = $this->make_request(); + $response = $this->get_response(); $this->validate_response($response); - $this->process_response($response); + $this->store_response($response); } /** * Returns the available update information for the given component * * This method returns null if the most recent response does not contain any information - * about it. Note that this does not mean that the information is not provided by the - * remote site. The recent request might be specific + * about it. The returned structure is an array of available updates for the given + * component. Each update info is an object with at least one property called + * 'version'. Other possible properties are 'release', 'maturity', 'url' and 'downloadurl'. * * @param string $component frankenstyle - * @return null|stdClass null if the most recent response does not provide any info + * @return null|stdClass null or array of objects */ public function get_update_info($component) { - $branch = moodle_major_version(); + $this->restore_response(); - if (!empty($this->config->components->$branch->$component)) { - return $this->config->components->$branch->$component; + if (!empty($this->recentresponse['updates'][$component])) { + $updates = array(); + foreach ($this->recentresponse['updates'][$component] as $info) { + $updates[] = new available_update_info($component, $info); + } + return $updates; } else { return null; } } /** - * Executes cURL request to get data from the remote site + * Makes cURL request to get data from the remote site * - * @return stdClass request result + * @return string raw request result * @throws available_update_checker_exception */ - protected function make_request() { + protected function get_response() { $curl = new curl(array('proxy' => true)); $response = $curl->post($this->prepare_request_url(), $this->prepare_request_params()); $curlinfo = $curl->get_info(); if ($curlinfo['http_code'] != 200) { throw new available_update_checker_exception('err_response_http_code', $curlinfo['http_code']); } - $response = json_decode($response); return $response; } /** * Makes sure the response is valid, has correct API format etc. * - * @param stdClass $response + * @param string $response raw response as returned by the {@link self::get_response()} * @throws available_update_checker_exception */ - protected function validate_response(stdClass $response) { + protected function validate_response($response) { + + $response = $this->decode_response($response); if (empty($response)) { throw new available_update_checker_exception('err_response_empty'); } - if (empty($response->status) or $response->status !== 'OK') { - throw new available_update_checker_exception('err_response_status', $response->status); + if (empty($response['status']) or $response['status'] !== 'OK') { + throw new available_update_checker_exception('err_response_status', $response['status']); } - if (empty($response->apiver) or $response->apiver != '1.0') { - throw new available_update_checker_exception('err_response_format_version', $response->apiver); + if (empty($response['apiver']) or $response['apiver'] !== '1.0') { + throw new available_update_checker_exception('err_response_format_version', $response['apiver']); + } + + if (empty($response['forbranch']) or $response['forbranch'] !== moodle_major_version(true)) { + throw new available_update_checker_exception('err_response_target_version', $response['target']); } } /** - * Stores the fetched response for later usage + * Decodes the raw string response from the update notifications provider + * + * @param string $response as returned by {@link self::get_response()} + * @return array decoded response structure + */ + protected function decode_response($response) { + return json_decode($response, true); + } + + /** + * Stores the valid fetched response for later usage * * This implementation uses the config_plugins table as the permanent storage. * - * @param stdClass $response the data returned by the updates info provider + * @param string $response raw valid data returned by {@link self::get_response()} */ - protected function process_response(stdClass $response) { + protected function store_response($response) { - $components = $this->merge_components_info($this->config->components, $response->components, $response->timegenerated); + set_config('recentfetch', time(), 'core_plugin'); + set_config('recentresponse', $response, 'core_plugin'); - set_config('timelastfetched', time(), 'core_plugin'); - set_config('ticket', $response->ticket, 'core_plugin'); - set_config('components', json_encode($components), 'core_plugin'); - - $this->load_config(true); + $this->restore_response(true); } /** - * Merges the current and the new info about the available updates + * Loads the most recent raw response record we have fetched * - * @param stdClass $old - * @param stdClass $new - * @param int $timegenerated the timestamp of when the $new was generated - * @return stdClass merged + * This implementation uses the config_plugins table as the permanent storage. + * + * @param bool $forcereload reload even if it was already loaded */ - protected function merge_components_info(stdClass $old, stdClass $new, $timegenerated=null) { - $merged = clone($old); - if (is_null($timegenerated)) { - $timegenerated = time(); + protected function restore_response($forcereload = false) { + + if (!$forcereload and !is_null($this->recentresponse)) { + // we already have it, nothing to do + return; } - foreach ($new as $branch => $components) { - foreach ($components as $component => $info) { - $info->timegenerated = $timegenerated; - if (isset($info->version)) { - $merged->$branch->$component = $info; - } + + $config = get_config('core_plugin'); + + if (!empty($config->recentresponse) and !empty($config->recentfetch)) { + try { + $this->validate_response($config->recentresponse); + $this->recentfetch = $config->recentfetch; + $this->recentresponse = $this->decode_response($config->recentresponse); + } + catch (available_update_checker_exception $e) { + // do not set recentresponse if the validation fails } - } - return $merged; - } - /** - * Loads the core_plugin subsystem config - * - * @param bool $forcereload reload the config even if it was already loaded - */ - protected function load_config($forcereload = false) { - if ($forcereload or is_null($this->config)) { - $this->config = get_config('core_plugin'); - } - - if (empty($this->config->components)) { - $this->config->components = new stdClass(); } else { - $this->config->components = json_decode($this->config->components); + $this->recentresponse = array(); } } @@ -820,11 +826,13 @@ class available_update_checker { protected function prepare_request_params() { global $CFG; + $this->restore_response(); + $params = array(); $params['format'] = 'json'; - if (isset($this->config->ticket)) { - $params['ticket'] = $this->config->ticket; + if (isset($this->recentresponse['ticket'])) { + $params['ticket'] = $this->recentresponse['ticket']; } if (isset($this->localversion)) { @@ -841,6 +849,44 @@ class available_update_checker { } +/** + * Defines the structure of objects returned by {@link available_update_checker::get_update_info()} + */ +class available_update_info { + + /** @var string frankenstyle component name */ + public $component; + /** @var int the available version of the component */ + public $version; + /** @var string|null optional release name */ + public $release = null; + /** @var int|null optional maturity info, eg {@link MATURITY_STABLE} */ + public $maturity = null; + /** @var string|null optional URL of a page with more info about the update */ + public $url = null; + /** @var string|null optional URL of a ZIP package that can be downloaded and installed */ + public $download = null; + + /** + * Creates new instance of the class + * + * The $info array must provide at least the 'version' value and optionally all other + * values to populate the object's properties. + * + * @param string $name the frankenstyle component name + * @param array $info associative array with other properties + */ + public function __construct($name, array $info) { + $this->component = $name; + foreach ($info as $k => $v) { + if (property_exists('available_update_info', $k) and $k != 'component') { + $this->$k = $v; + } + } + } +} + + /** * Factory class producing required subclasses of {@link plugininfo_base} */ @@ -905,8 +951,8 @@ abstract class plugininfo_base { public $instances; /** @var int order of the plugin among other plugins of the same type - not supported yet */ public $sortorder; - /** @var null|stdClass holds the information about the remote available update for this plugin */ - public $availableupdate; + /** @var array|null array of {@link available_update_info} for this plugin */ + public $availableupdates; /** * Gathers and returns the information about all plugins of the given type @@ -1136,35 +1182,43 @@ abstract class plugininfo_base { } /** - * Populates the property {@link $availableupdate} with the information provided by + * Populates the property {@link $availableupdates} with the information provided by * available update checker * * @param available_update_checker $provider the class providing the available update info */ - public function check_available_update(available_update_checker $provider) { - $this->availableupdate = $provider->get_update_info($this->component); + public function check_available_updates(available_update_checker $provider) { + $this->availableupdates = $provider->get_update_info($this->component); } /** - * If there is an update of this plugin available, returns the data about it. + * If there are updates for this plugin available, returns them. * - * Returns object with various properties about the available update, if such - * an update is available. Returns false if there is no update available for - * this plugin. Returns null if the update availabitlity is unknown. + * Returns array of {@link available_update_info} objects, if some update + * is available. Returns null if there is no update available or if the update + * availability is unknown. * - * @return stdClass|false|null + * @return array|null */ - public function available_update() { + public function available_updates() { - if (empty($this->availableupdate)) { + if (empty($this->availableupdates) or !is_array($this->availableupdates)) { return null; } - if ($this->availableupdate->version > $this->versiondisk) { - return $this->availableupdate; + $updates = array(); + + foreach ($this->availableupdates as $availableupdate) { + if ($availableupdate->version > $this->versiondisk) { + $updates[] = $availableupdate; + } } - return false; + if (empty($updates)) { + return null; + } + + return $updates; } /** diff --git a/lib/simpletest/testpluginlib.php b/lib/simpletest/testpluginlib.php index 381f9307b8b..488df5d6d96 100644 --- a/lib/simpletest/testpluginlib.php +++ b/lib/simpletest/testpluginlib.php @@ -93,15 +93,12 @@ class testable_plugin_manager extends plugin_manager { $CFG->dirroot.'/mod/foo', 'testable_plugininfo_mod'), 'bar' => plugininfo_default_factory::make('mod', $CFG->dirroot.'/bar', 'bar', $CFG->dirroot.'/mod/bar', 'testable_plugininfo_mod'), - 'buz' => plugininfo_default_factory::make('mod', $CFG->dirroot.'/buz', 'buz', - $CFG->dirroot.'/mod/buz', 'testable_plugininfo_mod'), ) ); $checker = testable_available_update_checker::instance(); - $this->pluginsinfo['mod']['foo']->check_available_update($checker); - $this->pluginsinfo['mod']['bar']->check_available_update($checker); - $this->pluginsinfo['mod']['buz']->check_available_update($checker); + $this->pluginsinfo['mod']['foo']->check_available_updates($checker); + $this->pluginsinfo['mod']['bar']->check_available_updates($checker); return $this->pluginsinfo; } @@ -113,6 +110,9 @@ class testable_plugin_manager extends plugin_manager { */ class testable_available_update_checker extends available_update_checker { + /** @var replaces the default DB table storage for the fetched response */ + protected $responsestorage; + /** * Factory method for this class * @@ -127,45 +127,65 @@ class testable_available_update_checker extends available_update_checker { return self::$singletoninstance; } - /** - * Do not load config in this testable subclass - */ - protected function load_config() { + protected function validate_response() { } - /** - * Do not fetch anything in this testable subclass - */ - public function fetch() { + protected function store_response($response) { + $this->responsestorage = $response; } - /** - * Here we simulate read access to the fetched remote statuses - */ - public function get_update_info($component) { - if ($component === 'mod_foo') { - // no update available - return (object)array( - 'version' => 2012030500, - ); - - } else if ($component === 'mod_bar') { - // there is an update available - return (object)array( - 'version' => 2012030501, - ); - - } else { - // nothing known to us - return null; - } + protected function restore_response($forcereload = false) { + $this->recentfetch = time(); + $this->recentresponse = $this->decode_response($this->get_fake_response()); } - /** - * Makes the method public so we can test it - */ - public function merge_components_info(stdClass $old, stdClass $new, $timegenerated=null) { - return parent::merge_components_info($old, $new, $timegenerated); + private function get_fake_response() { + $fakeresponse = array( + 'status' => 'OK', + 'provider' => 'http://download.moodle.org/api/1.0/updates.php', + 'apiver' => '1.0', + 'timegenerated' => time(), + 'forversion' => '2012010100.00', + 'forbranch' => '2.3', + 'ticket' => sha1('No, I am not going to mention the word "frog" here. Oh crap. I just did.'), + 'updates' => array( + 'core' => array( + array( + 'version' => 2012060103.00, + 'release' => '2.3.3 (Build: 20121201)', + 'maturity' => 200, + 'url' => 'http://download.moodle.org/', + 'download' => 'http://download.moodle.org/download.php/MOODLE_23_STABLE/moodle-2.3.3-latest.zip', + ), + array( + 'version' => 2012120100.00, + 'release' => '2.4 (Build: 20121201)', + 'maturity' => 200, + 'url' => 'http://download.moodle.org/', + 'download' => 'http://download.moodle.org/download.php/MOODLE_24_STABLE/moodle-2.4.0-latest.zip', + ), + ), + 'mod_foo' => array( + array( + 'version' => 2012030501, + 'requires' => 2012010100, + 'maturity' => 200, + 'release' => '1.1', + 'url' => 'http://moodle.org/plugins/blahblahblah/', + 'download' => 'http://moodle.org/plugins/download.php/blahblahblah', + ), + array( + 'version' => 2012030502, + 'requires' => 2012010100, + 'maturity' => 100, + 'release' => '1.2 beta', + 'url' => 'http://moodle.org/plugins/', + ), + ), + ), + ); + + return json_encode($fakeresponse); } } @@ -197,10 +217,11 @@ class plugin_manager_test extends UnitTestCase { public function test_available_update() { $pluginman = testable_plugin_manager::instance(); $plugins = $pluginman->get_plugins(); - $this->assertFalse($plugins['mod']['foo']->available_update()); - $this->assertNull($plugins['mod']['buz']->available_update()); - $this->assertIsA($plugins['mod']['bar']->available_update(), 'stdClass'); - $this->assertEqual($plugins['mod']['bar']->available_update()->version, 2012030501); + $this->assertNull($plugins['mod']['bar']->available_updates()); + $this->assertIsA($plugins['mod']['foo']->available_updates(), 'array'); + foreach ($plugins['mod']['foo']->available_updates() as $availableupdate) { + $this->assertIsA($availableupdate, 'available_update_info'); + } } } @@ -214,52 +235,4 @@ class available_update_checker_test extends UnitTestCase { $provider = testable_available_update_checker::instance(); $this->assertTrue($provider instanceof available_update_checker); } - - public function test_merge_components_info() { - $old = (object)array( - '2.2' => (object)array( - 'core' => (object)array( - 'version' => 2011120501.11, - 'release' => '2.2.1+ (Build: 20120301)', - 'maturity' => MATURITY_STABLE, - ), - 'mod_foo' => (object)array( - 'version' => 2011010100, - ), - 'mod_bar' => (object)array( - 'version' => 2011020200, - ) - ) - ); - $new = (object)array( - '2.2' => (object)array( - 'core' => (object)array( - 'version' => 2011120501.12, - 'release' => '2.2.1+ (Build: 20120302)', - 'maturity' => MATURITY_STABLE, - ), - 'mod_bar' => (object)array( - 'version' => 2011020201, - ), - ), - '2.3' => (object)array( - 'core' => (object)array( - 'version' => 2012030100.00, - 'release' => '2.3dev (Build: 20120301)', - 'maturity' => MATURITY_ALPHA, - ), - 'mod_foo' => (object)array( - 'version' => 2012010200, - ) - ) - ); - $checker = testable_available_update_checker::instance(); - $now = time(); - $merged = $checker->merge_components_info($old, $new, $now); - $this->assertEqual($merged->{2.2}->core->version, 2011120501.12); // from $new - $this->assertEqual($merged->{2.2}->mod_bar->version, 2011020201); // from $new - $this->assertEqual($merged->{2.2}->mod_foo->version, 2011010100); // from $old - $this->assertEqual($merged->{2.3}->core->version, 2012030100.00); // from $new - $this->assertFalse(isset($merged->{2.3}->mod_bar)); - } }