Merge branch 'wip-MDL-60281-master' of git://github.com/marinaglancy/moodle
authorDavid Monllao <davidm@moodle.com>
Tue, 17 Oct 2017 07:45:44 +0000 (09:45 +0200)
committerDavid Monllao <davidm@moodle.com>
Tue, 17 Oct 2017 07:45:44 +0000 (09:45 +0200)
187 files changed:
admin/index.php
admin/renderer.php
admin/tool/analytics/classes/output/renderer.php
admin/tool/httpsreplace/classes/form.php [new file with mode: 0644]
admin/tool/httpsreplace/classes/url_finder.php [new file with mode: 0644]
admin/tool/httpsreplace/cli/url_replace.php [new file with mode: 0644]
admin/tool/httpsreplace/index.php [new file with mode: 0644]
admin/tool/httpsreplace/lang/en/tool_httpsreplace.php [new file with mode: 0644]
admin/tool/httpsreplace/settings.php [new file with mode: 0644]
admin/tool/httpsreplace/tests/behat/httpsreplace.feature [new file with mode: 0644]
admin/tool/httpsreplace/tests/httpsreplace_test.php [new file with mode: 0644]
admin/tool/httpsreplace/tool.php [new file with mode: 0644]
admin/tool/httpsreplace/version.php [new file with mode: 0644]
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/tests/api_test.php
admin/webservice/testclient.php
admin/webservice/testclient_forms.php
analytics/classes/admin_setting_predictor.php
analytics/classes/dataset_manager.php
analytics/classes/local/analyser/base.php
analytics/classes/local/target/base.php
analytics/classes/local/target/discrete.php
analytics/classes/local/target/linear.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/tests/dataset_manager_test.php
analytics/tests/model_test.php
analytics/tests/prediction_test.php
auth/cas/CAS/CAS/Autoload.php
auth/cas/CAS/moodle_readme.txt
calendar/amd/build/drag_drop_data_store.min.js
calendar/amd/build/month_navigation_drag_drop.min.js
calendar/amd/build/month_view_drag_drop.min.js
calendar/amd/src/drag_drop_data_store.js
calendar/amd/src/month_navigation_drag_drop.js
calendar/amd/src/month_view_drag_drop.js
calendar/classes/external/calendar_event_exporter.php
calendar/classes/external/day_exporter.php
calendar/classes/external/event_subscription_exporter.php
calendar/templates/event_subscription.mustache
calendar/templates/month_detailed.mustache
calendar/tests/calendar_event_exporter_test.php [new file with mode: 0644]
course/classes/analytics/indicator/no_student.php [new file with mode: 0644]
filter/classes/external.php [new file with mode: 0644]
filter/tests/external_test.php [new file with mode: 0644]
lang/en/admin.php
lang/en/analytics.php
lang/en/moodle.php
lib/adminlib.php
lib/amd/build/loglevel.min.js
lib/amd/build/mustache.min.js
lib/amd/src/loglevel.js
lib/amd/src/mustache.js
lib/bennu/readme_moodle.txt
lib/classes/analytics/target/no_teaching.php
lib/classes/component.php
lib/classes/event/user_info_category_created.php [new file with mode: 0644]
lib/classes/event/user_info_category_deleted.php [new file with mode: 0644]
lib/classes/event/user_info_category_updated.php [new file with mode: 0644]
lib/classes/event/user_info_field_created.php [new file with mode: 0644]
lib/classes/event/user_info_field_deleted.php [new file with mode: 0644]
lib/classes/event/user_info_field_updated.php [new file with mode: 0644]
lib/classes/plugin_manager.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/dml/moodle_database.php
lib/dml/mysqli_native_moodle_database.php
lib/dml/pgsql_native_moodle_database.php
lib/dml/tests/dml_test.php
lib/enrollib.php
lib/filelib.php
lib/htmlpurifier/HTMLPurifier/AttrDef/URI/Host.php
lib/htmlpurifier/readme_moodle.txt
lib/mlbackend/php/classes/processor.php
lib/mlbackend/python/classes/processor.php
lib/moodlelib.php
lib/mustache/readme_moodle.txt
lib/mustache/src/Mustache/Autoloader.php
lib/mustache/src/Mustache/Cache.php
lib/mustache/src/Mustache/Cache/AbstractCache.php
lib/mustache/src/Mustache/Cache/FilesystemCache.php
lib/mustache/src/Mustache/Cache/NoopCache.php
lib/mustache/src/Mustache/Compiler.php
lib/mustache/src/Mustache/Context.php
lib/mustache/src/Mustache/Engine.php
lib/mustache/src/Mustache/Exception.php
lib/mustache/src/Mustache/Exception/InvalidArgumentException.php
lib/mustache/src/Mustache/Exception/LogicException.php
lib/mustache/src/Mustache/Exception/RuntimeException.php
lib/mustache/src/Mustache/Exception/SyntaxException.php
lib/mustache/src/Mustache/Exception/UnknownFilterException.php
lib/mustache/src/Mustache/Exception/UnknownHelperException.php
lib/mustache/src/Mustache/Exception/UnknownTemplateException.php
lib/mustache/src/Mustache/HelperCollection.php
lib/mustache/src/Mustache/LambdaHelper.php
lib/mustache/src/Mustache/Loader.php
lib/mustache/src/Mustache/Loader/ArrayLoader.php
lib/mustache/src/Mustache/Loader/CascadingLoader.php
lib/mustache/src/Mustache/Loader/FilesystemLoader.php
lib/mustache/src/Mustache/Loader/InlineLoader.php
lib/mustache/src/Mustache/Loader/MutableLoader.php
lib/mustache/src/Mustache/Loader/ProductionFilesystemLoader.php [new file with mode: 0644]
lib/mustache/src/Mustache/Loader/StringLoader.php
lib/mustache/src/Mustache/Logger.php
lib/mustache/src/Mustache/Logger/AbstractLogger.php
lib/mustache/src/Mustache/Logger/StreamLogger.php
lib/mustache/src/Mustache/Parser.php
lib/mustache/src/Mustache/Source.php [new file with mode: 0644]
lib/mustache/src/Mustache/Source/FilesystemSource.php [new file with mode: 0644]
lib/mustache/src/Mustache/Template.php
lib/mustache/src/Mustache/Tokenizer.php
lib/phpmailer/README.md
lib/phpmailer/README_MOODLE.txt
lib/phpmailer/VERSION
lib/phpmailer/changelog.md
lib/phpmailer/language/phpmailer.lang-ba.php [new file with mode: 0644]
lib/phpmailer/language/phpmailer.lang-cs.php [moved from lib/phpmailer/language/phpmailer.lang-cz.php with 95% similarity]
lib/phpmailer/language/phpmailer.lang-da.php [moved from lib/phpmailer/language/phpmailer.lang-dk.php with 100% similarity]
lib/phpmailer/language/phpmailer.lang-fr.php
lib/phpmailer/language/phpmailer.lang-nb.php [new file with mode: 0644]
lib/phpmailer/language/phpmailer.lang-nl.php
lib/phpmailer/language/phpmailer.lang-no.php [deleted file]
lib/phpmailer/language/phpmailer.lang-pt_br.php [moved from lib/phpmailer/language/phpmailer.lang-br.php with 65% similarity]
lib/phpmailer/language/phpmailer.lang-rs.php [moved from lib/phpmailer/language/phpmailer.lang-sr.php with 96% similarity]
lib/phpmailer/language/phpmailer.lang-sv.php [moved from lib/phpmailer/language/phpmailer.lang-se.php with 73% similarity]
lib/phpmailer/language/phpmailer.lang-tr.php
lib/phpmailer/language/phpmailer.lang-zh_cn.php
lib/phpmailer/moodle_phpmailer.php
lib/phpmailer/src/Exception.php [new file with mode: 0644]
lib/phpmailer/src/PHPMailer.php [moved from lib/phpmailer/class.phpmailer.php with 66% similarity]
lib/phpmailer/src/SMTP.php [moved from lib/phpmailer/class.smtp.php with 70% similarity]
lib/requirejs/require.js
lib/requirejs/require.min.js
lib/tcpdf/CHANGELOG.TXT
lib/tcpdf/README.TXT
lib/tcpdf/composer.json
lib/tcpdf/include/tcpdf_static.php
lib/tcpdf/readme_moodle.txt
lib/tcpdf/tcpdf.php
lib/templates/pix_icon_fontawesome.mustache
lib/tests/event_profile_field_test.php [new file with mode: 0644]
lib/tests/htmlpurifier_test.php
lib/thirdpartylibs.xml
lib/upgradelib.php
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-debug.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor-min.js
mod/assign/feedback/editpdf/yui/build/moodle-assignfeedback_editpdf-editor/moodle-assignfeedback_editpdf-editor.js
mod/assign/feedback/editpdf/yui/src/editor/js/comment.js
mod/assign/lib.php
mod/assign/locallib.php
mod/assign/tests/lib_test.php
mod/choice/lib.php
mod/choice/tests/lib_test.php
mod/data/backup/moodle2/backup_data_stepslib.php
mod/data/backup/moodle2/restore_data_stepslib.php
mod/data/db/tag.php [new file with mode: 0644]
mod/data/edit.php
mod/data/export.php
mod/data/export_form.php
mod/data/import.php
mod/data/lang/en/data.php
mod/data/lib.php
mod/data/locallib.php
mod/data/styles.css
mod/data/templates.php
mod/data/tests/behat/view_entries.feature
mod/data/tests/generator/lib.php
mod/data/tests/generator_test.php
mod/data/tests/lib_test.php
mod/data/tests/search_test.php
mod/data/version.php
mod/feedback/lang/en/feedback.php
mod/feedback/lib.php
mod/feedback/tests/lib_test.php
mod/workshop/lib.php
mod/workshop/tests/lib_test.php
phpunit.xml.dist
theme/bootstrapbase/less/moodle/bs4-compat.less
theme/bootstrapbase/style/moodle.css
user/editlib.php
user/profile/definelib.php
user/tests/behat/filter_participants.feature
version.php
webservice/upgrade.txt
webservice/xmlrpc/lib.php

index b077d44..e8f0816 100644 (file)
@@ -863,6 +863,7 @@ $cachewarnings = cache_helper::warnings();
 // Check if there are events 1 API handlers.
 $eventshandlers = $DB->get_records_sql('SELECT DISTINCT component FROM {events_handlers}');
 $themedesignermode = !empty($CFG->themedesignermode);
