MDL-49329 admin: Start using API 1.3 for fetching available updates
[moodle.git] / lib / classes / update / checker.php
CommitLineData
e87214bd
PS
1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Defines classes used for updates.
19 *
20 * @package core
21 * @copyright 2011 David Mudrak <david@moodle.com>
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24namespace core\update;
25
26use html_writer, coding_exception, core_component;
27
28defined('MOODLE_INTERNAL') || die();
29
30/**
31 * Singleton class that handles checking for available updates
32 */
33class checker {
34
35 /** @var \core\update\checker holds the singleton instance */
36 protected static $singletoninstance;
37 /** @var null|int the timestamp of when the most recent response was fetched */
38 protected $recentfetch = null;
39 /** @var null|array the recent response from the update notification provider */
40 protected $recentresponse = null;
41 /** @var null|string the numerical version of the local Moodle code */
42 protected $currentversion = null;
43 /** @var null|string the release info of the local Moodle code */
44 protected $currentrelease = null;
45 /** @var null|string branch of the local Moodle code */
46 protected $currentbranch = null;
47 /** @var array of (string)frankestyle => (string)version list of additional plugins deployed at this site */
48 protected $currentplugins = array();
49
50 /**
51 * Direct initiation not allowed, use the factory method {@link self::instance()}
52 */
53 protected function __construct() {
54 }
55
56 /**
57 * Sorry, this is singleton
58 */
59 protected function __clone() {
60 }
61
62 /**
63 * Factory method for this class
64 *
65 * @return \core\update\checker the singleton instance
66 */
67 public static function instance() {
68 if (is_null(self::$singletoninstance)) {
69 self::$singletoninstance = new self();
70 }
71 return self::$singletoninstance;
72 }
73
74 /**
75 * Reset any caches
76 * @param bool $phpunitreset
77 */
78 public static function reset_caches($phpunitreset = false) {
79 if ($phpunitreset) {
80 self::$singletoninstance = null;
81 }
82 }
83
fc281113 84 /**
e9d3c212
DM
85 * Is checking for available updates enabled?
86 *
87 * The feature is enabled unless it is prohibited via config.php.
88 * If enabled, the button for manual checking for available updates is
89 * displayed at admin screens. To perform scheduled checks for updates
90 * automatically, the admin setting $CFG->updateautocheck has to be enabled.
fc281113
PS
91 *
92 * @return bool
93 */
94 public function enabled() {
95 global $CFG;
96
e9d3c212 97 return empty($CFG->disableupdatenotifications);
fc281113
PS
98 }
99
e87214bd
PS
100 /**
101 * Returns the timestamp of the last execution of {@link fetch()}
102 *
103 * @return int|null null if it has never been executed or we don't known
104 */
105 public function get_last_timefetched() {
106
107 $this->restore_response();
108
109 if (!empty($this->recentfetch)) {
110 return $this->recentfetch;
111
112 } else {
113 return null;
114 }
115 }
116
117 /**
118 * Fetches the available update status from the remote site
119 *
120 * @throws checker_exception
121 */
122 public function fetch() {
c9f1b3a0 123
e87214bd
PS
124 $response = $this->get_response();
125 $this->validate_response($response);
126 $this->store_response($response);
c9f1b3a0
DM
127
128 // We need to reset plugin manager's caches - the currently existing
129 // singleton is not aware of eventually available updates we just fetched.
130 \core_plugin_manager::reset_caches();
e87214bd
PS
131 }
132
133 /**
134 * Returns the available update information for the given component
135 *
136 * This method returns null if the most recent response does not contain any information
137 * about it. The returned structure is an array of available updates for the given
138 * component. Each update info is an object with at least one property called
139 * 'version'. Other possible properties are 'release', 'maturity', 'url' and 'downloadurl'.
140 *
141 * For the 'core' component, the method returns real updates only (those with higher version).
142 * For all other components, the list of all known remote updates is returned and the caller
143 * (usually the {@link core_plugin_manager}) is supposed to make the actual comparison of versions.
144 *
145 * @param string $component frankenstyle
146 * @param array $options with supported keys 'minmaturity' and/or 'notifybuilds'
147 * @return null|array null or array of \core\update\info objects
148 */
149 public function get_update_info($component, array $options = array()) {
150
151 if (!isset($options['minmaturity'])) {
152 $options['minmaturity'] = 0;
153 }
154
155 if (!isset($options['notifybuilds'])) {
156 $options['notifybuilds'] = false;
157 }
158
159 if ($component === 'core') {
160 $this->load_current_environment();
161 }
162
163 $this->restore_response();
164
165 if (empty($this->recentresponse['updates'][$component])) {
166 return null;
167 }
168
169 $updates = array();
170 foreach ($this->recentresponse['updates'][$component] as $info) {
171 $update = new info($component, $info);
172 if (isset($update->maturity) and ($update->maturity < $options['minmaturity'])) {
173 continue;
174 }
175 if ($component === 'core') {
176 if ($update->version <= $this->currentversion) {
177 continue;
178 }
179 if (empty($options['notifybuilds']) and $this->is_same_release($update->release)) {
180 continue;
181 }
182 }
183 $updates[] = $update;
184 }
185
186 if (empty($updates)) {
187 return null;
188 }
189
190 return $updates;
191 }
192
193 /**
194 * The method being run via cron.php
195 */
196 public function cron() {
197 global $CFG;
198
e9d3c212 199 if (!$this->enabled() or !$this->cron_autocheck_enabled()) {
e87214bd
PS
200 $this->cron_mtrace('Automatic check for available updates not enabled, skipping.');
201 return;
202 }
203
204 $now = $this->cron_current_timestamp();
205
206 if ($this->cron_has_fresh_fetch($now)) {
207 $this->cron_mtrace('Recently fetched info about available updates is still fresh enough, skipping.');
208 return;
209 }
210
211 if ($this->cron_has_outdated_fetch($now)) {
212 $this->cron_mtrace('Outdated or missing info about available updates, forced fetching ... ', '');
213 $this->cron_execute();
214 return;
215 }
216
217 $offset = $this->cron_execution_offset();
218 $start = mktime(1, 0, 0, date('n', $now), date('j', $now), date('Y', $now)); // 01:00 AM today local time
219 if ($now > $start + $offset) {
220 $this->cron_mtrace('Regular daily check for available updates ... ', '');
221 $this->cron_execute();
222 return;
223 }
224 }
225
226 /* === End of public API === */
227
228 /**
229 * Makes cURL request to get data from the remote site
230 *
231 * @return string raw request result
232 * @throws checker_exception
233 */
234 protected function get_response() {
235 global $CFG;
236 require_once($CFG->libdir.'/filelib.php');
237
238 $curl = new \curl(array('proxy' => true));
239 $response = $curl->post($this->prepare_request_url(), $this->prepare_request_params(), $this->prepare_request_options());
240 $curlerrno = $curl->get_errno();
241 if (!empty($curlerrno)) {
242 throw new checker_exception('err_response_curl', 'cURL error '.$curlerrno.': '.$curl->error);
243 }
244 $curlinfo = $curl->get_info();
245 if ($curlinfo['http_code'] != 200) {
246 throw new checker_exception('err_response_http_code', $curlinfo['http_code']);
247 }
248 return $response;
249 }
250
251 /**
252 * Makes sure the response is valid, has correct API format etc.
253 *
254 * @param string $response raw response as returned by the {@link self::get_response()}
255 * @throws checker_exception
256 */
257 protected function validate_response($response) {
258
259 $response = $this->decode_response($response);
260
261 if (empty($response)) {
262 throw new checker_exception('err_response_empty');
263 }
264
265 if (empty($response['status']) or $response['status'] !== 'OK') {
266 throw new checker_exception('err_response_status', $response['status']);
267 }
268
dbdd02c1 269 if (empty($response['apiver']) or $response['apiver'] !== '1.3') {
e87214bd
PS
270 throw new checker_exception('err_response_format_version', $response['apiver']);
271 }
272
273 if (empty($response['forbranch']) or $response['forbranch'] !== moodle_major_version(true)) {
274 throw new checker_exception('err_response_target_version', $response['forbranch']);
275 }
276 }
277
278 /**
279 * Decodes the raw string response from the update notifications provider
280 *
281 * @param string $response as returned by {@link self::get_response()}
282 * @return array decoded response structure
283 */
284 protected function decode_response($response) {
285 return json_decode($response, true);
286 }
287
288 /**
289 * Stores the valid fetched response for later usage
290 *
291 * This implementation uses the config_plugins table as the permanent storage.
292 *
293 * @param string $response raw valid data returned by {@link self::get_response()}
294 */
295 protected function store_response($response) {
296
297 set_config('recentfetch', time(), 'core_plugin');
298 set_config('recentresponse', $response, 'core_plugin');
299
cac367e4
PS
300 if (defined('CACHE_DISABLE_ALL') and CACHE_DISABLE_ALL) {
301 // Very nasty hack to work around cache coherency issues on admin/index.php?cache=0 page,
302 // we definitely need to keep caches in sync when writing into DB at all times!
303 \cache_helper::purge_all(true);
304 }
305
e87214bd
PS
306 $this->restore_response(true);
307 }
308
309 /**
310 * Loads the most recent raw response record we have fetched
311 *
312 * After this method is called, $this->recentresponse is set to an array. If the
313 * array is empty, then either no data have been fetched yet or the fetched data
314 * do not have expected format (and thence they are ignored and a debugging
315 * message is displayed).
316 *
317 * This implementation uses the config_plugins table as the permanent storage.
318 *
319 * @param bool $forcereload reload even if it was already loaded
320 */
321 protected function restore_response($forcereload = false) {
322
323 if (!$forcereload and !is_null($this->recentresponse)) {
324 // We already have it, nothing to do.
325 return;
326 }
327
328 $config = get_config('core_plugin');
329
330 if (!empty($config->recentresponse) and !empty($config->recentfetch)) {
331 try {
332 $this->validate_response($config->recentresponse);
333 $this->recentfetch = $config->recentfetch;
334 $this->recentresponse = $this->decode_response($config->recentresponse);
335 } catch (checker_exception $e) {
336 // The server response is not valid. Behave as if no data were fetched yet.
337 // This may happen when the most recent update info (cached locally) has been
338 // fetched with the previous branch of Moodle (like during an upgrade from 2.x
339 // to 2.y) or when the API of the response has changed.
340 $this->recentresponse = array();
341 }
342
343 } else {
344 $this->recentresponse = array();
345 }
346 }
347
348 /**
349 * Compares two raw {@link $recentresponse} records and returns the list of changed updates
350 *
351 * This method is used to populate potential update info to be sent to site admins.
352 *
353 * @param array $old
354 * @param array $new
355 * @throws checker_exception
356 * @return array parts of $new['updates'] that have changed
357 */
358 protected function compare_responses(array $old, array $new) {
359
360 if (empty($new)) {
361 return array();
362 }
363
364 if (!array_key_exists('updates', $new)) {
365 throw new checker_exception('err_response_format');
366 }
367
368 if (empty($old)) {
369 return $new['updates'];
370 }
371
372 if (!array_key_exists('updates', $old)) {
373 throw new checker_exception('err_response_format');
374 }
375
376 $changes = array();
377
378 foreach ($new['updates'] as $newcomponent => $newcomponentupdates) {
379 if (empty($old['updates'][$newcomponent])) {
380 $changes[$newcomponent] = $newcomponentupdates;
381 continue;
382 }
383 foreach ($newcomponentupdates as $newcomponentupdate) {
384 $inold = false;
385 foreach ($old['updates'][$newcomponent] as $oldcomponentupdate) {
386 if ($newcomponentupdate['version'] == $oldcomponentupdate['version']) {
387 $inold = true;
388 }
389 }
390 if (!$inold) {
391 if (!isset($changes[$newcomponent])) {
392 $changes[$newcomponent] = array();
393 }
394 $changes[$newcomponent][] = $newcomponentupdate;
395 }
396 }
397 }
398
399 return $changes;
400 }
401
402 /**
403 * Returns the URL to send update requests to
404 *
405 * During the development or testing, you can set $CFG->alternativeupdateproviderurl
406 * to a custom URL that will be used. Otherwise the standard URL will be returned.
407 *
408 * @return string URL
409 */
410 protected function prepare_request_url() {
411 global $CFG;
412
413 if (!empty($CFG->config_php_settings['alternativeupdateproviderurl'])) {
414 return $CFG->config_php_settings['alternativeupdateproviderurl'];
415 } else {
dbdd02c1 416 return 'https://download.moodle.org/api/1.3/updates.php';
e87214bd
PS
417 }
418 }
419
420 /**
421 * Sets the properties currentversion, currentrelease, currentbranch and currentplugins
422 *
423 * @param bool $forcereload
424 */
425 protected function load_current_environment($forcereload=false) {
426 global $CFG;
427
428 if (!is_null($this->currentversion) and !$forcereload) {
429 // Nothing to do.
430 return;
431 }
432
433 $version = null;
434 $release = null;
435
436 require($CFG->dirroot.'/version.php');
437 $this->currentversion = $version;
438 $this->currentrelease = $release;
439 $this->currentbranch = moodle_major_version(true);
440
441 $pluginman = \core_plugin_manager::instance();
442 foreach ($pluginman->get_plugins() as $type => $plugins) {
443 foreach ($plugins as $plugin) {
444 if (!$plugin->is_standard()) {
445 $this->currentplugins[$plugin->component] = $plugin->versiondisk;
446 }
447 }
448 }
449 }
450
451 /**
452 * Returns the list of HTTP params to be sent to the updates provider URL
453 *
454 * @return array of (string)param => (string)value
455 */
456 protected function prepare_request_params() {
457 global $CFG;
458
459 $this->load_current_environment();
460 $this->restore_response();
461
462 $params = array();
463 $params['format'] = 'json';
464
465 if (isset($this->recentresponse['ticket'])) {
466 $params['ticket'] = $this->recentresponse['ticket'];
467 }
468
469 if (isset($this->currentversion)) {
470 $params['version'] = $this->currentversion;
471 } else {
472 throw new coding_exception('Main Moodle version must be already known here');
473 }
474
475 if (isset($this->currentbranch)) {
476 $params['branch'] = $this->currentbranch;
477 } else {
478 throw new coding_exception('Moodle release must be already known here');
479 }
480
481 $plugins = array();
482 foreach ($this->currentplugins as $plugin => $version) {
483 $plugins[] = $plugin.'@'.$version;
484 }
485 if (!empty($plugins)) {
486 $params['plugins'] = implode(',', $plugins);
487 }
488
489 return $params;
490 }
491
492 /**
493 * Returns the list of cURL options to use when fetching available updates data
494 *
495 * @return array of (string)param => (string)value
496 */
497 protected function prepare_request_options() {
498 $options = array(
499 'CURLOPT_SSL_VERIFYHOST' => 2, // This is the default in {@link curl} class but just in case.
500 'CURLOPT_SSL_VERIFYPEER' => true,
501 );
502
503 return $options;
504 }
505
506 /**
507 * Returns the current timestamp
508 *
509 * @return int the timestamp
510 */
511 protected function cron_current_timestamp() {
512 return time();
513 }
514
515 /**
516 * Output cron debugging info
517 *
518 * @see mtrace()
519 * @param string $msg output message
520 * @param string $eol end of line
521 */
522 protected function cron_mtrace($msg, $eol = PHP_EOL) {
523 mtrace($msg, $eol);
524 }
525
526 /**
527 * Decide if the autocheck feature is disabled in the server setting
528 *
529 * @return bool true if autocheck enabled, false if disabled
530 */
531 protected function cron_autocheck_enabled() {
532 global $CFG;
533
534 if (empty($CFG->updateautocheck)) {
535 return false;
536 } else {
537 return true;
538 }
539 }
540
541 /**
542 * Decide if the recently fetched data are still fresh enough
543 *
544 * @param int $now current timestamp
545 * @return bool true if no need to re-fetch, false otherwise
546 */
547 protected function cron_has_fresh_fetch($now) {
548 $recent = $this->get_last_timefetched();
549
550 if (empty($recent)) {
551 return false;
552 }
553
554 if ($now < $recent) {
555 $this->cron_mtrace('The most recent fetch is reported to be in the future, this is weird!');
556 return true;
557 }
558
559 if ($now - $recent > 24 * HOURSECS) {
560 return false;
561 }
562
563 return true;
564 }
565
566 /**
567 * Decide if the fetch is outadated or even missing
568 *
569 * @param int $now current timestamp
570 * @return bool false if no need to re-fetch, true otherwise
571 */
572 protected function cron_has_outdated_fetch($now) {
573 $recent = $this->get_last_timefetched();
574
575 if (empty($recent)) {
576 return true;
577 }
578
579 if ($now < $recent) {
580 $this->cron_mtrace('The most recent fetch is reported to be in the future, this is weird!');
581 return false;
582 }
583
584 if ($now - $recent > 48 * HOURSECS) {
585 return true;
586 }
587
588 return false;
589 }
590
591 /**
592 * Returns the cron execution offset for this site
593 *
594 * The main {@link self::cron()} is supposed to run every night in some random time
595 * between 01:00 and 06:00 AM (local time). The exact moment is defined by so called
596 * execution offset, that is the amount of time after 01:00 AM. The offset value is
597 * initially generated randomly and then used consistently at the site. This way, the
598 * regular checks against the download.moodle.org server are spread in time.
599 *
600 * @return int the offset number of seconds from range 1 sec to 5 hours
601 */
602 protected function cron_execution_offset() {
603 global $CFG;
604
605 if (empty($CFG->updatecronoffset)) {
606 set_config('updatecronoffset', rand(1, 5 * HOURSECS));
607 }
608
609 return $CFG->updatecronoffset;
610 }
611
612 /**
613 * Fetch available updates info and eventually send notification to site admins
614 */
615 protected function cron_execute() {
616
617 try {
618 $this->restore_response();
619 $previous = $this->recentresponse;
620 $this->fetch();
621 $this->restore_response(true);
622 $current = $this->recentresponse;
623 $changes = $this->compare_responses($previous, $current);
624 $notifications = $this->cron_notifications($changes);
625 $this->cron_notify($notifications);
626 $this->cron_mtrace('done');
627 } catch (checker_exception $e) {
628 $this->cron_mtrace('FAILED!');
629 }
630 }
631
632 /**
633 * Given the list of changes in available updates, pick those to send to site admins
634 *
635 * @param array $changes as returned by {@link self::compare_responses()}
636 * @return array of \core\update\info objects to send to site admins
637 */
638 protected function cron_notifications(array $changes) {
639 global $CFG;
640
32c670c8 641 if (empty($changes)) {
26f72b00 642 return array();
32c670c8
DM
643 }
644
e87214bd
PS
645 $notifications = array();
646 $pluginman = \core_plugin_manager::instance();
32c670c8 647 $plugins = $pluginman->get_plugins();
e87214bd
PS
648
649 foreach ($changes as $component => $componentchanges) {
650 if (empty($componentchanges)) {
651 continue;
652 }
653 $componentupdates = $this->get_update_info($component,
654 array('minmaturity' => $CFG->updateminmaturity, 'notifybuilds' => $CFG->updatenotifybuilds));
655 if (empty($componentupdates)) {
656 continue;
657 }
658 // Notify only about those $componentchanges that are present in $componentupdates
659 // to respect the preferences.
660 foreach ($componentchanges as $componentchange) {
661 foreach ($componentupdates as $componentupdate) {
662 if ($componentupdate->version == $componentchange['version']) {
663 if ($component == 'core') {
664 // In case of 'core', we already know that the $componentupdate
665 // is a real update with higher version ({@see self::get_update_info()}).
666 // We just perform additional check for the release property as there
667 // can be two Moodle releases having the same version (e.g. 2.4.0 and 2.5dev shortly
668 // after the release). We can do that because we have the release info
669 // always available for the core.
670 if ((string)$componentupdate->release === (string)$componentchange['release']) {
671 $notifications[] = $componentupdate;
672 }
673 } else {
674 // Use the core_plugin_manager to check if the detected $componentchange
675 // is a real update with higher version. That is, the $componentchange
676 // is present in the array of {@link \core\update\info} objects
677 // returned by the plugin's available_updates() method.
678 list($plugintype, $pluginname) = core_component::normalize_component($component);
679 if (!empty($plugins[$plugintype][$pluginname])) {
680 $availableupdates = $plugins[$plugintype][$pluginname]->available_updates();
681 if (!empty($availableupdates)) {
682 foreach ($availableupdates as $availableupdate) {
683 if ($availableupdate->version == $componentchange['version']) {
684 $notifications[] = $componentupdate;
685 }
686 }
687 }
688 }
689 }
690 }
691 }
692 }
693 }
694
695 return $notifications;
696 }
697
698 /**
699 * Sends the given notifications to site admins via messaging API
700 *
701 * @param array $notifications array of \core\update\info objects to send
702 */
703 protected function cron_notify(array $notifications) {
704 global $CFG;
705
706 if (empty($notifications)) {
32c670c8 707 $this->cron_mtrace('nothing to notify about. ', '');
e87214bd
PS
708 return;
709 }
710
711 $admins = get_admins();
712
713 if (empty($admins)) {
714 return;
715 }
716
717 $this->cron_mtrace('sending notifications ... ', '');
718
719 $text = get_string('updatenotifications', 'core_admin') . PHP_EOL;
720 $html = html_writer::tag('h1', get_string('updatenotifications', 'core_admin')) . PHP_EOL;
721
722 $coreupdates = array();
723 $pluginupdates = array();
724
725 foreach ($notifications as $notification) {
726 if ($notification->component == 'core') {
727 $coreupdates[] = $notification;
728 } else {
729 $pluginupdates[] = $notification;
730 }
731 }
732
733 if (!empty($coreupdates)) {
734 $text .= PHP_EOL . get_string('updateavailable', 'core_admin') . PHP_EOL;
735 $html .= html_writer::tag('h2', get_string('updateavailable', 'core_admin')) . PHP_EOL;
736 $html .= html_writer::start_tag('ul') . PHP_EOL;
737 foreach ($coreupdates as $coreupdate) {
738 $html .= html_writer::start_tag('li');
739 if (isset($coreupdate->release)) {
740 $text .= get_string('updateavailable_release', 'core_admin', $coreupdate->release);
741 $html .= html_writer::tag('strong', get_string('updateavailable_release', 'core_admin', $coreupdate->release));
742 }
743 if (isset($coreupdate->version)) {
744 $text .= ' '.get_string('updateavailable_version', 'core_admin', $coreupdate->version);
745 $html .= ' '.get_string('updateavailable_version', 'core_admin', $coreupdate->version);
746 }
747 if (isset($coreupdate->maturity)) {
748 $text .= ' ('.get_string('maturity'.$coreupdate->maturity, 'core_admin').')';
749 $html .= ' ('.get_string('maturity'.$coreupdate->maturity, 'core_admin').')';
750 }
751 $text .= PHP_EOL;
752 $html .= html_writer::end_tag('li') . PHP_EOL;
753 }
754 $text .= PHP_EOL;
755 $html .= html_writer::end_tag('ul') . PHP_EOL;
756
757 $a = array('url' => $CFG->wwwroot.'/'.$CFG->admin.'/index.php');
758 $text .= get_string('updateavailabledetailslink', 'core_admin', $a) . PHP_EOL;
759 $a = array('url' => html_writer::link($CFG->wwwroot.'/'.$CFG->admin.'/index.php', $CFG->wwwroot.'/'.$CFG->admin.'/index.php'));
760 $html .= html_writer::tag('p', get_string('updateavailabledetailslink', 'core_admin', $a)) . PHP_EOL;
657cb5ab
DM
761
762 $text .= PHP_EOL . get_string('updateavailablerecommendation', 'core_admin') . PHP_EOL;
763 $html .= html_writer::tag('p', get_string('updateavailablerecommendation', 'core_admin')) . PHP_EOL;
e87214bd
PS
764 }
765
766 if (!empty($pluginupdates)) {
767 $text .= PHP_EOL . get_string('updateavailableforplugin', 'core_admin') . PHP_EOL;
768 $html .= html_writer::tag('h2', get_string('updateavailableforplugin', 'core_admin')) . PHP_EOL;
769
770 $html .= html_writer::start_tag('ul') . PHP_EOL;
771 foreach ($pluginupdates as $pluginupdate) {
772 $html .= html_writer::start_tag('li');
773 $text .= get_string('pluginname', $pluginupdate->component);
774 $html .= html_writer::tag('strong', get_string('pluginname', $pluginupdate->component));
775
776 $text .= ' ('.$pluginupdate->component.')';
777 $html .= ' ('.$pluginupdate->component.')';
778
779 $text .= ' '.get_string('updateavailable', 'core_plugin', $pluginupdate->version);
780 $html .= ' '.get_string('updateavailable', 'core_plugin', $pluginupdate->version);
781
782 $text .= PHP_EOL;
783 $html .= html_writer::end_tag('li') . PHP_EOL;
784 }
785 $text .= PHP_EOL;
786 $html .= html_writer::end_tag('ul') . PHP_EOL;
787
788 $a = array('url' => $CFG->wwwroot.'/'.$CFG->admin.'/plugins.php');
789 $text .= get_string('updateavailabledetailslink', 'core_admin', $a) . PHP_EOL;
790 $a = array('url' => html_writer::link($CFG->wwwroot.'/'.$CFG->admin.'/plugins.php', $CFG->wwwroot.'/'.$CFG->admin.'/plugins.php'));
791 $html .= html_writer::tag('p', get_string('updateavailabledetailslink', 'core_admin', $a)) . PHP_EOL;
792 }
793
794 $a = array('siteurl' => $CFG->wwwroot);
657cb5ab 795 $text .= PHP_EOL . get_string('updatenotificationfooter', 'core_admin', $a) . PHP_EOL;
e87214bd
PS
796 $a = array('siteurl' => html_writer::link($CFG->wwwroot, $CFG->wwwroot));
797 $html .= html_writer::tag('footer', html_writer::tag('p', get_string('updatenotificationfooter', 'core_admin', $a),
798 array('style' => 'font-size:smaller; color:#333;')));
799
800 foreach ($admins as $admin) {
801 $message = new \stdClass();
802 $message->component = 'moodle';
803 $message->name = 'availableupdate';
804 $message->userfrom = get_admin();
805 $message->userto = $admin;
806 $message->subject = get_string('updatenotificationsubject', 'core_admin', array('siteurl' => $CFG->wwwroot));
807 $message->fullmessage = $text;
808 $message->fullmessageformat = FORMAT_PLAIN;
809 $message->fullmessagehtml = $html;
810 $message->smallmessage = get_string('updatenotifications', 'core_admin');
811 $message->notification = 1;
812 message_send($message);
813 }
814 }
815
816 /**
817 * Compare two release labels and decide if they are the same
818 *
819 * @param string $remote release info of the available update
820 * @param null|string $local release info of the local code, defaults to $release defined in version.php
821 * @return boolean true if the releases declare the same minor+major version
822 */
823 protected function is_same_release($remote, $local=null) {
824
825 if (is_null($local)) {
826 $this->load_current_environment();
827 $local = $this->currentrelease;
828 }
829
830 $pattern = '/^([0-9\.\+]+)([^(]*)/';
831
832 preg_match($pattern, $remote, $remotematches);
833 preg_match($pattern, $local, $localmatches);
834
835 $remotematches[1] = str_replace('+', '', $remotematches[1]);
836 $localmatches[1] = str_replace('+', '', $localmatches[1]);
837
838 if ($remotematches[1] === $localmatches[1] and rtrim($remotematches[2]) === rtrim($localmatches[2])) {
839 return true;
840 } else {
841 return false;
842 }
843 }
844}