+$mobileconfigured = !empty($CFG->enablemobilewebservice);
 
 // Check if a directory with development libraries exists.
 if (empty($CFG->disabledevlibdirscheck) && (is_dir($CFG->dirroot.'/vendor') || is_dir($CFG->dirroot.'/node_modules'))) {
@@ -877,4 +878,5 @@ $output = $PAGE->get_renderer('core', 'admin');
 
 echo $output->admin_notifications_page($maturity, $insecuredataroot, $errorsdisplayed, $cronoverdue, $dbproblems,
                                        $maintenancemode, $availableupdates, $availableupdatesfetch, $buggyiconvnomb,
-                                       $registered, $cachewarnings, $eventshandlers, $themedesignermode, $devlibdir);
+                                       $registered, $cachewarnings, $eventshandlers, $themedesignermode, $devlibdir,
+                                       $mobileconfigured);
index 7dd4808..1fd8cb5 100644 (file)
@@ -278,13 +278,14 @@ class core_admin_renderer extends plugin_renderer_base {
      * @param array $eventshandlers Events 1 API handlers.
      * @param bool $themedesignermode Warn about the theme designer mode.
      * @param bool $devlibdir Warn about development libs directory presence.
+     * @param bool $mobileconfigured Whether the mobile web services have been enabled
      *
      * @return string HTML to output.
      */
     public function admin_notifications_page($maturity, $insecuredataroot, $errorsdisplayed,
             $cronoverdue, $dbproblems, $maintenancemode, $availableupdates, $availableupdatesfetch,
             $buggyiconvnomb, $registered, array $cachewarnings = array(), $eventshandlers = 0,
-            $themedesignermode = false, $devlibdir = false) {
+            $themedesignermode = false, $devlibdir = false, $mobileconfigured = false) {
         global $CFG;
         $output = '';
 
@@ -303,6 +304,7 @@ class core_admin_renderer extends plugin_renderer_base {
         $output .= $this->cache_warnings($cachewarnings);
         $output .= $this->events_handlers($eventshandlers);
         $output .= $this->registration_warning($registered);
+        $output .= $this->mobile_configuration_warning($mobileconfigured);
 
         //////////////////////////////////////////////////////////////////////////////////////////////////
         ////  IT IS ILLEGAL AND A VIOLATION OF THE GPL TO HIDE, REMOVE OR MODIFY THIS COPYRIGHT NOTICE ///
@@ -830,6 +832,23 @@ class core_admin_renderer extends plugin_renderer_base {
         return $this->registration_warning(\core\hub\registration::is_registered());
     }
 
+    /**
+     * Display a warning about the Mobile Web Services being disabled.
+     *
+     * @param boolean $mobileconfigured true if mobile web services are enabled
+     * @return string HTML to output.
+     */
+    protected function mobile_configuration_warning($mobileconfigured) {
+        $output = '';
+        if (!$mobileconfigured) {
+            $settingslink = new moodle_url('/admin/settings.php', ['section' => 'mobilesettings']);
+            $configurebutton = $this->single_button($settingslink, get_string('enablemobilewebservice', 'admin'));
+            $output .= $this->warning(get_string('mobilenotconfiguredwarning', 'admin') . '&nbsp;' . $configurebutton);
+        }
+
+        return $output;
+    }
+
     /**
      * Helper method to render the information about the available Moodle update
      *
index 32bdcfc..628b097 100644 (file)
@@ -159,11 +159,12 @@ class renderer extends plugin_renderer_base {
             if ($trainresults->status == 0) {
                 $output .= $OUTPUT->notification(get_string('trainingprocessfinished', 'tool_analytics'),
                     \core\output\notification::NOTIFY_SUCCESS);
-            } else if ($trainresults->status === \core_analytics\model::NO_DATASET) {
+            } else if ($trainresults->status === \core_analytics\model::NO_DATASET ||
+                    $trainresults->status === \core_analytics\model::NOT_ENOUGH_DATA) {
                 $output .= $OUTPUT->notification(get_string('nodatatotrain', 'tool_analytics'),
                     \core\output\notification::NOTIFY_WARNING);
             } else {
-                $output .= $OUTPUT->notification(get_string('generalerror', 'analytics', $trainresults->status),
+                $output .= $OUTPUT->notification(get_string('generalerror', 'tool_analytics', $trainresults->status),
                     \core\output\notification::NOTIFY_ERROR);
             }
         }
@@ -183,11 +184,12 @@ class renderer extends plugin_renderer_base {
             if ($predictresults->status == 0) {
                 $output .= $OUTPUT->notification(get_string('predictionprocessfinished', 'tool_analytics'),
                     \core\output\notification::NOTIFY_SUCCESS);
-            } else if ($predictresults->status === \core_analytics\model::NO_DATASET) {
+            } else if ($predictresults->status === \core_analytics\model::NO_DATASET ||
+                    $predictresults->status === \core_analytics\model::NOT_ENOUGH_DATA) {
                 $output .= $OUTPUT->notification(get_string('nodatatopredict', 'tool_analytics'),
                     \core\output\notification::NOTIFY_WARNING);
             } else {
-                $output .= $OUTPUT->notification(get_string('generalerror', 'analytics', $predictresults->status),
+                $output .= $OUTPUT->notification(get_string('generalerror', 'tool_analytics', $predictresults->status),
                     \core\output\notification::NOTIFY_ERROR);
             }
         }
diff --git a/admin/tool/httpsreplace/classes/form.php b/admin/tool/httpsreplace/classes/form.php
new file mode 100644 (file)
index 0000000..cd83473
--- /dev/null
@@ -0,0 +1,52 @@
+<?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/>.
+
+/**
+ * Site wide http -> https search-replace form.
+ *
+ * @package    tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_httpsreplace;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/formslib.php");
+
+/**
+ * Site wide http -> https search-replace form.
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class form extends \moodleform {
+
+    /**
+     * Define the form.
+     */
+    public function definition() {
+        $mform = $this->_form;
+
+        $mform->addElement('header', 'confirmhdr', get_string('confirm'));
+        $mform->setExpanded('confirmhdr', true);
+        $mform->addElement('checkbox', 'sure', get_string('disclaimer', 'tool_httpsreplace'));
+        $mform->addRule('sure', get_string('required'), 'required', null, 'client');
+        $mform->disable_form_change_checker();
+
+        $this->add_action_buttons(false, get_string('doit', 'tool_httpsreplace'));
+    }
+}
diff --git a/admin/tool/httpsreplace/classes/url_finder.php b/admin/tool/httpsreplace/classes/url_finder.php
new file mode 100644 (file)
index 0000000..af9fc9b
--- /dev/null
@@ -0,0 +1,261 @@
+<?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/>.
+
+/**
+ * url_finder class definition.
+ *
+ * @package    tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_httpsreplace;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Examines DB for non-https src or data links
+ *
+ * @package tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class url_finder {
+
+    /**
+     * Returns a hash of what hosts are referred to over http and would need to be changed.
+     *
+     * @param progress_bar $progress Progress bar keeping track of this process.
+     * @return array Hash of domains with number of references as the value.
+     */
+    public function http_link_stats($progress = null) {
+        return $this->process(false, $progress);
+    }
+
+    /**
+     * Changes all resources referred to over http to https.
+     *
+     * @param progress_bar $progress Progress bar keeping track of this process.
+     * @return bool True upon success
+     */
+    public function upgrade_http_links($progress = null) {
+        return $this->process(true, $progress);
+    }
+
+    /**
+     * Replace http domains with https equivalent, with two types of exceptions
+     * for less straightforward swaps.
+     *
+     * @param string $table
+     * @param string $column
+     * @param string $domain
+     * @param string $search search string that has prefix, protocol, domain name and one extra character,
+     *      example1: src="http://host.com/
+     *      example2: DATA="HTTP://MYDOMAIN.EDU"
+     *      example3: src="HTTP://hello.world?
+     * @return void
+     */
+    protected function domain_swap($table, $column, $domain, $search) {
+        global $DB;
+
+        $renames = json_decode(get_config('tool_httpsreplace', 'renames'), true);
+
+        if (isset($renames[$domain])) {
+            $replace = preg_replace('|http://'.preg_quote($domain).'|i', 'https://' . $renames[$domain], $search);
+        } else {
+            $replace = preg_replace('|http://|i', 'https://', $search);
+        }
+        $DB->set_debug(true);
+        $DB->replace_all_text($table, $column, $search, $replace);
+        $DB->set_debug(false);
+    }
+
+    /**
+     * Returns SQL to be used to match embedded http links in the given column
+     *
+     * @param string $columnname name of the column (ready to be used in the SQL query)
+     * @return array
+     */
+    protected function get_select_search_in_column($columnname) {
+        global $DB;
+
+        if ($DB->sql_regex_supported()) {
+            // Database supports regex, use it for better match.
+            $select = $columnname . ' ' . $DB->sql_regex() . ' ?';
+            $params = ["(src|data)\ *=\ *[\\\"\']http://"];
+        } else {
+            // Databases without regex support should use case-insensitive LIKE.
+            // This will have false positive matches and more results than we need, we'll have to filter them in php.
+            $select = $DB->sql_like($columnname, '?', false);
+            $params = ['%=%http://%'];
+        }
+
+        return [$select, $params];
+    }
+
+    /**
+     * Originally forked from core function db_search().
+     * @param bool $replacing Whether or not to replace the found urls.
+     * @param progress_bar $progress Progress bar keeping track of this process.
+     * @return bool|array If $replacing, return true on success. If not, return hash of http urls to number of times used.
+     */
+    protected function process($replacing = false, $progress = null) {
+        global $DB, $CFG;
+
+        require_once($CFG->libdir.'/filelib.php');
+
+        // TODO: block_instances have HTML content as base64, need to decode then
+        // search, currently just skipped. See MDL-60024.
+        $skiptables = array(
+            'block_instances',
+            'config',
+            'config_log',
+            'config_plugins',
+            'events_queue',
+            'files',
+            'filter_config',
+            'grade_grades_history',
+            'grade_items_history',
+            'log',
+            'logstore_standard_log',
+            'repository_instance_config',
+            'sessions',
+            'upgrade_log',
+            'grade_categories_history',
+            '',
+        );
+
+        // Turn off time limits.
+        \core_php_time_limit::raise();
+        if (!$tables = $DB->get_tables() ) {    // No tables yet at all.
+            return false;
+        }
+
+        $urls = array();
+
+        $numberoftables = count($tables);
+        $tablenumber = 0;
+        foreach ($tables as $table) {
+            if ($progress) {
+                $progress->update($tablenumber, $numberoftables, get_string('searching', 'tool_httpsreplace', $table));
+                $tablenumber++;
+            }
+            if (in_array($table, $skiptables)) {
+                continue;
+            }
+            if ($columns = $DB->get_columns($table)) {
+                foreach ($columns as $column) {
+
+                    // Only convert columns that are either text or long varchar.
+                    if ($column->meta_type == 'X' || ($column->meta_type == 'C' && $column->max_length > 255)) {
+                        $columnname = $column->name;
+                        $columnnamequoted = $DB->get_manager()->generator->getEncQuoted($columnname);
+                        list($select, $params) = $this->get_select_search_in_column($columnnamequoted);
+                        $rs = $DB->get_recordset_select($table, $select, $params, '', $columnnamequoted);
+
+                        $found = array();
+                        foreach ($rs as $record) {
+                            // Regex to match src=http://etc. and data=http://etc.urls.
+                            // Standard warning on expecting regex to perfectly parse HTML
+                            // read http://stackoverflow.com/a/1732454 for more info.
+                            $regex = '#((src|data)\ *=\ *[\'\"])(http://)([^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))[\'\"]#i';
+                            preg_match_all($regex, $record->$columnname, $match);
+                            foreach ($match[0] as $i => $fullmatch) {
+                                if (strpos($fullmatch, $CFG->wwwroot) !== false) {
+                                    continue;
+                                }
+                                $prefix = $match[1][$i];
+                                $protocol = $match[3][$i];
+                                $url = $protocol . $match[4][$i];
+                                $host = \core_text::strtolower(parse_url($url, PHP_URL_HOST));
+                                if (empty($host)) {
+                                    continue;
+                                }
+                                if ($replacing) {
+                                    // For replace string use: prefix, protocol, host and one extra character.
+                                    $found[$prefix . substr($url, 0, strlen($host) + 8)] = $host;
+                                } else {
+                                    $entry["table"] = $table;
+                                    $entry["columnname"] = $columnname;
+                                    $entry["url"] = $url;
+                                    $entry["host"] = $host;
+                                    $entry["raw"] = $record->$columnname;
+                                    $entry["ssl"] = '';
+                                    $urls[] = $entry;
+                                }
+                            }
+                        }
+                        $rs->close();
+
+                        if ($replacing) {
+                            foreach ($found as $search => $domain) {
+                                $this->domain_swap($table, $column, $domain, $search);
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        if ($replacing) {
+            rebuild_course_cache(0, true);
+            purge_all_caches();
+            return true;
+        }
+
+        $domains = array_map(function ($i) {
+            return $i['host'];
+        }, $urls);
+
+        $uniquedomains = array_unique($domains);
+
+        $sslfailures = array();
+
+        foreach ($uniquedomains as $domain) {
+            if (!$this->check_domain_availability("https://$domain/")) {
+                $sslfailures[] = $domain;
+            }
+        }
+
+        $results = array();
+        foreach ($urls as $url) {
+            $host = $url['host'];
+            foreach ($sslfailures as $badhost) {
+                if ($host == $badhost) {
+                    if (!isset($results[$host])) {
+                        $results[$host] = 1;
+                    } else {
+                        $results[$host]++;
+                    }
+                }
+            }
+        }
+        return $results;
+    }
+
+    /**
+     * Check if url is available (GET request returns 200)
+     *
+     * @param string $url
+     * @return bool
+     */
+    protected function check_domain_availability($url) {
+        $curl = new \curl();
+        $curl->head($url);
+        $info = $curl->get_info();
+        return !empty($info['http_code']) && $info['http_code'] == 200;
+    }
+}
diff --git a/admin/tool/httpsreplace/cli/url_replace.php b/admin/tool/httpsreplace/cli/url_replace.php
new file mode 100644 (file)
index 0000000..7945a77
--- /dev/null
@@ -0,0 +1,93 @@
+<?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/>.
+
+/**
+ * url_replace cli script. Examines DB for non-https src or data links, and lists broken ones or replaces all links.
+ *
+ * @package    tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('CLI_SCRIPT', true);
+require(__DIR__ . '/../../../../config.php');
+require_once($CFG->libdir.'/clilib.php');
+
+list($options, $unrecognized) = cli_get_params(
+    array(
+        'help' => false,
+        'list' => false,
+        'replace' => false,
+        'confirm' => false,
+    ),
+    array(
+        'h' => 'help',
+        'l' => 'list',
+        'r' => 'replace',
+    )
+);
+if ($unrecognized) {
+    $unrecognized = implode("\n  ", $unrecognized);
+    cli_error(get_string('cliunknowoption', 'admin', $unrecognized), 2);
+}
+if ($options['help'] || (!$options['list'] && !$options['replace'])) {
+    $help = "Examines DB for non-https src or data links, and lists broken links or replaces all links.
+Options:
+-h, --help            Print out this help
+-l, --list            List of http (not https) urls on a site in the DB that would become broken.
+-r, --replace         List of http (not https) urls on a site in the DB that would become broken.
+--confirm             Replaces http urls with https across a site's content.
+Example:
+\$ sudo -u www-data /usr/bin/php admin/tool/httpsreplace/cli/url_replace.php --list \n";
+    echo $help;
+    exit(0);
+}
+
+if (!$DB->replace_all_text_supported()) {
+    echo $OUTPUT->notification(get_string('notimplemented', 'tool_httpsreplace'));
+    exit(1);
+}
+
+if (!is_https()) {
+    echo $OUTPUT->notification(get_string('httpwarning', 'tool_httpsreplace'), 'warning');
+    echo "\n";
+}
+
+if ($options['replace']) {
+
+    if ($options['confirm']) {
+
+        $urlfinder = new \tool_httpsreplace\url_finder();
+        $urlfinder->upgrade_http_links();
+    } else {
+        echo "Once this is tool run, changes made can't be reverted. \n" .
+             "A complete backup should be made before running this script. \n\n" .
+             "There is a low risk that the wrong content will be replaced, introducing problems. \n" .
+             "If you are sure you want to continue, add --confirm\n\n";
+    }
+
+} else {
+
+    $urlfinder = new \tool_httpsreplace\url_finder();
+    $results = $urlfinder->http_link_stats();
+    asort($urlfinder);
+    $fp = fopen('php://stdout', 'w');
+    fputcsv($fp, ['clientsite', 'httpdomain', 'urlcount']);
+    foreach ($results as $domain => $count) {
+        fputcsv($fp, [$SITE->shortname, $domain, $count]);
+    }
+    fclose($fp);
+}
diff --git a/admin/tool/httpsreplace/index.php b/admin/tool/httpsreplace/index.php
new file mode 100644 (file)
index 0000000..a3cec32
--- /dev/null
@@ -0,0 +1,59 @@
+<?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/>.
+
+/**
+ * Search and replace http -> https throughout all texts in the whole database
+ *
+ * @package    tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../../config.php');
+require_once($CFG->libdir . '/adminlib.php');
+
+admin_externalpage_setup('toolhttpsreplace');
+
+$context = context_system::instance();
+
+require_login();
+require_capability('moodle/site:config', $context);
+
+$PAGE->set_context($context);
+$PAGE->set_url(new moodle_url('/admin/tool/httpsreplace/index.php'));
+$PAGE->set_title(get_string('pageheader', 'tool_httpsreplace'));
+$PAGE->set_pagelayout('admin');
+
+echo $OUTPUT->header();
+
+echo $OUTPUT->heading(get_string('pageheader', 'tool_httpsreplace'));
+
+if (!$DB->replace_all_text_supported()) {
+    echo $OUTPUT->notification(get_string('notimplemented', 'tool_httpsreplace'));
+    echo $OUTPUT->footer();
+    die;
+}
+
+if (!is_https()) {
+    echo $OUTPUT->notification(get_string('httpwarning', 'tool_httpsreplace'), 'warning');
+}
+
+echo '<p>'.get_string('domainexplain', 'tool_httpsreplace').'</p>';
+echo '<p>'.page_doc_link(get_string('doclink', 'tool_httpsreplace')).'</p>';
+
+echo $OUTPUT->continue_button(new moodle_url('/admin/tool/httpsreplace/tool.php'));
+
+echo $OUTPUT->footer();
diff --git a/admin/tool/httpsreplace/lang/en/tool_httpsreplace.php b/admin/tool/httpsreplace/lang/en/tool_httpsreplace.php
new file mode 100644 (file)
index 0000000..9566874
--- /dev/null
@@ -0,0 +1,41 @@
+<?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/>.
+
+/**
+ * Strings for component 'tool_httpsreplace'
+ *
+ * @package    tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['complete'] = 'Completed.';
+$string['count'] = 'Number of embeded content items';
+$string['disclaimer'] = 'I understand the risks of this operation';
+$string['doclink'] = 'Read more documentation on the wiki';
+$string['doit'] = 'Perform replacement';
+$string['domain'] = 'Problematic domain';
+$string['domainexplain'] = 'When an instance is moved from HTTP to HTTPS, all embeded HTTP content will stop working. This tool allows you to automatically convert the HTTP content to HTTPS. Below you can run a report of content that may not work once you run this script. You may want to check each one has HTTPS available or find alternative resources.';
+$string['domainexplainhelp'] = 'These domains are found in your content, but do not appear to support HTTPS content. After switching to HTTPS, the content included from these sites will no longer display within Moodle for users with secure modern browsers. It is possible that these sites are temporarily or permanently unavailable and will not work with either security setting. Proceed only after reviewing these results and determining if this externally hosted content is non-essential. Note: This content would no longer work upon switching to HTTPS anyway.';
+$string['httpwarning'] = 'This instance is still running on HTTP. You can still run this tool and external content will be changed to HTTPS, but internal content will remain on HTTP. You will need to run this script again after switching to HTTPS to convert internal content.';
+$string['notimplemented'] = 'Sorry, this feature is not implemented in your database driver.';
+$string['oktoprocede'] = 'The scan finds no issues with your content. You can proceed to upgrade any HTTP content to use HTTPS.';
+$string['pageheader'] = 'Upgrade externally hosted content urls to HTTPS';
+$string['pluginname'] = 'HTTPS conversion tool';
+$string['replacing'] = 'Replacing HTTP content with HTTPS...';
+$string['searching'] = 'Searching {$a}';
+$string['takeabackupwarning'] = 'Once this is tool run, changes made can\'t be reverted. A complete backup should be made before running this script. There is a low risk that the wrong content will be replaced, introducing problems.';
+$string['toolintro'] = 'If you are planning on converting your site to HTTPS, you can use the <a href="{$a}">HTTPS conversion tool</a> to convert your embeded content to HTTPS.';
diff --git a/admin/tool/httpsreplace/settings.php b/admin/tool/httpsreplace/settings.php
new file mode 100644 (file)
index 0000000..bf5c259
--- /dev/null
@@ -0,0 +1,41 @@
+<?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/>.
+
+/**
+ * Link to http -> https replace script.
+ *
+ * @package tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+if ($hassiteconfig) {
+
+    $pluginname = get_string('pluginname', 'tool_httpsreplace');
+    $url = $CFG->wwwroot.'/'.$CFG->admin.'/tool/httpsreplace/index.php';
+    $ADMIN->add('security', new admin_externalpage('toolhttpsreplace', $pluginname, $url, 'moodle/site:config', true));
+
+    $httpsreplaceurl = $CFG->wwwroot.'/'.$CFG->admin.'/tool/httpsreplace/index.php';
+    $ADMIN->locate('httpsecurity')->add(
+        new admin_setting_heading(
+            'tool_httpsreplaceheader',
+            new lang_string('pluginname', 'tool_httpsreplace'),
+            new lang_string('toolintro', 'tool_httpsreplace', $httpsreplaceurl)
+        )
+    );
+}
diff --git a/admin/tool/httpsreplace/tests/behat/httpsreplace.feature b/admin/tool/httpsreplace/tests/behat/httpsreplace.feature
new file mode 100644 (file)
index 0000000..82bbf59
--- /dev/null
@@ -0,0 +1,29 @@
+@tool @tool_httpsreplace
+Feature: View the httpsreplace report
+  In order to switch to https
+  As an admin
+  I need to be able to automatically replace http links
+
+  Background: Create some http links
+    Given I am on site homepage
+    And the following "courses" exist:
+      | fullname | shortname | category | summary                                                                                                     |
+      | Course 1 | C1        | 0        | <img src="http://intentionally.unavailable/test.png"> <img src="http://download.moodle.org/unittest/test.jpg"> |
+    And I log in as "admin"
+
+  @javascript
+  Scenario: Go to the HTTPS replace report screen. Make sure broken domains are reported.
+    When I navigate to "HTTP security" node in "Site administration > Security"
+    And I follow "HTTPS conversion tool"
+    And I press "Continue"
+    Then I should see "intentionally.unavailable"
+
+  @javascript
+  Scenario: Use the find and replace tool.
+    When I navigate to "HTTP security" node in "Site administration > Security"
+    And I follow "HTTPS conversion tool"
+    And I press "Continue"
+    And I set the field "I understand the risks of this operation" to "1"
+    And I press "Perform replacement"
+    Then I should see "intentionally.unavailable"
+    And I should see "download.moodle.org"
diff --git a/admin/tool/httpsreplace/tests/httpsreplace_test.php b/admin/tool/httpsreplace/tests/httpsreplace_test.php
new file mode 100644 (file)
index 0000000..c10ea2f
--- /dev/null
@@ -0,0 +1,413 @@
+<?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/>.
+
+/**
+ * HTTPS find and replace Tests
+ *
+ * @package   tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_httpsreplace\tests;
+
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Tests the httpsreplace tool.
+ *
+ * @package   tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class httpsreplace_test extends \advanced_testcase {
+
+    /**
+     * Data provider for test_upgrade_http_links
+     */
+    public function upgrade_http_links_provider() {
+        global $CFG;
+        // Get the http url, since the default test wwwroot is https.
+        $wwwroothttp = preg_replace('/^https:/', 'http:', $CFG->wwwroot);
+        return [
+            "Test image from another site should be replaced" => [
+                "content" => '<img src="' . $this->getExternalTestFileUrl('/test.jpg', false) . '">',
+                "outputregex" => '/UPDATE/',
+                "expectedcontent" => '<img src="' . $this->getExternalTestFileUrl('/test.jpg', true) . '">',
+            ],
+            "Test object from another site should be replaced" => [
+                "content" => '<object data="' . $this->getExternalTestFileUrl('/test.swf', false) . '">',
+                "outputregex" => '/UPDATE/',
+                "expectedcontent" => '<object data="' . $this->getExternalTestFileUrl('/test.swf', true) . '">',
+            ],
+            "Test image from a site with international name should be replaced" => [
+                "content" => '<img src="http://中国互联网络信息中心.中国/logosy/201706/W01.png">',
+                "outputregex" => '/UPDATE/',
+                "expectedcontent" => '<img src="https://中国互联网络信息中心.中国/logosy/201706/W01.png">',
+            ],
+            "Link that is from this site should be replaced" => [
+                "content" => '<img src="' . $wwwroothttp . '/logo.png">',
+                "outputregex" => '/UPDATE/',
+                "expectedcontent" => '<img src="' . $CFG->wwwroot . '/logo.png">',
+            ],
+            "Link that is from this site, https new so doesn't need replacing" => [
+                "content" => '<img src="' . $CFG->wwwroot . '/logo.png">',
+                "outputregex" => '/^$/',
+                "expectedcontent" => '<img src="' . $CFG->wwwroot . '/logo.png">',
+            ],
+            "Unavailable image should be replaced" => [
+                "content" => '<img src="http://intentionally.unavailable/link1.jpg">',
+                "outputregex" => '/UPDATE/',
+                "expectedcontent" => '<img src="https://intentionally.unavailable/link1.jpg">',
+            ],
+            "Https content that has an http url as a param should not be replaced" => [
+                "content" => '<img src="https://anothersite.com?param=http://asdf.com">',
+                "outputregex" => '/^$/',
+                "expectedcontent" => '<img src="https://anothersite.com?param=http://asdf.com">',
+            ],
+            "Search for params should be case insensitive" => [
+                "content" => '<object DATA="' . $this->getExternalTestFileUrl('/test.swf', false) . '">',
+                "outputregex" => '/UPDATE/',
+                "expectedcontent" => '<object DATA="' . $this->getExternalTestFileUrl('/test.swf', true) . '">',
+            ],
+            "URL should be case insensitive" => [
+                "content" => '<object data="HTTP://some.site/path?query">',
+                "outputregex" => '/UPDATE/',
+                "expectedcontent" => '<object data="https://some.site/path?query">',
+            ],
+            "More params should not interfere" => [
+                "content" => '<img alt="A picture" src="' . $this->getExternalTestFileUrl('/test.png', false) .
+                    '" width="1”><p style="font-size: \'20px\'"></p>',
+                "outputregex" => '/UPDATE/',
+                "expectedcontent" => '<img alt="A picture" src="' . $this->getExternalTestFileUrl('/test.png', true) .
+                    '" width="1”><p style="font-size: \'20px\'"></p>',
+            ],
+            "Broken URL should not be changed" => [
+                "content" => '<img src="broken.' . $this->getExternalTestFileUrl('/test.png', false) . '">',
+                "outputregex" => '/^$/',
+                "expectedcontent" => '<img src="broken.' . $this->getExternalTestFileUrl('/test.png', false) . '">',
+            ],
+            "Link URL should not be changed" => [
+                "content" => '<a href="' . $this->getExternalTestFileUrl('/test.png', false) . '">' .
+                    $this->getExternalTestFileUrl('/test.png', false) . '</a>',
+                "outputregex" => '/^$/',
+                "expectedcontent" => '<a href="' . $this->getExternalTestFileUrl('/test.png', false) . '">' .
+                    $this->getExternalTestFileUrl('/test.png', false) . '</a>',
+            ],
+            "Test image from another site should be replaced but link should not" => [
+                "content" => '<a href="' . $this->getExternalTestFileUrl('/test.png', false) . '"><img src="' .
+                    $this->getExternalTestFileUrl('/test.jpg', false) . '"></a>',
+                "outputregex" => '/UPDATE/',
+                "expectedcontent" => '<a href="' . $this->getExternalTestFileUrl('/test.png', false) . '"><img src="' .
+                    $this->getExternalTestFileUrl('/test.jpg', true) . '"></a>',
+            ],
+        ];
+    }
+
+    /**
+     * Test upgrade_http_links
+     * @param string $content Example content that we'll attempt to replace.
+     * @param string $ouputregex Regex for what output we expect.
+     * @param string $expectedcontent What content we are expecting afterwards.
+     * @dataProvider upgrade_http_links_provider
+     */
+    public function test_upgrade_http_links($content, $ouputregex, $expectedcontent) {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->expectOutputRegex($ouputregex);
+
+        $finder = new tool_httpreplace_url_finder_test();
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course((object) [
+            'summary' => $content,
+        ]);
+
+        $finder->upgrade_http_links();
+
+        $summary = $DB->get_field('course', 'summary', ['id' => $course->id]);
+        $this->assertContains($expectedcontent, $summary);
+    }
+
+    /**
+     * Data provider for test_http_link_stats
+     */
+    public function http_link_stats_provider() {
+        global $CFG;
+        // Get the http url, since the default test wwwroot is https.
+        $wwwrootdomain = 'www.example.com';
+        $wwwroothttp = preg_replace('/^https:/', 'http:', $CFG->wwwroot);
+        $testdomain = 'download.moodle.org';
+        return [
+            "Test image from an available site so shouldn't be reported" => [
+                "content" => '<img src="' . $this->getExternalTestFileUrl('/test.jpg', false) . '">',
+                "domain" => $testdomain,
+                "expectedcount" => 0,
+            ],
+            "Link that is from this site shouldn't be reported" => [
+                "content" => '<img src="' . $wwwroothttp . '/logo.png">',
+                "domain" => $wwwrootdomain,
+                "expectedcount" => 0,
+            ],
+            "Unavailable, but https shouldn't be reported" => [
+                "content" => '<img src="https://intentionally.unavailable/logo.png">',
+                "domain" => 'intentionally.unavailable',
+                "expectedcount" => 0,
+            ],
+            "Unavailable image should be reported" => [
+                "content" => '<img src="http://intentionally.unavailable/link1.jpg">',
+                "domain" => 'intentionally.unavailable',
+                "expectedcount" => 1,
+            ],
+            "Unavailable object should be reported" => [
+                "content" => '<object data="http://intentionally.unavailable/file.swf">',
+                "domain" => 'intentionally.unavailable',
+                "expectedcount" => 1,
+            ],
+            "Link should not be reported" => [
+                "content" => '<a href="http://intentionally.unavailable/page.php">Link</a>',
+                "domain" => 'intentionally.unavailable',
+                "expectedcount" => 0,
+            ],
+            "Text should not be reported" => [
+                "content" => 'http://intentionally.unavailable/page.php',
+                "domain" => 'intentionally.unavailable',
+                "expectedcount" => 0,
+            ],
+        ];
+    }
+
+    /**
+     * Test http_link_stats
+     * @param string $content Example content that we'll attempt to replace.
+     * @param string $domain The domain we will check was replaced.
+     * @param string $expectedcount Number of urls from that domain that we expect to be replaced.
+     * @dataProvider http_link_stats_provider
+     */
+    public function test_http_link_stats($content, $domain, $expectedcount) {
+        $this->resetAfterTest();
+
+        $finder = new tool_httpreplace_url_finder_test();
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course((object) [
+            'summary' => $content,
+        ]);
+
+        $results = $finder->http_link_stats();
+
+        $this->assertEquals($expectedcount, $results[$domain] ?? 0);
+    }
+
+    /**
+     * Test links and text are not changed
+     */
+    public function test_links_and_text() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->expectOutputRegex('/^$/');
+
+        $finder = new tool_httpreplace_url_finder_test();
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course((object) [
+            'summary' => '<a href="http://intentionally.unavailable/page.php">Link</a> http://other.unavailable/page.php',
+        ]);
+
+        $results = $finder->http_link_stats();
+        $this->assertCount(0, $results);
+
+        $finder->upgrade_http_links();
+
+        $results = $finder->http_link_stats();
+        $this->assertCount(0, $results);
+
+        $summary = $DB->get_field('course', 'summary', ['id' => $course->id]);
+        $this->assertContains('http://intentionally.unavailable/page.php', $summary);
+        $this->assertContains('http://other.unavailable/page.php', $summary);
+        $this->assertNotContains('https://intentionally.unavailable', $summary);
+        $this->assertNotContains('https://other.unavailable', $summary);
+    }
+
+    /**
+     * If we have an http wwwroot then we shouldn't report it.
+     */
+    public function test_httpwwwroot() {
+        global $DB, $CFG;
+
+        $this->resetAfterTest();
+        $CFG->wwwroot = preg_replace('/^https:/', 'http:', $CFG->wwwroot);
+        $this->expectOutputRegex('/^$/');
+
+        $finder = new tool_httpreplace_url_finder_test();
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course((object) [
+            'summary' => '<img src="' . $CFG->wwwroot . '/image.png">',
+        ]);
+
+        $results = $finder->http_link_stats();
+        $this->assertCount(0, $results);
+
+        $finder->upgrade_http_links();
+        $summary = $DB->get_field('course', 'summary', ['id' => $course->id]);
+        $this->assertContains($CFG->wwwroot, $summary);
+    }
+
+    /**
+     * Test that links in excluded tables are not replaced
+     */
+    public function test_upgrade_http_links_excluded_tables() {
+        $this->resetAfterTest();
+
+        set_config('test_upgrade_http_links', '<img src="http://somesite/someimage.png" />');
+
+        $finder = new tool_httpreplace_url_finder_test();
+        ob_start();
+        $results = $finder->upgrade_http_links();
+        $output = ob_get_contents();
+        ob_end_clean();
+        $this->assertTrue($results);
+        $this->assertNotContains('https://somesite', $output);
+        $testconf = get_config('core', 'test_upgrade_http_links');
+        $this->assertContains('http://somesite', $testconf);
+        $this->assertNotContains('https://somesite', $testconf);
+    }
+
+    /**
+     * Test renamed domains
+     */
+    public function test_renames() {
+        global $DB, $CFG;
+        $this->resetAfterTest();
+        $this->expectOutputRegex('/UPDATE/');
+
+        $renames = [
+            'example.com' => 'secure.example.com',
+        ];
+
+        set_config('renames', json_encode($renames), 'tool_httpsreplace');
+
+        $finder = new tool_httpreplace_url_finder_test();
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course((object) [
+            'summary' => '<script src="http://example.com/test.js"><img src="http://EXAMPLE.COM/someimage.png">',
+        ]);
+
+        $results = $finder->http_link_stats();
+        $this->assertCount(0, $results);
+
+        $finder->upgrade_http_links();
+
+        $summary = $DB->get_field('course', 'summary', ['id' => $course->id]);
+        $this->assertContains('https://secure.example.com', $summary);
+        $this->assertNotContains('http://example.com', $summary);
+        $this->assertEquals('<script src="https://secure.example.com/test.js">' .
+            '<img src="https://secure.example.com/someimage.png">', $summary);
+    }
+
+    /**
+     * When there are many different pieces of contents from the same site, we should only run replace once
+     */
+    public function test_multiple() {
+        global $DB;
+        $this->resetAfterTest();
+        $original1 = '';
+        $expected1 = '';
+        $original2 = '';
+        $expected2 = '';
+        for ($i = 0; $i < 15; $i++) {
+            $original1 .= '<img src="http://example.com/image' . $i . '.png">';
+            $expected1 .= '<img src="https://example.com/image' . $i . '.png">';
+            $original2 .= '<img src="http://example.com/image' . ($i + 15 ) . '.png">';
+            $expected2 .= '<img src="https://example.com/image' . ($i + 15) . '.png">';
+        }
+        $finder = new tool_httpreplace_url_finder_test();
+
+        $generator = $this->getDataGenerator();
+        $course1 = $generator->create_course((object) ['summary' => $original1]);
+        $course2 = $generator->create_course((object) ['summary' => $original2]);
+
+        ob_start();
+        $finder->upgrade_http_links();
+        $output = ob_get_contents();
+        ob_end_clean();
+
+        // Make sure everything is replaced.
+        $summary1 = $DB->get_field('course', 'summary', ['id' => $course1->id]);
+        $this->assertEquals($expected1, $summary1);
+        $summary2 = $DB->get_field('course', 'summary', ['id' => $course2->id]);
+        $this->assertEquals($expected2, $summary2);
+
+        // Make sure only one UPDATE statment was called.
+        $this->assertEquals(1, preg_match_all('/UPDATE/', $output));
+    }
+
+    /**
+     * Test the tool when the column name is a reserved word in SQL (in this case 'where')
+     */
+    public function test_reserved_words() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->expectOutputRegex('/UPDATE/');
+
+        // Create a table with a field that is a reserved SQL word.
+        $dbman = $DB->get_manager();
+        $table = new \xmldb_table('reserved_words_temp');
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('where', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+        $dbman->create_table($table);
+
+        // Insert a record with an <img> in this table and run tool.
+        $content = '<img src="http://example.com/image.png">';
+        $expectedcontent = '<img src="https://example.com/image.png">';
+        $columnamequoted = $dbman->generator->getEncQuoted('where');
+        $DB->execute("INSERT INTO {reserved_words_temp} ($columnamequoted) VALUES (?)", [$content]);
+
+        $finder = new tool_httpreplace_url_finder_test();
+        $finder->upgrade_http_links();
+
+        $record = $DB->get_record('reserved_words_temp', []);
+        $this->assertContains($expectedcontent, $record->where);
+
+        $dbman->drop_table($table);
+    }
+}
+
+/**
+ * Class tool_httpreplace_url_finder_test for testing replace tool without calling curl
+ *
+ * @package   tool_httpsreplace
+ * @copyright 2017 Marina Glancy
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_httpreplace_url_finder_test extends \tool_httpsreplace\url_finder {
+    /**
+     * Check if url is available (check hardcoded for unittests)
+     *
+     * @param string $url
+     * @return bool
+     */
+    protected function check_domain_availability($url) {
+        return !preg_match('|\.unavailable/$|', $url);
+    }
+}
diff --git a/admin/tool/httpsreplace/tool.php b/admin/tool/httpsreplace/tool.php
new file mode 100644 (file)
index 0000000..12d5acb
--- /dev/null
@@ -0,0 +1,109 @@
+<?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/>.
+
+/**
+ * Search and replace http -> https throughout all texts in the whole database
+ *
+ * @package    tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('NO_OUTPUT_BUFFERING', true);
+
+require_once(__DIR__ . '/../../../config.php');
+require_once($CFG->dirroot . '/course/lib.php');
+require_once($CFG->libdir . '/adminlib.php');
+
+admin_externalpage_setup('toolhttpsreplace');
+
+$context = context_system::instance();
+
+require_login();
+require_capability('moodle/site:config', $context);
+
+$PAGE->set_context($context);
+$PAGE->set_url(new moodle_url('/admin/tool/httpsreplace/index.php'));
+$PAGE->set_title(get_string('pageheader', 'tool_httpsreplace'));
+$PAGE->set_pagelayout('admin');
+
+echo $OUTPUT->header();
+
+echo $OUTPUT->heading(get_string('pageheader', 'tool_httpsreplace'));
+
+if (!$DB->replace_all_text_supported()) {
+    echo $OUTPUT->notification(get_string('notimplemented', 'tool_httpsreplace'));
+    echo $OUTPUT->footer();
+    die;
+}
+
+if (!is_https()) {
+    echo $OUTPUT->notification(get_string('httpwarning', 'tool_httpsreplace'), 'warning');
+}
+
+$form = new \tool_httpsreplace\form();
+
+$finder = new \tool_httpsreplace\url_finder();
+
+$PAGE->set_cacheable(false);
+$progressbar = new progress_bar();
+
+if (!$data = $form->get_data()) {
+
+    echo $progressbar->create();
+
+    $results = $finder->http_link_stats($progressbar);
+
+    $progressbar->update_full(100, get_string('complete', 'tool_httpsreplace'));
+
+    if (empty($results)) {
+        echo '<p>'.get_string('oktoprocede', 'tool_httpsreplace').'</p>';
+    } else {
+        arsort($results);
+        $table = new html_table();
+        $table->id = 'plugins-check';
+        $table->head = array(
+            get_string('domain', 'tool_httpsreplace'),
+            get_string('count', 'tool_httpsreplace'),
+        );
+        $data = array();
+        foreach ($results as $domain => $count) {
+            $cleandomain = format_text($domain, FORMAT_PLAIN);
+            $data[] = [$cleandomain, $count];
+        }
+        $table->data = $data;
+        echo html_writer::table($table);
+        echo get_string('domainexplainhelp', 'tool_httpsreplace');
+    }
+    echo $OUTPUT->notification(get_string('takeabackupwarning', 'tool_httpsreplace'), 'warning');
+    $form->display();
+} else {
+    // Scroll to the end when finished.
+    $PAGE->requires->js_init_code("window.scrollTo(0, document.body.scrollHeight);");
+
+    echo html_writer::tag('p', get_string('replacing', 'tool_httpsreplace'));
+
+    echo $progressbar->create();
+
+    echo $OUTPUT->box_start();
+    $finder->upgrade_http_links($progressbar);
+    echo $OUTPUT->box_end();
+
+    $progressbar->update_full(100, get_string('complete', 'tool_httpsreplace'));
+
+    echo $OUTPUT->continue_button(new moodle_url('/admin/settings.php', ['section' => 'httpsecurity']));
+}
+echo $OUTPUT->footer();
diff --git a/admin/tool/httpsreplace/version.php b/admin/tool/httpsreplace/version.php
new file mode 100644 (file)
index 0000000..350331c
--- /dev/null
@@ -0,0 +1,29 @@
+<?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/>.
+
+/**
+ * Version details.
+ *
+ * @package    tool_httpsreplace
+ * @copyright Copyright (c) 2016 Blackboard Inc. (http://www.blackboard.com)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->version   = 2017082500; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->requires  = 2017082400; // Requires this Moodle version.
+$plugin->component = 'tool_httpsreplace'; // Full name of the plugin (used for diagnostics).
index 493b7da..8741016 100644 (file)
@@ -30,6 +30,7 @@ use context_system;
 use moodle_url;
 use moodle_exception;
 use lang_string;
+use curl;
 
 /**
  * API exposed by tool_mobile, to be used mostly by external functions and the plugin settings.
@@ -367,4 +368,86 @@ class api {
 
         return $features;
     }
+
+    /**
+     * This function check the current site for potential configuration issues that may prevent the mobile app to work.
+     *
+     * @return array list of potential issues
+     * @since  Moodle 3.4
+     */
+    public static function get_potential_config_issues() {
+        global $CFG;
+        require_once($CFG->dirroot . "/lib/filelib.php");
+        require_once($CFG->dirroot . '/message/lib.php');
+
+        $warnings = array();
+
+        $curl = new curl();
+        // Return certificate information and verify the certificate.
+        $curl->setopt(array('CURLOPT_CERTINFO' => 1, 'CURLOPT_SSL_VERIFYPEER' => true));
+        $httpswwwroot = str_replace('http:', 'https:', $CFG->wwwroot); // Force https url.
+        $curl->head($httpswwwroot . "/login/index.php");
+        $info = $curl->get_info();
+
+        // First of all, check the server certificate (if any).
+        if (empty($info['http_code']) or ($info['http_code'] >= 400)) {
+            $warnings[] = ['nohttpsformobilewarning', 'admin'];
+        } else {
+            // Check the certificate is not self-signed or has an untrusted-root.
+            // This may be weak in some scenarios (when the curl SSL verifier is outdated).
+            if (empty($info['certinfo'])) {
+                $warnings[] = ['selfsignedoruntrustedcertificatewarning', 'tool_mobile'];
+            } else {
+                $timenow = time();
+                $expectedissuer = null;
+                foreach ($info['certinfo'] as $cert) {
+                    // Check if the signature algorithm is weak (Android won't work with SHA-1).
+                    if ($cert['Signature Algorithm'] == 'sha1WithRSAEncryption' || $cert['Signature Algorithm'] == 'sha1WithRSA') {
+                        $warnings[] = ['insecurealgorithmwarning', 'tool_mobile'];
+                    }
+                    // Check certificate start date.
+                    if (strtotime($cert['Start date']) > $timenow) {
+                        $warnings[] = ['invalidcertificatestartdatewarning', 'tool_mobile'];
+                    }
+                    // Check certificate end date.
+                    if (strtotime($cert['Expire date']) < $timenow) {
+                        $warnings[] = ['invalidcertificateexpiredatewarning', 'tool_mobile'];
+                    }
+                    // Check the chain.
+                    if ($expectedissuer !== null) {
+                        if ($expectedissuer !== $cert['Subject'] || $cert['Subject'] === $cert['Issuer']) {
+                            $warnings[] = ['invalidcertificatechainwarning', 'tool_mobile'];
+                        }
+                    }
+                    $expectedissuer = $cert['Issuer'];
+                }
+            }
+        }
+        // Now check typical configuration problems.
+        if ((int) $CFG->userquota === PHP_INT_MAX) {
+            // In old Moodle version was a text so was possible to have numeric values > PHP_INT_MAX.
+            $warnings[] = ['invaliduserquotawarning', 'tool_mobile'];
+        }
+        // Check ADOdb debug enabled.
+        if (get_config('auth_db', 'debugauthdb') || get_config('enrol_database', 'debugdb')) {
+            $warnings[] = ['adodbdebugwarning', 'tool_mobile'];
+        }
+        // Check display errors on.
+        if (!empty($CFG->debugdisplay)) {
+            $warnings[] = ['displayerrorswarning', 'tool_mobile'];
+        }
+        // Check mobile notifications.
+        $processors = get_message_processors();
+        $enabled = false;
+        foreach ($processors as $processor => $status) {
+            if ($processor == 'airnotifier' && $status->enabled) {
+                $enabled = true;
+            }
+        }
+        if (!$enabled) {
+            $warnings[] = ['mobilenotificationsdisabledwarning', 'tool_mobile'];
+        }
+
+        return $warnings;
+    }
 }
index c8b207c..d82695a 100644 (file)
@@ -22,6 +22,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['adodbdebugwarning'] = 'ADOdb debugging is enabled. It should be disabled in the external database authentication or external database enrolment plugin settings.';
 $string['androidappid'] = 'Android app\'s unique identifier';
 $string['androidappid_desc'] = 'This setting may be left as default unless you have a custom Android app.';
 $string['autologinkeygenerationlockout'] = 'Auto-login key generation is blocked because of too many requests within an hour.';
@@ -49,6 +50,7 @@ Mis calificaciones|https://someurl.xyz/local/mygrades/index.php|embedded|es
 </pre>';
 $string['disabledfeatures'] = 'Disabled features';
 $string['disabledfeatures_desc'] = 'Select here the features you want to disable in the Mobile app for your site. Please note that some features listed here could be already disabled via other site settings. You will have to log out and log in again in the app to see the changes.';
+$string['displayerrorswarning'] = 'Display debug messages (debugdisplay) is enabled. It should be disabled.';
 $string['enablesmartappbanners'] = 'Enable App Banners';
 $string['enablesmartappbanners_desc'] = 'If enabled, a banner promoting the mobile app will be displayed when accessing the site using a mobile browser.';
 $string['forcedurlscheme'] = 'If you want to allow only your custom branded app to be opened via a browser window, then specify its URL scheme here; otherwise leave the field empty.';
@@ -56,7 +58,12 @@ $string['forcedurlscheme_key'] = 'URL scheme';
 $string['forcelogout'] = 'Force log out';
 $string['forcelogout_desc'] = 'If enabled, the app option \'Change site\' is replaced by \'Log out\'. This results in the user being completely logged out. They must then re-enter their password the next time they wish to access the site.';
 $string['httpsrequired'] = 'HTTPS required';
+$string['insecurealgorithmwarning'] = 'It seems that the HTTPS certificate uses an insecure algorithm for signing (SHA-1). Please try updating the certificate.';
+$string['invalidcertificatechainwarning'] = 'It seems that the certificate chain is invalid.';
+$string['invalidcertificateexpiredatewarning'] = 'It seems that the HTTPS certificate for the site has expired.';
+$string['invalidcertificatestartdatewarning'] = 'It seems that the HTTPS certificate for the site is not yet valid (with a start date in the future).';
 $string['invalidprivatetoken'] = 'Invalid private token. Token should not be empty or passed via GET parameter.';
+$string['invaliduserquotawarning'] = 'The user quota (userquota) is set to an invalid number. It should be set to a valid number (an integer value) in Site policies.';
 $string['iosappid'] = 'iOS app\'s unique identifier';
 $string['iosappid_desc'] = 'This setting may be left as default unless you have a custom iOS app.';
 $string['loginintheapp'] = 'Via the app';
@@ -70,8 +77,10 @@ $string['mobileappearance'] = 'Mobile appearance';
 $string['mobileauthentication'] = 'Mobile authentication';
 $string['mobilecssurl'] = 'CSS';
 $string['mobilefeatures'] = 'Mobile features';
+$string['mobilenotificationsdisabledwarning'] = 'Mobile notifications are not enabled. They should be enabled in Manage message outputs.';
 $string['mobilesettings'] = 'Mobile settings';
 $string['pluginname'] = 'Moodle Mobile tools';
+$string['selfsignedoruntrustedcertificatewarning'] = 'It seems that the HTTPS certificate is self-signed or not trusted. The mobile app will only work with trusted sites.';
 $string['setuplink'] = 'App download page';
 $string['setuplink_desc'] = 'URL of page with links to download the mobile app from the App Store and Google Play.';
 $string['smartappbanners'] = 'App Banners';
index ece75c4..f5ee434 100644 (file)
@@ -63,4 +63,35 @@ class tool_mobile_api_testcase extends externallib_advanced_testcase {
         $this->assertTimeCurrent($key->validuntil - api::LOGIN_KEY_TTL);
         $this->assertEquals('0.0.0.0', $key->iprestriction);
     }
+
+    /**
+     * Test get_potential_config_issues.
+     */
+    public function test_get_potential_config_issues() {
+        global $CFG;
+        require_once($CFG->dirroot . '/message/lib.php');
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $CFG->userquota = '73289234723498234723423489273423497234234';
+        $CFG->debugdisplay = 1;
+        set_config('debugauthdb', 1, 'auth_db');
+        set_config('debugdb', 1, 'enrol_database');
+        $expectedissues = array('nohttpsformobilewarning', 'invaliduserquotawarning', 'adodbdebugwarning', 'displayerrorswarning',
+            'mobilenotificationsdisabledwarning');
+
+        $processors = get_message_processors();
+        foreach ($processors as $processor => $status) {
+            if ($processor == 'airnotifier' && $status->enabled) {
+                unset($expectedissues['mobilenotificationsdisabledwarning']);
+            }
+        }
+
+        $issues = api::get_potential_config_issues();
+        $this->assertCount(count($expectedissues), $issues);
+        foreach ($issues as $issue) {
+            $this->assertTrue(in_array($issue[0], $expectedissues));
+        }
+    }
 }
index 444e384..e56c325 100644 (file)
@@ -54,7 +54,7 @@ foreach ($allfunctions as $f) {
         //some plugins may want to have own test client forms
         include_once($CFG->dirroot.'/'.$finfo->testclientpath);
     }
-    $class = $f->name.'_form';
+    $class = $f->name.'_testclient_form';
     if (class_exists($class)) {
         $functions[$f->name] = $f->name;
         continue;
@@ -103,7 +103,7 @@ if (!$function or !$protocol) {
     die;
 }
 
-$class = $function.'_form';
+$class = $function.'_testclient_form';
 
 $mform = new $class(null, array('authmethod' => $authmethod));
 $mform->set_data(array('function'=>$function, 'protocol'=>$protocol));
index b01b78b..9f89f30 100644 (file)
@@ -29,19 +29,23 @@ class webservice_test_client_form extends moodleform {
 // === Test client forms ===
 
 /**
- * Form class for create_categories() web service function test.
+ * Base class for implementations of WS test client forms.
  *
  * @package   core_webservice
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @copyright 2012 Fabio Souto
+ * @copyright 2017 Marina Glancy
  */
-class core_course_create_categories_form extends moodleform {
+abstract class webservice_test_client_base_form extends moodleform {
+
+    /**
+     * Definition of the parameters used by this WS function
+     */
+    protected abstract function test_client_definition();
+
     /**
      * The form definition.
      */
     public function definition() {
-        global $CFG;
-
         $mform = $this->_form;
 
         $mform->addElement('header', 'wstestclienthdr', get_string('testclient', 'webservice'));
@@ -59,19 +63,7 @@ class core_course_create_categories_form extends moodleform {
         }
 
         $mform->addElement('hidden', 'authmethod', $data['authmethod']);
-        $mform->setType('authmethod', core_user::get_property_type('auth'));
-        $mform->addElement('text', 'name[0]', 'name[0]');
-        $mform->addElement('text', 'parent[0]', 'parent[0]');
-        $mform->addElement('text', 'idnumber[0]', 'idnumber[0]');
-        $mform->addElement('text', 'description[0]', 'description[0]');
-        $mform->addElement('text', 'name[1]', 'name[1]');
-        $mform->addElement('text', 'parent[1]', 'parent[1]');
-        $mform->addElement('text', 'idnumber[1]', 'idnumber[1]');
-        $mform->addElement('text', 'description[1]', 'description[1]');
-        $mform->setType('name', core_user::get_property_type('firstname'));
-        $mform->setType('parent', core_user::get_property_type('id'));
-        $mform->setType('idnumber', core_user::get_property_type('idnumber'));
-        $mform->setType('description', core_user::get_property_type('description'));
+        $mform->setType('authmethod', PARAM_ALPHA);
 
         $mform->addElement('hidden', 'function');
         $mform->setType('function', PARAM_PLUGIN);
@@ -79,6 +71,8 @@ class core_course_create_categories_form extends moodleform {
         $mform->addElement('hidden', 'protocol');
         $mform->setType('protocol', PARAM_ALPHA);
 
+        $this->test_client_definition();
+
         $this->add_action_buttons(true, get_string('execute', 'webservice'));
     }
 
@@ -90,19 +84,51 @@ class core_course_create_categories_form extends moodleform {
         if (!$data = $this->get_data()) {
             return null;
         }
-        // Remove unused from form data.
-        unset($data->submitbutton);
-        unset($data->protocol);
-        unset($data->function);
-        unset($data->wsusername);
-        unset($data->wspassword);
-        unset($data->token);
-        unset($data->authmethod);
+        return array_diff_key((array)$data, ['submitbutton' => 1, 'protocol' => 1, 'function' => 1,
+            'wsusername' => 1, 'wspassword' => 1, 'token' => 1, 'authmethod' => 1]);
+    }
+}
+
+/**
+ * Form class for create_categories() web service function test.
+ *
+ * @package   core_webservice
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @copyright 2012 Fabio Souto
+ */
+class core_course_create_categories_testclient_form extends webservice_test_client_base_form {
+    /**
+     * The form definition.
+     */
+    protected function test_client_definition() {
+        $mform = $this->_form;
+        $mform->addElement('text', 'name[0]', 'name[0]');
+        $mform->addElement('text', 'parent[0]', 'parent[0]');
+        $mform->addElement('text', 'idnumber[0]', 'idnumber[0]');
+        $mform->addElement('text', 'description[0]', 'description[0]');
+        $mform->addElement('text', 'name[1]', 'name[1]');
+        $mform->addElement('text', 'parent[1]', 'parent[1]');
+        $mform->addElement('text', 'idnumber[1]', 'idnumber[1]');
+        $mform->addElement('text', 'description[1]', 'description[1]');
+        $mform->setType('name', PARAM_TEXT);
+        $mform->setType('parent', PARAM_INT);
+        $mform->setType('idnumber', PARAM_RAW);
+        $mform->setType('description', PARAM_RAW);
+    }
+
+    /**
+     * Get the parameters that the user submitted using the form.
+     * @return array|null
+     */
+    public function get_params() {
+        if (!$data = $this->get_data()) {
+            return null;
+        }
 
         $params = array();
         $params['categories'] = array();
         for ($i=0; $i<10; $i++) {
-            if (empty($data->name[$i]) or empty($data->parent[$i])) {
+            if (empty($data->name[$i])) {
                 continue;
             }
             $params['categories'][] = array('name'=>$data->name[$i], 'parent'=>$data->parent[$i],
@@ -119,48 +145,21 @@ class core_course_create_categories_form extends moodleform {
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  * @copyright 2012 Fabio Souto
  */
-class core_course_delete_categories_form extends moodleform {
+class core_course_delete_categories_testclient_form extends webservice_test_client_base_form {
     /**
      * The form definition.
      */
-    public function definition() {
-        global $CFG;
-
+    protected function test_client_definition() {
         $mform = $this->_form;
-
-        $mform->addElement('header', 'wstestclienthdr', get_string('testclient', 'webservice'));
-
-        // Note: these values are intentionally PARAM_RAW - we want users to test any rubbish as parameters.
-        $data = $this->_customdata;
-        if ($data['authmethod'] == 'simple') {
-            $mform->addElement('text', 'wsusername', 'wsusername');
-            $mform->setType('wsusername', core_user::get_property_type('username'));
-            $mform->addElement('text', 'wspassword', 'wspassword');
-            $mform->setType('wspassword', core_user::get_property_type('password'));
-        } else if ($data['authmethod'] == 'token') {
-            $mform->addElement('text', 'token', 'token');
-            $mform->setType('token', PARAM_RAW_TRIMMED);
-        }
-
-        $mform->addElement('hidden', 'authmethod', $data['authmethod']);
-        $mform->setType('authmethod', core_user::get_property_type('auth'));
         $mform->addElement('text', 'id[0]', 'id[0]');
         $mform->addElement('text', 'newparent[0]', 'newparent[0]');
         $mform->addElement('text', 'recursive[0]', 'recursive[0]');
         $mform->addElement('text', 'id[1]', 'id[1]');
         $mform->addElement('text', 'newparent[1]', 'newparent[1]');
         $mform->addElement('text', 'recursive[1]', 'recursive[1]');
-        $mform->setType('id', core_user::get_property_type('id'));
+        $mform->setType('id', PARAM_INT);
         $mform->setType('newparent', PARAM_INT);
         $mform->setType('recursive', PARAM_BOOL);
-
-        $mform->addElement('hidden', 'function');
-        $mform->setType('function', PARAM_PLUGIN);
-
-        $mform->addElement('hidden', 'protocol');
-        $mform->setType('protocol', PARAM_ALPHA);
-
-        $this->add_action_buttons(true, get_string('execute', 'webservice'));
     }
 
     /**
@@ -171,15 +170,6 @@ class core_course_delete_categories_form extends moodleform {
         if (!$data = $this->get_data()) {
             return null;
         }
-        // Remove unused from form data.
-        unset($data->submitbutton);
-        unset($data->protocol);
-        unset($data->function);
-        unset($data->wsusername);
-        unset($data->wspassword);
-        unset($data->token);
-        unset($data->authmethod);
-
         $params = array();
         $params['categories'] = array();
         for ($i=0; $i<10; $i++) {
@@ -207,31 +197,12 @@ class core_course_delete_categories_form extends moodleform {
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  * @copyright 2012 Fabio Souto
  */
-class core_course_update_categories_form extends moodleform {
+class core_course_update_categories_testclient_form extends webservice_test_client_base_form {
     /**
      * The form definition.
      */
-    public function definition() {
-        global $CFG;
-
+    protected function test_client_definition() {
         $mform = $this->_form;
-
-        $mform->addElement('header', 'wstestclienthdr', get_string('testclient', 'webservice'));
-
-        // Note: these values are intentionally PARAM_RAW - we want users to test any rubbish as parameters.
-        $data = $this->_customdata;
-        if ($data['authmethod'] == 'simple') {
-            $mform->addElement('text', 'wsusername', 'wsusername');
-            $mform->setType('wsusername', core_user::get_property_type('username'));
-            $mform->addElement('text', 'wspassword', 'wspassword');
-            $mform->setType('wspassword', core_user::get_property_type('password'));
-        } else if ($data['authmethod'] == 'token') {
-            $mform->addElement('text', 'token', 'token');
-            $mform->setType('token', PARAM_RAW_TRIMMED);
-        }
-
-        $mform->addElement('hidden', 'authmethod', $data['authmethod']);
-        $mform->setType('authmethod', core_user::get_property_type('auth'));
         $mform->addElement('text', 'id[0]', 'id[0]');
         $mform->addElement('text', 'name[0]', 'name[0]');
         $mform->addElement('text', 'parent[0]', 'parent[0]');
@@ -242,19 +213,11 @@ class core_course_update_categories_form extends moodleform {
         $mform->addElement('text', 'parent[1]', 'parent[1]');
         $mform->addElement('text', 'idnumber[1]', 'idnumber[1]');
         $mform->addElement('text', 'description[1]', 'description[1]');
-        $mform->setType('id', core_user::get_property_type('id'));
-        $mform->setType('name', core_user::get_property_type('firstname'));
+        $mform->setType('id', PARAM_INT);
+        $mform->setType('name', PARAM_TEXT);
         $mform->setType('parent', PARAM_INT);
-        $mform->setType('idnumber', core_user::get_property_type('idnumber'));
-        $mform->setType('description', core_user::get_property_type('description'));
-
-        $mform->addElement('hidden', 'function');
-        $mform->setType('function', PARAM_PLUGIN);
-
-        $mform->addElement('hidden', 'protocol');
-        $mform->setType('protocol', PARAM_ALPHA);
-
-        $this->add_action_buttons(true, get_string('execute', 'webservice'));
+        $mform->setType('idnumber', PARAM_RAW);
+        $mform->setType('description', PARAM_RAW);
     }
 
     /**
@@ -265,15 +228,6 @@ class core_course_update_categories_form extends moodleform {
         if (!$data = $this->get_data()) {
             return null;
         }
-        // Remove unused from form data.
-        unset($data->submitbutton);
-        unset($data->protocol);
-        unset($data->function);
-        unset($data->wsusername);
-        unset($data->wspassword);
-        unset($data->token);
-        unset($data->authmethod);
-
         $params = array();
         $params['categories'] = array();
         for ($i=0; $i<10; $i++) {
@@ -299,4 +253,100 @@ class core_course_update_categories_form extends moodleform {
         }
         return $params;
     }
-}
\ No newline at end of file
+}
+
+/**
+ * Test class for WS function core_fetch_notifications
+ *
+ * @package   core_webservice
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @copyright 2017 Marina Glancy
+ */
+class core_fetch_notifications_testclient_form extends webservice_test_client_base_form {
+    /**
+     * The form definition.
+     */
+    protected function test_client_definition() {
+        $mform = $this->_form;
+        $mform->addElement('text', 'contextid', 'contextid');
+        $mform->setType('contextid', PARAM_INT);
+        $mform->setDefault('contextid', context_system::instance()->id);
+    }
+}
+
+/**
+ * Test class for WS function get_site_info
+ *
+ * @package   core_webservice
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @copyright 2017 Marina Glancy
+ */
+class core_webservice_get_site_info_testclient_form extends webservice_test_client_base_form {
+    /**
+     * The form definition.
+     */
+    protected function test_client_definition() {
+    }
+}
+
+/**
+ * Test class for WS function core_get_string
+ *
+ * @package   core_webservice
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @copyright 2017 Marina Glancy
+ */
+class core_get_string_testclient_form extends webservice_test_client_base_form {
+    /**
+     * The form definition.
+     */
+    protected function test_client_definition() {
+        $mform = $this->_form;
+        $mform->addElement('text', 'stringid', 'stringid');
+        $mform->setType('stringid', PARAM_STRINGID);
+        $mform->addElement('text', 'component', 'component');
+        $mform->setType('component', PARAM_COMPONENT);
+        $mform->addElement('text', 'lang', 'lang');
+        $mform->setType('lang', PARAM_LANG);
+        $mform->addElement('text', 'stringparams_name[1]', 'Parameter 1 name');
+        $mform->setType('stringparams_name[1]', PARAM_ALPHANUMEXT);
+        $mform->addElement('text', 'stringparams_value[1]', 'Parameter 1 value');
+        $mform->setType('stringparams_value[1]', PARAM_RAW);
+        $mform->addElement('text', 'stringparams_name[2]', 'Parameter 2 name');
+        $mform->setType('stringparams_name[2]', PARAM_ALPHANUMEXT);
+        $mform->addElement('text', 'stringparams_value[2]', 'Parameter 2 value');
+        $mform->setType('stringparams_value[2]', PARAM_RAW);
+        $mform->addElement('text', 'stringparams_name[3]', 'Parameter 3 name');
+        $mform->setType('stringparams_name[3]', PARAM_ALPHANUMEXT);
+        $mform->addElement('text', 'stringparams_value[3]', 'Parameter 3 value');
+        $mform->setType('stringparams_value[3]', PARAM_RAW);
+        $mform->addElement('static', 'paramnote', '', 'If a parameter is not an object, only specify "Parameter 1 value"');
+    }
+
+    /**
+     * Get the parameters that the user submitted using the form.
+     * @return array|null
+     */
+    public function get_params() {
+        $params = parent::get_params();
+        if ($params === null) {
+            return null;
+        }
+
+        $params['stringparams'] = [];
+        for ($idx = 1; $idx <= 3; $idx++) {
+            $name = isset($params['stringparams_name'][$idx]) ? strval($params['stringparams_name'][$idx]) : '';
+            $value = isset($params['stringparams_value'][$idx]) ? strval($params['stringparams_value'][$idx]) : '';
+            if ($name !== '' || $value !== '') {
+                if ($name === '') {
+                    $params['stringparams'][] = ['value' => $value];
+                } else {
+                    $params['stringparams'][] = ['name' => $name, 'value' => $value];
+                }
+            }
+        }
+        unset($params['stringparams_name']);
+        unset($params['stringparams_value']);
+        return $params;
+    }
+}
index 203ad55..c523697 100644 (file)
@@ -58,6 +58,15 @@ class admin_setting_predictor extends \admin_setting_configselect {
             return get_string('errorprocessornotready', 'analytics', $isready);
         }
 
+        $currentvalue = get_config('analytics', 'predictionsprocessor');
+        if (!empty($currentvalue) && $currentvalue != str_replace('\\\\', '\\', $data)) {
+            // Clear all models data.
+            $models = \core_analytics\manager::get_all_models();
+            foreach ($models as $model) {
+                $model->clear();
+            }
+        }
+
         return ($this->config_write($this->name, $data) ? '' : get_string('errorsetting', 'admin'));
     }
 }
index 4b457e8..b467168 100644 (file)
@@ -202,6 +202,61 @@ class dataset_manager {
             '/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/', self::EVALUATION_FILENAME);
     }
 
+    /**
+     * Gets the list of files that couldn't be previously used for training and prediction.
+     *
+     * @param int $modelid
+     * @param bool $includetarget
+     * @param string[] $timesplittingids
+     * @return null
+     */
+    public static function get_pending_files($modelid, $includetarget, $timesplittingids) {
+        global $DB;
+
+        $fs = get_file_storage();
+
+        if ($includetarget) {
+            $filearea = self::LABELLED_FILEAREA;
+            $usedfileaction = 'trained';
+        } else {
+            $filearea = self::UNLABELLED_FILEAREA;
+            $usedfileaction = 'predicted';
+        }
+
+        $select = 'modelid = :modelid AND action = :action';
+        $params = array('modelid' => $modelid, 'action' => $usedfileaction);
+        $usedfileids = $DB->get_fieldset_select('analytics_used_files', 'fileid', $select, $params);
+
+        // Very likely that we will only have 1 time splitting method here.
+        $filesbytimesplitting = array();
+        foreach ($timesplittingids as $timesplittingid) {
+
+            $filepath = '/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/';
+            $files = $fs->get_directory_files(\context_system::instance()->id, 'analytics', $filearea, $modelid, $filepath);
+            foreach ($files as $file) {
+
+                // Discard evaluation files.
+                if ($file->get_filename() === self::EVALUATION_FILENAME) {
+                    continue;
+                }
+
+                // No dirs.
+                if ($file->is_directory()) {
+                    continue;
+                }
+
+                // Already used for training.
+                if (in_array($file->get_id(), $usedfileids)) {
+                    continue;
+                }
+
+                $filesbytimesplitting[$timesplittingid][] = $file;
+            }
+        }
+
+        return $filesbytimesplitting;
+    }
+
     /**
      * Deletes previous evaluation files of this model.
      *
index e516977..8d792b4 100644 (file)
@@ -190,13 +190,13 @@ abstract class base {
         list($analysables, $processedanalysables) = $this->get_sorted_analysables($includetarget);
 
         $inittime = time();
-        foreach ($analysables as $analysable) {
+        foreach ($analysables as $key => $analysable) {
 
             $files = $this->process_analysable($analysable, $includetarget);
 
             // Later we will need to aggregate data by time splitting method.
             foreach ($files as $timesplittingid => $file) {
-                $filesbytimesplitting[$timesplittingid][$analysable->get_id()] = $file;
+                $filesbytimesplitting[$timesplittingid][] = $file;
             }
 
             $this->update_analysable_analysed_time($processedanalysables, $analysable->get_id(), $includetarget);
@@ -208,11 +208,35 @@ abstract class base {
                     break;
                 }
             }
+
+            unset($analysables[$key]);
+        }
+
+        if ($this->options['evaluation'] === false) {
+            // Look for previous training and prediction files we generated and couldn't be used
+            // by machine learning backends because they weren't big enough.
+
+            $pendingfiles = \core_analytics\dataset_manager::get_pending_files($this->modelid, $includetarget,
+                array_keys($filesbytimesplitting));
+            foreach ($pendingfiles as $timesplittingid => $files) {
+                foreach ($files as $file) {
+                    $filesbytimesplitting[$timesplittingid][] = $file;
+                }
+            }
         }
 
         // We join the datasets by time splitting method.
         $timesplittingfiles = $this->merge_analysable_files($filesbytimesplitting, $includetarget);
 
+        if (!empty($pendingfiles)) {
+            // We must remove them now as they are already part of another dataset.
+            foreach ($pendingfiles as $timesplittingid => $files) {
+                foreach ($files as $file) {
+                    $file->delete();
+                }
+            }
+        }
+
         return $timesplittingfiles;
     }
 
index 5b741d2..3e84120 100644 (file)
@@ -261,7 +261,7 @@ abstract class base extends \core_analytics\calculable {
     }
 
     /**
-     * Should the model callback be triggered?
+     * This method determines if a prediction is interesing for the model or not.
      *
      * @param mixed $predictedvalue
      * @param float $predictionscore
index cbd8fe0..26044c7 100644 (file)
@@ -152,7 +152,10 @@ abstract class discrete extends base {
     }
 
     /**
-     * Should the model callback be triggered?
+     * This method determines if a prediction is interesing for the model or not.
+     *
+     * This method internally calls ignored_predicted_classes to skip classes
+     * flagged by the target as not important for users.
      *
      * @param mixed $predictedvalue
      * @param float $predictionscore
index d16ad96..f10d843 100644 (file)
@@ -84,7 +84,7 @@ abstract class linear extends base {
     }
 
     /**
-     * Should the model callback be triggered?
+     * This method determines if a prediction is interesing for the model or not.
      *
      * @param mixed $predictedvalue
      * @param float $predictionscore
index 3f116f3..0fdbb3a 100644 (file)
@@ -469,6 +469,13 @@ class manager {
             '\mod_wiki\analytics\indicator\social_breadth',
             '\mod_workshop\analytics\indicator\cognitive_depth',
             '\mod_workshop\analytics\indicator\social_breadth',
+            '\core_course\analytics\indicator\completion_enabled',
+            '\core_course\analytics\indicator\potential_cognitive_depth',
+            '\core_course\analytics\indicator\potential_social_breadth',
+            '\core\analytics\indicator\any_access_after_end',
+            '\core\analytics\indicator\any_access_before_start',
+            '\core\analytics\indicator\any_write_action',
+            '\core\analytics\indicator\read_actions',
         );
         $indicators = array();
         foreach ($coiindicators as $coiindicator) {
@@ -483,7 +490,8 @@ class manager {
         $target = self::get_target('\core\analytics\target\no_teaching');
         $timesplittingmethod = '\core\analytics\time_splitting\single_range';
         $noteacher = self::get_indicator('\core_course\analytics\indicator\no_teacher');
-        $indicators = array($noteacher->get_id() => $noteacher);
+        $nostudent = self::get_indicator('\core_course\analytics\indicator\no_student');
+        $indicators = array($noteacher->get_id() => $noteacher, $nostudent->get_id() => $nostudent);
         if (!\core_analytics\model::exists($target, $indicators)) {
             \core_analytics\model::create($target, $indicators, $timesplittingmethod);
         }
index 44b9e1e..ef1435c 100644 (file)
@@ -53,12 +53,12 @@ class model {
     /**
      * Model with low prediction accuracy.
      */
-    const EVALUATE_LOW_SCORE = 4;
+    const LOW_SCORE = 4;
 
     /**
      * Not enough data to evaluate the model properly.
      */
-    const EVALUATE_NOT_ENOUGH_DATA = 8;
+    const NOT_ENOUGH_DATA = 8;
 
     /**
      * Invalid analysable for the time splitting method.
@@ -437,7 +437,7 @@ class model {
                 $this->model->indicators !== $indicatorsstr) {
 
             // Delete generated predictions before changing the model version.
-            $this->clear_model();
+            $this->clear();
 
             // It needs to be reset as the version changes.
             $this->uniqueid = null;
@@ -474,9 +474,9 @@ class model {
 
         \core_analytics\manager::check_can_manage_models();
 
-        $this->clear_model();
+        $this->clear();
 
-        // Method self::clear_model is already clearing the current model version.
+        // Method self::clear is already clearing the current model version.
         $predictor = \core_analytics\manager::get_predictions_processor();
         $predictor->delete_output_dir($this->get_output_dir(array(), true));
 
@@ -633,6 +633,10 @@ class model {
         $result->status = $predictorresult->status;
         $result->info = $predictorresult->info;
 
+        if ($result->status !== self::OK) {
+            return $result;
+        }
+
         $this->flag_file_as_used($samplesfile, 'trained');
 
         // Mark the model as trained if it wasn't.
@@ -689,7 +693,7 @@ class model {
         $samplesfile = $samplesdata[$this->model->timesplitting];
 
         // We need to throw an exception if we are trying to predict stuff that was already predicted.
-        $params = array('modelid' => $this->model->id, 'fileid' => $samplesfile->get_id(), 'action' => 'predicted');
+        $params = array('modelid' => $this->model->id, 'action' => 'predicted', 'fileid' => $samplesfile->get_id());
         if ($predicted = $DB->get_record('analytics_used_files', $params)) {
             throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id());
         }
@@ -717,6 +721,10 @@ class model {
             $result->predictions = $this->format_predictor_predictions($predictorresult);
         }
 
+        if ($result->status !== self::OK) {
+            return $result;
+        }
+
         if ($result->predictions) {
             $samplecontexts = $this->execute_prediction_callbacks($result->predictions, $indicatorcalculations);
         }
@@ -780,15 +788,16 @@ class model {
 
         // Here we will store all predictions' contexts, this will be used to limit which users will see those predictions.
         $samplecontexts = array();
+        $records = array();
 
         foreach ($predictions as $uniquesampleid => $prediction) {
 
-            if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
+            // The unique sample id contains both the sampleid and the rangeindex.
+            list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
 
-                // The unique sample id contains both the sampleid and the rangeindex.
-                list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
+            if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
 
-                // Store the predicted values.
+                // Prepare the record to store the predicted values.
                 list($record, $samplecontext) = $this->prepare_prediction_record($sampleid, $rangeindex, $prediction->prediction,
                     $prediction->predictionscore, json_encode($indicatorcalculations[$uniquesampleid]));
 
@@ -803,7 +812,9 @@ class model {
             }
         }
 
-        $this->save_predictions($records);
+        if (!empty($records)) {
+            $this->save_predictions($records);
+        }
 
         return $samplecontexts;
     }
@@ -990,7 +1001,7 @@ class model {
             }
 
             // Delete generated predictions before changing the model version.
-            $this->clear_model();
+            $this->clear();
 
             // It needs to be reset as the version changes.
             $this->uniqueid = null;
@@ -1268,7 +1279,7 @@ class model {
             $outputdir = rtrim($CFG->dataroot, '/') . DIRECTORY_SEPARATOR . 'models';
         }
 
-        // Append model id
+        // Append model id.
         $outputdir .= DIRECTORY_SEPARATOR . $this->model->id;
         if (!$onlymodelid) {
             // Append version + subdirs.
@@ -1435,9 +1446,11 @@ class model {
      *
      * @return void
      */
-    private function clear_model() {
+    public function clear() {
         global $DB;
 
+        \core_analytics\manager::check_can_manage_models();
+
         // Delete current model version stored stuff.
         $predictor = \core_analytics\manager::get_predictions_processor();
         $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
index d36baf5..3856c5f 100644 (file)
@@ -34,22 +34,30 @@ defined('MOODLE_INTERNAL') || die();
 class dataset_manager_testcase extends advanced_testcase {
 
     /**
-     * test_create_dataset
+     * setUp
      *
-     * @return
+     * @return null
      */
-    public function test_create_dataset() {
+    public function setUp() {
         $this->resetAfterTest(true);
 
-        $sharedtoprows = array(
+        $this->sharedtoprows = array(
             array('var1', 'var2'),
             array('value1', 'value2'),
             array('header1', 'header2')
         );
+    }
+
+    /**
+     * test_create_dataset
+     *
+     * @return null
+     */
+    public function test_create_dataset() {
 
         $dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
         $dataset1->init_process();
-        $dataset1data = array_merge($sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
+        $dataset1data = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
         $f1 = $dataset1->store($dataset1data);
         $dataset1->close_process();
 
@@ -63,26 +71,19 @@ class dataset_manager_testcase extends advanced_testcase {
     /**
      * test_merge_datasets
      *
-     * @return
+     * @return null
      */
     public function test_merge_datasets() {
-        $this->resetAfterTest(true);
-
-        $sharedtoprows = array(
-            array('var1', 'var2'),
-            array('value1', 'value2'),
-            array('header1', 'header2')
-        );
 
         $dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
         $dataset1->init_process();
-        $dataset1data = array_merge($sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
+        $dataset1data = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
         $f1 = $dataset1->store($dataset1data);
         $dataset1->close_process();
 
         $dataset2 = new \core_analytics\dataset_manager(1, 2, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
         $dataset2->init_process();
-        $dataset2data = array_merge($sharedtoprows, array(array('no', 'no', 'no')));
+        $dataset2data = array_merge($this->sharedtoprows, array(array('no', 'no', 'no')));
         $f2 = $dataset2->store($dataset2data);
         $dataset2->close_process();
 
@@ -97,4 +98,70 @@ class dataset_manager_testcase extends advanced_testcase {
         $this->assertContains('value1', $mergedfilecontents);
         $this->assertContains('header1', $mergedfilecontents);
     }
+
+    /**
+     * test_get_pending_files
+     *
+     * @return null
+     */
+    public function test_get_pending_files() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $fakemodelid = 123;
+        $timesplittingids = array(
+            '\core\analytics\time_splitting\quarters',
+            '\core\analytics\time_splitting\quarters_accum',
+        );
+
+        // No files.
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids));
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
+
+        // We will reuse this analysable file to create training and prediction datasets (analysable level files are
+        // merged into training and prediction files).
+        $analysabledataset = new \core_analytics\dataset_manager($fakemodelid, 1, 'whatever',
+            \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
+        $analysabledataset->init_process();
+        $analysabledatasetdata = array_merge($this->sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
+        $file = $analysabledataset->store($analysabledatasetdata);
+        $analysabledataset->close_process();
+
+        // Evaluation files ignored.
+        $evaluationdataset = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
+            '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, true);
+
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids));
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
+
+        // Training and prediction files are not mixed up.
+        $trainingfile1 = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
+            '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
+        $trainingfile2 = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
+            '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
+
+        $bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids);
+        $this->assertFalse(isset($bytimesplitting['\core\analytics\time_splitting\quarters_accum']));
+        $this->assertCount(2, $bytimesplitting['\core\analytics\time_splitting\quarters']);
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
+
+        $predictionfile = \core_analytics\dataset_manager::merge_datasets(array($file), $fakemodelid,
+            '\core\analytics\time_splitting\quarters', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, false);
+        $bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids);
+        $this->assertFalse(isset($bytimesplitting['\core\analytics\time_splitting\quarters_accum']));
+        $this->assertCount(1, $bytimesplitting['\core\analytics\time_splitting\quarters']);
+
+        // Already used for training and prediction are discarded.
+        $usedfile = (object)['modelid' => $fakemodelid, 'fileid' => $trainingfile1->get_id(), 'action' => 'trained',
+            'time' => time()];
+        $DB->insert_record('analytics_used_files', $usedfile);
+        $bytimesplitting = \core_analytics\dataset_manager::get_pending_files($fakemodelid, true, $timesplittingids);
+        $this->assertCount(1, $bytimesplitting['\core\analytics\time_splitting\quarters']);
+
+        $usedfile->fileid = $predictionfile->get_id();
+        $usedfile->action = 'predicted';
+        $DB->insert_record('analytics_used_files', $usedfile);
+        $this->assertEmpty(\core_analytics\dataset_manager::get_pending_files($fakemodelid, false, $timesplittingids));
+    }
 }
index ef2715a..2685b64 100644 (file)
@@ -155,13 +155,10 @@ class analytics_model_testcase extends advanced_testcase {
         $modelversionoutputdir = $this->model->get_output_dir();
         $this->assertTrue(is_dir($modelversionoutputdir));
 
-        // Update to an empty time splitting method to force clear_model execution.
-        $this->model->update(1, false, '');
+        // Update to an empty time splitting method to force model::clear execution.
+        $this->model->clear();
         $this->assertFalse(is_dir($modelversionoutputdir));
 
-        // Restore previous time splitting method.
-        $this->model->enable('\core\analytics\time_splitting\no_splitting');
-
         // Check that most of the stuff got deleted.
         $this->assertEquals(1, $DB->count_records('analytics_models', array('id' => $this->modelobj->id)));
         $this->assertEquals(1, $DB->count_records('analytics_models_log', array('modelid' => $this->modelobj->id)));
index 7f30037..f97db57 100644 (file)
@@ -273,6 +273,96 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         return $this->add_prediction_processors($cases);
     }
 
+    /**
+     * Test the system classifiers returns.
+     *
+     * This test checks that all mlbackend plugins in the system are able to return proper status codes
+     * even under weird situations.
+     *
+     * @dataProvider provider_ml_classifiers_return
+     * @param int $success
+     * @param int $nsamples
+     * @param int $classes
+     * @param string $predictionsprocessorclass
+     * @return void
+     */
+    public function test_ml_classifiers_return($success, $nsamples, $classes, $predictionsprocessorclass) {
+        $this->resetAfterTest();
+
+        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
+        if ($predictionsprocessor->is_ready() !== true) {
+            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
+        }
+
+        if ($nsamples % count($classes) != 0) {
+            throw new \coding_exception('The number of samples should be divisible by the number of classes');
+        }
+        $samplesperclass = $nsamples / count($classes);
+
+        // Metadata (we pass 2 classes even if $classes only provides 1 class samples as we want to test
+        // what the backend does in this case.
+        $dataset = "nfeatures,targetclasses,targettype" . PHP_EOL;
+        $dataset .= "3,\"[0,1]\",\"discrete\"" . PHP_EOL;
+
+        // Headers.
+        $dataset .= "feature1,feature2,feature3,target" . PHP_EOL;
+        foreach ($classes as $class) {
+            for ($i = 0; $i < $samplesperclass; $i++) {
+                $dataset .= "1,0,1,$class" . PHP_EOL;
+            }
+        }
+
+        $trainingfile = array(
+            'contextid' => \context_system::instance()->id,
+            'component' => 'analytics',
+            'filearea' => 'labelled',
+            'itemid' => 123,
+            'filepath' => '/',
+            'filename' => 'whocares.csv'
+        );
+        $fs = get_file_storage();
+        $dataset = $fs->create_file_from_string($trainingfile, $dataset);
+
+        // Training should work correctly if at least 1 sample of each class is included.
+        $dir = make_request_directory();
+        $result = $predictionsprocessor->train_classification('whatever', $dataset, $dir);
+
+        switch ($success) {
+            case 'yes':
+                $this->assertEquals(\core_analytics\model::OK, $result->status);
+                break;
+            case 'no':
+                $this->assertNotEquals(\core_analytics\model::OK, $result->status);
+                break;
+            case 'maybe':
+            default:
+                // We just check that an object is returned so we don't have an empty check,
+                // what we really want to check is that an exception was not thrown.
+                $this->assertInstanceOf(\stdClass::class, $result);
+        }
+    }
+
+    /**
+     * test_ml_classifiers_return provider
+     *
+     * We can not be very specific here as test_ml_classifiers_return only checks that
+     * mlbackend plugins behave and expected and control properly backend errors even
+     * under weird situations.
+     *
+     * @return array
+     */
+    public function provider_ml_classifiers_return() {
+        // Using verbose options as the first argument for readability.
+        $cases = array(
+            '1-samples' => array('maybe', 1, [0]),
+            '2-samples-same-class' => array('maybe', 2, [0]),
+            '2-samples-different-classes' => array('yes', 2, [0, 1]),
+            '4-samples-different-classes' => array('yes', 4, [0, 1])
+        );
+
+        // We need to test all system prediction processors.
+        return $this->add_prediction_processors($cases);
+    }
 
     /**
      * Basic test to check that prediction processors work as expected.
@@ -426,8 +516,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
                 'expectedresults' => array(
                     // The course duration is too much to be processed by in weekly basis.
                     '\core\analytics\time_splitting\weekly' => \core_analytics\model::NO_DATASET,
-                    '\core\analytics\time_splitting\single_range' => \core_analytics\model::EVALUATE_LOW_SCORE,
-                    '\core\analytics\time_splitting\quarters' => \core_analytics\model::EVALUATE_LOW_SCORE,
+                    '\core\analytics\time_splitting\single_range' => \core_analytics\model::LOW_SCORE,
+                    '\core\analytics\time_splitting\quarters' => \core_analytics\model::LOW_SCORE,
                 )
             ),
             'good' => array(
index e56dbdf..5e9b140 100644 (file)
@@ -74,32 +74,17 @@ function CAS_autoload($class)
 }
 
 // set up __autoload
-if (function_exists('spl_autoload_register')) {
-    if (!(spl_autoload_functions())
-        || !in_array('CAS_autoload', spl_autoload_functions())
+if (!(spl_autoload_functions())
+    || !in_array('CAS_autoload', spl_autoload_functions())
+) {
+    spl_autoload_register('CAS_autoload');
+    if (function_exists('__autoload')
+        && !in_array('__autoload', spl_autoload_functions())
     ) {
-        spl_autoload_register('CAS_autoload');
-        if (function_exists('__autoload')
-            && !in_array('__autoload', spl_autoload_functions())
-        ) {
-            // __autoload() was being used, but now would be ignored, add
-            // it to the autoload stack
-            spl_autoload_register('__autoload');
-        }
-    }
-} elseif (!function_exists('__autoload')) {
-
-    /**
-     * Autoload a class
-     *
-     * @param string $class Class name
-     *
-     * @return bool
-     */
-    function __autoload($class)
-    {
-        return CAS_autoload($class);
+        // __autoload() was being used, but now would be ignored, add
+        // it to the autoload stack
+        spl_autoload_register('__autoload');
     }
 }
 
-?>
\ No newline at end of file
+?>
index 62f9f91..b368b32 100644 (file)
@@ -1,3 +1,4 @@
 Description of phpCAS 1.3.5 library import
 
 * downloaded from http://downloads.jasig.org/cas-clients/php/current/
+* applied patch https://github.com/apereo/phpCAS/pull/247 for PHP 7.2 compatibility (MDL-60280)
\ No newline at end of file
index e2cc7de..5185c8b 100644 (file)
Binary files a/calendar/amd/build/drag_drop_data_store.min.js and b/calendar/amd/build/drag_drop_data_store.min.js differ
index a8888bf..6e6cbc3 100644 (file)
Binary files a/calendar/amd/build/month_navigation_drag_drop.min.js and b/calendar/amd/build/month_navigation_drag_drop.min.js differ
index b2ee13d..5381270 100644 (file)
Binary files a/calendar/amd/build/month_view_drag_drop.min.js and b/calendar/amd/build/month_view_drag_drop.min.js differ
index 6414f8e..893d11a 100644 (file)
@@ -30,6 +30,14 @@ define([], function() {
     var eventId = null;
     /* @var {int|null} durationDays How many days the event spans */
     var durationDays = null;
+    /* @var {int|null} minTimestart The earliest valid timestart */
+    var minTimestart = null;
+    /* @var {int|null} maxTimestart The latest valid tiemstart */
+    var maxTimestart = null;
+    /* @var {string|null} minError Error message for min timestamp violation */
+    var minError = null;
+    /* @var {string|null} maxError Error message for max timestamp violation */
+    var maxError = null;
 
     /**
      * Store the id of the event being dragged.
@@ -76,12 +84,108 @@ define([], function() {
         return durationDays;
     };
 
+    /**
+     * Store the minimum timestart valid for an event being dragged.
+     *
+     * @param {int} timestamp The unix timstamp
+     */
+    var setMinTimestart = function(timestamp) {
+        minTimestart = timestamp;
+    };
+
+    /**
+     * Get the minimum valid timestart.
+     *
+     * @return {int|null}
+     */
+    var getMinTimestart = function() {
+        return minTimestart;
+    };
+
+    /**
+     * Check if a minimum timestamp is set.
+     *
+     * @return {bool}
+     */
+    var hasMinTimestart = function() {
+        return minTimestart !== null;
+    };
+
+    /**
+     * Store the maximum timestart valid for an event being dragged.
+     *
+     * @param {int} timestamp The unix timstamp
+     */
+    var setMaxTimestart = function(timestamp) {
+        maxTimestart = timestamp;
+    };
+
+    /**
+     * Get the maximum valid timestart.
+     *
+     * @return {int|null}
+     */
+    var getMaxTimestart = function() {
+        return maxTimestart;
+    };
+
+    /**
+     * Check if a maximum timestamp is set.
+     *
+     * @return {bool}
+     */
+    var hasMaxTimestart = function() {
+        return maxTimestart !== null;
+    };
+
+    /**
+     * Store the error string to display if trying to drag an event
+     * earlier than the minimum allowed date.
+     *
+     * @param {string} message The error message
+     */
+    var setMinError = function(message) {
+        minError = message;
+    };
+
+    /**
+     * Get the error message for a minimum time start violation.
+     *
+     * @return {string|null}
+     */
+    var getMinError = function() {
+        return minError;
+    };
+
+    /**
+     * Store the error string to display if trying to drag an event
+     * later than the maximum allowed date.
+     *
+     * @param {string} message The error message
+     */
+    var setMaxError = function(message) {
+        maxError = message;
+    };
+
+    /**
+     * Get the error message for a maximum time start violation.
+     *
+     * @return {string|null}
+     */
+    var getMaxError = function() {
+        return maxError;
+    };
+
     /**
      * Reset all of the stored values.
      */
     var clearAll = function() {
         setEventId(null);
         setDurationDays(null);
+        setMinTimestart(null);
+        setMaxTimestart(null);
+        setMinError(null);
+        setMaxError(null);
     };
 
     return {
@@ -90,6 +194,16 @@ define([], function() {
         hasEventId: hasEventId,
         setDurationDays: setDurationDays,
         getDurationDays: getDurationDays,
+        setMinTimestart: setMinTimestart,
+        getMinTimestart: getMinTimestart,
+        hasMinTimestart: hasMinTimestart,
+        setMaxTimestart: setMaxTimestart,
+        getMaxTimestart: getMaxTimestart,
+        hasMaxTimestart: hasMaxTimestart,
+        setMinError: setMinError,
+        getMinError: getMinError,
+        setMaxError: setMaxError,
+        getMaxError: getMaxError,
         clearAll: clearAll
     };
 });
index 0e192df..dca8f61 100644 (file)
@@ -119,6 +119,11 @@ define([
      * @param {event} e The dragover event
      */
     var dragoverHandler = function(e) {
+        // Ignore dragging of non calendar events.
+        if (!DataStore.hasEventId()) {
+            return;
+        }
+
         e.preventDefault();
         var target = getTargetFromEvent(e);
 
@@ -153,6 +158,11 @@ define([
      * @param {event} e The dragstart event
      */
     var dragleaveHandler = function(e) {
+        // Ignore dragging of non calendar events.
+        if (!DataStore.hasEventId()) {
+            return;
+        }
+
         var target = getTargetFromEvent(e);
 
         if (!target) {
@@ -176,6 +186,11 @@ define([
      * @param {event} e The drop event
      */
     var dropHandler = function(e) {
+        // Ignore dragging of non calendar events.
+        if (!DataStore.hasEventId()) {
+            return;
+        }
+
         removeDropZoneIndicator();
         var target = getTargetFromEvent(e);
 
index 05b3733..b94af90 100644 (file)
  */
 define([
             'jquery',
+            'core/notification',
+            'core/str',
             'core_calendar/events',
             'core_calendar/drag_drop_data_store'
         ],
         function(
             $,
+            Notification,
+            Str,
             CalendarEvents,
             DataStore
         ) {
@@ -40,7 +44,10 @@ define([
         DROP_ZONE: '[data-drop-zone="month-view-day"]',
         WEEK: '[data-region="month-view-week"]',
     };
-    var HOVER_CLASS = 'bg-primary text-white';
+    var INVALID_DROP_ZONE_CLASS = 'bg-faded';
+    var INVALID_HOVER_CLASS = 'bg-danger text-white';
+    var VALID_HOVER_CLASS = 'bg-primary text-white';
+    var ALL_CLASSES = INVALID_DROP_ZONE_CLASS + ' ' + INVALID_HOVER_CLASS + ' ' + VALID_HOVER_CLASS;
     /* @var {bool} registered If the event listeners have been added */
     var registered = false;
 
@@ -56,10 +63,73 @@ define([
         return (dropZone.length) ? dropZone : null;
     };
 
+    /**
+     * Determine if the given dropzone element is within the acceptable
+     * time range.
+     *
+     * The drop zone timestamp is midnight on that day so we should check
+     * that the event's acceptable timestart value
+     *
+     * @param {object} dropZone The drop zone day from the calendar
+     * @return {bool}
+     */
+    var isValidDropZone = function(dropZone) {
+        var dropTimestamp = dropZone.attr('data-day-timestamp');
+        var minTimestart = DataStore.getMinTimestart();
+        var maxTimestart = DataStore.getMaxTimestart();
+
+        if (minTimestart && minTimestart > dropTimestamp) {
+            return false;
+        }
+
+        if (maxTimestart && maxTimestart < dropTimestamp) {
+            return false;
+        }
+
+        return true;
+    };
+
+    /**
+     * Get the error string to display for a given drop zone element
+     * if it is invalid.
+     *
+     * @param {object} dropZone The drop zone day from the calendar
+     * @return {string}
+     */
+    var getDropZoneError = function(dropZone) {
+        var dropTimestamp = dropZone.attr('data-day-timestamp');
+        var minTimestart = DataStore.getMinTimestart();
+        var maxTimestart = DataStore.getMaxTimestart();
+
+        if (minTimestart && minTimestart > dropTimestamp) {
+            return DataStore.getMinError();
+        }
+
+        if (maxTimestart && maxTimestart < dropTimestamp) {
+            return DataStore.getMaxError();
+        }
+
+        return null;
+    };
+
+    /**
+     * Remove all of the styling from each of the drop zones in the calendar.
+     */
+    var clearAllDropZonesState = function() {
+        $(SELECTORS.ROOT).find(SELECTORS.DROP_ZONE).each(function(index, dropZone) {
+            dropZone = $(dropZone);
+            dropZone.removeClass(ALL_CLASSES);
+        });
+    };
+
     /**
      * Update the hover state for the event in the calendar to reflect
      * which days the event will be moved to.
      *
+     * If the drop zone is not being hovered then it will apply some
+     * styling to reflect whether the drop zone is a valid or invalid
+     * drop place for the current dragging event.
+     *
      * This funciton supports events spanning multiple days and will
      * recurse to highlight (or remove highlight) each of the days
      * that the event will be moved to.
@@ -79,10 +149,22 @@ define([
             count = DataStore.getDurationDays();
         }
 
+        var valid = isValidDropZone(dropZone);
+        dropZone.removeClass(ALL_CLASSES);
+
         if (hovered) {
-            dropZone.addClass(HOVER_CLASS);
+
+            if (valid) {
+                dropZone.addClass(VALID_HOVER_CLASS);
+            } else {
+                dropZone.addClass(INVALID_HOVER_CLASS);
+            }
         } else {
-            dropZone.removeClass(HOVER_CLASS);
+            dropZone.removeClass(VALID_HOVER_CLASS + ' ' + INVALID_HOVER_CLASS);
+
+            if (!valid) {
+                dropZone.addClass(INVALID_DROP_ZONE_CLASS);
+            }
         }
 
         count--;
@@ -110,6 +192,21 @@ define([
         }
     };
 
+    /**
+     * Find all of the calendar event drop zones in the calendar and update the display
+     * for the user to indicate which zones are valid and invalid.
+     */
+    var updateAllDropZonesState = function() {
+        $(SELECTORS.ROOT).find(SELECTORS.DROP_ZONE).each(function(index, dropZone) {
+            dropZone = $(dropZone);
+
+            if (!isValidDropZone(dropZone)) {
+                updateHoverState(dropZone, false);
+            }
+        });
+    };
+
+
     /**
      * Set up the module level variables to track which event is being
      * dragged and how many days it spans.
@@ -117,27 +214,49 @@ define([
      * @param {event} e The dragstart event
      */
     var dragstartHandler = function(e) {
-        var eventElement = $(e.target).closest(SELECTORS.DRAGGABLE);
+        var target = $(e.target);
+        var draggableElement = target.closest(SELECTORS.DRAGGABLE);
 
-        if (!eventElement.length) {
+        if (!draggableElement.length) {
             return;
         }
 
-        eventElement = eventElement.find('[data-event-id]');
-
+        var eventElement = draggableElement.find('[data-event-id]');
         var eventId = eventElement.attr('data-event-id');
+        var minTimestart = draggableElement.attr('data-min-day-timestamp');
+        var maxTimestart = draggableElement.attr('data-max-day-timestamp');
+        var minError = draggableElement.attr('data-min-day-error');
+        var maxError = draggableElement.attr('data-max-day-error');
         var eventsSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
         var duration = $(eventsSelector).length;
 
         DataStore.setEventId(eventId);
         DataStore.setDurationDays(duration);
 
+        if (minTimestart) {
+            DataStore.setMinTimestart(minTimestart);
+        }
+
+        if (maxTimestart) {
+            DataStore.setMaxTimestart(maxTimestart);
+        }
+
+        if (minError) {
+            DataStore.setMinError(minError);
+        }
+
+        if (maxError) {
+            DataStore.setMaxError(maxError);
+        }
+
         e.dataTransfer.effectAllowed = "move";
         e.dataTransfer.dropEffect = "move";
         // Firefox requires a value to be set here or the drag won't
         // work and the dragover handler won't fire.
         e.dataTransfer.setData('text/plain', eventId);
         e.dropEffect = "move";
+
+        updateAllDropZonesState();
     };
 
     /**
@@ -150,6 +269,11 @@ define([
      * @param {event} e The dragstart event
      */
     var dragoverHandler = function(e) {
+        // Ignore dragging of non calendar events.
+        if (!DataStore.hasEventId()) {
+            return;
+        }
+
         e.preventDefault();
 
         var dropZone = getDropZoneFromEvent(e);
@@ -171,6 +295,11 @@ define([
      * @param {event} e The dragstart event
      */
     var dragleaveHandler = function(e) {
+        // Ignore dragging of non calendar events.
+        if (!DataStore.hasEventId()) {
+            return;
+        }
+
         var dropZone = getDropZoneFromEvent(e);
 
         if (!dropZone) {
@@ -193,30 +322,66 @@ define([
      * @param {event} e The dragstart event
      */
     var dropHandler = function(e) {
+        // Ignore dragging of non calendar events.
+        if (!DataStore.hasEventId()) {
+            return;
+        }
+
         var dropZone = getDropZoneFromEvent(e);
 
         if (!dropZone) {
             DataStore.clearAll();
+            clearAllDropZonesState();
             return;
         }
 
-        var eventId = DataStore.getEventId();
-        var eventElementSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
-        var eventElement = $(eventElementSelector);
-        var origin = null;
-        var destination = $(e.target).closest(SELECTORS.DROP_ZONE);
+        if (isValidDropZone(dropZone)) {
+            var eventId = DataStore.getEventId();
+            var eventElementSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
+            var eventElement = $(eventElementSelector);
+            var origin = null;
+
+            if (eventElement.length) {
+                origin = eventElement.closest(SELECTORS.DROP_ZONE);
+            }
 
-        if (eventElement.length) {
-            origin = eventElement.closest(SELECTORS.DROP_ZONE);
+            $('body').trigger(CalendarEvents.moveEvent, [eventId, origin, dropZone]);
+        } else {
+            // If the drop zone is not valid then there is not need for us to
+            // try to process it. Instead we can just show an error to the user.
+            var message = getDropZoneError(dropZone);
+            Str.get_string('errorinvaliddate', 'calendar').then(function(string) {
+                Notification.exception({
+                    name: string,
+                    message: message || string
+                });
+            });
         }
 
-        updateHoverState(dropZone, false);
-        $('body').trigger(CalendarEvents.moveEvent, [eventId, origin, destination]);
         DataStore.clearAll();
+        clearAllDropZonesState();
 
         e.preventDefault();
     };
 
+    /**
+     * Clear the data store and remove the drag indicators from the UI
+     * when the drag event has finished.
+     */
+    var dragendHandler = function() {
+        DataStore.clearAll();
+        clearAllDropZonesState();
+    };
+
+    /**
+     * Re-render the drop zones in the new month to highlight
+     * which areas are or aren't acceptable to drop the calendar
+     * event.
+     */
+    var calendarMonthChangedHandler = function() {
+        updateAllDropZonesState();
+    };
+
     return {
         /**
          * Initialise the event handlers for the drag events.
@@ -231,6 +396,8 @@ define([
                 document.addEventListener('dragover', dragoverHandler, false);
                 document.addEventListener('dragleave', dragleaveHandler, false);
                 document.addEventListener('drop', dropHandler, false);
+                document.addEventListener('dragend', dragendHandler, false);
+                $('body').on(CalendarEvents.monthChanged, calendarMonthChangedHandler);
                 registered = true;
             }
         },
index 423cd92..f7d2555 100644 (file)
@@ -26,6 +26,7 @@ namespace core_calendar\external;
 
 defined('MOODLE_INTERNAL') || die();
 
+use \core_calendar\local\event\container;
 use \core_course\external\course_summary_exporter;
 use \renderer_base;
 require_once($CFG->dirroot . '/course/lib.php');
@@ -57,6 +58,22 @@ class calendar_event_exporter extends event_exporter_base {
         $values['popupname'] = [
             'type' => PARAM_RAW,
         ];
+        $values['mindaytimestamp'] = [
+            'type' => PARAM_INT,
+            'optional' => true
+        ];
+        $values['mindayerror'] = [
+            'type' => PARAM_TEXT,
+            'optional' => true
+        ];
+        $values['maxdaytimestamp'] = [
+            'type' => PARAM_INT,
+            'optional' => true
+        ];
+        $values['maxdayerror'] = [
+            'type' => PARAM_TEXT,
+            'optional' => true
+        ];
 
         return $values;
     }
@@ -89,9 +106,9 @@ class calendar_event_exporter extends event_exporter_base {
         } else {
             // TODO MDL-58866 We do not have any way to find urls for events outside of course modules.
             $course = $event->get_course()->get('id') ?: SITEID;
-
             $url = course_get_url($course);
         }
+
         $values['url'] = $url->out(false);
         $values['islastday'] = false;
         $today = $this->related['type']->timestamp_to_date_array($this->related['today']);
@@ -153,6 +170,10 @@ class calendar_event_exporter extends event_exporter_base {
 
         $values['calendareventtype'] = $this->get_calendar_event_type();
 
+        if ($event->get_course_module()) {
+            $values = array_merge($values, $this->get_module_timestamp_limits($event));
+        }
+
         return $values;
     }
 
@@ -177,11 +198,117 @@ class calendar_event_exporter extends event_exporter_base {
      * @return string
      */
     public function get_calendar_event_type() {
-        $type = $this->event->get_type();
-        if ($type == 'open' || $type == 'close') {
-            $type = 'course';
+        if ($this->event->get_course_module()) {
+            return 'course';
+        }
+
+        return $this->event->get_type();
+    }
+
+    /**
+     * Return the set of minimum and maximum date timestamp values
+     * for the given event.
+     *
+     * @param event_interface $event
+     * @return array
+     */
+    protected function get_module_timestamp_limits($event) {
+        $values = [];
+        $mapper = container::get_event_mapper();
+        $starttime = $event->get_times()->get_start_time();
+
+        list($min, $max) = component_callback(
+            'mod_' . $event->get_course_module()->get('modname'),
+            'core_calendar_get_valid_event_timestart_range',
+            [$mapper->from_event_to_legacy_event($event)],
+            [null, null]
+        );
+
+        if ($min) {
+            $values = array_merge($values, $this->get_module_timestamp_min_limit($starttime, $min));
+        }
+
+        if ($max) {
+            $values = array_merge($values, $this->get_module_timestamp_max_limit($starttime, $max));
         }
 
-        return $type;
+        return $values;
+    }
+
+    /**
+     * Get the correct minimum midnight day limit based on the event start time
+     * and the module's minimum timestamp limit.
+     *
+     * @param DateTimeInterface $starttime The event start time
+     * @param array $min The module's minimum limit for the event
+     */
+    protected function get_module_timestamp_min_limit(\DateTimeInterface $starttime, $min) {
+        // We need to check that the minimum valid time is earlier in the
+        // day than the current event time so that if the user drags and drops
+        // the event to this day (which changes the date but not the time) it
+        // will result in a valid time start for the event.
+        //
+        // For example:
+        // An event that starts on 2017-01-10 08:00 with a minimum cutoff
+        // of 2017-01-05 09:00 means that 2017-01-05 is not a valid start day
+        // for the drag and drop because it would result in the event start time
+        // being set to 2017-01-05 08:00, which is invalid. Instead the minimum
+        // valid start day would be 2017-01-06.
+        $values = [];
+        $timestamp = $min[0];
+        $errorstring = $min[1];
+        $mindate = (new \DateTimeImmutable())->setTimestamp($timestamp);
+        $minstart = $mindate->setTime(
+            $starttime->format('H'),
+            $starttime->format('i'),
+            $starttime->format('s')
+        );
+        $midnight = usergetmidnight($timestamp);
+
+        if ($mindate <= $minstart) {
+            $values['mindaytimestamp'] = $midnight;
+        } else {
+            $tomorrow = (new \DateTime())->setTimestamp($midnight)->modify('+1 day');
+            $values['mindaytimestamp'] = $tomorrow->getTimestamp();
+        }
+
+        // Get the human readable error message to display if the min day
+        // timestamp is violated.
+        $values['mindayerror'] = $errorstring;
+        return $values;
+    }
+
+    /**
+     * Get the correct maximum midnight day limit based on the event start time
+     * and the module's maximum timestamp limit.
+     *
+     * @param DateTimeInterface $starttime The event start time
+     * @param array $max The module's maximum limit for the event
+     */
+    protected function get_module_timestamp_max_limit(\DateTimeInterface $starttime, $max) {
+        // We're doing a similar calculation here as we are for the minimum
+        // day timestamp. See the explanation above.
+        $values;
+        $timestamp = $max[0];
+        $errorstring = $max[1];
+        $maxdate = (new \DateTimeImmutable())->setTimestamp($timestamp);
+        $maxstart = $maxdate->setTime(
+            $starttime->format('H'),
+            $starttime->format('i'),
+            $starttime->format('s')
+        );
+        $midnight = usergetmidnight($timestamp);
+
+        if ($maxdate >= $maxstart) {
+            $values['maxdaytimestamp'] = $midnight;
+        } else {
+            $yesterday = (new \DateTime())->setTimestamp($midnight)->modify('-1 day');
+            $values['maxdaytimestamp'] = $yesterday->getTimestamp();
+        }
+
+        // Get the human readable error message to display if the max day
+        // timestamp is violated.
+        $values['maxdayerror'] = $errorstring;
+        return $values;
     }
 }
index ebe8d86..6600b23 100644 (file)
@@ -130,6 +130,10 @@ class day_exporter extends exporter {
                 'type' => calendar_event_exporter::read_properties_definition(),
                 'multiple' => true,
             ],
+            'hasevents' => [
+                'type' => PARAM_BOOL,
+                'default' => false,
+            ],
             'calendareventtypes' => [
                 'type' => PARAM_RAW,
                 'multiple' => true,
@@ -211,6 +215,8 @@ class day_exporter extends exporter {
             return $exporter->export($output);
         }, $eventexporters);
 
+        $return['hasevents'] = !empty($return['events']);
+
         $return['calendareventtypes'] = array_map(function($exporter) {
             return $exporter->get_calendar_event_type();
         }, $eventexporters);
index 5a8075b..65bba6f 100644 (file)
@@ -53,9 +53,9 @@ class event_subscription_exporter extends exporter {
             if (!empty($subscription) && $CFG->calendar_showicalsource) {
                 $data->displayeventsource = true;
                 if (!empty($subscription->url)) {
-                    $data->url = $subscription->url;
+                    $data->subscriptionurl = $subscription->url;
                 }
-                $data->name = $subscription->name;
+                $data->subscriptionname = $subscription->name;
             }
         }
 
@@ -72,11 +72,11 @@ class event_subscription_exporter extends exporter {
             'displayeventsource' => [
                 'type' => PARAM_BOOL
             ],
-            'name' => [
+            'subscriptionname' => [
                 'type' => PARAM_RAW,
                 'optional' => true
             ],
-            'url' => [
+            'subscriptionurl' => [
                 'type' => PARAM_URL,
                 'optional' => true
             ],
index 0a57928..ccfd7be 100644 (file)
 {{#subscription}}
     {{#displayeventsource}}
         <div>
-            {{#url}}
-                <p><a href="{{url}}">{{#str}}subscriptionsource, core_calendar, {{name}}{{/str}}</a></p>
-            {{/url}}
-            {{^url}}
-                <p>{{#str}}subscriptionsource, core_calendar, {{name}}{{/str}}</p>
-            {{/url}}
+            {{#subscriptionurl}}
+                <p><a href="{{subscriptionurl}}">{{#str}}subscriptionsource, core_calendar, {{{subscriptionname}}}{{/str}}</a></p>
+            {{/subscriptionurl}}
+            {{^subscriptionurl}}
+                <p>{{#str}}subscriptionsource, core_calendar, {{{subscriptionname}}}{{/str}}</p>
+            {{/subscriptionurl}}
         </div>
     {{/displayeventsource}}
 {{/subscription}}
index 39cacee..3ebebaa 100644 (file)
                         data-region="day"
                         data-new-event-timestamp="{{neweventtimestamp}}">
                         <div class="hidden-sm-down text-xs-center">
-                            {{#events.0}}
+                            {{#hasevents}}
                                 <a data-action="view-day-link" href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
-                            {{/events.0}}
-                            {{^events.0}}
+                            {{/hasevents}}
+                            {{^hasevents}}
                                 {{mday}}
-                            {{/events.0}}
-                            {{#events.0}}
+                            {{/hasevents}}
+                            {{#hasevents}}
                                 <div data-region="day-content">
                                     <ul>
                                         {{#events}}
                                                 {{#canedit}}
                                                     draggable="true"
                                                     data-drag-type="move"
+                                                    {{#mindaytimestamp}}
+                                                        data-min-day-timestamp="{{.}}"
+                                                    {{/mindaytimestamp}}
+                                                    {{#mindayerror}}
+                                                        data-min-day-error="{{.}}"
+                                                    {{/mindayerror}}
+                                                    {{#maxdaytimestamp}}
+                                                        data-max-day-timestamp="{{.}}"
+                                                    {{/maxdaytimestamp}}
+                                                    {{#maxdayerror}}
+                                                        data-max-day-error="{{.}}"
+                                                    {{/maxdayerror}}
                                                 {{/canedit}}>
 
                                                 <a data-action="view-event" data-event-id="{{id}}" href="{{url}}" title="{{name}}">
                                         {{/events}}
                                     </ul>
                                 </div>
-                            {{/events.0}}
+                            {{/hasevents}}
                         </div>
                         <div class="hidden-md-up hidden-desktop">
-                            {{#events.0}}
+                            {{#hasevents}}
                                 <a href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
-                            {{/events.0}}
-                            {{^events.0}}
+                            {{/hasevents}}
+                            {{^hasevents}}
                                 <div data-region="day-content">
                                     {{mday}}
                                 </div>
-                            {{/events.0}}
+                            {{/hasevents}}
                         </div>
                     </td>
                 {{/days}}
@@ -145,10 +157,26 @@ require([
         M.util.js_pending("month-detailed-{{uniqid}}-filterChanged");
         // A filter value has been changed.
         // Find all matching cells in the popover data, and hide them.
-        $("#month-detailed-{{uniqid}}")
-            .find(CalendarSelectors.eventType[data.type])
-            .toggleClass('hidden', !!data.hidden);
-        M.util.js_complete("month-detailed-{{uniqid}}-filterChanged");
+        var target = $("#month-detailed-{{uniqid}}").find(CalendarSelectors.eventType[data.type]);
+
+        var transitionPromise = $.Deferred();
+        if (data.hidden) {
+            transitionPromise.then(function() {
+                return target.slideUp('fast').promise();
+            });
+        } else {
+            transitionPromise.then(function() {
+                return target.slideDown('fast').promise();
+            });
+        }
+
+        transitionPromise.then(function() {
+            M.util.js_complete("month-detailed-{{uniqid}}-filterChanged");
+
+            return;
+        });
+
+        transitionPromise.resolve();
     });
 });
 {{/js}}
diff --git a/calendar/tests/calendar_event_exporter_test.php b/calendar/tests/calendar_event_exporter_test.php
new file mode 100644 (file)
index 0000000..5ae3edb
--- /dev/null
@@ -0,0 +1,150 @@
+<?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/>.
+
+/**
+ * Calendar event exporter tests tests.
+ *
+ * @package    core_calendar
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_calendar\external\calendar_event_exporter;
+use core_calendar\local\event\container;
+
+/**
+ * Calendar event exporter testcase.
+ *
+ * @copyright 2017 Ryan Wyllie <ryan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_calendar_event_exporter_testcase extends advanced_testcase {
+    /**
+     * Data provider for the module timestamp min limit test case to confirm
+     * that the minimum time limit is set correctly on the boundary cases.
+     */
+    public function get_module_timestamp_min_limit_test_cases() {
+        $now = time();
+        $todaymidnight = usergetmidnight($now);
+        $tomorrowmidnight = $todaymidnight + DAYSECS;
+        $eightam = $todaymidnight + (60 * 60 * 8);
+        $starttime = (new DateTime())->setTimestamp($eightam);
+
+        return [
+            'before min' => [
+                $starttime,
+                [
+                    ($starttime->getTimestamp() + 1),
+                    'some error'
+                ],
+                $tomorrowmidnight
+            ],
+            'equal min' => [
+                $starttime,
+                [
+                    $starttime->getTimestamp(),
+                    'some error'
+                ],
+                $todaymidnight
+            ],
+            'after min' => [
+                $starttime,
+                [
+                    ($starttime->getTimestamp() - 1),
+                    'some error'
+                ],
+                $todaymidnight
+            ]
+        ];
+    }
+
+    /**
+     * @dataProvider get_module_timestamp_min_limit_test_cases()
+     */
+    public function test_get_module_timestamp_min_limit($starttime, $min, $expected) {
+        $class = \core_calendar\external\calendar_event_exporter::class;
+        $mock = $this->getMockBuilder($class)
+            ->disableOriginalConstructor()
+            ->setMethods(null)
+            ->getMock();
+        $reflector = new ReflectionClass($class);
+        $method = $reflector->getMethod('get_module_timestamp_min_limit');
+        $method->setAccessible(true);
+
+        $result = $method->invoke($mock, $starttime, $min);
+        $this->assertEquals($expected, $result['mindaytimestamp']);
+        $this->assertEquals($min[1], $result['mindayerror']);
+    }
+
+    /**
+     * Data provider for the module timestamp min limit test case to confirm
+     * that the minimum time limit is set correctly on the boundary cases.
+     */
+    public function get_module_timestamp_max_limit_test_cases() {
+        $now = time();
+        $todaymidnight = usergetmidnight($now);
+        $yesterdaymidnight = $todaymidnight - DAYSECS;
+        $eightam = $todaymidnight + (60 * 60 * 8);
+        $starttime = (new DateTime())->setTimestamp($eightam);
+
+        return [
+            'before max' => [
+                $starttime,
+                [
+                    ($starttime->getTimestamp() + 1),
+                    'some error'
+                ],
+                $todaymidnight
+            ],
+            'equal max' => [
+                $starttime,
+                [
+                    $starttime->getTimestamp(),
+                    'some error'
+                ],
+                $todaymidnight
+            ],
+            'after max' => [
+                $starttime,
+                [
+                    ($starttime->getTimestamp() - 1),
+                    'some error'
+                ],
+                $yesterdaymidnight
+            ]
+        ];
+    }
+
+    /**
+     * @dataProvider get_module_timestamp_max_limit_test_cases()
+     */
+    public function test_get_module_timestamp_max_limit($starttime, $max, $expected) {
+        $class = \core_calendar\external\calendar_event_exporter::class;
+        $mock = $this->getMockBuilder($class)
+            ->disableOriginalConstructor()
+            ->setMethods(null)
+            ->getMock();
+        $reflector = new ReflectionClass($class);
+        $method = $reflector->getMethod('get_module_timestamp_max_limit');
+        $method->setAccessible(true);
+
+        $result = $method->invoke($mock, $starttime, $max);
+        $this->assertEquals($expected, $result['maxdaytimestamp']);
+        $this->assertEquals($max[1], $result['maxdayerror']);
+    }
+}
diff --git a/course/classes/analytics/indicator/no_student.php b/course/classes/analytics/indicator/no_student.php
new file mode 100644 (file)
index 0000000..a55df38
--- /dev/null
@@ -0,0 +1,112 @@
+<?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/>.
+
+/**
+ * No student indicator.
+ *
+ * @package   core_course
+ * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\analytics\indicator;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * No student indicator.
+ *
+ * @package   core_course
+ * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class no_student extends \core_analytics\local\indicator\binary {
+
+    /**
+     * Student role ids.
+     *
+     * @var array|null
+     */
+    protected $studentroleids = null;
+
+    /**
+     * Returns the name.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:nostudent', 'moodle');
+    }
+
+    /**
+     * required_sample_data
+     *
+     * @return string[]
+     */
+    public static function required_sample_data() {
+        // We require course because, although calculate_sample only reads context, we need the context to be course
+        // or below.
+        return array('context', 'course');
+    }
+
+    /**
+     * Reversed because the indicator is in 'negative' and the max returned value means student present.
+     *
+     * @param float $value
+     * @param string $subtype
+     * @return string
+     */
+    public function get_display_value($value, $subtype = false) {
+
+        // No subtypes for binary values by default.
+        if ($value == -1) {
+            return get_string('yes');
+        } else if ($value == 1) {
+            return get_string('no');
+        }
+    }
+
+    /**
+     * calculate_sample
+     *
+     * @param int $sampleid
+     * @param string $sampleorigin
+     * @param int|false $notusedstarttime
+     * @param int|false $notusedendtime
+     * @return float
+     */
+    public function calculate_sample($sampleid, $sampleorigin, $notusedstarttime = false, $notusedendtime = false) {
+
+        $context = $this->retrieve('context', $sampleid);
+
+        if (is_null($this->studentroleids)) {
+            $this->studentroleids = array_keys(get_archetype_roles('student'));
+        }
+
+        foreach ($this->studentroleids as $role) {
+            // We look for roles, not enrolments as a student assigned at category level is supposed to be a
+            // course student.
+            $students = get_role_users($role, $context, false, 'u.id', 'u.id');
+            if ($students) {
+                return self::get_max_value();
+            }
+        }
+
+        return self::get_min_value();
+    }
+}
diff --git a/filter/classes/external.php b/filter/classes/external.php
new file mode 100644 (file)
index 0000000..ed6218f
--- /dev/null
@@ -0,0 +1,134 @@
+<?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/>.
+
+/**
+ * This is the external API for the filter component.
+ *
+ * @package    core_filters
+ * @copyright  2017 Juan Leyva
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_filters;
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/externallib.php');
+require_once($CFG->libdir . '/filterlib.php');
+
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_single_structure;
+use external_multiple_structure;
+use external_warnings;
+use Exception;
+
+/**
+ * This is the external API for the filter component.
+ *
+ * @copyright  2017 Juan Leyva
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external extends external_api {
+
+    /**
+     * Returns description of get_available_in_context() parameters.
+     *
+     * @return external_function_parameters
+     * @since  Moodle 3.4
+     */
+    public static function get_available_in_context_parameters() {
+        return new external_function_parameters (
+            array(
+                'contexts' => new external_multiple_structure(
+                    new external_single_structure(
+                        array(
+                            'contextlevel' => new external_value(PARAM_ALPHA, 'The context level where the filters are:
+                                (coursecat, course, module)'),
+                            'instanceid' => new external_value(PARAM_INT, 'The instance id of item associated with the context.')
+                        )
+                    ), 'The list of contexts to check.'
+                ),
+            )
+        );
+    }
+
+    /**
+     * Returns the filters available in the given contexts.
+     *
+     * @param array $contexts the list of contexts to check
+     * @return array with the filters information and warnings
+     * @since Moodle 3.4
+     */
+    public static function get_available_in_context($contexts) {
+        $params = self::validate_parameters(self::get_available_in_context_parameters(), array('contexts' => $contexts));
+        $filters = $warnings = array();
+
+        foreach ($params['contexts'] as $contextinfo) {
+            try {
+                $context = self::get_context_from_params($contextinfo);
+                self::validate_context($context);
+                $contextinfo['contextid'] = $context->id;
+            } catch (Exception $e) {
+                $warnings[] = array(
+                    'item' => 'context',
+                    'itemid' => $context['instanceid'],
+                    'warningcode' => $e->getCode(),
+                    'message' => $e->getMessage(),
+                );
+                continue;
+            }
+            $contextfilters = filter_get_available_in_context($context);
+
+            foreach ($contextfilters as $filter) {
+                $filters[] = array_merge($contextinfo, (array) $filter);
+            }
+        }
+
+        return array(
+            'filters' => $filters,
+            'warnings' => $warnings,
+        );
+    }
+
+    /**
+     * Returns description of get_available_in_context() result value.
+     *
+     * @return external_single_structure
+     * @since  Moodle 3.4
+     */
+    public static function get_available_in_context_returns() {
+        return new external_single_structure(
+            array(
+                'filters' => new external_multiple_structure(
+                    new external_single_structure(
+                        array(
+                            'contextlevel' => new external_value(PARAM_ALPHA, 'The context level where the filters are:
+                                (coursecat, course, module).'),
+                            'instanceid' => new external_value(PARAM_INT, 'The instance id of item associated with the context.'),
+                            'contextid' => new external_value(PARAM_INT, 'The context id.'),
+                            'filter'  => new external_value(PARAM_PLUGIN, 'Filter plugin name.'),
+                            'localstate' => new external_value(PARAM_INT, 'Filter state: 1 for on, -1 for off, 0 if inherit.'),
+                            'inheritedstate' => new external_value(PARAM_INT, '1 or 0 to use when localstate is set to inherit.'),
+                        )
+                    ),
+                    'Available filters'
+                ),
+                'warnings' => new external_warnings(),
+            )
+        );
+    }
+}
diff --git a/filter/tests/external_test.php b/filter/tests/external_test.php
new file mode 100644 (file)
index 0000000..71bdef4
--- /dev/null
@@ -0,0 +1,191 @@
+<?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/>.
+
+/**
+ * External filter functions unit tests.
+ *
+ * @package    core_filters
+ * @category   external
+ * @copyright  2017 Juan Leyva
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.4
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+use core_filters\external;
+
+/**
+ * External filter functions unit tests.
+ *
+ * @package    core_filters
+ * @category   external
+ * @copyright  2017 Juan Leyva
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.4
+ */
+class core_filter_external_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Test get_available_in_context_system
+     */
+    public function test_get_available_in_context_system() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $this->expectException('moodle_exception');
+        external::get_available_in_context(array(array('contextlevel' => 'system', 'instanceid' => 0)));
+    }
+
+    /**
+     * Test get_available_in_context_category
+     */
+    public function test_get_available_in_context_category() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $category = self::getDataGenerator()->create_category();
+
+        // Get all filters and disable them all globally.
+        $allfilters = filter_get_all_installed();
+        foreach ($allfilters as $filter => $filtername) {
+            filter_set_global_state($filter, TEXTFILTER_DISABLED);
+        }
+
+        $result = external::get_available_in_context(array(array('contextlevel' => 'coursecat', 'instanceid' => $category->id)));
+        $result = external_api::clean_returnvalue(external::get_available_in_context_returns(), $result);
+        $this->assertEmpty($result['filters']); // No filters, all disabled.
+        $this->assertEmpty($result['warnings']);
+
+        // Enable one filter at global level.
+        reset($allfilters);
+        $firstfilter = key($allfilters);
+        filter_set_global_state($firstfilter, TEXTFILTER_ON);
+
+        $result = external::get_available_in_context(array(array('contextlevel' => 'coursecat', 'instanceid' => $category->id)));
+        $result = external_api::clean_returnvalue(external::get_available_in_context_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertEquals($firstfilter, $result['filters'][0]['filter']); // OK, the filter is enabled.
+        $this->assertEquals(TEXTFILTER_INHERIT, $result['filters'][0]['localstate']); // Inherits the parent context status.
+        $this->assertEquals(TEXTFILTER_ON, $result['filters'][0]['inheritedstate']); // In the parent context is available.
+
+        // Set off the same filter at local context level.
+        filter_set_local_state($firstfilter, context_coursecat::instance($category->id)->id, TEXTFILTER_OFF);
+        $result = external::get_available_in_context(array(array('contextlevel' => 'coursecat', 'instanceid' => $category->id)));
+        $result = external_api::clean_returnvalue(external::get_available_in_context_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertEquals($firstfilter, $result['filters'][0]['filter']); // OK, the filter is enabled globally.
+        $this->assertEquals(TEXTFILTER_OFF, $result['filters'][0]['localstate']); // It is not available in this context.
+        $this->assertEquals(TEXTFILTER_ON, $result['filters'][0]['inheritedstate']); // In the parent context is available.
+    }
+
+    /**
+     * Test get_available_in_context_course
+     */
+    public function test_get_available_in_context_course() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $course = self::getDataGenerator()->create_course();
+
+        // Get all filters and disable them all globally.
+        $allfilters = filter_get_all_installed();
+        foreach ($allfilters as $filter => $filtername) {
+            filter_set_global_state($filter, TEXTFILTER_DISABLED);
+        }
+
+        $result = external::get_available_in_context(array(array('contextlevel' => 'course', 'instanceid' => $course->id)));
+        $result = external_api::clean_returnvalue(external::get_available_in_context_returns(), $result);
+        $this->assertEmpty($result['filters']); // No filters, all disabled at global level.
+        $this->assertEmpty($result['warnings']);
+
+        // Enable one filter at global level.
+        reset($allfilters);
+        $firstfilter = key($allfilters);
+        filter_set_global_state($firstfilter, TEXTFILTER_ON);
+
+        $result = external::get_available_in_context(array(array('contextlevel' => 'course', 'instanceid' => $course->id)));
+        $result = external_api::clean_returnvalue(external::get_available_in_context_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertEquals($firstfilter, $result['filters'][0]['filter']); // OK, the filter is enabled.
+        $this->assertEquals(TEXTFILTER_INHERIT, $result['filters'][0]['localstate']); // Inherits the parent context status.
+        $this->assertEquals(TEXTFILTER_ON, $result['filters'][0]['inheritedstate']); // In the parent context is available.
+
+        // Set off the same filter at local context level.
+        filter_set_local_state($firstfilter, context_course::instance($course->id)->id, TEXTFILTER_OFF);
+        $result = external::get_available_in_context(array(array('contextlevel' => 'course', 'instanceid' => $course->id)));
+        $result = external_api::clean_returnvalue(external::get_available_in_context_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertEquals($firstfilter, $result['filters'][0]['filter']); // OK, the filter is enabled globally.
+        $this->assertEquals(TEXTFILTER_OFF, $result['filters'][0]['localstate']); // It is not available in this context.
+        $this->assertEquals(TEXTFILTER_ON, $result['filters'][0]['inheritedstate']); // In the parent context is available.
+    }
+
+    /**
+     * Test get_available_in_context_module
+     */
+    public function test_get_available_in_context_module() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        // Create one activity.
+        $course = self::getDataGenerator()->create_course();
+        $forum = self::getDataGenerator()->create_module('forum', (object) array('course' => $course->id));
+
+        // Get all filters and disable them all globally.
+        $allfilters = filter_get_all_installed();
+        foreach ($allfilters as $filter => $filtername) {
+            filter_set_global_state($filter, TEXTFILTER_DISABLED);
+        }
+
+        $result = external::get_available_in_context(array(array('contextlevel' => 'module', 'instanceid' => $forum->cmid)));
+        $result = external_api::clean_returnvalue(external::get_available_in_context_returns(), $result);
+        $this->assertEmpty($result['filters']); // No filters, all disabled at global level.
+        $this->assertEmpty($result['warnings']);
+
+        // Enable one filter at global level.
+        reset($allfilters);
+        $firstfilter = key($allfilters);
+        filter_set_global_state($firstfilter, TEXTFILTER_ON);
+
+        $result = external::get_available_in_context(array(array('contextlevel' => 'module', 'instanceid' => $forum->cmid)));
+        $result = external_api::clean_returnvalue(external::get_available_in_context_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertEquals($firstfilter, $result['filters'][0]['filter']); // OK, the filter is enabled.
+        $this->assertEquals(TEXTFILTER_INHERIT, $result['filters'][0]['localstate']); // Inherits the parent context status.
+        $this->assertEquals(TEXTFILTER_ON, $result['filters'][0]['inheritedstate']); // In the parent context is available.
+
+        // Set off the same filter at local context level.
+        filter_set_local_state($firstfilter, context_module::instance($forum->cmid)->id, TEXTFILTER_OFF);
+        $result = external::get_available_in_context(array(array('contextlevel' => 'module', 'instanceid' => $forum->cmid)));
+        $result = external_api::clean_returnvalue(external::get_available_in_context_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertEquals($firstfilter, $result['filters'][0]['filter']); // OK, the filter is enabled globally.
+        $this->assertEquals(TEXTFILTER_OFF, $result['filters'][0]['localstate']); // It is not available in this context.
+        $this->assertEquals(TEXTFILTER_ON, $result['filters'][0]['inheritedstate']); // In the parent context is available.
+    }
+}
index 60a865f..b6034b0 100644 (file)
@@ -750,6 +750,7 @@ $string['mnetrestore_extusers_admin'] = '<strong>Note:</strong> This backup file
 $string['mnetrestore_extusers_mismatch'] = '<strong>Note:</strong> This backup file apparently originates from a different Moodle installation and contains remote Moodle Network user accounts that may fail to restore. This operation is unsupported. If you are certain that it was created on this Moodle installation, or you can ensure that all the needed Moodle Network Hosts are configured, you may want to still try the restore.';
 $string['mnetrestore_extusers_noadmin'] = '<strong>Note:</strong> This backup file seems to come from a different Moodle installation and contains remote Moodle Network user accounts. You are not allowed to execute this type of restore. Contact the administrator of the site or, alternatively, restore this course without any user information (modules, files...)';
 $string['mnetrestore_extusers_switchuserauth'] = 'Remote Moodle Network user {$a->username} (coming from {$a->mnethosturl}) switched to local {$a->auth} authenticated user.';
+$string['mobilenotconfiguredwarning'] = 'Moodle Mobile is not enabled.';
 $string['modchooserdefault'] = 'Activity chooser default';
 $string['modeditdefaults'] = 'Default values for activity settings';
 $string['modsettings'] = 'Manage activities';
index 40ebdf5..cb0ff33 100644 (file)
@@ -82,7 +82,7 @@ $string['novalidsamples'] = 'No valid samples available';
 $string['onlycli'] = 'Analytics processes execution via command line only';
 $string['onlycliinfo'] = 'Analytics processes like evaluating models, training machine learning algorithms or getting predictions can take some time, they will run as cron tasks and they can be forced via command line. Disable this setting if you want your site managers to be able to run these processes manually via web interface';
 $string['predictionsprocessor'] = 'Predictions processor';
-$string['predictionsprocessor_help'] = 'Prediction processors are the machine learning backends that process the datasets generated by calculating models\' indicators and targets.';
+$string['predictionsprocessor_help'] = 'A predictions processor is the machine-learning backend that processes the datasets generated by calculating models\' indicators and targets. All trained algorithms and predictions will be deleted if you change to another predictions processor.';
 $string['processingsitecontents'] = 'Processing site contents';
 $string['successfullyanalysed'] = 'Successfully analysed';
 $string['timesplittingmethod'] = 'Time-splitting method';
index 6844d5f..acc04e5 100644 (file)
@@ -484,6 +484,7 @@ $string['deletechecktypename'] = 'Are you sure that you want to delete the {$a->
 $string['deletecheckfiles'] = 'Are you absolutely sure you want to delete these files?';
 $string['deletecheckfull'] = 'Are you absolutely sure you want to completely delete the user {$a}, including their enrolments, activity and other user data?';
 $string['deletecheckwarning'] = 'You are about to delete these files';
+$string['deletepicture'] = 'Delete picture';
 $string['deletesection'] = 'Delete section';
 $string['deleteselected'] = 'Delete selected';
 $string['deleteselectedkey'] = 'Delete selected key';
@@ -771,6 +772,12 @@ $string['eventuserloggedout'] = 'User logged out';
 $string['eventuserpasswordupdated'] = 'User password updated';
 $string['eventuserprofileviewed'] = 'User profile viewed';
 $string['eventuserupdated'] = 'User updated';
+$string['eventuserinfofieldcreated'] = 'User profile field created';
+$string['eventuserinfofieldupdated'] = 'User profile field updated';
+$string['eventuserinfofielddeleted'] = 'User profile field deleted';
+$string['eventuserinfocategorycreated'] = 'User profile field category created';
+$string['eventuserinfocategoryupdated'] = 'User profile field category updated';
+$string['eventuserinfocategorydeleted'] = 'User profile field category deleted';
 $string['everybody'] = 'Everybody';
 $string['executeat'] = 'Execute at';
 $string['existing'] = 'Existing';
@@ -1025,22 +1032,24 @@ $string['includeroleassignments'] = 'Include role assignments';
 $string['includesitefiles'] = 'Include site files used in this course';
 $string['includeuserfiles'] = 'Include user files';
 $string['increasesections'] = 'Increase the number of sections';
-$string['indicator:accessesafterend'] = 'Accesses after the end date';
-$string['indicator:accessesafterend_help'] = 'This indicator reflects the accesses by the student after the end date of the course.';
-$string['indicator:accessesbeforestart'] = 'Accesses before the start date';
-$string['indicator:accessesbeforestart_help'] = 'This indicator reflects the accesses by the student before the start date of the course.';
+$string['indicator:accessesafterend'] = 'Course was accessed after the end date';
+$string['indicator:accessesafterend_help'] = 'This indicator reflects if the student accessed the course after the end date of the course.';
+$string['indicator:accessesbeforestart'] = 'Course was accessed before the start date';
+$string['indicator:accessesbeforestart_help'] = 'This indicator reflects if the student accessed the course before the start date of the course';
 $string['indicator:anywrite'] = 'Any write action';
 $string['indicator:anywrite_help'] = 'This indicator represents any write (submit) action taken by the student.';
 $string['indicator:completeduserprofile'] = 'User profile is completed';
 $string['indicator:completeduserprofile_help'] = 'This indicator represents that the student has completed their user profile.';
-$string['indicator:completionenabled'] = 'Completion enabled';
-$string['indicator:completionenabled_help'] = 'This indicator represents that completion tracking has been enabled.';
+$string['indicator:completionenabled'] = 'Course completion enabled';
+$string['indicator:completionenabled_help'] = 'This indicator represents that completion tracking has been enabled for this course.';
+$string['indicator:nostudent'] = 'There are no students';
+$string['indicator:nostudent_help'] = 'This indicator reflects that this course has no students.';
 $string['indicator:noteacher'] = 'There are no teachers';
 $string['indicator:noteacher_help'] = 'This indicator reflects that this course has no teachers.';
-$string['indicator:potentialcognitive'] = 'Potential cognitive depth';
-$string['indicator:potentialcognitive_help'] = 'This indicator is based on the potential cognitive depth that could be reached by a student in this activity.';
-$string['indicator:potentialsocial'] = 'Potential social breadth';
-$string['indicator:potentialsocial_help'] = 'This indicator is based on the potential social breadth that could be reached by the student in the activity.';
+$string['indicator:potentialcognitive'] = 'Course potential cognitive depth';
+$string['indicator:potentialcognitive_help'] = 'This indicator is based on the potential cognitive depth that could be reached by a student in this course activities.';
+$string['indicator:potentialsocial'] = 'Course potential social breadth';
+$string['indicator:potentialsocial_help'] = 'This indicator is based on the potential social breadth that could be reached by the student in this course activities.';
 $string['indicator:readactions'] = 'Read actions amount';
 $string['indicator:readactions_help'] = 'This indicator represents the number of read (view) actions taken by the student.';
 $string['indicator:userforumstracking'] = 'User is tracking forums';
@@ -1904,7 +1913,7 @@ $string['tags'] = 'Tags';
 $string['target:coursedropout'] = 'Students at risk of dropping out';
 $string['target:coursedropout_help'] = 'This target describes whether the student is considered at risk of dropping out.';
 $string['target:noteachingactivity'] = 'No teaching';
-$string['target:noteachingactivity_help'] = 'This target describes whether or not this teacher is likely to engage in any activity in the course.';
+$string['target:noteachingactivity_help'] = 'This target describes whether courses that start during the following week will have teaching activity.';
 $string['targetlabelstudentdropoutyes'] = 'Student at risk of dropping out';
 $string['targetlabelstudentdropoutno'] = 'Not at risk';
 $string['targetlabelteachingyes'] = 'Users with teaching capabilities have access to the course';
index 3c42a34..c4e031f 100644 (file)
@@ -8747,17 +8747,14 @@ class admin_setting_enablemobileservice extends admin_setting_configcheckbox {
      * @return string XHTML
      */
     public function output_html($data, $query='') {
-        global $CFG, $OUTPUT;
+        global $OUTPUT;
         $html = parent::output_html($data, $query);
 
         if ((string)$data === $this->yes) {
-            require_once($CFG->dirroot . "/lib/filelib.php");
-            $curl = new curl();
-            $httpswwwroot = str_replace('http:', 'https:', $CFG->wwwroot); //force https url
-            $curl->head($httpswwwroot . "/login/index.php");
-            $info = $curl->get_info();
-            if (empty($info['http_code']) or ($info['http_code'] >= 400)) {
-               $html .= $OUTPUT->notification(get_string('nohttpsformobilewarning', 'admin'));
+            $notifications = tool_mobile\api::get_potential_config_issues(); // Safe to call, plugin available if we reach here.
+            foreach ($notifications as $notification) {
+                $message = get_string($notification[0], $notification[1]);
+                $html .= $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING);
             }
         }
 
index 55af9b4..654be89 100644 (file)
Binary files a/lib/amd/build/loglevel.min.js and b/lib/amd/build/loglevel.min.js differ
index 3380287..e28c528 100644 (file)
Binary files a/lib/amd/build/mustache.min.js and b/lib/amd/build/mustache.min.js differ
index 07e932e..c475ecf 100644 (file)
@@ -23,7 +23,7 @@
 //
 
 // Description of import into Moodle:
-// Download from https://github.com/pimterry/loglevel/releases
+// Download from https://github.com/pimterry/loglevel/dist
 // Copy loglevel.js into lib/amd/src/ in Moodle folder.
 // Add the license as a comment to the file and these instructions.
 
@@ -33,6 +33,7 @@
  * Copyright (c) 2013 Tim Perry
  * Licensed under the MIT license.
  */
+/*! loglevel - v1.5.1 - https://github.com/pimterry/loglevel - (c) 2017 Tim Perry - licensed MIT */
 (function (root, definition) {
     "use strict";
     if (typeof define === 'function' && define.amd) {
     var noop = function() {};
     var undefinedType = "undefined";
 
-    // Build the best logging method possible for this env
-    // Wherever possible we want to bind, not wrap, to preserve stack traces
-    function realMethod(methodName) {
-        if (typeof console === undefinedType) {
-            return false; // No method possible, for now - fixed later by enableLoggingWhenConsoleArrives
-        } else if (console[methodName] !== undefined) {
-            return bindMethod(console, methodName);
-        } else if (console.log !== undefined) {
-            return bindMethod(console, 'log');
-        } else {
-            return noop;
-        }
-    }
+    var logMethods = [
+        "trace",
+        "debug",
+        "info",
+        "warn",
+        "error"
+    ];
 
     // Cross-browser bind equivalent that works at least back to IE6
     function bindMethod(obj, methodName) {
         }
     }
 
-    // These private functions always need `this` to be set properly
+    // Build the best logging method possible for this env
+    // Wherever possible we want to bind, not wrap, to preserve stack traces
+    function realMethod(methodName) {
+        if (methodName === 'debug') {
+            methodName = 'log';
+        }
 
-    // In old IE versions, the console isn't present until you first open it.
-    // We build realMethod() replacements here that regenerate logging methods
-    function enableLoggingWhenConsoleArrives(methodName, level, loggerName) {
-        return function () {
-            if (typeof console !== undefinedType) {
-                replaceLoggingMethods.call(this, level, loggerName);
-                this[methodName].apply(this, arguments);
-            }
-        };
+        if (typeof console === undefinedType) {
+            return false; // No method possible, for now - fixed later by enableLoggingWhenConsoleArrives
+        } else if (console[methodName] !== undefined) {
+            return bindMethod(console, methodName);
+        } else if (console.log !== undefined) {
+            return bindMethod(console, 'log');
+        } else {
+            return noop;
+        }
     }
 
+    // These private functions always need `this` to be set properly
+
     function replaceLoggingMethods(level, loggerName) {
         /*jshint validthis:true */
         for (var i = 0; i < logMethods.length; i++) {
                 noop :
                 this.methodFactory(methodName, level, loggerName);
         }
+
+        // Define log.log as an alias for log.debug
+        this.log = this.debug;
+    }
+
+    // In old IE versions, the console isn't present until you first open it.
+    // We build realMethod() replacements here that regenerate logging methods
+    function enableLoggingWhenConsoleArrives(methodName, level, loggerName) {
+        return function () {
+            if (typeof console !== undefinedType) {
+                replaceLoggingMethods.call(this, level, loggerName);
+                this[methodName].apply(this, arguments);
+            }
+        };
     }
 
     // By default, we use closely bound real methods wherever possible, and
     function defaultMethodFactory(methodName, level, loggerName) {
         /*jshint validthis:true */
         return realMethod(methodName) ||
-            enableLoggingWhenConsoleArrives.apply(this, arguments);
+               enableLoggingWhenConsoleArrives.apply(this, arguments);
     }
 
-    var logMethods = [
-        "trace",
-        "debug",
-        "info",
-        "warn",
-        "error"
-    ];
-
     function Logger(name, defaultLevel, factory) {
-        var self = this;
-        var currentLevel;
-        var storageKey = "loglevel";
-        if (name) {
-            storageKey += ":" + name;
-        }
-
-        function persistLevelIfPossible(levelNum) {
-            var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
-
-            // Use localStorage if available
-            try {
-                window.localStorage[storageKey] = levelName;
-                return;
-            } catch (ignore) {}
-
-            // Use session cookie as fallback
-            try {
-                window.document.cookie =
-                    encodeURIComponent(storageKey) + "=" + levelName + ";";
-            } catch (ignore) {}
-        }
-
-        function getPersistedLevel() {
-            var storedLevel;
-
-            try {
-                storedLevel = window.localStorage[storageKey];
-            } catch (ignore) {}
-
-            // Fallback to cookies if local storage gives us nothing
-            if (typeof storedLevel === undefinedType) {
-                try {
-                    var cookie = window.document.cookie;
-                    var location = cookie.indexOf(
-                        encodeURIComponent(storageKey) + "=");
-                    if (location) {
-                        storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1];
-                    }
-                } catch (ignore) {}
-            }
-
-            // If the stored level is not valid, treat it as if nothing was stored.
-            if (self.levels[storedLevel] === undefined) {
-                storedLevel = undefined;
-            }
-
-            return storedLevel;
-        }
-
-        /*
-         *
-         * Public logger API - see https://github.com/pimterry/loglevel for details
-         *
-         */
-
-        self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3,
-            "ERROR": 4, "SILENT": 5};
-
-        self.methodFactory = factory || defaultMethodFactory;
-
-        self.getLevel = function () {
-            return currentLevel;
-        };
-
-        self.setLevel = function (level, persist) {
-            if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) {
-                level = self.levels[level.toUpperCase()];
-            }
-            if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) {
-                currentLevel = level;
-                if (persist !== false) {  // defaults to true
-                    persistLevelIfPossible(level);
-                }
-                replaceLoggingMethods.call(self, level, name);
-                if (typeof console === undefinedType && level < self.levels.SILENT) {
-                    return "No console available for logging";
-                }
-            } else {
-                throw "log.setLevel() called with invalid level: " + level;
-            }
-        };
-
-        self.setDefaultLevel = function (level) {
-            if (!getPersistedLevel()) {
-                self.setLevel(level, false);
-            }
-        };
-
-        self.enableAll = function(persist) {
-            self.setLevel(self.levels.TRACE, persist);
-        };
-
-        self.disableAll = function(persist) {
-            self.setLevel(self.levels.SILENT, persist);
-        };
-
-        // Initialize with the right level
-        var initialLevel = getPersistedLevel();
-        if (initialLevel == null) {
-            initialLevel = defaultLevel == null ? "WARN" : defaultLevel;
-        }
-        self.setLevel(initialLevel, false);
+      var self = this;
+      var currentLevel;
+      var storageKey = "loglevel";
+      if (name) {
+        storageKey += ":" + name;
+      }
+
+      function persistLevelIfPossible(levelNum) {
+          var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
+
+          if (typeof window === undefinedType) return;
+
+          // Use localStorage if available
+          try {
+              window.localStorage[storageKey] = levelName;
+              return;
+          } catch (ignore) {}
+
+          // Use session cookie as fallback
+          try {
+              window.document.cookie =
+                encodeURIComponent(storageKey) + "=" + levelName + ";";
+          } catch (ignore) {}
+      }
+
+      function getPersistedLevel() {
+          var storedLevel;
+
+          if (typeof window === undefinedType) return;
+
+          try {
+              storedLevel = window.localStorage[storageKey];
+          } catch (ignore) {}
+
+          // Fallback to cookies if local storage gives us nothing
+          if (typeof storedLevel === undefinedType) {
+              try {
+                  var cookie = window.document.cookie;
+                  var location = cookie.indexOf(
+                      encodeURIComponent(storageKey) + "=");
+                  if (location !== -1) {
+                      storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1];
+                  }
+              } catch (ignore) {}
+          }
+
+          // If the stored level is not valid, treat it as if nothing was stored.
+          if (self.levels[storedLevel] === undefined) {
+              storedLevel = undefined;
+          }
+
+          return storedLevel;
+      }
+
+      /*
+       *
+       * Public logger API - see https://github.com/pimterry/loglevel for details
+       *
+       */
+
+      self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3,
+          "ERROR": 4, "SILENT": 5};
+
+      self.methodFactory = factory || defaultMethodFactory;
+
+      self.getLevel = function () {
+          return currentLevel;
+      };
+
+      self.setLevel = function (level, persist) {
+          if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) {
+              level = self.levels[level.toUpperCase()];
+          }
+          if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) {
+              currentLevel = level;
+              if (persist !== false) {  // defaults to true
+                  persistLevelIfPossible(level);
+              }
+              replaceLoggingMethods.call(self, level, name);
+              if (typeof console === undefinedType && level < self.levels.SILENT) {
+                  return "No console available for logging";
+              }
+          } else {
+              throw "log.setLevel() called with invalid level: " + level;
+          }
+      };
+
+      self.setDefaultLevel = function (level) {
+          if (!getPersistedLevel()) {
+              self.setLevel(level, false);
+          }
+      };
+
+      self.enableAll = function(persist) {
+          self.setLevel(self.levels.TRACE, persist);
+      };
+
+      self.disableAll = function(persist) {
+          self.setLevel(self.levels.SILENT, persist);
+      };
+
+      // Initialize with the right level
+      var initialLevel = getPersistedLevel();
+      if (initialLevel == null) {
+          initialLevel = defaultLevel == null ? "WARN" : defaultLevel;
+      }
+      self.setLevel(initialLevel, false);
     }
 
     /*
     var _loggersByName = {};
     defaultLogger.getLogger = function getLogger(name) {
         if (typeof name !== "string" || name === "") {
-            throw new TypeError("You must supply a name when creating a logger.");
+          throw new TypeError("You must supply a name when creating a logger.");
         }
 
         var logger = _loggersByName[name];
         if (!logger) {
-            logger = _loggersByName[name] = new Logger(
-                name, defaultLogger.getLevel(), defaultLogger.methodFactory);
+          logger = _loggersByName[name] = new Logger(
+            name, defaultLogger.getLevel(), defaultLogger.methodFactory);
         }
         return logger;
     };
     var _log = (typeof window !== undefinedType) ? window.log : undefined;
     defaultLogger.noConflict = function() {
         if (typeof window !== undefinedType &&
-            window.log === defaultLogger) {
+               window.log === defaultLogger) {
             window.log = _log;
         }
 
index a3ce19e..29135a1 100644 (file)
     var tokens = cache[template];
 
     if (tokens == null)
-      tokens = cache[template] = parseTemplate(template, tags);
+      tokens = cache[template + ':' + (tags || mustache.tags).join(':')] = parseTemplate(template, tags);
 
     return tokens;
   };
   };
 
   mustache.name = 'mustache.js';
-  mustache.version = '2.2.1';
+  mustache.version = '2.3.0';
   mustache.tags = [ '{{', '}}' ];
 
   // All high-level mustache.* functions use this writer.
   mustache.Context = Context;
   mustache.Writer = Writer;
 
+  return mustache;
 }));
 /* jshint ignore:end */
index 845664c..a5cfdd5 100644 (file)
@@ -1,6 +1,22 @@
-Description of Bennu library import - customised library by author, this version is not available upstream
+Bennu - PHP iCalendar library
+=============================
+
+Bennu is an object-oriented library written in PHP that implements the
+iCalendar standard (RFC 2445). It is easy to use, fully standards compliant,
+and powerful. Applications can use Bennu to read and write iCalendar files,
+making them compatible with programs like Microsoft Outlook, Apple iCal, and
+Mozilla Sunbird.
+
+Information
+-----------
+
+* Bennu version: based on upstream 0.1 from 2005, heavily customized since then
+* Licence: GNU LGPL 2.1
+* WWW: http://bennu.sourceforge.net/
+
+Changelog
+---------
 
-modifications:
 1/ removed ereg functions deprecated as of php 5.3 (18 Nov 2009)
 2/ replaced mbstring functions with moodle core_text (28 Nov 2011)
 3/ replaced explode in iCalendar_component::unserialize() with preg_split to support various line breaks (20 Nov 2012)
index 739670e..f4b1352 100644 (file)
@@ -168,8 +168,10 @@ class no_teaching extends \core_analytics\local\target\binary {
     protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
 
         $noteachersindicator = $this->retrieve('\core_course\analytics\indicator\no_teacher', $sampleid);
-        if ($noteachersindicator == \core_course\analytics\indicator\no_teacher::get_min_value()) {
-            // No teachers :( we flag this as 1.
+        $nostudentsindicator = $this->retrieve('\core_course\analytics\indicator\no_student', $sampleid);
+        if ($noteachersindicator == \core_course\analytics\indicator\no_teacher::get_min_value() ||
+                $nostudentsindicator == \core_course\analytics\indicator\no_student::get_min_value()) {
+            // No teachers or no students :(.
             return 1;
         }
         return 0;
index 277d7db..2009ade 100644 (file)
@@ -84,6 +84,7 @@ class core_component {
         'MatthiasMullie\\PathConverter' => 'lib/minify/matthiasmullie-pathconverter/src/',
         'IMSGlobal\LTI' => 'lib/ltiprovider/src',
         'Phpml' => 'lib/mlbackend/php/phpml/src/Phpml',
+        'PHPMailer\\PHPMailer' => 'lib/phpmailer/src',
     );
 
     /**
@@ -443,7 +444,7 @@ $cache = '.var_export($cache, true).';
             'filepicker'  => null,
             'fileconverter' => $CFG->dirroot.'/files/converter',
             'files'       => $CFG->dirroot.'/files',
-            'filters'     => null,
+            'filters'     => $CFG->dirroot.'/filter',
             //'fonts'       => null, // Bogus.
             'form'        => $CFG->dirroot.'/lib/form',
             'grades'      => $CFG->dirroot.'/grade',
diff --git a/lib/classes/event/user_info_category_created.php b/lib/classes/event/user_info_category_created.php
new file mode 100644 (file)
index 0000000..e8d2692
--- /dev/null
@@ -0,0 +1,128 @@
+<?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/>.
+
+/**
+ * User profile field created event.
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * User profile info category created event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - string name: the name of the field.
+ * }
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @since      Moodle 3.4
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_info_category_created extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'user_info_category';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from a profile info category.
+     *
+     * @since Moodle 3.4
+     * @param \stdClass $category A sna[pshot of the created category.
+     * @return \core\event\base
+     */
+    public static function create_from_category($category) {
+        $event = self::create(array(
+            'objectid' => $category->id,
+            'context' => \context_system::instance(),
+            'other' => array(
+                'name' => $category->name,
+            )
+        ));
+
+        $event->add_record_snapshot('user_info_category', $category);
+
+        return $event;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventuserinfocategorycreated');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        $name = s($this->other['name']);
+        return "The user with id '$this->userid' created the user profile field category '$name' with id '$this->objectid'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/user/profile/index.php', array(
+            'action' => 'editcategory',
+            'id' => $this->objectid
+        ));
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Get the backup/restore table mapping for this event.
+     *
+     * @return string
+     */
+    public static function get_objectid_mapping() {
+        return base::NOT_MAPPED;
+    }
+}
diff --git a/lib/classes/event/user_info_category_deleted.php b/lib/classes/event/user_info_category_deleted.php
new file mode 100644 (file)
index 0000000..01db25b
--- /dev/null
@@ -0,0 +1,115 @@
+<?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/>.
+
+/**
+ * User profile field deleted event.
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * User deleted event class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - string name: the name of the field.
+ * }
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @since      Moodle 3.4
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_info_category_deleted extends base {
+
+    /**
+     * Initialise required event data properties.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'user_info_category';
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from a profile info category.
+     *
+     * @since Moodle 3.4
+     * @param \stdClass $category A snapshot of the deleted category.
+     * @return \core\event\base
+     */
+    public static function create_from_category($category) {
+        $event = self::create(array(
+            'objectid' => $category->id,
+            'context' => \context_system::instance(),
+            'other' => array(
+                'name' => $category->name,
+            )
+        ));
+
+        $event->add_record_snapshot('user_info_category', $category);
+
+        return $event;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventuserinfocategorydeleted');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        $name = s($this->other['name']);
+        return "The user with id '$this->userid' deleted the user profile field category '$name' with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Get the backup/restore table mapping for this event.
+     *
+     * @return string
+     */
+    public static function get_objectid_mapping() {
+        return base::NOT_MAPPED;
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/event/user_info_category_updated.php b/lib/classes/event/user_info_category_updated.php
new file mode 100644 (file)
index 0000000..e2ebb5f
--- /dev/null
@@ -0,0 +1,128 @@
+<?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/>.
+
+/**
+ * User profile field updated event.
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Event when user profile is updated.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - string name: the name of the field.
+ * }
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @since      Moodle 3.4
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_info_category_updated extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'user_info_category';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from a profile info category.
+     *
+     * @since Moodle 3.4
+     * @param \stdClass $category A snapshot of the updated category.
+     * @return \core\event\base
+     */
+    public static function create_from_category($category) {
+        $event = self::create(array(
+            'objectid' => $category->id,
+            'context' => \context_system::instance(),
+            'other' => array(
+                'name' => $category->name,
+            )
+        ));
+
+        $event->add_record_snapshot('user_info_category', $category);
+
+        return $event;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventuserinfocategoryupdated');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        $name = s($this->other['name']);
+        return "The user with id '$this->userid' updated the user profile field category '$name' with id '$this->objectid'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/user/profile/index.php', array(
+            'action' => 'editcategory',
+            'id' => $this->objectid
+        ));
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Get the backup/restore table mapping for this event.
+     *
+     * @return string
+     */
+    public static function get_objectid_mapping() {
+        return base::NOT_MAPPED;
+    }
+}
diff --git a/lib/classes/event/user_info_field_created.php b/lib/classes/event/user_info_field_created.php
new file mode 100644 (file)
index 0000000..8e1a4b6
--- /dev/null
@@ -0,0 +1,141 @@
+<?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/>.
+
+/**
+ * User profile field created event.
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * User profile info field created event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - string shortname: the shortname of the field.
+ *      - string name: the name of the field.
+ *      - string datatype: the data type of the field.
+ * }
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @since      Moodle 3.4
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_info_field_created extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'user_info_field';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from a profile field.
+     *
+     * @since Moodle 3.4
+     * @param \stdClass $field A snapshot of the created field.
+     * @return \core\event\base
+     */
+    public static function create_from_field($field) {
+        $event = self::create(array(
+            'objectid' => $field->id,
+            'context' => \context_system::instance(),
+            'other' => array(
+                'shortname' => $field->shortname,
+                'name'      => $field->name,
+                'datatype'  => $field->datatype,
+            )
+        ));
+
+        $event->add_record_snapshot('user_info_field', $field);
+
+        return $event;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventuserinfofieldcreated');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        $name = s($this->other['name']);
+        return "The user with id '$this->userid' created the user profile field '$name' with id '$this->objectid'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/user/profile/index.php', array(
+            'action' => 'editfield',
+            'id' => $this->objectid,
+            'datatype' => $this->other['datatype']
+        ));
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['shortname'])) {
+            throw new \coding_exception('The \'shortname\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+
+        if (!isset($this->other['datatype'])) {
+            throw new \coding_exception('The \'datatype\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Get the backup/restore table mapping for this event.
+     *
+     * @return string
+     */
+    public static function get_objectid_mapping() {
+        return base::NOT_MAPPED;
+    }
+}
diff --git a/lib/classes/event/user_info_field_deleted.php b/lib/classes/event/user_info_field_deleted.php
new file mode 100644 (file)
index 0000000..a3af8c7
--- /dev/null
@@ -0,0 +1,127 @@
+<?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/>.
+
+/**
+ * User profile info field deleted event.
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * User profile info field deleted event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - string shortname: the shortname of the field.
+ *      - string name: the name of the field.
+ *      - string datatype: the data type of the field.
+ * }
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @since      Moodle 3.4
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_info_field_deleted extends base {
+
+    /**
+     * Initialise required event data properties.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'user_info_field';
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from a profile field.
+     *
+     * @since Moodle 3.4
+     * @param \stdClass $field A snapshot of the deleted field.
+     * @return \core\event\base
+     */
+    public static function create_from_field($field) {
+        $event = self::create(array(
+            'objectid' => $field->id,
+            'context' => \context_system::instance(),
+            'other' => array(
+                'shortname' => $field->shortname,
+                'name'      => $field->name,
+                'datatype'  => $field->datatype,
+            )
+        ));
+
+        $event->add_record_snapshot('user_info_field', $field);
+
+        return $event;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventuserinfofielddeleted');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        $name = s($this->other['name']);
+        return "The user with id '$this->userid' deleted the user profile field '$name' with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['shortname'])) {
+            throw new \coding_exception('The \'shortname\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+
+        if (!isset($this->other['datatype'])) {
+            throw new \coding_exception('The \'datatype\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Get the backup/restore table mapping for this event.
+     *
+     * @return string
+     */
+    public static function get_objectid_mapping() {
+        return base::NOT_MAPPED;
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/event/user_info_field_updated.php b/lib/classes/event/user_info_field_updated.php
new file mode 100644 (file)
index 0000000..270de98
--- /dev/null
@@ -0,0 +1,141 @@
+<?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/>.
+
+/**
+ * User profile info field updated event.
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * User profile info field updated event.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *
+ *      - string shortname: the shortname of the field.
+ *      - string name: the name of the field.
+ *      - string datatype: the data type of the field.
+ * }
+ *
+ * @package    core
+ * @copyright  2017 Web Courseworks, Ltd. {@link http://www.webcourseworks.com}
+ * @since      Moodle 3.4
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class user_info_field_updated extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'user_info_field';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from a profile field.
+     *
+     * @since Moodle 3.4
+     * @param \stdClass $field A snapshot of the updated field.
+     * @return \core\event\base
+     */
+    public static function create_from_field($field) {
+        $event = self::create(array(
+            'objectid' => $field->id,
+            'context' => \context_system::instance(),
+            'other' => array(
+                'shortname' => $field->shortname,
+                'name'      => $field->name,
+                'datatype'  => $field->datatype,
+            )
+        ));
+
+        $event->add_record_snapshot('user_info_field', $field);
+
+        return $event;
+    }
+
+    /**
+     * Returns localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventuserinfofieldupdated');
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        $name = s($this->other['name']);
+        return "The user with id '$this->userid' updated the user profile field '$name' with id '$this->objectid'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return new \moodle_url('/user/profile/index.php', array(
+            'action' => 'editfield',
+            'id' => $this->objectid,
+            'datatype' => $this->other['datatype']
+        ));
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['shortname'])) {
+            throw new \coding_exception('The \'shortname\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+
+        if (!isset($this->other['datatype'])) {
+            throw new \coding_exception('The \'datatype\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Get the backup/restore table mapping for this event.
+     *
+     * @return string
+     */
+    public static function get_objectid_mapping() {
+        return base::NOT_MAPPED;
+    }
+}
index 75b98b4..7af9689 100644 (file)
@@ -1905,7 +1905,7 @@ class core_plugin_manager {
 
             'tool' => array(
                 'analytics', 'assignmentupgrade', 'availabilityconditions', 'behat', 'capability', 'cohortroles', 'customlang',
-                'dbtransfer', 'filetypes', 'generator', 'health', 'innodb', 'installaddon',
+                'dbtransfer', 'filetypes', 'generator', 'health', 'httpsreplace', 'innodb', 'installaddon',
                 'langimport', 'log', 'lp', 'lpimportcsv', 'lpmigrate', 'messageinbound', 'mobile', 'multilangupgrade',
                 'monitor', 'oauth2', 'phpunit', 'profiling', 'recyclebin', 'replace', 'spamcleaner', 'task', 'templatelibrary',
                 'unittest', 'uploadcourse', 'uploaduser', 'unsuproles', 'usertours', 'xmldb'
index e636b71..bbf96b4 100644 (file)
         <KEY NAME="fileid" TYPE="foreign" FIELDS="fileid" REFTABLE="files" REFFIELDS="id"/>
       </KEYS>
       <INDEXES>
-        <INDEX NAME="modelidandfileidandaction" UNIQUE="false" FIELDS="modelid, fileid, action" COMMENT="Index on modelid and fileid and action"/>
+        <INDEX NAME="modelidandactionandfileid" UNIQUE="false" FIELDS="modelid, action, fileid" COMMENT="Index on modelid and action and fileid"/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="analytics_indicator_calc" COMMENT="Stored indicator calculations">
index 2f2814e..34425f6 100644 (file)
@@ -2075,6 +2075,15 @@ $functions = array(
         'capabilities'  => '',
         'services'      => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+
+    // Filters functions.
+    'core_filters_get_available_in_context' => array(
+        'classname'   => 'core_filters\external',
+        'methodname'  => 'get_available_in_context',
+        'description' => 'Returns the filters available in the given contexts.',
+        'type'        => 'read',
+        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    )
 );
 
 $services = array(
index 5877954..e8b25fc 100644 (file)
@@ -2725,5 +2725,29 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2017101200.00);
     }
 
+    // Index modification upgrade step.
+    if ($oldversion < 2017101300.01) {
+
+        $table = new xmldb_table('analytics_used_files');
+
+        // Define index modelidandfileidandaction (not unique) to be dropped form analytics_used_files.
+        $index = new xmldb_index('modelidandfileidandaction', XMLDB_INDEX_NOTUNIQUE, array('modelid', 'fileid', 'action'));
+
+        // Conditionally launch drop index modelidandfileidandaction.
+        if ($dbman->index_exists($table, $index)) {
+            $dbman->drop_index($table, $index);
+        }
+
+        // Define index modelidandactionandfileid (not unique) to be dropped form analytics_used_files.
+        $index = new xmldb_index('modelidandactionandfileid', XMLDB_INDEX_NOTUNIQUE, array('modelid', 'action', 'fileid'));
+
+        // Conditionally launch add index modelidandactionandfileid.
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2017101300.01);
+    }
     return true;
 }
index 8741823..1b72c00 100644 (file)
@@ -2341,10 +2341,12 @@ abstract class moodle_database {
     /**
      * Returns the driver specific syntax (SQL part) for matching regex positively or negatively (inverted matching).
      * Eg: 'REGEXP':'NOT REGEXP' or '~*' : '!~*'
+     *
      * @param bool $positivematch
+     * @param bool $casesensitive
      * @return string or empty if not supported
      */
-    public function sql_regex($positivematch=true) {
+    public function sql_regex($positivematch = true, $casesensitive = false) {
         return '';
     }
 
index fdfccf7..437e4b3 100644 (file)
@@ -1738,10 +1738,25 @@ class mysqli_native_moodle_database extends moodle_database {
     /**
      * Return regex positive or negative match sql
      * @param bool $positivematch
+     * @param bool $casesensitive
      * @return string or empty if not supported
      */
-    public function sql_regex($positivematch=true) {
-        return $positivematch ? 'REGEXP' : 'NOT REGEXP';
+    public function sql_regex($positivematch = true, $casesensitive = false) {
+        $collation = '';
+        if ($casesensitive) {
+            if (substr($this->get_dbcollation(), -4) !== '_bin') {
+                $collationinfo = explode('_', $this->get_dbcollation());
+                $collation = 'COLLATE ' . $collationinfo[0] . '_bin ';
+            }
+        } else {
+            if ($this->get_dbcollation() == 'utf8_bin') {
+                $collation = 'COLLATE utf8_unicode_ci ';
+            } else if ($this->get_dbcollation() == 'utf8mb4_bin') {
+                $collation = 'COLLATE utf8mb4_unicode_ci ';
+            }
+        }
+
+        return $collation . ($positivematch ? 'REGEXP' : 'NOT REGEXP');
     }
 
     /**
index aaac5c8..44d298c 100644 (file)
@@ -1260,8 +1260,12 @@ class pgsql_native_moodle_database extends moodle_database {
         return true;
     }
 
-    public function sql_regex($positivematch=true) {
-        return $positivematch ? '~*' : '!~*';
+    public function sql_regex($positivematch = true, $casesensitive = false) {
+        if ($casesensitive) {
+            return $positivematch ? '~' : '!~';
+        } else {
+            return $positivematch ? '~*' : '!~*';
+        }
     }
 
     /**
index 8786585..866625d 100644 (file)
@@ -4347,6 +4347,9 @@ class core_dml_testcase extends database_driver_testcase {
     public function test_sql_regex() {
         $DB = $this->tdb;
         $dbman = $DB->get_manager();
+        if (!$DB->sql_regex_supported()) {
+            $this->markTestSkipped($DB->get_name().' does not support regular expressions');
+        }
 
         $table = $this->get_test_table();
         $tablename = $table->getName();
@@ -4356,27 +4359,33 @@ class core_dml_testcase extends database_driver_testcase {
         $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
         $dbman->create_table($table);
 
-        $DB->insert_record($tablename, array('name'=>'lalala'));
+        $DB->insert_record($tablename, array('name'=>'LALALA'));
         $DB->insert_record($tablename, array('name'=>'holaaa'));
         $DB->insert_record($tablename, array('name'=>'aouch'));
 
+        // Regex /a$/i (case-insensitive).
         $sql = "SELECT * FROM {{$tablename}} WHERE name ".$DB->sql_regex()." ?";
         $params = array('a$');
-        if ($DB->sql_regex_supported()) {
-            $records = $DB->get_records_sql($sql, $params);
-            $this->assertCount(2, $records);
-        } else {
-            $this->assertTrue(true, 'Regexp operations not supported. Test skipped');
-        }
+        $records = $DB->get_records_sql($sql, $params);
+        $this->assertCount(2, $records);
 
+        // Regex ! (not) /.a/i (case insensitive).
         $sql = "SELECT * FROM {{$tablename}} WHERE name ".$DB->sql_regex(false)." ?";
         $params = array('.a');
-        if ($DB->sql_regex_supported()) {
-            $records = $DB->get_records_sql($sql, $params);
-            $this->assertCount(1, $records);
-        } else {
-            $this->assertTrue(true, 'Regexp operations not supported. Test skipped');
-        }
+        $records = $DB->get_records_sql($sql, $params);
+        $this->assertCount(1, $records);
+
+        // Regex /a$/ (case-sensitive).
+        $sql = "SELECT * FROM {{$tablename}} WHERE name ".$DB->sql_regex(true, true)." ?";
+        $params = array('a$');
+        $records = $DB->get_records_sql($sql, $params);
+        $this->assertCount(1, $records);
+
+        // Regex ! (not) /.a/ (case sensitive).
+        $sql = "SELECT * FROM {{$tablename}} WHERE name ".$DB->sql_regex(false, true)." ?";
+        $params = array('.a');
+        $records = $DB->get_records_sql($sql, $params);
+        $this->assertCount(2, $records);
 
     }
 
index f303557..359209d 100644 (file)
@@ -1419,8 +1419,8 @@ function get_enrolled_join(context $context, $useridcolumn, $onlyactive = false,
                 "{$prefix}e1.courseid = :{$prefix}_e1_courseid",
             );
             if ($enrolid) {
-                $enrolconditions[] = "{$prefix}e1.id = :{$prefix}e1.enrolid";
-                $params[$prefix . 'e1.enrolid'] = $enrolid;
+                $enrolconditions[] = "{$prefix}e1.id = :{$prefix}e1_enrolid";
+                $params[$prefix . 'e1_enrolid'] = $enrolid;
             }
             $enrolconditionssql = implode(" AND ", $enrolconditions);
             $joins[] = "JOIN {enrol} {$prefix}e1 ON ($enrolconditionssql)";
index 6adebda..61b5bce 100644 (file)
@@ -2425,7 +2425,7 @@ function send_stored_file($stored_file, $lifetime=null, $filter=0, $forcedownloa
                 $size = 256;
             }
             $fileicon = file_file_icon($stored_file, $size);
-            send_file($CFG->dirroot.'/pix/'.$fileicon.'.svg', basename($fileicon).'.svg');
+            send_file($CFG->dirroot.'/pix/'.$fileicon.'.png', basename($fileicon).'.png');
         } else {
             // preview images have fixed cache lifetime and they ignore forced download
             // (they are generated by GD and therefore they are considered reasonably safe).
index 3b4d186..e54a334 100644 (file)
@@ -97,7 +97,7 @@ class HTMLPurifier_AttrDef_URI_Host extends HTMLPurifier_AttrDef
 
         // PHP 5.3 and later support this functionality natively
         if (function_exists('idn_to_ascii')) {
-            $string = idn_to_ascii($string);
+            $string = idn_to_ascii($string, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
 
         // If we have Net_IDNA2 support, we can support IRIs by
         // punycoding them. (This is the most portable thing to do,
index 43c714b..7b85b2f 100644 (file)
@@ -13,3 +13,7 @@ Description of HTML Purifier v4.9.3 library import into Moodle
     HTMLPurifier.path.php
 * add locallib.php with Moodle specific extensions to /lib/htmlpurifier/
 * add this readme_moodle.txt to /lib/htmlpurifier/
+
+Modifications:
+* MDL-60337 use correct IDN variant for converting domain names to ascii
+  Check status of https://github.com/ezyang/htmlpurifier/pull/148
index 9a84c5c..cfba593 100644 (file)
@@ -129,16 +129,27 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
             $samples[] = array_slice($sampledata, 0, $metadata['nfeatures']);
             $targets[] = intval($data[$metadata['nfeatures']]);
 
-            if (count($samples) === self::BATCH_SIZE) {
+            $nsamples = count($samples);
+            if ($nsamples === self::BATCH_SIZE) {
                 // Training it batches to avoid running out of memory.
 
                 $classifier->partialTrain($samples, $targets, array(0, 1));
                 $samples = array();
                 $targets = array();
             }
+            if (empty($morethan1sample) && $nsamples > 1) {
+                $morethan1sample = true;
+            }
         }
         fclose($fh);
 
+        if (empty($morethan1sample)) {
+            $resultobj = new \stdClass();
+            $resultobj->status = \core_analytics\model::NO_DATASET;
+            $resultobj->info = array();
+            return $resultobj;
+        }
+
         // Train the remaining samples.
         if ($samples) {
             $classifier->partialTrain($samples, $targets, array(0, 1));
@@ -288,7 +299,7 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
         }
         if (!empty($notenoughdata)) {
             $resultobj = new \stdClass();
-            $resultobj->status = \core_analytics\model::EVALUATE_NOT_ENOUGH_DATA;
+            $resultobj->status = \core_analytics\model::NOT_ENOUGH_DATA;
             $resultobj->score = 0;
             $resultobj->info = array(get_string('errornotenoughdata', 'mlbackend_php'));
             return $resultobj;
@@ -350,7 +361,7 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
 
         // If each iteration results varied too much we need more data to confirm that this is a valid model.
         if ($modeldev > $maxdeviation) {
-            $resultobj->status = $resultobj->status + \core_analytics\model::EVALUATE_NOT_ENOUGH_DATA;
+            $resultobj->status = $resultobj->status + \core_analytics\model::NOT_ENOUGH_DATA;
             $a = new \stdClass();
             $a->deviation = $modeldev;
             $a->accepteddeviation = $maxdeviation;
@@ -358,7 +369,7 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
         }
 
         if ($resultobj->score < \core_analytics\model::MIN_SCORE) {
-            $resultobj->status = $resultobj->status + \core_analytics\model::EVALUATE_LOW_SCORE;
+            $resultobj->status = $resultobj->status + \core_analytics\model::LOW_SCORE;
             $a = new \stdClass();
             $a->score = $resultobj->score;
             $a->minscore = \core_analytics\model::MIN_SCORE;
index 1ba59c8..69420b0 100644 (file)
@@ -38,7 +38,7 @@ class processor implements  \core_analytics\classifier, \core_analytics\regresso
     /**
      * The required version of the python package that performs all calculations.
      */
-    const REQUIRED_PIP_PACKAGE_VERSION = '0.0.2';
+    const REQUIRED_PIP_PACKAGE_VERSION = '0.0.3';
 
     /**
      * The path to the Python bin.
@@ -150,7 +150,11 @@ class processor implements  \core_analytics\classifier, \core_analytics\regresso
         }
 
         if ($exitcode != 0) {
-            throw new \moodle_exception('errorpredictionsprocessor', 'analytics', '', implode(', ', $resultobj->errors));
+            $errors = $resultobj->errors;
+            if (is_array($errors)) {
+                $errors = implode(', ', $errors);
+            }
+            $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors));
         }
 
         return $resultobj;
@@ -191,7 +195,11 @@ class processor implements  \core_analytics\classifier, \core_analytics\regresso
         }
 
         if ($exitcode != 0) {
-            throw new \moodle_exception('errorpredictionsprocessor', 'analytics', '', implode(', ', $resultobj->errors));
+            $errors = $resultobj->errors;
+            if (is_array($errors)) {
+                $errors = implode(', ', $errors);
+            }
+            $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors));
         }
 
         return $resultobj;
index d75a072..9918926 100644 (file)
@@ -5581,7 +5581,7 @@ function get_mailer($action='get') {
             // Use SMTP directly.
             $mailer->isSMTP();
             if (!empty($CFG->debugsmtp)) {
-                $mailer->SMTPDebug = true;
+                $mailer->SMTPDebug = 3;
             }
             // Specify main and backup servers.
             $mailer->Host          = $CFG->smtphosts;
index 27e56b4..6bc4acf 100644 (file)
@@ -6,8 +6,8 @@ at https://github.com/bobthecow/mustache.php/releases)
 2) Move the src/ and LICENSE file into lib/mustache
 
 e.g.
-wget https://github.com/bobthecow/mustache.php/archive/v2.11.1.zip
-unzip v2.11.1.zip
-cd mustache.php-2.11.1/
+wget https://github.com/bobthecow/mustache.php/archive/v2.12.0.zip
+unzip v2.12.0.zip
+cd mustache.php-2.12.0/
 mv src /path/to/moodle/lib/mustache/
 mv LICENSE /path/to/moodle/lib/mustache/
index b16ac14..e8ea3f4 100644 (file)
@@ -3,7 +3,7 @@
 /*
  * This file is part of Mustache.php.
  *
- * (c) 2010-2016 Justin Hileman
+ * (c) 2010-2017 Justin Hileman
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
@@ -16,6 +16,14 @@ class Mustache_Autoloader
 {
     private $baseDir;
 
+    /**
+     * An array where the key is the baseDir and the key is an instance of this
+     * class.
+     *
+     * @var array
+     */
+    private static $instances;
+
     /**
      * Autoloader constructor.
      *
@@ -45,7 +53,13 @@ class Mustache_Autoloader
      */
     public static function register($baseDir = null)
     {
-        $loader = new self($baseDir);
+        $key = $baseDir ? $baseDir : 0;
+
+        if (!isset(self::$instances[$key])) {
+            self::$instances[$key] = new self($baseDir);
+        }
+
+        $loader = self::$instances[$key];
         spl_autoload_register(array($loader, 'autoload'));
 
         return $loader;
index 7b465ab..3b5b3f1 100644 (file)
@@ -3,7 +3,7 @@
 /*
  * This file is part of Mustache.php.
  *
- * (c) 2010-2016 Justin Hileman
+ * (c) 2010-2017 Justin Hileman
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
index 495090b..365eafa 100644 (file)
@@ -3,7 +3,7 @@
 /*
  * This file is part of Mustache.php.
  *
- * (c) 2010-2016 Justin Hileman
+ * (c) 2010-2017 Justin Hileman
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
index 0dbe8f9..3e742b7 100644 (file)
@@ -3,7 +3,7 @@
 /*
  * This file is part of Mustache.php.
  *
- * (c) 2010-2016 Justin Hileman
+ * (c) 2010-2017 Justin Hileman
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
@@ -108,9 +108,11 @@ class Mustache_Cache_FilesystemCache extends Mustache_Cache_AbstractCache
             );
 
             @mkdir($dirName, 0777, true);
+            // @codeCoverageIgnoreStart
             if (!is_dir($dirName)) {
                 throw new Mustache_Exception_RuntimeException(sprintf('Failed to create cache directory "%s".', $dirName));
             }
+            // @codeCoverageIgnoreEnd
         }
 
         return $dirName;
@@ -143,13 +145,17 @@ class Mustache_Cache_FilesystemCache extends Mustache_Cache_AbstractCache
                 return;
             }
 
+            // @codeCoverageIgnoreStart
             $this->log(
                 Mustache_Logger::ERROR,
                 'Unable to rename Mustache temp cache file: "{tempName}" -> "{fileName}"',
                 array('tempName' => $tempFile, 'fileName' => $fileName)
             );
+            // @codeCoverageIgnoreEnd
         }
 
+        // @codeCoverageIgnoreStart
         throw new Mustache_Exception_RuntimeException(sprintf('Failed to write cache file "%s".', $fileName));
+        // @codeCoverageIgnoreEnd
     }
 }
index ca0007d..ed9eec9 100644 (file)
@@ -3,7 +3,7 @@
 /*
  * This file is part of Mustache.php.
  *
- * (c) 2010-2016 Justin Hileman
+ * (c) 2010-2017 Justin Hileman
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
index 2a831d6..610369e 100644 (file)
@@ -3,7 +3,7 @@
 /*
  * This file is part of Mustache.php.
  *
- * (c) 2010-2016 Justin Hileman
+ * (c) 2010-2017 Justin Hileman
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
@@ -191,7 +191,6 @@ class Mustache_Compiler
             {
                 $this->lambdaHelper = new Mustache_LambdaHelper($this->mustache, $context);
                 $buffer = \'\';
-                $blocksContext = array();
         %s
 
                 return $buffer;
@@ -207,7 +206,6 @@ class Mustache_Compiler
             public function renderInternal(Mustache_Context $context, $indent = \'\')
             {
                 $buffer = \'\';
-                $blocksContext = array();
         %s
 
                 return $buffer;
@@ -240,10 +238,11 @@ class Mustache_Compiler
         $blockFunction = $context->findInBlock(%s);
         if (is_callable($blockFunction)) {
             $buffer .= call_user_func($blockFunction, $context);
-        } else {%s
-        }
+        %s}
     ';
 
+    const BLOCK_VAR_ELSE = '} else {%s';
+
     /**
      * Generate Mustache Template inheritance block variable PHP source.
      *
@@ -261,10 +260,15 @@ class Mustache_Compiler
     {
         $id = var_export($id, true);
 
-        return sprintf($this->prepare(self::BLOCK_VAR, $level), $id, $this->walk($nodes, $level));
+        $else = $this->walk($nodes, $level);
+        if ($else !== '') {
+            $else = sprintf($this->prepare(self::BLOCK_VAR_ELSE, $level + 1, false, true), $else);
+        }
+
+        return sprintf($this->prepare(self::BLOCK_VAR, $level), $id, $else);
     }
 
-    const BLOCK_ARG = '$blocksContext[%s] = array($this, \'block%s\');';
+    const BLOCK_ARG = '%s => array($this, \'block%s\'),';
 
     /**
      * Generate Mustache Template inheritance block argument PHP source.
@@ -285,14 +289,13 @@ class Mustache_Compiler
         $keystr = var_export($key, true);
         $id = var_export($id, true);
 
-        return sprintf($this->prepare(self::BLOCK_ARG, 1), $id, $key);
+        return sprintf($this->prepare(self::BLOCK_ARG, $level), $id, $key);
     }
 
     const BLOCK_FUNCTION = '
         public function block%s($context)
         {
-            $indent = $buffer = \'\';
-            $blocksContext = array();%s
+            $indent = $buffer = \'\';%s
 
             return $buffer;
         }
@@ -327,7 +330,6 @@ class Mustache_Compiler
         private function section%s(Mustache_Context $context, $indent, $value)
         {
             $buffer = \'\';
-            $blocksContext = array();
 
             if (%s) {
                 $source = %s;
@@ -363,11 +365,10 @@ class Mustache_Compiler
      * @param string   $otag    Current Mustache opening tag
      * @param string   $ctag    Current Mustache closing tag
      * @param int      $level
-     * @param bool     $arg     (default: false)
      *
      * @return string Generated section PHP source code
      */
-    private function section($nodes, $id, $filters, $start, $end, $otag, $ctag, $level, $arg = false)
+    private function section($nodes, $id, $filters, $start, $end, $otag, $ctag, $level)
     {
         $source   = var_export(substr($this->source, $start, $end - $start), true);
         $callable = $this->getCallable();
@@ -387,15 +388,11 @@ class Mustache_Compiler
             $this->sections[$key] = sprintf($this->prepare(self::SECTION), $key, $callable, $source, $helper, $delims, $this->walk($nodes, 2));
         }
 
-        if ($arg === true) {
-            return $key;
-        } else {
-            $method  = $this->getFindMethod($id);
-            $id      = var_export($id, true);
-            $filters = $this->getFilters($filters, $level);
+        $method  = $this->getFindMethod($id);
+        $id      = var_export($id, true);
+        $filters = $this->getFilters($filters, $level);
 
-            return sprintf($this->prepare(self::SECTION_CALL, $level), $id, $method, $id, $filters, $key);
-        }
+        return sprintf($this->prepare(self::SECTION_CALL, $level), $id, $method, $id, $filters, $key);
     }
 
     const INVERTED_SECTION = '
@@ -457,15 +454,20 @@ class Mustache_Compiler
     }
 
     const PARENT = '
-        %s
-
         if ($parent = $this->mustache->loadPartial(%s)) {
-            $context->pushBlockContext($blocksContext);
+            $context->pushBlockContext(array(%s
+            ));
             $buffer .= $parent->renderInternal($context, $indent);
             $context->popBlockContext();
         }
     ';
 
+    const PARENT_NO_CONTEXT = '
+        if ($parent = $this->mustache->loadPartial(%s)) {
+            $buffer .= $parent->renderInternal($context, $indent);
+        }
+    ';
+
     /**
      * Generate Mustache Template inheritance parent call PHP source.
      *
@@ -480,11 +482,14 @@ class Mustache_Compiler
     {
         $realChildren = array_filter($children, array(__CLASS__, 'onlyBlockArgs'));
 
+        if (empty($realChildren)) {
+            return sprintf($this->prepare(self::PARENT_NO_CONTEXT, $level), var_export($id, true));
+        }
+
         return sprintf(
             $this->prepare(self::PARENT, $level),
-            $this->walk($realChildren, $level),
             var_export($id, true),
-            var_export($indent, true)
+            $this->walk($realChildren, $level + 1)
         );
     }
 
@@ -621,7 +626,7 @@ class Mustache_Compiler
     /**
      * Select the appropriate Context `find` method for a given $id.
      *
-     * The return value will be one of `find`, `findDot` or `last`.
+     * The return value will be one of `find`, `findDot`, `findAnchoredDot` or `last`.
      *
      * @see Mustache_Context::find
      * @see Mustache_Context::findDot
index f59faea..69c02e0 100644 (file)
@@ -3,7 +3,7 @@
 /*
  * This file is part of Mustache.php.
  *
- * (c) 2010-2016 Justin Hileman
+ * (c) 2010-2017 Justin Hileman
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
index 856d4ef..9110977 100644 (file)
@@ -3,7 +3,7 @@
 /*
  * This file is part of Mustache.php.
  *
- * (c) 2010-2016 Justin Hileman
+ * (c) 2010-2017 Justin Hileman
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
@@ -23,7 +23,7 @@
  */
 class Mustache_Engine
 {
-    const VERSION        = '2.11.1';
+    const VERSION        = '2.12.0';
     const SPEC_VERSION   = '1.1.2';
 
     const PRAGMA_FILTERS      = 'FILTERS';
@@ -54,6 +54,7 @@ class Mustache_Engine
     private $logger;
     private $strictCallables = false;
     private $pragmas = array();
+    private $delimiters;
 
     // Services
     private $tokenizer;
@@ -81,6 +82,14 @@ class Mustache_Engine
      *         // sections are often too dynamic to benefit from caching.
      *         'cache_lambda_templates' => true,
      *
+     *         // Customize the tag delimiters used by this engine instance. Note that overriding here changes the
+     *         // delimiters used to parse all templates and partials loaded by this instance. To override just for a
+     *         // single template, use an inline "change delimiters" tag at the start of the template file:
+     *         //
+     *         //     {{=<% %>=}}
+     *         //
+     *         'delimiters' => '<% %>',
+     *
      *         // A Mustache template loader instance. Uses a StringLoader if not specified.
      *         'loader' => new Mustache_Loader_FilesystemLoader(dirname(__FILE__).'/views'),
      *
@@ -133,6 +142,10 @@ class Mustache_Engine
     public function __construct(array $options = array())
     {
         if (isset($options['template_class_prefix'])) {
+            if ((string) $options['template_class_prefix'] === '') {
+                throw new Mustache_Exception_InvalidArgumentException('Mustache Constructor "template_class_prefix" must not be empty');
+            }
+
             $this->templateClassPrefix = $options['template_class_prefix'];
         }
 
@@ -191,6 +204,10 @@ class Mustache_Engine
             $this->strictCallables = $options['strict_callables'];
         }
 
+        if (isset($options['delimiters'])) {
+            $this->delimiters = $options['delimiters'];
+        }
+
         if (isset($options['pragmas'])) {
             foreach ($options['pragmas'] as $pragma) {
                 if (!isset(self::$knownPragmas[$pragma])) {
@@ -589,22 +606,43 @@ class Mustache_Engine
     /**
      * Helper method to generate a Mustache template class.
      *
-     * @param string $source
+     * This method must be updated any time options are added which make it so
+     * the same template could be parsed and compiled multiple different ways.
+     *
+     * @param string|Mustache_Source $source
      *
      * @return string Mustache Template class name
      */
     public function getTemplateClassName($source)
     {
-        return $this->templateClassPrefix . md5(sprintf(
-         &n