Merge branch 'install_master' of https://git.in.moodle.com/amosbot/moodle-install
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 17 Apr 2019 18:06:58 +0000 (20:06 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 17 Apr 2019 18:06:58 +0000 (20:06 +0200)
91 files changed:
admin/tool/analytics/classes/clihelper.php
admin/tool/analytics/classes/output/helper.php
admin/tool/analytics/classes/output/invalid_analysables.php
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/classes/output/restorable_models.php [new file with mode: 0644]
admin/tool/analytics/classes/task/predict_models.php
admin/tool/analytics/classes/task/train_models.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/analytics/restoredefault.php [new file with mode: 0644]
admin/tool/analytics/templates/models_list.mustache
admin/tool/analytics/templates/restorable_models.mustache [new file with mode: 0644]
admin/tool/analytics/tests/behat/restoredefault.feature [new file with mode: 0644]
admin/tool/analytics/version.php
admin/tool/log/classes/helper/buffered_writer.php
admin/tool/log/classes/helper/reader.php
admin/tool/log/classes/local/privacy/helper.php
admin/tool/log/store/database/classes/log/store.php
admin/tool/log/store/database/db/upgrade.php
admin/tool/log/store/database/lang/en/logstore_database.php
admin/tool/log/store/database/settings.php
admin/tool/log/store/database/tests/store_test.php
admin/tool/log/store/database/version.php
admin/tool/log/store/standard/classes/log/store.php
admin/tool/log/store/standard/db/upgrade.php
admin/tool/log/store/standard/lang/en/logstore_standard.php
admin/tool/log/store/standard/settings.php
admin/tool/log/store/standard/tests/store_test.php
admin/tool/log/store/standard/version.php
admin/tool/log/upgrade.txt
analytics/classes/manager.php
analytics/classes/model.php
analytics/lib.php [new file with mode: 0644]
analytics/tests/manager_test.php
analytics/tests/model_test.php
analytics/upgrade.txt
auth/nologin/auth.php
auth/oauth2/classes/auth.php
auth/oauth2/lang/en/auth_oauth2.php
auth/oauth2/tests/auth_test.php [new file with mode: 0644]
auth/upgrade.txt
backup/controller/backup_controller.class.php
backup/controller/restore_controller.class.php
backup/util/dbops/backup_controller_dbops.class.php
backup/util/dbops/restore_controller_dbops.class.php
backup/util/helper/async_helper.class.php
backup/util/ui/tests/behat/duplicate_activities.feature
blocks/myoverview/amd/build/view.min.js
blocks/myoverview/amd/src/view.js
blocks/myoverview/block_myoverview.php
blocks/myoverview/classes/output/main.php
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/lib.php
blocks/myoverview/settings.php [new file with mode: 0644]
blocks/myoverview/templates/courses-view.mustache
blocks/myoverview/templates/view-list.mustache
blocks/myoverview/templates/view-summary.mustache
cache/stores/mongodb/addinstanceform.php
course/tests/behat/app_course_completion.feature [new file with mode: 0644]
course/view.php
grade/import/csv/classes/load_data.php
grade/import/csv/tests/load_data_test.php
lib/authlib.php
lib/datalib.php
lib/db/install.xml
lib/db/upgrade.php
lib/moodlelib.php
lib/testing/classes/util.php
lib/tests/behat/app_behat_runtime.js
lib/tests/moodlelib_test.php
lib/tests/targets_test.php
lib/upgrade.txt
mod/chat/tests/lib_test.php
mod/forum/amd/build/discussion.min.js [new file with mode: 0644]
mod/forum/amd/build/selectors.min.js
mod/forum/amd/src/discussion.js [new file with mode: 0644]
mod/forum/amd/src/selectors.js
mod/forum/classes/local/exporters/author.php
mod/forum/classes/local/factories/renderer.php
mod/forum/lang/en/forum.php
mod/forum/templates/forum_discussion.mustache
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/forum_discussion_threaded_post.mustache
mod/forum/tests/vaults_post_test.php
repository/webdav/lib.php
theme/boost/scss/moodle/drawer.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php
webservice/lib.php
webservice/tests/lib_test.php

index c46e6fc..8736580 100644 (file)
@@ -48,7 +48,7 @@ class clihelper {
         foreach ($models as $model) {
             $modelid = $model->get_id();
             $isenabled = $model->is_enabled() ? get_string('enabled', 'tool_analytics') : get_string('disabled', 'tool_analytics');
-            $name = $model->get_target()->get_name();
+            $name = $model->get_name();
             echo str_pad($modelid, 15, ' ') . ' ' . str_pad($name, 50, ' ') . ' ' . str_pad($isenabled, 15, ' ') . "\n";
         }
     }
index e00ee0b..1db9981 100644 (file)
@@ -86,7 +86,7 @@ class helper {
         if ($analyticmodels = $PAGE->settingsnav->find('analyticmodels', \navigation_node::TYPE_SETTING)) {
             $PAGE->navbar->add($analyticmodels->get_content(), $analyticmodels->action());
         }
-        $PAGE->navbar->add($title, $url);
+        $PAGE->navbar->add($title);
 
         $PAGE->set_pagelayout('report');
         $PAGE->set_title($title);
index dbdaa00..fd205f1 100644 (file)
@@ -125,7 +125,7 @@ class invalid_analysables implements \renderable, \templatable {
 
         // Prepare the context object.
         $data = new \stdClass();
-        $data->modelname = $this->model->get_target()->get_name();
+        $data->modelname = $this->model->get_name();
 
         if ($this->page > 0) {
             $prev = clone $PAGE->url;
index b0431a3..c6831d0 100644 (file)
@@ -62,8 +62,33 @@ class models_list implements \renderable, \templatable {
         global $PAGE;
 
         $data = new \stdClass();
-        $data->importmodelurl = new \moodle_url('/admin/tool/analytics/importmodel.php');
-        $data->createmodelurl = new \moodle_url('/admin/tool/analytics/createmodel.php');
+
+        $newmodelmenu = new \action_menu();
+        $newmodelmenu->set_menu_trigger(get_string('newmodel', 'tool_analytics'), 'btn btn-default');
+        $newmodelmenu->set_alignment(\action_menu::TL, \action_menu::BL);
+
+        $newmodelmenu->add(new \action_menu_link(
+            new \moodle_url('/admin/tool/analytics/createmodel.php'),
+            new \pix_icon('i/edit', ''),
+            get_string('createmodel', 'tool_analytics'),
+            false
+        ));
+
+        $newmodelmenu->add(new \action_menu_link(
+            new \moodle_url('/admin/tool/analytics/importmodel.php'),
+            new \pix_icon('i/import', ''),
+            get_string('importmodel', 'tool_analytics'),
+            false
+        ));
+
+        $newmodelmenu->add(new \action_menu_link(
+            new \moodle_url('/admin/tool/analytics/restoredefault.php'),
+            new \pix_icon('i/reload', ''),
+            get_string('restoredefault', 'tool_analytics'),
+            false
+        ));
+
+        $data->newmodelmenu = $newmodelmenu->export_for_template($output);
 
         $onlycli = get_config('analytics', 'onlycli');
         if ($onlycli === false) {
@@ -84,7 +109,7 @@ class models_list implements \renderable, \templatable {
 
         $data->models = array();
         foreach ($this->models as $model) {
-            $modeldata = $model->export();
+            $modeldata = $model->export($output);
 
             // Check if there is a help icon for the target to show.
             $identifier = $modeldata->target->get_identifier();
@@ -120,6 +145,8 @@ class models_list implements \renderable, \templatable {
                 $modeldata->indicators = $indicators;
             }
 
+            $modeldata->indicatorsnum = count($modeldata->indicators);
+
             // Check if there is a help icon for the time splitting method.
             if (!empty($modeldata->timesplitting)) {
                 $identifier = $modeldata->timesplitting->get_identifier();
@@ -299,16 +326,14 @@ class models_list implements \renderable, \templatable {
             }
 
             // Delete model.
-            if (!$model->is_static()) {
-                $actionid = 'delete-' . $model->get_id();
-                $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'delete']);
-                $urlparams['action'] = 'delete';
-                $url = new \moodle_url('model.php', $urlparams);
-                $icon = new \action_menu_link_secondary($url, new \pix_icon('t/delete',
-                    get_string('delete', 'tool_analytics')), get_string('delete', 'tool_analytics'),
-                    ['data-action-id' => $actionid]);
-                $actionsmenu->add($icon);
-            }
+            $actionid = 'delete-' . $model->get_id();
+            $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'delete']);
+            $urlparams['action'] = 'delete';
+            $url = new \moodle_url('model.php', $urlparams);
+            $icon = new \action_menu_link_secondary($url, new \pix_icon('t/delete',
+                get_string('delete', 'tool_analytics')), get_string('delete', 'tool_analytics'),
+                ['data-action-id' => $actionid]);
+            $actionsmenu->add($icon);
 
             $modeldata->actions = $actionsmenu->export_for_template($output);
 
diff --git a/admin/tool/analytics/classes/output/restorable_models.php b/admin/tool/analytics/classes/output/restorable_models.php
new file mode 100644 (file)
index 0000000..a4e3456
--- /dev/null
@@ -0,0 +1,142 @@
+<?php
+// This file is part of Moodle - https://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/>.
+
+/**
+ * Provides {@link \tool_analytics\output\restorable_models} class.
+ *
+ * @package     tool_analytics
+ * @category    output
+ * @copyright   2019 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_analytics\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Represents the list of default models that can be eventually restored.
+ *
+ * @copyright 2019 David Mudrák <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class restorable_models implements \renderable, \templatable {
+
+    /** @var array */
+    protected $models;
+
+    /**
+     * Instantiate an object of this class.
+     *
+     * @param array $models List of models as returned by {@link \core_analytics\manager::load_default_models_for_all_components()}
+     */
+    public function __construct(array $models) {
+
+        $this->models = $models;
+    }
+
+    /**
+     * Export the list of models to be rendered.
+     *
+     * @param renderer_base $output
+     * @return string
+     */
+    public function export_for_template(\renderer_base $output) {
+
+        $components = [];
+
+        foreach ($this->models as $componentname => $modelslist) {
+            $component = [
+                'name' => $this->component_name($componentname),
+                'component' => $componentname,
+                'models' => [],
+            ];
+
+            foreach ($modelslist as $definition) {
+                list($target, $indicators) = \core_analytics\manager::get_declared_target_and_indicators_instances($definition);
+
+                if (\core_analytics\model::exists($target, $indicators)) {
+                    continue;
+                }
+
+                $targetnamelangstring = $target->get_name();
+
+                $model = [
+                    'defid' => \core_analytics\manager::model_declaration_identifier($definition),
+                    'targetname' => $targetnamelangstring,
+                    'targetclass' => $definition['target'],
+                    'indicatorsnum' => count($definition['indicators']),
+                    'indicators' => [],
+                ];
+
+                if (get_string_manager()->string_exists($targetnamelangstring->get_identifier().'_help',
+                        $targetnamelangstring->get_component())) {
+                    $helpicon = new \help_icon($targetnamelangstring->get_identifier(), $targetnamelangstring->get_component());
+                    $model['targethelp'] = $helpicon->export_for_template($output);
+                }
+
+                foreach ($indicators as $indicator) {
+                    $indicatornamelangstring = $indicator->get_name();
+                    $indicatordata = [
+                        'name' => $indicatornamelangstring,
+                        'classname' => $indicator->get_id(),
+                    ];
+
+                    if (get_string_manager()->string_exists($indicatornamelangstring->get_identifier().'_help',
+                            $indicatornamelangstring->get_component())) {
+                        $helpicon = new \help_icon($indicatornamelangstring->get_identifier(),
+                            $indicatornamelangstring->get_component());
+                        $indicatordata['indicatorhelp'] = $helpicon->export_for_template($output);
+                    }
+
+                    $model['indicators'][] = $indicatordata;
+                }
+
+                $component['models'][] = $model;
+            }
+
+            if (!empty($component['models'])) {
+                $components[] = $component;
+            }
+        }
+
+        $result = [
+            'hasdata' => !empty($components),
+            'components' => array_values($components),
+            'submiturl' => new \moodle_url('/admin/tool/analytics/restoredefault.php'),
+            'backurl' => new \moodle_url('/admin/tool/analytics/index.php'),
+            'sesskey' => sesskey(),
+        ];
+
+        return $result;
+    }
+
+    /**
+     * Return a human readable name for the given frankenstyle component.
+     *
+     * @param string $component Frankenstyle component such as 'core', 'core_analytics' or 'mod_workshop'
+     * @return string Human readable name of the component
+     */
+    protected function component_name(string $component): string {
+
+        if ($component === 'core' || strpos($component, 'core_')) {
+            return get_string('componentcore', 'tool_analytics');
+
+        } else {
+            return get_string('pluginname', $component);
+        }
+    }
+}
index 91f3c85..6ea0d0d 100644 (file)
@@ -65,7 +65,7 @@ class predict_models extends \core\task\scheduled_task {
             \tool_analytics\output\helper::reset_page();
 
             if ($result) {
-                echo $OUTPUT->heading(get_string('modelresults', 'tool_analytics', $model->get_target()->get_name()));
+                echo $OUTPUT->heading(get_string('modelresults', 'tool_analytics', $model->get_name()));
                 $renderer = $PAGE->get_renderer('tool_analytics');
                 echo $renderer->render_get_predictions_results(false, array(), $result, $model->get_analyser()->get_logs());
             }
index 3c0c3c9..67c0a3a 100644 (file)
@@ -76,7 +76,7 @@ class train_models extends \core\task\scheduled_task {
             \tool_analytics\output\helper::reset_page();
 
             if ($result) {
-                echo $OUTPUT->heading(get_string('modelresults', 'tool_analytics', $model->get_target()->get_name()));
+                echo $OUTPUT->heading(get_string('modelresults', 'tool_analytics', $model->get_name()));
 
                 $renderer = $PAGE->get_renderer('tool_analytics');
                 echo $renderer->render_get_predictions_results($result, $model->get_analyser()->get_logs());
index 34c1585..7e4d208 100644 (file)
@@ -36,6 +36,10 @@ $string['clearmodelpredictions'] = 'Are you sure you want to clear all "{$a}" pr
 $string['clienablemodel'] = 'You can enable the model by selecting a time-splitting method by its ID. Note that you can also enable it later using the web interface (\'none\' to exit).';
 $string['clievaluationandpredictions'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. You can allow these processes to be executed manually via the web interface by disabling the <a href="{$a}">\'onlycli\'</a> analytics setting.';
 $string['clievaluationandpredictionsnoadmin'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. It may be enabled by a site administrator.';
+$string['component'] = 'Component';
+$string['componentcore'] = 'Core';
+$string['componentselect'] = 'Select all models provided by the component \'{$a}\'';
+$string['componentselectnone'] = 'Unselect all';
 $string['createmodel'] = 'Create model';
 $string['currenttimesplitting'] = 'Current time-splitting method';
 $string['delete'] = 'Delete';
@@ -81,6 +85,7 @@ $string['importmodel'] = 'Import model';
 $string['indicators'] = 'Indicators';
 $string['indicators_help'] = 'The indicators are what you think will lead to an accurate prediction of the target.';
 $string['indicators_link'] = 'Indicators';
+$string['indicatorsnum'] = 'Number of indicators: {$a}';
 $string['info'] = 'Info';
 $string['ignoreversionmismatches'] = 'Ignore version mismatches';
 $string['ignoreversionmismatchescheckbox'] = 'Ignore the differences between this site version and the original site version.';
@@ -96,8 +101,10 @@ $string['loginfo'] = 'Log extra info';
 $string['missingmoodleversion'] = 'Imported file does not define a moodle version number';
 $string['modelid'] = 'Model ID';
 $string['modelinvalidanalysables'] = 'Invalid analysable elements for "{$a}" model';
+$string['modelname'] = 'Model name';
 $string['modelresults'] = '{$a} results';
 $string['modeltimesplitting'] = 'Time splitting';
+$string['newmodel'] = 'New model';
 $string['nextpage'] = 'Next page';
 $string['nodatatoevaluate'] = 'There is no data to evaluate the model';
 $string['nodatatopredict'] = 'No new elements to get predictions for';
@@ -110,6 +117,12 @@ $string['predictmodels'] = 'Predict models';
 $string['predictorresultsin'] = 'Predictor logged information in {$a} directory';
 $string['predictionprocessfinished'] = 'Prediction process finished';
 $string['previouspage'] = 'Previous page';
+$string['restoredefault'] = 'Restore default models';
+$string['restoredefaultempty'] = 'Please select models to be restored.';
+$string['restoredefaultinfo'] = 'These default models are missing or have changed since being installed. You can restore selected default models.';
+$string['restoredefaultnone'] = 'All default models provided by the Moodle core and installed plugins have been already created. No new models were found, there is nothing to restore.';
+$string['restoredefaultsome'] = 'Succesfully re-created {$a->count} new model(s).';
+$string['restoredefaultsubmit'] = 'Restore selected';
 $string['samestartdate'] = 'Current start date is good';
 $string['sameenddate'] = 'Current end date is good';
 $string['selecttimesplittingforevaluation'] = 'Select the time-splitting method you want to use to evaluate the model configuration.';
index d70fad3..c6cc950 100644 (file)
@@ -40,7 +40,7 @@ $url = new \moodle_url('/admin/tool/analytics/model.php', $params);
 switch ($action) {
 
     case 'edit':
-        $title = get_string('editmodel', 'tool_analytics', $model->get_target()->get_name());
+        $title = get_string('editmodel', 'tool_analytics', $model->get_name());
         break;
     case 'evaluate':
         $title = get_string('evaluatemodel', 'tool_analytics');
@@ -103,9 +103,7 @@ switch ($action) {
     case 'delete':
         confirm_sesskey();
 
-        if (!$model->is_static()) {
-            $model->delete();
-        }
+        $model->delete();
         redirect($returnurl);
         break;
 
diff --git a/admin/tool/analytics/restoredefault.php b/admin/tool/analytics/restoredefault.php
new file mode 100644 (file)
index 0000000..28b1b02
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+// This file is part of Moodle - https://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/>.
+
+/**
+ * Check and create missing default prediction models.
+ *
+ * @package     tool_analytics
+ * @copyright   2019 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../../config.php');
+
+require_login();
+\core_analytics\manager::check_can_manage_models();
+
+$confirmed = optional_param('confirmed', false, PARAM_BOOL);
+$restoreids = optional_param_array('restoreid', [], PARAM_ALPHANUM);
+
+$returnurl = new \moodle_url('/admin/tool/analytics/index.php');
+$myurl = new \moodle_url('/admin/tool/analytics/restoredefault.php');
+
+\tool_analytics\output\helper::set_navbar(get_string('restoredefault', 'tool_analytics'), $myurl);
+
+if (data_submitted()) {
+    require_sesskey();
+
+    if (empty($restoreids)) {
+        $message = get_string('restoredefaultempty', 'tool_analytics');
+        $type = \core\output\notification::NOTIFY_WARNING;
+        redirect($myurl, $message, null, $type);
+    }
+
+    $numcreated = 0;
+
+    foreach (\core_analytics\manager::load_default_models_for_all_components() as $componentname => $modelslist) {
+        foreach ($modelslist as $definition) {
+            if (!in_array(\core_analytics\manager::model_declaration_identifier($definition), $restoreids)) {
+                // This model has not been selected by the user.
+                continue;
+            }
+
+            list($target, $indicators) = \core_analytics\manager::get_declared_target_and_indicators_instances($definition);
+
+            if (\core_analytics\model::exists($target, $indicators)) {
+                // This model exists (normally this should not happen as we do not show such models in the UI to select).
+                continue;
+            }
+
+            \core_analytics\manager::create_declared_model($definition);
+            $numcreated++;
+        }
+    }
+
+    $message = get_string('restoredefaultsome', 'tool_analytics', ['count' => $numcreated]);
+    $type = \core\output\notification::NOTIFY_SUCCESS;
+
+    redirect($returnurl, $message, null, $type);
+}
+
+$models = \core_analytics\manager::load_default_models_for_all_components();
+$ui = new \tool_analytics\output\restorable_models($models);
+
+echo $OUTPUT->header();
+echo $PAGE->get_renderer('tool_analytics')->render($ui);
+echo $OUTPUT->footer();
index b49c9e2..39d8e6b 100644 (file)
     Template for models list.
 
     Classes required for JS:
-    * none
+    * The list od models wrapped within a id="predictionmodelslist" element.
 
     Data attributes required for JS:
-    * none
+    * [data-widget="toggle"] indicates the clickable element for expanding/collapsing
+      the list of indicators used by the given model.
 
     Context variables required for this template:
-    * none
+    * models: array - list of models to display
+        - id: int - model unique identifier
+        - name: object - data for the inplace editable element template
+        - target: string - name of the target associated with the model
+        - targetclass: string - fully qualified name of the target class
+        - targethelp: object - data for the help tooltip template
+        - enabled: bool - is the model enabled
+        - indicatorsnum: int - number of indicators
+        - indicators: array - list of indicators used by the model
+            + name: string - name of the indicator
+            + help: object - data for the help tooltip template
+        - insights: object - data for the single select template
+        - noinsights: string - text to display instead of insights
+    * warnings: array - list of data for notification warning template
+    * infos: array - list of data for notification info template
+    * createmodelurl: string - URL to create a new model
+    * importmodelurl: string - URL to import a model
 
     Example context (json):
     {
         "models": [
             {
+                "id": 11,
+                "name": {
+                    "component": "local_analyticsdemo",
+                    "itemtype": "modelname",
+                    "itemid": 42,
+                    "displayvalue": "Prevent devs at risk",
+                    "value": ""
+                },
                 "target": "Prevent devs at risk",
-                "targethelp": [
-                    {
-                        "title": "Help with something",
-                        "url": "http://example.org/help",
-                        "linktext": "",
-                        "icon":{
-                            "extraclasses": "iconhelp",
-                            "attributes": [
-                                {"name": "src", "value": "../../../pix/help.svg"},
-                                {"name": "alt", "value": "Help icon"}
-                            ]
-                        }
+                "targetclass": "\\local_analyticsdemo\\analytics\\target\\dev_risk",
+                "targethelp": {
+                    "title": "Help with Prevent devs at risk",
+                    "text": "This target blah blah ...",
+                    "url": "http://example.org/help",
+                    "linktext": "",
+                    "icon": {
+                        "extraclasses": "iconhelp",
+                        "attributes": [
+                            {"name": "src", "value": "../../../pix/help.svg"},
+                            {"name": "alt", "value": "Help icon"}
+                        ]
                     }
-                ],
+                },
                 "enabled": 1,
-                "indicators": [{
-                    "name": "Indicator 1",
-                    "help": [{
-                            "title": "Help with something",
+                "indicatorsnum": 2,
+                "indicators": [
+                    {
+                        "name": "Indicator 1",
+                        "help": {
+                            "text": "This indicator blah blah ...",
+                            "title": "Help with Indicator 1",
                             "url": "http://example.org/help",
                             "linktext": "",
-                            "icon":{
+                            "icon": {
                                 "extraclasses": "iconhelp",
                                 "attributes": [
                                     {"name": "src", "value": "../../../pix/help.svg"},
                                     {"name": "alt", "value": "Help icon"}
                                 ]
                             }
-                        }]
+                        }
                     },
                     {
-                    "name": "Indicator 2",
-                    "help": [{
-                            "title": "Help with something",
+                        "name": "Indicator 2",
+                        "help": {
+                            "text": "This indicator blah blah ...",
+                            "title": "Help with Indicator 2",
                             "url": "http://example.org/help",
                             "linktext": "",
-                            "icon":{
+                            "icon": {
                                 "extraclasses": "iconhelp",
                                 "attributes": [
                                     {"name": "src", "value": "../../../pix/help.svg"},
                                     {"name": "alt", "value": "Help icon"}
                                 ]
                             }
-                        }]
-                    }],
-                "timesplitting": "Quarters",
-                "timesplittinghelp": [
-                    {
-                        "title": "Help with something",
-                        "url": "http://example.org/help",
-                        "linktext": "",
-                        "icon":{
-                            "extraclasses": "iconhelp",
-                            "attributes": [
-                                {"name": "src", "value": "../../../pix/help.svg"},
-                                {"name": "alt", "value": "Help icon"}
-                            ]
                         }
                     }
                 ],
+                "timesplitting": "Quarters",
+                "timesplittinghelp": {
+                    "text": "This time splitting methof blah blah ...",
+                    "title": "Help with Quarters",
+                    "url": "http://example.org/help",
+                    "linktext": "",
+                    "icon": {
+                        "extraclasses": "iconhelp",
+                        "attributes": [
+                            {"name": "src", "value": "../../../pix/help.svg"},
+                            {"name": "alt", "value": "Help icon"}
+                        ]
+                    }
+                },
                 "noinsights": "No insights available yet"
             }
         ],
-        "warnings": {
-            "message": "Hey, this is a warning"
-        }
+        "warnings": [
+            {
+                "message": "Be ware, this is just an example!"
+            }
+        ],
+        "createmodelurl": "#",
+        "importmodelurl": "#"
     }
 }}
 
 
 <div class="box">
     <div class="top-nav d-flex">
-        <a href="{{createmodelurl}}" class="btn btn-secondary mr-2">{{#str}}createmodel, tool_analytics{{/str}}</a>
-        <a href="{{importmodelurl}}" class="btn btn-secondary">{{#str}}importmodel, tool_analytics{{/str}}</a>
+        {{#newmodelmenu}}
+        {{>core/action_menu}}
+        {{/newmodelmenu}}
     </div>
-    <table class="generaltable fullwidth">
+    <table id="predictionmodelslist" class="generaltable fullwidth">
         <caption>{{#str}}analyticmodels, tool_analytics{{/str}}</caption>
         <thead>
             <tr>
-                <th scope="col">{{#str}}target, tool_analytics{{/str}}</th>
+                <th scope="col">{{#str}}modelname, tool_analytics{{/str}}</th>
                 <th scope="col">{{#str}}enabled, tool_analytics{{/str}}</th>
                 <th scope="col">{{#str}}indicators, tool_analytics{{/str}}</th>
                 <th scope="col">{{#str}}modeltimesplitting, tool_analytics{{/str}}</th>
         {{#models}}
             <tr>
                 <td>
-                    <span class="target-name">{{target}}</span>
-                    {{#targethelp}}
-                        {{>core/help_icon}}
-                    {{/targethelp}}
+                    {{#name}}
+                        <span class="model-name">{{>core/inplace_editable}}</span>
+                    {{/name}}
+                    <div>
+                        <small class="target-class">{{targetclass}}</small>
+                        {{#targethelp}}
+                            {{>core/help_icon}}
+                        {{/targethelp}}
+                    </div>
                 </td>
                 <td>
                     {{#enabled}}
                     {{/enabled}}
                 </td>
                 <td>
-                    <ul>
+                    <a data-widget="toggle"
+                           title="{{#str}} clicktohideshow {{/str}}"
+                           aria-expanded="false"
+                           aria-controls="indicators-{{id}}"
+                           role="button"
+                           href="">
+                        {{#str}} indicatorsnum, tool_analytics, {{indicatorsnum}} {{/str}}
+                    </a>
+                    <ul class="hidden" id="indicators-{{id}}">
                     {{#indicators}}
                         <li>
                             {{name}}
         </tbody>
     </table>
 </div>
+{{#js}}
+require(['jquery'], function($) {
+
+    // Toggle the visibility of the indicators list.
+    $('#predictionmodelslist').on('click', '[data-widget="toggle"]', function(e) {
+        e.preventDefault();
+        var toggle = $(e.currentTarget);
+        var listid = toggle.attr('aria-controls');
+
+        $(document.getElementById(listid)).toggle();
+
+        if (toggle.attr('aria-expanded') == 'false') {
+            toggle.attr('aria-expanded', 'true');
+        } else {
+            toggle.attr('aria-expanded', 'false');
+        }
+    });
+});
+{{/js}}
diff --git a/admin/tool/analytics/templates/restorable_models.mustache b/admin/tool/analytics/templates/restorable_models.mustache
new file mode 100644 (file)
index 0000000..da85f7b
--- /dev/null
@@ -0,0 +1,225 @@
+{{!
+    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/>.
+}}
+{{!
+    @template tool_analytics/restorable_models
+
+    Displays the list of missing prediction models that can be restored.
+
+    Classes required for JS:
+    * The list should be wrapped within a id="restorablemodelslist" element.
+
+    Data attributes required for JS:
+    * [data-widget="toggle"] indicates the clickable element for expanding/collapsing
+      the list of indicators used by the given model.
+    * [data-select] indicates a clickable element used for selecting multiple checkboxes.
+    * [data-component] should be set for checkboxes that select the particular model.
+
+    Context variables required for this template:
+    * hasdata: boolean - do we have data to display
+    * submiturl: string - URL where the form should be submitted
+    * backurl: string - URL where the user should be sent without making any changes
+    * sesskey: string
+    * components: array - list of components to display
+        - name: string - human readable name of the component
+        - component: string - frankenstyle name of the component
+        - models: array - list of restorable models provided by the component
+            + defid: string - model definition identifier
+            + targetname: string - human readable name of the target
+            + targetclass: string - fully qualified classname of the target
+            + indicatorsnum: int - number of indicators
+            + indicators: array - list of indicators
+                ~ name: string - human readable name of the indicator
+                ~ classname: string - fully qualified classname of the indicator
+
+    Example context (json):
+    {
+        "hasdata": true,
+        "submiturl": "https://example.com/moodle/admin/tool/analytics/restoredefault.php",
+        "backurl": "https://example.com/moodle/admin/tool/analytics/index.php",
+        "sesskey": "abcdefg123456",
+        "components": [
+            {
+                "name": "Core",
+                "component": "core",
+                "models": [
+                    {
+                        "defid": "id24680aceg",
+                        "targetname": "No teaching",
+                        "targetclass": "\\core\\analytics\\target\\no_teaching",
+                        "indicatorsnum": 2,
+                        "indicators": [
+                            {
+                                "name": "There are no teachers",
+                                "classname": "\\core\\analytics\\indicator\\no_teacher"
+                            },
+                            {
+                                "name": "There are no students",
+                                "classname": "\\core\\analytics\\indicator\\no_students"
+                            }
+                        ]
+                    },
+                    {
+                        "defid": "id13579bdfi",
+                        "targetname": "Students at risk of dropping out",
+                        "targetclass": "\\core\\analytics\\target\\course_dropout",
+                        "indicatorsnum": 1,
+                        "indicators": [
+                            {
+                                "name": "Read actions amount",
+                                "classname": "\\core\\analytics\\indicator\\read_actions"
+                            }
+                        ]
+                    }
+                ]
+            },
+            {
+                "name": "Custom analytics plugin",
+                "component": "tool_customanalytics",
+                "models": [
+                    {
+                        "defid": "id566dsgffg655",
+                        "targetname": "Cheater",
+                        "targetclass": "\\tool_customanalytics\\analytics\\target\\cheater",
+                        "indicatorsnum": 1,
+                        "indicators": [
+                            {
+                                "name": "Copy-pasted submissions",
+                                "classname": "\\tool_customanalytics\\analytics\\indicator\\copy_paster_submissions"
+                            }
+                        ]
+                    }
+                ]
+            }
+        ]
+    }
+}}
+<div class="box">
+    {{^hasdata}}
+    <p>{{#str}} restoredefaultnone, tool_analytics {{/str}}</p>
+    <div><a href="{{backurl}}" class="btn btn-secondary">{{#str}} back {{/str}}</a></div>
+    {{/hasdata}}
+
+    {{#hasdata}}
+    <p>{{#str}} restoredefaultinfo, tool_analytics {{/str}}</p>
+    <form method="post" action="{{submiturl}}">
+        <table id="restorablemodelslist" class="generaltable fullwidth">
+            <colgroup>
+                <col width="10%">
+                <col width="45%">
+                <col width="45%">
+            </colgroup>
+            <thead>
+                <tr>
+                    <th scope="col"><a href="" data-select="*">{{#str}} selectall {{/str}}</a></th>
+                    <th scope="col">{{#str}} target, tool_analytics {{/str}}</th>
+                    <th scope="col">{{#str}} indicators, tool_analytics {{/str}}</th>
+                </tr>
+            </thead>
+            <tbody>
+            {{#components}}
+                <tr>
+                    <th scope="rowgroup" colspan="3">
+                        <span class="component-name">
+                            <a href=""
+                                    title="{{#str}} componentselect, tool_analytics, {{name}} {{/str}}"
+                                    data-select="{{component}}">
+                                {{name}}
+                            </a>
+                        </span>
+                        <div><small class="component-frankenstyle">{{component}}</small></div>
+                    </th>
+                </tr>
+                {{#models}}
+                <tr>
+                    <td>
+                        <input data-component="{{component}}" type="checkbox" name="restoreid[]" value="{{defid}}">
+                    </td>
+                    <td>
+                        <span class="target-name">{{targetname}}</span>
+                        {{#targethelp}}
+                            {{>core/help_icon}}
+                        {{/targethelp}}
+                        <div><small class="target-class">{{targetclass}}</small></div>
+                    </td>
+                    <td>
+                        <a data-widget="toggle"
+                                title="{{#str}} clicktohideshow {{/str}}"
+                                aria-expanded="false"
+                                aria-controls="indicators-{{defid}}"
+                                role="button"
+                                href="">
+                            {{#str}} indicatorsnum, tool_analytics, {{indicatorsnum}} {{/str}}
+                        </a>
+                        <ul class="hidden listunstyled" id="indicators-{{defid}}">
+                        {{#indicators}}
+                            <li>
+                                {{name}}
+                                {{#indicatorhelp}}
+                                    {{>core/help_icon}}
+                                {{/indicatorhelp}}
+                                <div><small>{{classname}}</small></div>
+                            </li>
+                        {{/indicators}}
+                        </ul>
+                    </td>
+                </tr>
+                {{/models}}
+            {{/components}}
+            </tbody>
+        </table>
+        <div>
+            <input class="btn btn-primary" type="submit" value="{{#str}} restoredefaultsubmit, tool_analytics {{/str}}">
+            <input class="btn btn-secondary" type="reset" value="{{#str}} componentselectnone, tool_analytics {{/str}}">
+            <a href="{{backurl}}" class="btn btn-secondary">{{#str}} back {{/str}}</a>
+            <input type="hidden" name="sesskey" value="{{sesskey}}">
+        </div>
+    </form>
+    {{/hasdata}}
+</div>
+
+{{#js}}
+require(['jquery'], function($) {
+
+    // Toggle the visibility of the indicators list.
+    $('#restorablemodelslist').on('click', '[data-widget="toggle"]', function(e) {
+        e.preventDefault();
+        var toggle = $(e.currentTarget);
+        var listid = toggle.attr('aria-controls');
+
+        $(document.getElementById(listid)).toggle();
+
+        if (toggle.attr('aria-expanded') == 'false') {
+            toggle.attr('aria-expanded', 'true');
+        } else {
+            toggle.attr('aria-expanded', 'false');
+        }
+    });
+
+    // Selecting all / all in component checkboxes.
+    $('#restorablemodelslist').on('click', '[data-select]', function(e) {
+        e.preventDefault();
+        var handler = $(e.currentTarget);
+        var component = handler.attr('data-select');
+
+        if (component == '*') {
+            $('input[data-component]').prop('checked', true);
+        } else {
+            $('input[data-component="' + component + '"]').prop('checked', true);
+        }
+    });
+});
+{{/js}}
diff --git a/admin/tool/analytics/tests/behat/restoredefault.feature b/admin/tool/analytics/tests/behat/restoredefault.feature
new file mode 100644 (file)
index 0000000..74ff239
--- /dev/null
@@ -0,0 +1,103 @@
+@tool @tool_analytics
+Feature: Restoring default models
+  In order to get prediction models into their initial state
+  As a manager
+  I need to be able to restore deleted default models
+
+  Background:
+    Given the following "users" exist:
+      | username       | firstname     | lastname | email              |
+      | manager        | Max           | Manager  | man@example.com    |
+    And the following "role assigns" exist:
+      | user           | role          | contextlevel  | reference             |
+      | manager        | manager               | System        |                                                       |
+
+  Scenario: Restore a single deleted default model
+    Given I log in as "manager"
+    And I navigate to "Analytics > Analytics models" in site administration
+    # Delete 'No teaching' model.
+    And I click on "Delete" "link" in the "No teaching" "table_row"
+    And I should see "Analytics models"
+    And I should not see "No teaching"
+    # Delete 'Students at risk of dropping out' model.
+    And I click on "Delete" "link" in the "Students at risk of dropping out" "table_row"
+    And I should see "Analytics models"
+    And I should not see "Students at risk of dropping out"
+    # Go to the page for restoring deleted models.
+    When I click on "Restore default models" "link"
+    And I should see "No teaching"
+    And I should see "Students at risk of dropping out"
+    # Select and restore the 'No teaching' model.
+    And I set the field with xpath "//tr[contains(normalize-space(.), 'No teaching')]//input[@type='checkbox']" to "1"
+    And I click on "Restore selected" "button"
+    Then I should see "Succesfully re-created 1 new model(s)."
+    And I should see "Analytics models"
+    And I should see "No teaching"
+    And I should not see "Students at risk of dropping out"
+
+  Scenario: Restore multiple deleted default models at once
+    Given I log in as "manager"
+    And I navigate to "Analytics > Analytics models" in site administration
+    # Delete 'No teaching' model.
+    And I click on "Delete" "link" in the "No teaching" "table_row"
+    And I should see "Analytics models"
+    And I should not see "No teaching"
+    # Delete 'Students at risk of dropping out' model.
+    And I click on "Delete" "link" in the "Students at risk of dropping out" "table_row"
+    And I should see "Analytics models"
+    And I should not see "Students at risk of dropping out"
+    # Go to the page for restoring deleted models.
+    When I click on "Restore default models" "link"
+    And I should see "No teaching"
+    And I should see "Students at risk of dropping out"
+    # Select and restore both models.
+    And I set the field with xpath "//tr[contains(normalize-space(.), 'No teaching')]//input[@type='checkbox']" to "1"
+    And I set the field with xpath "//tr[contains(normalize-space(.), 'Students at risk of dropping out')]//input[@type='checkbox']" to "1"
+    And I click on "Restore selected" "button"
+    Then I should see "Succesfully re-created 2 new model(s)."
+    And I should see "Analytics models"
+    And I should see "No teaching"
+    And I should see "Students at risk of dropping out"
+
+  Scenario: Going to the restore page while no models can be restored
+    Given I log in as "manager"
+    And I navigate to "Analytics > Analytics models" in site administration
+    And I should see "Analytics models"
+    And I should see "No teaching"
+    When I click on "Restore default models" "link"
+    Then I should see "All default models provided by the Moodle core and installed plugins have been already created. No new models were found, there is nothing to restore."
+    And I click on "Back" "link"
+    And I should see "Analytics models"
+
+  @javascript
+  Scenario: User can select and restore all missing models
+    Given I log in as "manager"
+    And I navigate to "Analytics > Analytics models" in site administration
+    # Delete 'No teaching' model.
+    And I click on "Actions" "link" in the "No teaching" "table_row"
+    And I click on "Delete" "link" in the "No teaching" "table_row"
+    And I click on "Delete" "button" in the "Delete" "dialogue"
+    And I should see "Analytics models"
+    And I should not see "No teaching"
+    # Delete 'Students at risk of dropping out' model.
+    And I click on "Actions" "link" in the "Students at risk of dropping out" "table_row"
+    And I click on "Delete" "link" in the "Students at risk of dropping out" "table_row"
+    And I click on "Delete" "button" in the "Delete" "dialogue"
+    And I should see "Analytics models"
+    And I should not see "No teaching"
+    And I should not see "Students at risk of dropping out"
+    # Go to the page for restoring deleted models.
+    And I click on "New model" "link"
+    And I click on "Restore default models" "link"
+    And I should see "No teaching"
+    And I should see "Students at risk of dropping out"
+    # Attempt to submit the form without selecting any model.
+    And I click on "Restore selected" "button"
+    And I should see "Please select models to be restored."
+    # Select all models.
+    When I click on "Select all" "link"
+    And I click on "Restore selected" "button"
+    Then I should see "Succesfully re-created 2 new model(s)."
+    And I should see "Analytics models"
+    And I should see "No teaching"
+    And I should see "Students at risk of dropping out"
index c3d9813..fae12cb 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2018120300; // The current plugin version (Date: YYYYMMDDXX).
-$plugin->requires  = 2018112800; // Requires this Moodle version.
+$plugin->version   = 2019032800; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->requires  = 2019032200; // Requires this Moodle version.
 $plugin->component = 'tool_analytics'; // Full name of the plugin (used for diagnostics).
index 96cfd0c..e88a683 100644 (file)
@@ -43,6 +43,9 @@ trait buffered_writer {
     /** @var int $count Counter. */
     protected $count = 0;
 
+    /** @var bool If true, writes JSON instead of PHP serialized data for 'other' field */
+    protected $jsonformat = false;
+
     /**
      * Should the event be ignored (== not logged)?
      * @param \core\event\base $event
@@ -69,7 +72,11 @@ trait buffered_writer {
         // at the same time this lowers memory use because
         // snapshots and custom objects may be garbage collected.
         $entry = $event->get_data();
-        $entry['other'] = serialize($entry['other']);
+        if ($this->jsonformat) {
+            $entry['other'] = json_encode($entry['other']);
+        } else {
+            $entry['other'] = serialize($entry['other']);
+        }
         $entry['origin'] = $PAGE->requestorigin;
         $entry['ip'] = $PAGE->requestip;
         $entry['realuserid'] = \core\session\manager::is_loggedinas() ? $GLOBALS['USER']->realuser : null;
index 93e0af1..2e94774 100644 (file)
@@ -62,6 +62,25 @@ trait reader {
         return $this->store;
     }
 
+    /**
+     * Function decodes the other field into an array using either PHP serialisation or JSON.
+     *
+     * Note that this does not rely on the config setting, it supports both formats, so you can
+     * use it for data before/after making a change to the config setting.
+     *
+     * The return value is usually an array but it can also be null or a boolean or something.
+     *
+     * @param string $other Other value
+     * @return mixed Decoded value
+     */
+    public static function decode_other(?string $other) {
+        if ($other === 'N;' || preg_match('~^.:~', $other)) {
+            return unserialize($other);
+        } else {
+            return json_decode($other, true);
+        }
+    }
+
     /**
      * Adds ID column to $sort to make sure events from one request
      * within 1 second are returned in the same order.
index 8a057cf..95c09e9 100644 (file)
@@ -37,6 +37,7 @@ use core_privacy\local\request\transform;
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class helper {
+    use \tool_log\helper\reader;
 
     /**
      * Returns an event from a standard record.
@@ -49,7 +50,7 @@ class helper {
         $extra = ['origin' => $data->origin, 'ip' => $data->ip, 'realuserid' => $data->realuserid];
         $data = (array) $data;
         $id = $data['id'];
-        $data['other'] = unserialize($data['other']);
+        $data['other'] = self::decode_other($data['other']);
         if ($data['other'] === false) {
             $data['other'] = [];
         }
index 10398df..5c85e3d 100644 (file)
@@ -57,6 +57,9 @@ class store implements \tool_log\log\writer, \core\log\sql_reader {
         $levels = $this->get_config('includelevels', '');
         $this->includeactions = $actions === '' ? array() : explode(',', $actions);
         $this->includelevels = $levels === '' ? array() : explode(',', $levels);
+        // JSON writing defaults to false (table format compatibility with older versions).
+        // Note: This variable is defined in the buffered_writer trait.
+        $this->jsonformat = (bool)$this->get_config('jsonformat', false);
     }
 
     /**
@@ -223,7 +226,7 @@ class store implements \tool_log\log\writer, \core\log\sql_reader {
         $extra = array('origin' => $data->origin, 'ip' => $data->ip, 'realuserid' => $data->realuserid);
         $data = (array)$data;
         $id = $data['id'];
-        $data['other'] = unserialize($data['other']);
+        $data['other'] = self::decode_other($data['other']);
         if ($data['other'] === false) {
             $data['other'] = array();
         }
index 04137ab..b5536ab 100644 (file)
@@ -39,5 +39,12 @@ function xmldb_logstore_database_upgrade($oldversion) {
     // Automatically generated Moodle v3.6.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2019032800) {
+        // For existing installations, set the new jsonformat option to off (no behaviour change).
+        // New installations default to on.
+        set_config('jsonformat', 0, 'logstore_database');
+        upgrade_plugin_savepoint(true, 2019032800, 'logstore', 'database');
+    }
+
     return true;
 }
index 31edf3a..3fd8017 100644 (file)
@@ -39,6 +39,8 @@ $string['includeactions'] = 'Include actions of these types';
 $string['includelevels'] = 'Include actions with these educational levels';
 $string['filters'] = 'Filter logs';
 $string['filters_help'] = 'Enable filters that exclude some actions from being logged.';
+$string['jsonformat'] = 'JSON format';
+$string['jsonformat_desc'] = 'Use standard JSON format instead of PHP serialised data in the \'other\' database field.';
 $string['logguests'] = 'Log guest actions';
 $string['other'] = 'Other';
 $string['participating'] = 'Participating';
index 4e45038..d406fd0 100644 (file)
@@ -59,6 +59,10 @@ if ($hassiteconfig) {
     $settings->add(new admin_setting_configtext('logstore_database/buffersize', get_string('buffersize',
         'logstore_database'), get_string('buffersize_help', 'logstore_database'), 50));
 
+    $settings->add(new admin_setting_configcheckbox('logstore_database/jsonformat',
+            new lang_string('jsonformat', 'logstore_database'),
+            new lang_string('jsonformat_desc', 'logstore_database'), 1));
+
     // Filters.
     $settings->add(new admin_setting_heading('filters', get_string('filters', 'logstore_database'), get_string('filters_help',
         'logstore_database')));
index a05c33f..10dab29 100644 (file)
@@ -28,11 +28,21 @@ require_once(__DIR__ . '/fixtures/event.php');
 require_once(__DIR__ . '/fixtures/store.php');
 
 class logstore_database_store_testcase extends advanced_testcase {
-    public function test_log_writing() {
+    /**
+     * Tests log writing.
+     *
+     * @param bool $jsonformat True to test with JSON format
+     * @dataProvider test_log_writing_provider
+     * @throws moodle_exception
+     */
+    public function test_log_writing(bool $jsonformat) {
         global $DB, $CFG;
         $this->resetAfterTest();
         $this->preventResetByRollback(); // Logging waits till the transaction gets committed.
 
+        // Apply JSON format system setting.
+        set_config('jsonformat', $jsonformat ? 1 : 0, 'logstore_database');
+
         $dbman = $DB->get_manager();
         $this->assertTrue($dbman->table_exists('logstore_standard_log'));
         $DB->delete_records('logstore_standard_log');
@@ -118,7 +128,11 @@ class logstore_database_store_testcase extends advanced_testcase {
 
         $log1 = reset($logs);
         unset($log1->id);
-        $log1->other = unserialize($log1->other);
+        if ($jsonformat) {
+            $log1->other = json_decode($log1->other, true);
+        } else {
+            $log1->other = unserialize($log1->other);
+        }
         $log1 = (array)$log1;
         $data = $event1->get_data();
         $data['origin'] = 'cli';
@@ -145,7 +159,11 @@ class logstore_database_store_testcase extends advanced_testcase {
 
         $log3 = array_shift($logs);
         unset($log3->id);
-        $log3->other = unserialize($log3->other);
+        if ($jsonformat) {
+            $log3->other = json_decode($log3->other, true);
+        } else {
+            $log3->other = unserialize($log3->other);
+        }
         $log3 = (array)$log3;
         $data = $event2->get_data();
         $data['origin'] = 'cli';
@@ -229,6 +247,19 @@ class logstore_database_store_testcase extends advanced_testcase {
         get_log_manager(true);
     }
 
+    /**
+     * Returns different JSON format settings so the test can be run with JSON format either on or
+     * off.
+     *
+     * @return [bool] Array of true/false
+     */
+    public static function test_log_writing_provider(): array {
+        return [
+            [false],
+            [true]
+        ];
+    }
+
     /**
      * Test method is_event_ignored.
      */
index dbf85d7..4159a06 100644 (file)
 /**
  * External database log store.
  *
- * @package    logstore_standard
+ * @package    logstore_database
  * @copyright  2013 Petr Skoda {@link http://skodak.org}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version = 2018120300; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version = 2019032800; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires = 2018112800; // Requires this Moodle version.
 $plugin->component = 'logstore_database'; // Full name of the plugin (used for diagnostics).
index fe93ff5..c296546 100644 (file)
@@ -38,6 +38,9 @@ class store implements \tool_log\log\writer, \core\log\sql_internal_table_reader
         $this->helper_setup($manager);
         // Log everything before setting is saved for the first time.
         $this->logguests = $this->get_config('logguests', 1);
+        // JSON writing defaults to false (table format compatibility with older versions).
+        // Note: This variable is defined in the buffered_writer trait.
+        $this->jsonformat = (bool)$this->get_config('jsonformat', false);
     }
 
     /**
@@ -120,7 +123,7 @@ class store implements \tool_log\log\writer, \core\log\sql_internal_table_reader
         $extra = array('origin' => $data->origin, 'ip' => $data->ip, 'realuserid' => $data->realuserid);
         $data = (array)$data;
         $id = $data['id'];
-        $data['other'] = unserialize($data['other']);
+        $data['other'] = self::decode_other($data['other']);
         if ($data['other'] === false) {
             $data['other'] = array();
         }
index d6cd26d..e1ac5a2 100644 (file)
@@ -39,5 +39,12 @@ function xmldb_logstore_standard_upgrade($oldversion) {
     // Automatically generated Moodle v3.6.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2019032800) {
+        // For existing installations, set the new jsonformat option to off (no behaviour change).
+        // New installations default to on.
+        set_config('jsonformat', 0, 'logstore_standard');
+        upgrade_plugin_savepoint(true, 2019032800, 'logstore', 'standard');
+    }
+
     return true;
 }
index 14a1d2b..12a4c42 100644 (file)
@@ -23,6 +23,8 @@
  */
 
 $string['buffersize'] = 'Write buffer size';
+$string['jsonformat'] = 'JSON format';
+$string['jsonformat_desc'] = 'Use standard JSON format instead of PHP serialised data in the \'other\' database field.';
 $string['pluginname'] = 'Standard log';
 $string['pluginname_desc'] = 'A log plugin stores log entries in a Moodle database table.';
 $string['privacy:metadata:log'] = 'A collection of past events';
index daa8131..fc4a894 100644 (file)
@@ -30,6 +30,10 @@ if ($hassiteconfig) {
         new lang_string('logguests', 'core_admin'),
         new lang_string('logguests_help', 'core_admin'), 1));
 
+    $settings->add(new admin_setting_configcheckbox('logstore_standard/jsonformat',
+            new lang_string('jsonformat', 'logstore_standard'),
+            new lang_string('jsonformat_desc', 'logstore_standard'), 1));
+
     $options = array(
         0    => new lang_string('neverdeletelogs'),
         1000 => new lang_string('numdays', '', 1000),
index def6684..25963af 100644 (file)
@@ -33,11 +33,21 @@ class logstore_standard_store_testcase extends advanced_testcase {
      */
     private $wedisabledgc = false;
 
-    public function test_log_writing() {
+    /**
+     * Tests log writing.
+     *
+     * @param bool $jsonformat True to test with JSON format
+     * @dataProvider test_log_writing_provider
+     * @throws moodle_exception
+     */
+    public function test_log_writing(bool $jsonformat) {
         global $DB;
         $this->resetAfterTest();
         $this->preventResetByRollback(); // Logging waits till the transaction gets committed.
 
+        // Apply JSON format system setting.
+        set_config('jsonformat', $jsonformat ? 1 : 0, 'logstore_standard');
+
         $this->setAdminUser();
         $user1 = $this->getDataGenerator()->create_user();
         $user2 = $this->getDataGenerator()->create_user();
@@ -82,7 +92,11 @@ class logstore_standard_store_testcase extends advanced_testcase {
 
         $log1 = reset($logs);
         unset($log1->id);
-        $log1->other = unserialize($log1->other);
+        if ($jsonformat) {
+            $log1->other = json_decode($log1->other, true);
+        } else {
+            $log1->other = unserialize($log1->other);
+        }
         $log1 = (array)$log1;
         $data = $event1->get_data();
         $data['origin'] = 'cli';
@@ -112,7 +126,11 @@ class logstore_standard_store_testcase extends advanced_testcase {
 
         $log3 = array_shift($logs);
         unset($log3->id);
-        $log3->other = unserialize($log3->other);
+        if ($jsonformat) {
+            $log3->other = json_decode($log3->other, true);
+        } else {
+            $log3->other = unserialize($log3->other);
+        }
         $log3 = (array)$log3;
         $data = $event2->get_data();
         $data['origin'] = 'restore';
@@ -200,6 +218,19 @@ class logstore_standard_store_testcase extends advanced_testcase {
         get_log_manager(true);
     }
 
+    /**
+     * Returns different JSON format settings so the test can be run with JSON format either on or
+     * off.
+     *
+     * @return [bool] Array of true/false
+     */
+    public static function test_log_writing_provider(): array {
+        return [
+            [false],
+            [true]
+        ];
+    }
+
     /**
      * Test logmanager::get_supported_reports returns all reports that require this store.
      */
@@ -332,6 +363,38 @@ class logstore_standard_store_testcase extends advanced_testcase {
         $this->assertEquals(1, $DB->count_records('logstore_standard_log'));
     }
 
+    /**
+     * Tests the decode_other function can cope with both JSON and PHP serialized format.
+     *
+     * @param mixed $value Value to encode and decode
+     * @dataProvider test_decode_other_provider
+     */
+    public function test_decode_other($value) {
+        $this->assertEquals($value, \logstore_standard\log\store::decode_other(serialize($value)));
+        $this->assertEquals($value, \logstore_standard\log\store::decode_other(json_encode($value)));
+    }
+
+    public function test_decode_other_with_wrongly_encoded_contents() {
+        $this->assertSame(null, \logstore_standard\log\store::decode_other(null));
+    }
+
+    /**
+     * List of possible values for 'other' field.
+     *
+     * I took these types from our logs based on the different first character of PHP serialized
+     * data - my query found only these types. The normal case is an array.
+     *
+     * @return array Array of parameters
+     */
+    public function test_decode_other_provider(): array {
+        return [
+            [['info' => 'd2819896', 'logurl' => 'discuss.php?d=2819896']],
+            [null],
+            ['just a string'],
+            [32768]
+        ];
+    }
+
     /**
      * Disable the garbage collector if it's enabled to ensure we don't adjust memory statistics.
      */
index 89ab35a..2a6b63d 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version = 2018120300; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version = 2019032800; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires = 2018112800; // Requires this Moodle version.
 $plugin->component = 'logstore_standard'; // Full name of the plugin (used for diagnostics).
index e1b65e3..abaf3e2 100644 (file)
@@ -2,7 +2,14 @@ This files describes API changes in /admin/tool/log - plugins,
 information provided here is intended especially for developers.
 
 
+=== 3.7 ===
+
+* The new jsonformat option, which defaults to 'on' for a new install (and 'off' for existing installs) means that
+  the 'other' event field is now stored in JSON format instead of PHP serialize format in the database. The system
+  can read data in both formats but if any third-party software directly accesses the database field, it may need
+  to be modified (or require users to turn off jsonformat).
+
 === 3.6 ===
 
 * The legacy log store is in its first stage of deprecation and is due for removal in Moodle 4.0. Please use one of
-  the other log stores such as "standard" and "database".
\ No newline at end of file
+  the other log stores such as "standard" and "database".
index 4a4eede..6c40795 100644 (file)
@@ -683,6 +683,43 @@ class manager {
         return $models;
     }
 
+    /**
+     * Return the list of all the models declared anywhere in this Moodle installation.
+     *
+     * Models defined by the core and core subsystems come first, followed by those provided by plugins.
+     *
+     * @return array indexed by the frankenstyle component
+     */
+    public static function load_default_models_for_all_components(): array {
+
+        $tmp = [];
+
+        foreach (\core_component::get_component_list() as $type => $components) {
+            foreach (array_keys($components) as $component) {
+                if ($loaded = static::load_default_models_for_component($component)) {
+                    $tmp[$type][$component] = $loaded;
+                }
+            }
+        }
+
+        $result = [];
+
+        if ($loaded = static::load_default_models_for_component('core')) {
+            $result['core'] = $loaded;
+        }
+
+        if (!empty($tmp['core'])) {
+            $result += $tmp['core'];
+            unset($tmp['core']);
+        }
+
+        foreach ($tmp as $components) {
+            $result += $components;
+        }
+
+        return $result;
+    }
+
     /**
      * Validate the declaration of prediction models according the syntax expected in the component's db folder.
      *
@@ -747,14 +784,7 @@ class manager {
      */
     public static function create_declared_model(array $definition): \core_analytics\model {
 
-        $target = static::get_target($definition['target']);
-
-        $indicators = [];
-
-        foreach ($definition['indicators'] as $indicatorname) {
-            $indicator = static::get_indicator($indicatorname);
-            $indicators[$indicator->get_id()] = $indicator;
-        }
+        list($target, $indicators) = static::get_declared_target_and_indicators_instances($definition);
 
         if (isset($definition['timesplitting'])) {
             $timesplitting = $definition['timesplitting'];
@@ -770,4 +800,34 @@ class manager {
 
         return $created;
     }
+
+    /**
+     * Returns a string uniquely representing the given model declaration.
+     *
+     * @param array $model Model declaration
+     * @return string complying with PARAM_ALPHANUM rules and starting with an 'id' prefix
+     */
+    public static function model_declaration_identifier(array $model) : string {
+        return 'id'.sha1(serialize($model));
+    }
+
+    /**
+     * Given a model definition, return actual target and indicators instances.
+     *
+     * @param array $definition See {@link self::validate_models_declaration()} for the syntax.
+     * @return array [0] => target instance, [1] => array of indicators instances
+     */
+    public static function get_declared_target_and_indicators_instances(array $definition): array {
+
+        $target = static::get_target($definition['target']);
+
+        $indicators = [];
+
+        foreach ($definition['indicators'] as $indicatorname) {
+            $indicator = static::get_indicator($indicatorname);
+            $indicators[$indicator->get_id()] = $indicator;
+        }
+
+        return [$target, $indicators];
+    }
 }
index 5113a85..ced29b2 100644 (file)
@@ -1438,14 +1438,18 @@ class model {
     /**
      * Exports the model data for displaying it in a template.
      *
+     * @param \renderer_base $output The renderer to use for exporting
      * @return \stdClass
      */
-    public function export() {
+    public function export(\renderer_base $output) {
 
         \core_analytics\manager::check_can_manage_models();
 
         $data = clone $this->model;
+
+        $data->name = $this->inplace_editable_name()->export_for_template($output);
         $data->target = $this->get_target()->get_name();
+        $data->targetclass = $this->get_target()->get_id();
 
         if ($timesplitting = $this->get_time_splitting()) {
             $data->timesplitting = $timesplitting->get_name();
@@ -1690,6 +1694,54 @@ class model {
         $DB->update_record('analytics_models', $this->model);
     }
 
+    /**
+     * Returns the name of the model.
+     *
+     * By default, models use their target's name as their own name. They can have their explicit name, too. In which
+     * case, the explicit name is used instead of the default one.
+     *
+     * @return string|lang_string
+     */
+    public function get_name() {
+
+        if (trim($this->model->name) === '') {
+            return $this->get_target()->get_name();
+
+        } else {
+            return $this->model->name;
+        }
+    }
+
+    /**
+     * Renames the model to the given name.
+     *
+     * When given an empty string, the model falls back to using the associated target's name as its name.
+     *
+     * @param string $name The new name for the model, empty string for using the default name.
+     */
+    public function rename(string $name) {
+        global $DB, $USER;
+
+        $this->model->name = $name;
+        $this->model->timemodified = time();
+        $this->model->usermodified = $USER->id;
+
+        $DB->update_record('analytics_models', $this->model);
+    }
+
+    /**
+     * Returns an inplace editable element with the model's name.
+     *
+     * @return \core\output\inplace_editable
+     */
+    public function inplace_editable_name() {
+
+        $displayname = format_string($this->get_name());
+
+        return new \core\output\inplace_editable('core_analytics', 'modelname', $this->model->id,
+            has_capability('moodle/analytics:managemodels', \context_system::instance()), $displayname, $this->model->name);
+    }
+
     /**
      * Adds the id from {analytics_predictions} db table to the prediction \stdClass objects.
      *
diff --git a/analytics/lib.php b/analytics/lib.php
new file mode 100644 (file)
index 0000000..9b6b41d
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+// This file is part of Moodle - https://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/>.
+
+/**
+ * The interface library between the core and the subsystem.
+ *
+ * @package     core_analytics
+ * @copyright   2019 David Mudrák <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Implements the inplace editable feature.
+ *
+ * @param string $itemtype Type if the inplace editable element
+ * @param int $itemid Identifier of the element
+ * @param string $newvalue New value for the element
+ * @return \core\output\inplace_editable
+ */
+function core_analytics_inplace_editable($itemtype, $itemid, $newvalue) {
+
+    if ($itemtype === 'modelname') {
+        \external_api::validate_context(context_system::instance());
+        require_capability('moodle/analytics:managemodels', \context_system::instance());
+
+        $model = new \core_analytics\model($itemid);
+        $model->rename(clean_param($newvalue, PARAM_NOTAGS));
+
+        return $model->inplace_editable_name();
+    }
+}
index 6d2cb69..ceb5796 100644 (file)
@@ -172,6 +172,20 @@ class analytics_manager_testcase extends advanced_testcase {
         $this->assertSame([], \core_analytics\manager::load_default_models_for_component('foo_bar2776327736558'));
     }
 
+    /**
+     * Tests for the {@link \core_analytics\manager::load_default_models_for_all_components()} implementation.
+     */
+    public function test_load_default_models_for_all_components() {
+        $this->resetAfterTest();
+
+        $models = \core_analytics\manager::load_default_models_for_all_components();
+
+        $this->assertTrue(is_array($models['core']));
+        $this->assertNotEmpty($models['core']);
+        $this->assertNotEmpty($models['core'][0]['target']);
+        $this->assertNotEmpty($models['core'][0]['indicators']);
+    }
+
     /**
      * Tests for the successful execution of the {@link \core_analytics\manager::validate_models_declaration()}.
      */
@@ -403,4 +417,62 @@ class analytics_manager_testcase extends advanced_testcase {
         $defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
         $this->assertArrayNotHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
     }
+
+    /**
+     * Test the implementation of the {@link \core_analytics\manager::model_declaration_identifier()}.
+     */
+    public function test_model_declaration_identifier() {
+
+        $noteaching1 = $this->load_models_from_fixture_file('no_teaching');
+        $noteaching2 = $this->load_models_from_fixture_file('no_teaching');
+        $noteaching3 = $this->load_models_from_fixture_file('no_teaching');
+
+        // Same model declaration should always lead to same identifier.
+        $this->assertEquals(
+            \core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
+            \core_analytics\manager::model_declaration_identifier(reset($noteaching2))
+        );
+
+        // If something is changed, the identifier should change, too.
+        $noteaching2[0]['target'] .= '_';
+        $this->assertNotEquals(
+            \core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
+            \core_analytics\manager::model_declaration_identifier(reset($noteaching2))
+        );
+
+        $noteaching3[0]['indicators'][] = '\core_analytics\local\indicator\binary';
+        $this->assertNotEquals(
+            \core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
+            \core_analytics\manager::model_declaration_identifier(reset($noteaching3))
+        );
+
+        // The identifier is supposed to contain PARAM_ALPHANUM only.
+        $this->assertEquals(
+            \core_analytics\manager::model_declaration_identifier(reset($noteaching1)),
+            clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching1)), PARAM_ALPHANUM)
+        );
+        $this->assertEquals(
+            \core_analytics\manager::model_declaration_identifier(reset($noteaching2)),
+            clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching2)), PARAM_ALPHANUM)
+        );
+        $this->assertEquals(
+            \core_analytics\manager::model_declaration_identifier(reset($noteaching3)),
+            clean_param(\core_analytics\manager::model_declaration_identifier(reset($noteaching3)), PARAM_ALPHANUM)
+        );
+    }
+
+    /**
+     * Tests for the {@link \core_analytics\manager::get_declared_target_and_indicators_instances()}.
+     */
+    public function test_get_declared_target_and_indicators_instances() {
+        $this->resetAfterTest();
+
+        $definition = $this->load_models_from_fixture_file('no_teaching');
+
+        list($target, $indicators) = \core_analytics\manager::get_declared_target_and_indicators_instances($definition[0]);
+
+        $this->assertTrue($target instanceof \core_analytics\local\target\base);
+        $this->assertNotEmpty($indicators);
+        $this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators);
+    }
 }
index 97a7e3b..e2e0468 100644 (file)
@@ -437,6 +437,63 @@ class analytics_model_testcase extends advanced_testcase {
         $this->assertCount(1, $modeldata->indicators);
     }
 
+    /**
+     * Test the implementation of {@link \core_analytics\model::inplace_editable_name()}.
+     */
+    public function test_inplace_editable_name() {
+        global $PAGE;
+
+        $this->resetAfterTest();
+
+        $output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL);
+
+        // Check as a user with permission to edit the name.
+        $this->setAdminUser();
+        $ie = $this->model->inplace_editable_name();
+        $this->assertInstanceOf(\core\output\inplace_editable::class, $ie);
+        $data = $ie->export_for_template($output);
+        $this->assertEquals('core_analytics', $data['component']);
+        $this->assertEquals('modelname', $data['itemtype']);
+
+        // Check as a user without permission to edit the name.
+        $this->setGuestUser();
+        $ie = $this->model->inplace_editable_name();
+        $this->assertInstanceOf(\core\output\inplace_editable::class, $ie);
+        $data = $ie->export_for_template($output);
+        $this->assertArrayHasKey('displayvalue', $data);
+    }
+
+    /**
+     * Test how the models present themselves in the UI and that they can be renamed.
+     */
+    public function test_get_name_and_rename() {
+        global $PAGE;
+
+        $this->resetAfterTest();
+
+        $output = new \core_renderer($PAGE, RENDERER_TARGET_GENERAL);
+
+        // By default, the model exported for template uses its target's name in the name inplace editable element.
+        $this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name());
+        $data = $this->model->export($output);
+        $this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name());
+        $this->assertEquals($data->name['value'], '');
+
+        // Rename the model.
+        $this->model->rename('Nějaký pokusný model');
+        $this->assertEquals($this->model->get_name(), 'Nějaký pokusný model');
+        $data = $this->model->export($output);
+        $this->assertEquals($data->name['displayvalue'], 'Nějaký pokusný model');
+        $this->assertEquals($data->name['value'], 'Nějaký pokusný model');
+
+        // Undo the renaming.
+        $this->model->rename('');
+        $this->assertEquals($this->model->get_name(), $this->model->get_target()->get_name());
+        $data = $this->model->export($output);
+        $this->assertEquals($data->name['displayvalue'], $this->model->get_target()->get_name());
+        $this->assertEquals($data->name['value'], '');
+    }
+
     /**
      * Generates a model log record.
      */
index 31316d4..2ae0869 100644 (file)
@@ -15,6 +15,7 @@ information provided here is intended especially for developers.
   by updating the lib/db/analytics.php file and bumping the core version.
 * \core_analytics\model::execute_prediction_callbacks now returns an array with both sample's contexts
   and the prediction records.
+* \core_analytics\model::export() now expects the renderer instance as an argument.
 * Time splitting methods:
     * \core_analytics\local\time_splitting\base::append_rangeindex and
       \core_analytics\local\time_splitting\base::infer_sample_info are now marked as final and can not
index 9a1ec28..7b7dd3a 100644 (file)
@@ -103,6 +103,33 @@ class auth_plugin_nologin extends auth_plugin_base {
     function can_be_manually_set() {
         return true;
     }
+
+    /**
+     * Returns information on how the specified user can change their password.
+     * User accounts with authentication type set to nologin are disabled accounts.
+     * They cannot change their password.
+     *
+     * @param stdClass $user A user object
+     * @return string[] An array of strings with keys subject and message
+     */
+    public function get_password_change_info(stdClass $user) : array {
+        $site = get_site();
+
+        $data = new stdClass();
+        $data->firstname = $user->firstname;
+        $data->lastname  = $user->lastname;
+        $data->username  = $user->username;
+        $data->sitename  = format_string($site->fullname);
+        $data->admin     = generate_email_signoff();
+
+        $message = get_string('emailpasswordchangeinfodisabled', '', $data);
+        $subject = get_string('emailpasswordchangeinfosubject', '', format_string($site->fullname));
+
+        return [
+            'subject' => $subject,
+            'message' => $message
+        ];
+    }
 }
 
 
index 1ecfca8..448c00e 100644 (file)
@@ -611,4 +611,30 @@ class auth extends \auth_plugin_base {
         $this->update_picture($user);
         redirect($redirecturl);
     }
+
+    /**
+     * Returns information on how the specified user can change their password.
+     * The password of the oauth2 accounts is not stored in Moodle.
+     *
+     * @param stdClass $user A user object
+     * @return string[] An array of strings with keys subject and message
+     */
+    public function get_password_change_info(stdClass $user) : array {
+        $site = get_site();
+
+        $data = new stdClass();
+        $data->firstname = $user->firstname;
+        $data->lastname  = $user->lastname;
+        $data->username  = $user->username;
+        $data->sitename  = format_string($site->fullname);
+        $data->admin     = generate_email_signoff();
+
+        $message = get_string('emailpasswordchangeinfo', 'auth_oauth2', $data);
+        $subject = get_string('emailpasswordchangeinfosubject', 'auth_oauth2', format_string($site->fullname));
+
+        return [
+            'subject' => $subject,
+            'message' => $message
+        ];
+    }
 }
index ed38e2d..b6fe4b6 100644 (file)
@@ -70,6 +70,15 @@ $string['emailconfirmlinksent'] = '<p>An existing account was found with this em
    <p>An email should have been sent to your address at <b>{$a}</b>.</p>
    <p>It contains easy instructions to link your accounts.</p>
    <p>If you have any difficulty, contact the site administrator.</p>';
+$string['emailpasswordchangeinfo'] = 'Hi {$a->firstname},
+
+Someone (probably you) has requested a new password for your account on \'{$a->sitename}\'.
+
+However your password cannot be reset because you are using your account on another site to log in.
+
+Please log in as before, using the link on the login page.
+{$a->admin}';
+$string['emailpasswordchangeinfosubject'] = '{$a}: Change password information';
 $string['info'] = 'External account';
 $string['issuer'] = 'OAuth 2 Service';
 $string['issuernologin'] = 'This issuer can not be used to login';
diff --git a/auth/oauth2/tests/auth_test.php b/auth/oauth2/tests/auth_test.php
new file mode 100644 (file)
index 0000000..ab5b2ff
--- /dev/null
@@ -0,0 +1,53 @@
+<?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/>.
+
+/**
+ * Auth oauth2 auth functions tests.
+ *
+ * @package    auth_oauth2
+ * @category   test
+ * @copyright  2019 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+/**
+ * Tests for the \auth_oauth2\auth class.
+ *
+ * @copyright  2019 Shamim Rezaie
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class auth_oauth2_auth_testcase extends advanced_testcase {
+
+    public function test_get_password_change_info() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user(['auth' => 'oauth2']);
+        $auth = get_auth_plugin($user->auth);
+        $info = $auth->get_password_change_info($user);
+
+        $this->assertEquals(
+                ['subject', 'message'],
+                array_keys($info),
+                '', 0.0, 10, true);
+        $this->assertContains(
+                'your password cannot be reset because you are using your account on another site to log in',
+                $info['message']);
+    }
+}
\ No newline at end of file
index 4e0502b..9171659 100644 (file)
@@ -1,6 +1,11 @@
 This files describes API changes in /auth/* - plugins,
 information provided here is intended especially for developers.
 
+=== 3.7 ===
+
+* get_password_change_info() method is added to the base class and returns an array containing the subject and body of the message
+  to the user that contains instructions on how to change their password. Authentication plugins can override this method if needed.
+
 === 3.6 ===
 
 * Login forms generated from Moodle must include a login token to protect automated logins. See \core\session\manager::get_login_token().
index 8ea8d51..9ac5f5b 100644 (file)
@@ -326,11 +326,11 @@ class backup_controller extends base_controller {
         // Basic/initial prevention against time/memory limits
         core_php_time_limit::raise(1 * 60 * 60); // 1 hour for 1 course initially granted
         raise_memory_limit(MEMORY_EXTRA);
-        // If this is not a course backup, inform the plan we are not
+        // If this is not a course backup, or single activity backup (e.g. duplicate) inform the plan we are not
         // including all the activities for sure. This will affect any
         // task/step executed conditionally to stop including information
         // for section and activity backup. MDL-28180.
-        if ($this->get_type() !== backup::TYPE_1COURSE) {
+        if ($this->get_type() !== backup::TYPE_1COURSE && $this->get_type() !== backup::TYPE_1ACTIVITY) {
             $this->log('notifying plan about excluded activities by type', backup::LOG_DEBUG);
             $this->plan->set_excluding_activities();
         }
index cc5b869..b709f78 100644 (file)
@@ -341,11 +341,11 @@ class restore_controller extends base_controller {
         // Basic/initial prevention against time/memory limits
         core_php_time_limit::raise(1 * 60 * 60); // 1 hour for 1 course initially granted
         raise_memory_limit(MEMORY_EXTRA);
-        // If this is not a course restore, inform the plan we are not
+        // If this is not a course restore or single activity restore (e.g. duplicate), inform the plan we are not
         // including all the activities for sure. This will affect any
         // task/step executed conditionally to stop processing information
         // for section and activity restore. MDL-28180.
-        if ($this->get_type() !== backup::TYPE_1COURSE) {
+        if ($this->get_type() !== backup::TYPE_1COURSE && $this->get_type() !== backup::TYPE_1ACTIVITY) {
             $this->log('notifying plan about excluded activities by type', backup::LOG_DEBUG);
             $this->plan->set_excluding_activities();
         }
index 3f94cd0..ce9c1ba 100644 (file)
@@ -580,6 +580,17 @@ abstract class backup_controller_dbops extends backup_dbops {
                         'backup_import_competencies'       => 'competencies'
                 );
                 self::apply_admin_config_defaults($controller, $settings, true);
+                if ((!$controller->get_interactive()) &&
+                        $controller->get_type() == backup::TYPE_1ACTIVITY) {
+                    // This is duplicate - there is no concept of defaults - these settings must be on.
+                    $settings = array(
+                         'activities',
+                         'blocks',
+                         'filters',
+                         'questionbank'
+                    );
+                    self::force_enable_settings($controller, $settings);
+                }
                 break;
             case backup::MODE_AUTOMATED:
                 // Load the automated defaults.
@@ -607,6 +618,30 @@ abstract class backup_controller_dbops extends backup_dbops {
         }
     }
 
+    /**
+     * Turn these settings on. No defaults from admin settings.
+     *
+     * @param backup_controller $controller
+     * @param array $settings a map from admin config names to setting names (Config name => Setting name)
+     */
+    private static function force_enable_settings(backup_controller $controller, array $settings) {
+        $plan = $controller->get_plan();
+        foreach ($settings as $config => $settingname) {
+            $value = true;
+            if ($plan->setting_exists($settingname)) {
+                $setting = $plan->get_setting($settingname);
+                // We do not allow this setting to be locked for a duplicate function.
+                if ($setting->get_status() !== base_setting::NOT_LOCKED) {
+                    $setting->set_status(base_setting::NOT_LOCKED);
+                }
+                $setting->set_value($value);
+                $setting->set_status(base_setting::LOCKED_BY_CONFIG);
+            } else {
+                $controller->log('Unknown setting: ' . $setting, BACKUP::LOG_DEBUG);
+            }
+        }
+    }
+
     /**
      * Sets the controller settings default values from the admin config.
      *
index 38c6575..1894eeb 100644 (file)
@@ -183,6 +183,18 @@ abstract class restore_controller_dbops extends restore_dbops {
             );
             self::apply_admin_config_defaults($controller, $settings, true);
         }
+        if ($controller->get_mode() == backup::MODE_IMPORT &&
+                (!$controller->get_interactive()) &&
+                $controller->get_type() == backup::TYPE_1ACTIVITY) {
+            // This is duplicate - there is no concept of defaults - these settings must be on.
+            $settings = array(
+                    'activities',
+                    'blocks',
+                    'filters',
+                    'questionbank'
+            );
+            self::force_enable_settings($controller, $settings);
+        };
 
         // Add some dependencies.
         $plan = $controller->get_plan();
@@ -233,6 +245,30 @@ abstract class restore_controller_dbops extends restore_dbops {
         return $value;
     }
 
+    /**
+     * Turn these settings on. No defaults from admin settings.
+     *
+     * @param restore_controller $controller
+     * @param array $settings a map from admin config names to setting names (Config name => Setting name)
+     */
+    private static function force_enable_settings(restore_controller $controller, array $settings) {
+        $plan = $controller->get_plan();
+        foreach ($settings as $config => $settingname) {
+            $value = true;
+            if ($plan->setting_exists($settingname)) {
+                $setting = $plan->get_setting($settingname);
+                // We do not allow this setting to be locked for a duplicate function.
+                if ($setting->get_status() !== base_setting::NOT_LOCKED) {
+                    $setting->set_status(base_setting::NOT_LOCKED);
+                }
+                $setting->set_value($value);
+                $setting->set_status(base_setting::LOCKED_BY_CONFIG);
+            } else {
+                $controller->log('Unknown setting: ' . $settingname, BACKUP::LOG_DEBUG);
+            }
+        }
+    }
+
     /**
      * Sets the controller settings default values from the admin config.
      *
index 3cf391f..c6ac5bb 100644 (file)
@@ -208,10 +208,13 @@ class async_helper  {
      * @return boolean $asyncpedning Is there a pending async operation.
      */
     public static function is_async_pending($id, $type, $operation) {
-        global $DB, $USER;
+        global $DB, $USER, $CFG;
         $asyncpending = false;
 
         // Only check for pending async operations if async mode is enabled.
+        require_once($CFG->dirroot . '/backup/util/interfaces/checksumable.class.php');
+        require_once($CFG->dirroot . '/backup/backup.class.php');
+
         if (self::is_async_enabled()) {
             $select = 'userid = ? AND itemid = ? AND type = ? AND operation = ? AND execution = ? AND status < ? AND status > ?';
             $params = array(
index 40a65dc..9b1b9af 100644 (file)
@@ -14,6 +14,10 @@ Feature: Duplicate activities
     And the following "course enrolments" exist:
       | user | course | role |
       | teacher1 | C1 | editingteacher |
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | backup_import_activities    | 0 |
+    And I log out
     And I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
     And I add a "Database" to section "1" and I fill the form with:
index 661f018..90bf195 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js and b/blocks/myoverview/amd/build/view.min.js differ
index f342949..6a83646 100644 (file)
@@ -90,7 +90,8 @@ function(
         return {
             display: courseRegion.attr('data-display'),
             grouping: courseRegion.attr('data-grouping'),
-            sort: courseRegion.attr('data-sort')
+            sort: courseRegion.attr('data-sort'),
+            displaycategories: courseRegion.attr('data-displaycategories'),
         };
     };
 
@@ -370,9 +371,17 @@ function(
             currentTemplate = TEMPLATES.COURSES_SUMMARY;
         }
 
+        // Delete the course category if it is not to be displayed
+        if (filters.displaycategories != 'on') {
+            coursesData.courses = coursesData.courses.map(function(course) {
+                delete course.coursecategory;
+                return course;
+            });
+        }
+
         if (coursesData.courses.length) {
             return Templates.render(currentTemplate, {
-                courses: coursesData.courses
+                courses: coursesData.courses,
             });
         } else {
             var nocoursesimg = root.find(Selectors.courseView.region).attr('data-nocoursesimg');
index 85c208e..6177719 100644 (file)
@@ -72,4 +72,14 @@ class block_myoverview extends block_base {
     public function applicable_formats() {
         return array('my' => true);
     }
+
+    /**
+     * Allow the block to have a configuration page
+     *
+     * @return boolean
+     */
+    public function has_config() {
+        return true;
+    }
 }
+
index eb28dce..7ef8c38 100644 (file)
@@ -66,6 +66,13 @@ class main implements renderable, templatable {
      */
     private $paging;
 
+    /**
+     * Store the display categories config setting
+     *
+     * @var boolean
+     */
+    private $displaycategories;
+
     /**
      * main constructor.
      * Initialize the user preferences
@@ -79,6 +86,12 @@ class main implements renderable, templatable {
         $this->sort = $sort ? $sort : BLOCK_MYOVERVIEW_SORTING_TITLE;
         $this->view = $view ? $view : BLOCK_MYOVERVIEW_VIEW_CARD;
         $this->paging = $paging ? $paging : BLOCK_MYOVERVIEW_PAGING_12;
+        $config = get_config('block_myoverview');
+        if (!$config->displaycategories) {
+            $this->displaycategories = BLOCK_MYOVERVIEW_DISPLAY_CATEGORIES_OFF;
+        } else {
+            $this->displaycategories = BLOCK_MYOVERVIEW_DISPLAY_CATEGORIES_ON;
+        }
     }
 
     /**
@@ -110,7 +123,8 @@ class main implements renderable, templatable {
             'grouping' => $this->grouping,
             'sort' => $this->sort == BLOCK_MYOVERVIEW_SORTING_TITLE ? 'fullname' : 'ul.timeaccess desc',
             'view' => $this->view,
-            'paging' => $this->paging
+            'paging' => $this->paging,
+            'displaycategories' => $this->displaycategories,
         ];
 
         $preferences = $this->get_preferences_as_booleans();
index 3ef06af..b6f51b8 100644 (file)
@@ -49,6 +49,8 @@ $string['card'] = 'Card';
 $string['cards'] = 'Cards';
 $string['courseprogress'] = 'Course progress:';
 $string['completepercent'] = '{$a}% complete';
+$string['displaycategories'] = 'Display Categories';
+$string['displaycategories_help'] = 'Display the Course Category on dashboard course items including cards, list items and summary items';
 $string['favourites'] = 'Starred';
 $string['future'] = 'Future';
 $string['inprogress'] = 'In progress';
index ed7334a..d5bc9e2 100644 (file)
@@ -55,6 +55,12 @@ define('BLOCK_MYOVERVIEW_PAGING_12', 12);
 define('BLOCK_MYOVERVIEW_PAGING_24', 24);
 define('BLOCK_MYOVERVIEW_PAGING_48', 48);
 
+/**
+ * Constants for the admin category display setting
+ */
+define('BLOCK_MYOVERVIEW_DISPLAY_CATEGORIES_ON', 'on');
+define('BLOCK_MYOVERVIEW_DISPLAY_CATEGORIES_OFF', 'off');
+
 /**
  * Get the current user preferences that are available
  *
diff --git a/blocks/myoverview/settings.php b/blocks/myoverview/settings.php
new file mode 100644 (file)
index 0000000..f3b2edf
--- /dev/null
@@ -0,0 +1,37 @@
+<?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/>.
+
+/**
+ * Settings for the myoverview block
+ *
+ * @package    block_myoverview
+ * @copyright  2019 Tom Dickman <tomdickman@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+if ($ADMIN->fulltree) {
+    require_once($CFG->dirroot . '/blocks/myoverview/lib.php');
+
+    // Display Course Categories on Dashboard course items (cards, lists, summary items).
+    $settings->add(new admin_setting_configcheckbox(
+        'block_myoverview/displaycategories',
+        get_string('displaycategories', 'block_myoverview'),
+        get_string('displaycategories_help', 'block_myoverview'),
+        1));
+
+}
index 754d525..951cef9 100644 (file)
@@ -34,7 +34,8 @@
     data-sort="{{sort}}"
     data-prev-display="{{view}}"
     data-paging="{{paging}}"
-    data-nocoursesimg="{{nocoursesimg}}">
+    data-nocoursesimg="{{nocoursesimg}}"
+    data-displaycategories="{{displaycategories}}">
     <div data-region="course-view-content">
         {{> block_myoverview/placeholders }}
     </div>
index b4aa8f9..eafb72e 100644 (file)
@@ -47,7 +47,9 @@
                         <span class="sr-only">
                             {{#str}}aria:coursecategory, core_course{{/str}}
                         </span>
-                        <div>{{{coursecategory}}}</div>
+                        {{$coursecategory}}
+                            <div>{{{coursecategory}}}</div>
+                        {{/coursecategory}}
                         {{#showshortname}}
                         <div class="pl-1 pr-1">|</div>
                         <span class="sr-only">
index 24e7cf6..8b35555 100644 (file)
@@ -54,7 +54,9 @@
                             <span class="sr-only">
                                 {{#str}}aria:coursecategory, core_course{{/str}}
                             </span>
-                            <div>{{{coursecategory}}}</div>
+                            {{$coursecategory}}
+                                <div>{{{coursecategory}}}</div>
+                            {{/coursecategory}}
                             {{#showshortname}}
                             <div class="pl-1 pr-1">|</div>
                             <span class="sr-only">
@@ -84,4 +86,4 @@
         </div>
     </div>
 {{/courses}}
-</div>
\ No newline at end of file
+</div>
index 95d45b9..467e1c9 100644 (file)
@@ -71,7 +71,7 @@ class cachestore_mongodb_addinstance_form extends cachestore_addinstance_form {
         $form->addHelpButton('username', 'username', 'cachestore_mongodb');
         $form->setType('username', PARAM_ALPHANUMEXT);
 
-        $form->addElement('text', 'password', get_string('password', 'cachestore_mongodb'));
+        $form->addElement('passwordunmask', 'password', get_string('password', 'cachestore_mongodb'));
         $form->addHelpButton('password', 'password', 'cachestore_mongodb');
         $form->setType('password', PARAM_TEXT);
 
@@ -98,4 +98,4 @@ class cachestore_mongodb_addinstance_form extends cachestore_addinstance_form {
         $form->setAdvanced('extendedmode');
         $form->setType('extendedmode', PARAM_BOOL);
     }
-}
\ No newline at end of file
+}
diff --git a/course/tests/behat/app_course_completion.feature b/course/tests/behat/app_course_completion.feature
new file mode 100644 (file)
index 0000000..f0c30ba
--- /dev/null
@@ -0,0 +1,36 @@
+@core @core_course @app @javascript
+Feature: Check course completion feature.
+  In order to track the progress of the course on mobile device
+  As a student
+  I need to be able to update the activity completion status.
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | enablecompletion |
+      | Course 1 | C1        | 0        | 1                |
+    And the following "course enrolments" exist:
+      | user     | course | role    |
+      | student1 | C1     | student |
+
+  Scenario: Complete the activity manually by clicking at the completion checkbox.
+    Given the following "activities" exist:
+      | activity | name         | course | idnumber | completion | completionview |
+      | forum    | First forum  | C1     | forum1   | 1          | 0              |
+      | forum    | Second forum | C1     | forum2   | 1          | 0              |
+    When I enter the app
+    And I log in as "student1"
+    And I press "Course 1" near "Recently accessed courses" in the app
+    # Set activities as completed.
+    And I should see "0%"
+    And I press "Not completed: First forum. Select to mark as complete." in the app
+    And I should see "50%"
+    And I press "Not completed: Second forum. Select to mark as complete." in the app
+    And I should see "100%"
+    # Set activities as not completed.
+    And I press "Completed: First forum. Select to mark as not complete." in the app
+    And I should see "50%"
+    And I press "Completed: Second forum. Select to mark as not complete." in the app
+    And I should see "0%"
index e1a3e3c..6d65bc9 100644 (file)
@@ -5,7 +5,6 @@
     require_once('../config.php');
     require_once('lib.php');
     require_once($CFG->libdir.'/completionlib.php');
-    require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
 
     $id          = optional_param('id', 0, PARAM_INT);
     $name        = optional_param('name', '', PARAM_TEXT);
     $PAGE->set_heading($course->fullname);
     echo $OUTPUT->header();
 
-if ($USER->editing == 1 && async_helper::is_async_pending($id, 'course', 'backup')) {
-    echo $OUTPUT->notification(get_string('pendingasyncedit', 'backup'), 'warning');
-}
+    if ($USER->editing == 1 && !empty($CFG->enableasyncbackup)) {
+
+        // MDL-65321 The backup libraries are quite heavy, only require the bare minimum.
+        require_once($CFG->dirroot . '/backup/util/helper/async_helper.class.php');
+
+        if (async_helper::is_async_pending($id, 'course', 'backup')) {
+            echo $OUTPUT->notification(get_string('pendingasyncedit', 'backup'), 'warning');
+        }
+    }
 
     if ($completion->is_enabled()) {
         // This value tracks whether there has been a dynamic change to the page.
index d6ad6e9..c1d5067 100644 (file)
@@ -225,8 +225,23 @@ class gradeimport_csv_load_data {
         $errorkey = false;
         // The user may use the incorrect field to match the user. This could result in an exception.
         try {
+            $field = $userfields['field'];
+            // Fields that can be queried in a case-insensitive manner.
+            $caseinsensitivefields = [
+                'email',
+                'username',
+            ];
+            // Build query predicate.
+            if (in_array($field, $caseinsensitivefields)) {
+                // Case-insensitive.
+                $select = $DB->sql_equal($field, ':' . $field, false);
+            } else {
+                // Exact-value.
+                $select = "{$field} = :{$field}";
+            }
+
             // Make sure the record exists and that there's only one matching record found.
-            $user = $DB->get_record('user', array($userfields['field'] => $value), '*', MUST_EXIST);
+            $user = $DB->get_record_select('user', $select, array($userfields['field'] => $value), '*', MUST_EXIST);
         } catch (dml_missing_record_exception $missingex) {
             $errorkey = 'usermappingerror';
         } catch (dml_multiple_records_exception $multiex) {
index 70d41cd..f05e8b5 100644 (file)
@@ -252,6 +252,9 @@ Bobby,Bunce,,"Moodle HQ","Rock on!",student5@example.com,75.00,,75.00,{exportdat
             'Fetch by email' => [
                 'email', 's1@example.com', true
             ],
+            'Fetch by email, different case' => [
+                'email', 'S1@EXAMPLE.COM', true
+            ],
             'Fetch data using a non-existent email' => [
                 'email', 's2@example.com', false
             ],
@@ -267,6 +270,9 @@ Bobby,Bunce,,"Moodle HQ","Rock on!",student5@example.com,75.00,,75.00,{exportdat
             'Fetch data using a valid username' => [
                 'username', 's1', true
             ],
+            'Fetch data using a valid username, different case' => [
+                'username', 'S1', true
+            ],
             'Fetch data using an invalid username' => [
                 'username', 's2', false
             ],
index 33a193f..4308a43 100644 (file)
@@ -758,6 +758,45 @@ class auth_plugin_base {
         }
         return $data;
     }
+
+    /**
+     * Returns information on how the specified user can change their password.
+     *
+     * @param stdClass $user A user object
+     * @return string[] An array of strings with keys subject and message
+     */
+    public function get_password_change_info(stdClass $user) : array {
+        $site = get_site();
+        $systemcontext = context_system::instance();
+
+        $data = new stdClass();
+        $data->firstname = $user->firstname;
+        $data->lastname  = $user->lastname;
+        $data->username  = $user->username;
+        $data->sitename  = format_string($site->fullname);
+        $data->admin     = generate_email_signoff();
+
+        if ($this->can_change_password() and $this->change_password_url()) {
+            // We have some external url for password changing.
+            $data->link = $this->change_password_url();
+        } else {
+            // No way to change password, sorry.
+            $data->link = '';
+        }
+
+        if (!empty($data->link) and has_capability('moodle/user:changeownpassword', $systemcontext, $user->id)) {
+            $subject = get_string('emailpasswordchangeinfosubject', '', format_string($site->fullname));
+            $message = get_string('emailpasswordchangeinfo', '', $data);
+        } else {
+            $subject = get_string('emailpasswordchangeinfosubject', '', format_string($site->fullname));
+            $message = get_string('emailpasswordchangeinfofail', '', $data);
+        }
+
+        return [
+            'subject' => $subject,
+            'message' => $message
+        ];
+    }
 }
 
 /**
index 64ea06a..52946fa 100644 (file)
@@ -45,6 +45,9 @@ define('MAX_COURSE_CATEGORIES', 10000);
  *
  * We allow overwrites from config.php, useful to ensure coherence in performance
  * tests results.
+ *
+ * Note: For web service requests in the external_tokens field, we use a different constant
+ * webservice::TOKEN_LASTACCESS_UPDATE_SECS.
  */
 if (!defined('LASTACCESS_UPDATE_SECS')) {
     define('LASTACCESS_UPDATE_SECS', 60);
index 519bb11..9631290 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20190328" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20190402" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="enabled" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="trained" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="name" TYPE="char" LENGTH="1333" NOTNULL="false" SEQUENCE="false" COMMENT="Explicit name of the model, the localised target name is used when left empty"/>
         <FIELD NAME="target" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="indicators" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="timesplitting" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
index 8bf8063..76dc143 100644 (file)
@@ -2988,5 +2988,17 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019041000.02);
     }
 
+    if ($oldversion < 2019041300.01) {
+        // Add the field 'name' to the 'analytics_models' table.
+        $table = new xmldb_table('analytics_models');
+        $field = new xmldb_field('name', XMLDB_TYPE_CHAR, '1333', null, null, null, null, 'trained');
+
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        upgrade_main_savepoint(true, 2019041300.01);
+    }
+
     return true;
 }
index 1cc6caa..ad2c738 100644 (file)
@@ -2764,6 +2764,8 @@ function require_login($courseorid = null, $autologinguest = true, $cm = null, $
         $CFG->forceclean = true;
     }
 
+    $afterlogins = get_plugins_with_function('after_require_login', 'lib.php');
+
     // Do not bother admins with any formalities, except for activities pending deletion.
     if (is_siteadmin() && !($cm && $cm->deletioninprogress)) {
         // Set the global $COURSE.
@@ -2778,6 +2780,12 @@ function require_login($courseorid = null, $autologinguest = true, $cm = null, $
         if (!WS_SERVER && !AJAX_SCRIPT) {
             user_accesstime_log($course->id);
         }
+
+        foreach ($afterlogins as $plugintype => $plugins) {
+            foreach ($plugins as $pluginfunction) {
+                $pluginfunction($courseorid, $autologinguest, $cm, $setwantsurltome, $preventredirect);
+            }
+        }
         return;
     }
 
@@ -2995,6 +3003,12 @@ function require_login($courseorid = null, $autologinguest = true, $cm = null, $
         $PAGE->set_course($course);
     }
 
+    foreach ($afterlogins as $plugintype => $plugins) {
+        foreach ($plugins as $pluginfunction) {
+            $pluginfunction($courseorid, $autologinguest, $cm, $setwantsurltome, $preventredirect);
+        }
+    }
+
     // Finally access granted, update lastaccess times.
     // Do not update access time for webservice or ajax requests.
     if (!WS_SERVER && !AJAX_SCRIPT) {
@@ -6417,17 +6431,14 @@ function send_password_change_confirmation_email($user, $resetrecord) {
 }
 
 /**
- * Sends an email containinginformation on how to change your password.
+ * Sends an email containing information on how to change your password.
  *
  * @param stdClass $user A {@link $USER} object
  * @return bool Returns true if mail was sent OK and false if there was an error.
  */
 function send_password_change_info($user) {
-    global $CFG;
-
     $site = get_site();
     $supportuser = core_user::get_support_user();
-    $systemcontext = context_system::instance();
 
     $data = new stdClass();
     $data->firstname = $user->firstname;
@@ -6436,35 +6447,18 @@ function send_password_change_info($user) {
     $data->sitename  = format_string($site->fullname);
     $data->admin     = generate_email_signoff();
 
-    $userauth = get_auth_plugin($user->auth);
-
-    if (!is_enabled_auth($user->auth) or $user->auth == 'nologin') {
+    if (!is_enabled_auth($user->auth)) {
         $message = get_string('emailpasswordchangeinfodisabled', '', $data);
         $subject = get_string('emailpasswordchangeinfosubject', '', format_string($site->fullname));
         // Directly email rather than using the messaging system to ensure its not routed to a popup or jabber.
         return email_to_user($user, $supportuser, $subject, $message);
     }
 
-    if ($userauth->can_change_password() and $userauth->change_password_url()) {
-        // We have some external url for password changing.
-        $data->link .= $userauth->change_password_url();
-
-    } else {
-        // No way to change password, sorry.
-        $data->link = '';
-    }
-
-    if (!empty($data->link) and has_capability('moodle/user:changeownpassword', $systemcontext, $user->id)) {
-        $message = get_string('emailpasswordchangeinfo', '', $data);
-        $subject = get_string('emailpasswordchangeinfosubject', '', format_string($site->fullname));
-    } else {
-        $message = get_string('emailpasswordchangeinfofail', '', $data);
-        $subject = get_string('emailpasswordchangeinfosubject', '', format_string($site->fullname));
-    }
+    $userauth = get_auth_plugin($user->auth);
+    ['subject' => $subject, 'message' => $message] = $userauth->get_password_change_info($user);
 
     // Directly email rather than using the messaging system to ensure its not routed to a popup or jabber.
     return email_to_user($user, $supportuser, $subject, $message);
-
 }
 
 /**
index 176700b..737c753 100644 (file)
@@ -926,12 +926,11 @@ abstract class testing_util {
 
             if (defined('BEHAT_SITE_RUNNING')) {
                 $tablesupdatedfile = self::get_tables_updated_by_scenario_list_path();
-                if ($tablesupdated = @json_decode(file_get_contents($tablesupdatedfile), true)) {
-                    $tablesupdated[$table] = true;
-                } else {
+                $tablesupdated = @json_decode(file_get_contents($tablesupdatedfile), true);
+                if (!isset($tablesupdated[$table])) {
                     $tablesupdated[$table] = true;
+                    @file_put_contents($tablesupdatedfile, json_encode($tablesupdated, JSON_PRETTY_PRINT));
                 }
-                @file_put_contents($tablesupdatedfile, json_encode($tablesupdated, JSON_PRETTY_PRINT));
             }
         }
     }
index d88fa92..08ccc8c 100644 (file)
         // Find all the Aria labels that contain this text.
         var exactLabelMatches = [];
         var anyLabelMatches = [];
-        findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText +
-                '")]', function(match) {
+        findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText + '")]' +
+                '| //img[@alt and contains(@alt, "' + escapedText + '")]', function(match) {
                     // Add to array depending on if it's an exact or partial match.
-                    if (match.getAttribute('aria-label').trim() === text) {
+                    var attributeData = match.getAttribute('aria-label') || match.getAttribute('alt');
+                    if (attributeData.trim() === text) {
                         exactLabelMatches.push(match);
                     } else {
                         anyLabelMatches.push(match);
index 199f3f1..fc742d5 100644 (file)
@@ -4354,4 +4354,20 @@ class core_moodlelib_testcase extends advanced_testcase {
             $this->assertFalse($fetcheduser);
         }
     }
+
+    /**
+     * Test for send_password_change_().
+     */
+    public function test_send_password_change_info() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $sink = $this->redirectEmails(); // Make sure we are redirecting emails.
+        send_password_change_info($user);
+        $result = $sink->get_messages();
+        $sink->close();
+
+        $this->assertContains('passwords cannot be reset on this site', $result[0]->body);
+    }
 }
index 0250731..4c55dc7 100644 (file)
@@ -396,12 +396,22 @@ class core_analytics_targets_testcase extends advanced_testcase {
         $dg->enrol_user($student2->id, $course1->id, $studentrole->id);
         $dg->enrol_user($student3->id, $course1->id, $studentrole->id);
 
+        // get_all_samples() does not guarantee any order, so let's
+        // explicitly define the expectations here for later comparing.
+        // Expectations format being array($userid => expectation, ...)
+        $expectations = [];
+
         $courseitem = grade_item::fetch_course_item($course1->id);
-        // Student1 fails.
+        // Student1 (< gradepass) fails, so it's non achieved sample.
         $courseitem->update_final_grade($student1->id, 30);
-        // Student2 pass.
+        $expectations[$student1->id] = 1;
+
+        // Student2 (> gradepass) passes, so it's achieved sample.
         $courseitem->update_final_grade($student2->id, 60);
-        // Student 3 has no grade.
+        $expectations[$student2->id] = 0;
+
+        // Student 3 (has no grade) fails, so it's non achieved sample.
+        $expectations[$student3->id] = 1;
 
         $courseitem->gradepass = 50;
         $DB->update_record('grade_items', $courseitem);
@@ -417,26 +427,13 @@ class core_analytics_targets_testcase extends advanced_testcase {
         list($sampleids, $samplesdata) = $method->invoke($analyser, $analysable);
         $target->add_sample_data($samplesdata);
 
-        // Users in array $sampleids are sorted by user id, so student1 is the first sample.
-        $sampleid = reset($sampleids);
-
         $class = new ReflectionClass('\core\analytics\target\course_gradetopass');
         $method = $class->getMethod('calculate_sample');
         $method->setAccessible(true);
 
-        // Method calculate_sample() returns 1 when the user has not successfully graded to pass the course.
-        $this->assertEquals(1, $method->invoke($target, $sampleid, $analysable));
-
-        // Student2.
-        $sampleid = next($sampleids);
-
-        // Method calculate_sample() returns 0 when the user has successfully graded to pass the course.
-        $this->assertEquals(0, $method->invoke($target, $sampleid, $analysable));
-
-        // Student3.
-        $sampleid = next($sampleids);
-
-        // Method calculate_sample() returns 1 when the user has not been graded.
-        $this->assertEquals(1, $method->invoke($target, $sampleid, $analysable));
+        // Verify all the expectations are fulfilled.
+        foreach ($sampleids as $sampleid => $key) {
+            $this->assertEquals($expectations[$samplesdata[$key]['user']->id], $method->invoke($target, $sampleid, $analysable));
+        }
     }
 }
index f1bbece..d0baed6 100644 (file)
@@ -30,6 +30,7 @@ attribute on forms to avoid collisions in forms loaded in AJAX requests.
   in this category. To work with list of courses use API methods in core_course_category and also 'course' form element.
 * It is possible to pass additional conditions to get_courses_search();
   core_course_category::search_courses() now allows to search only among courses with completion enabled.
+* Add support for a new xxx_after_require_login callback
 
 === 3.6 ===
 
index 7609906..fa7ad53 100644 (file)
@@ -367,14 +367,14 @@ class mod_chat_lib_testcase extends advanced_testcase {
         // We don't know when this test is being ran and there is no standard way to
         // mock the time() function (MDL-37327 to handle that).
         if ($hour < 10) {
-            $timezone1 = 'Europe/London';       // GMT or GMT +01:00.
+            $timezone1 = 'UTC';                 // GMT.
             $timezone2 = 'Pacific/Pago_Pago';   // GMT -11:00.
         } else if ($hour < 11) {
             $timezone1 = 'Pacific/Kiritimati';  // GMT +14:00.
             $timezone2 = 'America/Sao_Paulo';   // GMT -03:00.
         } else {
             $timezone1 = 'Pacific/Kiritimati';  // GMT +14:00.
-            $timezone2 = 'Europe/London';       // GMT or GMT +01:00.
+            $timezone2 = 'UTC';                 // GMT.
         }
 
         $this->setTimezone($timezone2);
@@ -411,7 +411,7 @@ class mod_chat_lib_testcase extends advanced_testcase {
         $actionevent22 = mod_chat_core_calendar_provide_event_action($event2, $factory, $student2->id);
 
         // Confirm event1 is not shown to student1 at all.
-        $this->assertNull($actionevent11);
+        $this->assertNull($actionevent11, 'Failed for UTC time ' . gmdate('H:i'));
 
         // Confirm event1 was decorated for student2 and it is actionable.
         $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent12);
diff --git a/mod/forum/amd/build/discussion.min.js b/mod/forum/amd/build/discussion.min.js
new file mode 100644 (file)
index 0000000..a6e7947
Binary files /dev/null and b/mod/forum/amd/build/discussion.min.js differ
index 8a80492..bb3a8f9 100644 (file)
Binary files a/mod/forum/amd/build/selectors.min.js and b/mod/forum/amd/build/selectors.min.js differ
diff --git a/mod/forum/amd/src/discussion.js b/mod/forum/amd/src/discussion.js
new file mode 100644 (file)
index 0000000..1a45edb
--- /dev/null
@@ -0,0 +1,226 @@
+// 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/>.
+
+/**
+ * Module for viewing a discussion.
+ *
+ * @module     mod_forum/discussion_list
+ * @package    mod_forum
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(
+[
+    'jquery',
+    'core/custom_interaction_events',
+    'mod_forum/selectors'
+],
+function(
+    $,
+    CustomEvents,
+    Selectors
+) {
+
+    /**
+     * Set the focus on the previous post in the list. Previous post is calculated
+     * based on position in list as viewed top to bottom.
+     *
+     * @param {Object} currentPost The post that currently has focus
+     */
+    var focusPreviousPost = function(currentPost) {
+        // See if there is a previous sibling post.
+        var prevPost = currentPost.prev(Selectors.post.post);
+
+        if (prevPost.length) {
+            // The previous post might have replies that appear visually between
+            // it and the current post (see nested view) so if that's the case
+            // then the last reply will be the previous post in the list.
+            var replyPost = prevPost.find(Selectors.post.post).last();
+
+            if (replyPost.length) {
+                // Focus the last reply.
+                replyPost.focus();
+            } else {
+                // No replies so we can focus straight on the sibling.
+                prevPost.focus();
+            }
+        } else {
+            // If there are no siblings then jump up the tree to the parent
+            // post and focus the first parent post we find.
+            currentPost.parents(Selectors.post.post).first().focus();
+        }
+    };
+
+    /**
+     * Set the focus on the next post in the list. Previous post is calculated
+     * based on position in list as viewed top to bottom.
+     *
+     * @param {Object} currentPost The post that currently has focus
+     */
+    var focusNextPost = function(currentPost) {
+        // The next post in the visual list would be the first reply to this one
+        // so let's see if we have one.
+        var replyPost = currentPost.find(Selectors.post.post).first();
+
+        if (replyPost.length) {
+            // Got a reply.
+            replyPost.focus();
+        } else {
+            // If we don't have a reply then the next post in the visual list would
+            // be a sibling post (replying to the same parent).
+            var siblingPost = currentPost.next(Selectors.post.post);
+
+            if (siblingPost.length) {
+                siblingPost.focus();
+            } else {
+                // No siblings either. That means we're the lowest level reply in a thread
+                // so we need to walk back up the tree of posts and find an ancestor post that
+                // has a sibling post we can focus.
+                currentPost.parents().toArray().forEach(function(parent) {
+                    var ancestorSiblingPost = $(parent).next(Selectors.post.post);
+
+                    if (ancestorSiblingPost.length) {
+                        ancestorSiblingPost.focus();
+                        return;
+                    }
+                });
+            }
+        }
+    };
+
+    /**
+     * Initialise the keyboard accessibility controls for the discussion.
+     *
+     * @param {Object} root The discussion root element
+     */
+    var initAccessibilityKeyboardNav = function(root) {
+        var posts = root.find(Selectors.post.post);
+
+        // Take each post action out of the tab index.
+        posts.each(function(index, post) {
+            var actions = $(post).find(Selectors.post.action);
+            var firstAction = actions.first();
+            actions.attr('tabindex', '-1');
+            firstAction.attr('tabindex', 0);
+        });
+
+        CustomEvents.define(root, [
+            CustomEvents.events.up,
+            CustomEvents.events.down,
+            CustomEvents.events.next,
+            CustomEvents.events.previous,
+            CustomEvents.events.home,
+            CustomEvents.events.end,
+        ]);
+
+        root.on(CustomEvents.events.up, function() {
+            var focusPost = $(document.activeElement).closest(Selectors.post.post);
+
+            if (focusPost.length) {
+                focusPreviousPost(focusPost);
+            } else {
+                root.find(Selectors.post.post).first().focus();
+            }
+        });
+
+        root.on(CustomEvents.events.down, function() {
+            var focusPost = $(document.activeElement).closest(Selectors.post.post);
+
+            if (focusPost.length) {
+                focusNextPost(focusPost);
+            } else {
+                root.find(Selectors.post.post).first().focus();
+            }
+        });
+
+        root.on(CustomEvents.events.home, function() {
+            root.find(Selectors.post.post).first().focus();
+        });
+
+        root.on(CustomEvents.events.end, function() {
+            root.find(Selectors.post.post).last().focus();
+        });
+
+        root.on(CustomEvents.events.next, Selectors.post.action, function(e, data) {
+            var currentAction = $(e.target);
+            var container = currentAction.closest(Selectors.post.actionsContainer);
+            var actions = container.find(Selectors.post.action);
+            var nextAction = currentAction.next(Selectors.post.action);
+
+            actions.attr('tabindex', '-1');
+
+            if (!nextAction.length) {
+                nextAction = actions.first();
+            }
+
+            nextAction.attr('tabindex', 0);
+            nextAction.focus();
+
+            data.originalEvent.preventDefault();
+        });
+
+        root.on(CustomEvents.events.previous, Selectors.post.action, function(e, data) {
+            var currentAction = $(e.target);
+            var container = currentAction.closest(Selectors.post.actionsContainer);
+            var actions = container.find(Selectors.post.action);
+            var nextAction = currentAction.prev(Selectors.post.action);
+
+            actions.attr('tabindex', '-1');
+
+            if (!nextAction.length) {
+                nextAction = actions.last();
+            }
+
+            nextAction.attr('tabindex', 0);
+            nextAction.focus();
+
+            data.originalEvent.preventDefault();
+        });
+
+        root.on(CustomEvents.events.home, Selectors.post.action, function(e, data) {
+            var currentAction = $(e.target);
+            var container = currentAction.closest(Selectors.post.actionsContainer);
+            var actions = container.find(Selectors.post.action);
+            var firstAction = actions.first();
+
+            actions.attr('tabindex', '-1');
+            firstAction.attr('tabindex', 0);
+            firstAction.focus();
+
+            e.stopPropagation();
+            data.originalEvent.preventDefault();
+        });
+
+        root.on(CustomEvents.events.end, Selectors.post.action, function(e, data) {
+            var currentAction = $(e.target);
+            var container = currentAction.closest(Selectors.post.actionsContainer);
+            var actions = container.find(Selectors.post.action);
+            var lastAction = actions.last();
+
+            actions.attr('tabindex', '-1');
+            lastAction.attr('tabindex', 0);
+            lastAction.focus();
+
+            e.stopPropagation();
+            data.originalEvent.preventDefault();
+        });
+    };
+
+    return {
+        init: function(root) {
+            initAccessibilityKeyboardNav(root);
+        }
+    };
+});
index e547569..b8d935a 100644 (file)
@@ -25,6 +25,11 @@ define([], function() {
     return {
         subscription: {
             toggle: "[data-type='subscription-toggle'][data-action='toggle']",
+        },
+        post: {
+            post: '[data-region="post"]',
+            action: '[data-region="post-action"]',
+            actionsContainer: '[data-region="post-actions-container"]'
         }
     };
 });
index bd6e34d..fb64846 100644 (file)
@@ -82,9 +82,22 @@ class author extends exporter {
                 'null' => NULL_ALLOWED
             ],
             'groups' => [
-                'type' => group_exporter::read_properties_definition(),
                 'multiple' => true,
                 'optional' => true,
+                'type' => [
+                    'id' => ['type' => PARAM_INT],
+                    'name' => ['type' => PARAM_TEXT],
+                    'urls' => [
+                        'type' => [
+                            'image' => [
+                                'type' => PARAM_URL,
+                                'optional' => true,
+                                'default' => null,
+                                'null' => NULL_ALLOWED
+                            ]
+                        ]
+                    ]
+                ]
             ],
             'urls' => [
                 'type' => [
@@ -122,6 +135,7 @@ class author extends exporter {
                 $imageurl = get_group_picture_url($group, $group->courseid);
                 return [
                     'id' => $group->id,
+                    'name' => $group->name,
                     'urls' => [
                         'image' => $imageurl ? $imageurl->out(false) : null
                     ]
index dada4e0..75c39a3 100644 (file)
@@ -187,15 +187,18 @@ class renderer {
             function($exportedposts, $forums) use ($displaymode, $readonly, $exportedpostssorter) {
                 $forum = array_shift($forums);
                 $seenfirstunread = false;
+                $postcount = count($exportedposts);
                 $exportedposts = array_map(
                     function($exportedpost) use ($forum, $readonly, $seenfirstunread) {
                         if ($forum->get_type() == 'single' && !$exportedpost->hasparent) {
                             // Remove the author from any posts that don't have a parent.
                             unset($exportedpost->author);
+                            unset($exportedpost->html['authorsubheading']);
                         }
 
                         $exportedpost->firstpost = false;
                         $exportedpost->readonly = $readonly;
+                        $exportedpost->hasreplycount = false;
                         $exportedpost->hasreplies = false;
                         $exportedpost->replies = [];
 
@@ -215,7 +218,20 @@ class renderer {
                     $sortintoreplies = function($nestedposts) use (&$sortintoreplies) {
                         return array_map(function($postdata) use (&$sortintoreplies) {
                             [$post, $replies] = $postdata;
-                            $post->replies = $sortintoreplies($replies);
+                            $sortedreplies = $sortintoreplies($replies);
+                            // Set the parent author name on the replies. This is used for screen
+                            // readers to help them identify the structure of the discussion.
+                            $sortedreplies = array_map(function($reply) use ($post) {
+                                if (isset($post->author)) {
+                                    $reply->parentauthorname = $post->author->fullname;
+                                } else {
+                                    // The only time the author won't be set is for a single discussion
+                                    // forum. See above for where it gets unset.
+                                    $reply->parentauthorname = get_string('firstpost', 'mod_forum');
+                                }
+                                return $reply;
+                            }, $sortedreplies);
+                            $post->replies = $sortedreplies;
                             $post->hasreplies = !empty($post->replies);
                             return $post;
                         }, $nestedposts);
@@ -232,6 +248,8 @@ class renderer {
                 if (!empty($exportedposts)) {
                     // Need to identify the first post so that we can use it in behat tests.
                     $exportedposts[0]->firstpost = true;
+                    $exportedposts[0]->hasreplycount = true;
+                    $exportedposts[0]->replycount = $postcount - 1;
                 }
 
                 return $exportedposts;
index 7ae8c1c..0d40c17 100644 (file)
@@ -39,6 +39,7 @@ $string['anyfile'] = 'Any file';
 $string['areaattachment'] = 'Attachments';
 $string['areapost'] = 'Messages';
 $string['attachment'] = 'Attachment';
+$string['attachmentname'] = 'Attachment {$a}';
 $string['attachment_help'] = 'You can optionally attach one or more files to a forum post. If you attach an image, it will be displayed after the message.';
 $string['attachmentnopost'] = 'You cannot export attachments without a post id';
 $string['attachments'] = 'Attachments';
@@ -227,6 +228,8 @@ $string['everyoneisnowsubscribed'] = 'Everyone is now subscribed to this forum';
 $string['everyoneissubscribed'] = 'Everyone is subscribed to this forum';
 $string['existingsubscribers'] = 'Existing subscribers';
 $string['exportdiscussion'] = 'Export whole discussion to portfolio';
+$string['exportattachmentname'] = 'Export attachment {$a} to portfolio';
+$string['firstpost'] = 'First post';
 $string['forcedreadtracking'] = 'Allow forced read tracking';
 $string['forcedreadtracking_desc'] = 'Allows forums to be set to forced read tracking. Will result in decreased performance for some users, particularly on courses with many forums and posts. When off, any forums previously set to Forced are treated as optional.';
 $string['forcesubscribed_help'] = 'This forum has been configured so that you cannot unsubscribe from discussions.';
@@ -290,6 +293,7 @@ $string['indicator:cognitivedepth_help'] = 'This indicator is based on the cogni
 $string['indicator:socialbreadth'] = 'Forum social';
 $string['indicator:socialbreadth_help'] = 'This indicator is based on the social breadth reached by the student in a Forum activity.';
 $string['inforum'] = 'in {$a}';
+$string['inreplyto'] = 'In reply to {$a}';
 $string['introblog'] = 'The posts in this forum were copied here automatically from blogs of users in this course because those blog entries are no longer available';
 $string['intronews'] = 'General news and announcements';
 $string['introsocial'] = 'An open forum for chatting about anything you want to';
@@ -408,6 +412,7 @@ $string['nownottracking'] = '{$a->name} is no longer tracking \'{$a->forum}\'.';
 $string['nowsubscribed'] = '{$a->name} will be notified of new posts in \'{$a->forum}\'';
 $string['nowtracking'] = '{$a->name} is now tracking \'{$a->forum}\'.';
 $string['numposts'] = '{$a} posts';
+$string['numberofreplies'] = 'Number of replies: {$a}';
 $string['olderdiscussions'] = 'Older discussions';
 $string['oldertopics'] = 'Older topics';
 $string['oldpostdays'] = 'Read after days';
@@ -419,6 +424,8 @@ $string['page-mod-forum-discuss'] = 'Forum module discussion thread page';
 $string['parent'] = 'Show parent';
 $string['parentofthispost'] = 'Parent of this post';
 $string['permalink'] = 'Permalink';
+$string['permanentlinktopost'] = 'Permanent link to this post';
+$string['permanentlinktoparentpost'] = 'Permanent link to the parent of this post';
 $string['postisprivatereply'] = 'This post was made privately and is not visible to all users.';
 $string['posttomygroups'] = 'Post a copy to all groups';
 $string['posttomygroups_help'] = 'Posts a copy of this message to all groups you have access to. Participants in groups you do not have access to will not see this post';
index 5599310..9ba956c 100644 (file)
@@ -30,6 +30,7 @@
     }
 }}
 
+<div id="discussion-container-{{uniqid}}">
 {{#html}}
     {{{subscribe}}}
     {{{neighbourlinks}}}
 {{{html.posts}}}
 
 {{#html.neighbourlinks}}{{{.}}}{{/html.neighbourlinks}}
+</div>
+{{#js}}
+require(['jquery', 'mod_forum/discussion'], function($, Discussion) {
+    var root = $('#discussion-container-{{uniqid}}');
+    Discussion.init(root);
+});
+{{/js}}
index ce437f8..aeb91bb 100644 (file)
     {
     }
 }}
-<article id="p{{id}}" class="relativelink mb-2" tabindex="-1" data-post-id="{{id}}">
+<article
+    id="p{{id}}"
+    class="relativelink mb-2"
+    data-post-id="{{id}}"
+    data-region="post"
+    tabindex="-1"
+    aria-labelledby="post-header-{{id}}"
+    aria-describedby="post-content-{{id}}"
+>
 
     <!-- The firstpost and starter classes below aren't used for anything other than to identify the first post in behat -->
     <div
         class="d-flex border p-2 mb-2 forumpost {{#unread}}unread{{/unread}} {{#firstpost}}firstpost starter{{/firstpost}}"
         aria-label='{{#str}} postbyuser, mod_forum, {"post": "{{subject}}", "user": "{{author.fullname}}"} {{/str}}'
     >
-        {{#isfirstunread}}<a id="unread"></a>{{/isfirstunread}}
-        <div style="width: 45px;">
-            {{^isdeleted}}
-                {{#author}}
+        {{#isfirstunread}}<a id="unread" aria-hidden="true"></a>{{/isfirstunread}}
+        {{^isdeleted}}
+            {{#author}}
+                <div style="width: 45px;">
                     {{#urls.profileimage}}
-                        <img class="rounded-circle w-100" src="{{{.}}}">
+                        <img
+                            class="rounded-circle w-100"
+                            src="{{{.}}}"
+                            alt="{{#str}} pictureof, core, {{author.fullname}} {{/str}}"
+                            aria-hidden="true"
+                        >
                     {{/urls.profileimage}}
                     {{#groups}}
-                        <img class="rounded-circle w-100" src="{{{urls.image}}}">
+                        {{#urls.image}}
+                            <img
+                                class="rounded-circle w-100"
+                                src="{{{.}}}"
+                                alt="{{#str}} pictureof, core, {{name}} {{/str}}"
+                            >
+                        {{/urls.image}}
                     {{/groups}}
-                {{/author}}
-            {{/isdeleted}}
-        </div>
+                </div>
+            {{/author}}
+        {{/isdeleted}}
 
         <div class="d-flex flex-column ml-2 w-100">
-            <header class="mb-2 header row">
+            <header id="post-header-{{id}}" class="mb-2 header row">
+                {{#parentauthorname}}
+                    <span class="sr-only">{{#str}} inreplyto, mod_forum, {{.}} {{/str}}</span>
+                {{/parentauthorname}}
                 <h3 class="h6 font-weight-bold mb-0">{{$subject}}{{{subject}}}{{/subject}}</h3>
                 {{^isdeleted}}
-                    <address>
-                        {{{html.authorsubheading}}}
+                    <address tabindex="-1">
+                        {{#html.authorsubheading}}{{{.}}}{{/html.authorsubheading}}
+                        {{^html.authorsubheading}}
+                            <time>
+                                {{#userdate}} {{timecreated}}, {{#str}} strftimedaydatetime, core_langconfig {{/str}} {{/userdate}}
+                            </time>
+                        {{/html.authorsubheading}}
                     </address>
                 {{/isdeleted}}
                 {{#isprivatereply}}
                       {{#str}}postisprivatereply, forum{{/str}}
                     </div>
                 {{/isprivatereply}}
+                {{#hasreplycount}}
+                    <span class="sr-only">{{#str}} numberofreplies, mod_forum, {{replycount}} {{/str}}</span>
+                {{/hasreplycount}}
             </header>
-            <div>
+            <div id="post-content-{{id}}">
                 {{{message}}}
             </div>
 
                 {{#attachments}}
                     {{#isimage}}
                         <div>
-                            <img src="{{{url}}}" alt="{{filename}}" style="max-width: 100%">
+                            <img
+                                src="{{{url}}}"
+                                alt="{{#str}} attachmentname, mod_forum, {{filename}} {{/str}}"
+                                style="max-width: 100%"
+                            >
                             {{#urls.export}}
                                 <a href="{{{.}}}" title="{{#str}} addtoportfolio, core_portfolio {{/str}}">
                                     {{#pix}} t/portfolioadd, core {{/pix}}
                 {{#attachments}}
                     {{^isimage}}
                         <div>
-                            <a href="{{{url}}}">{{#pix}} {{icon}}, core {{/pix}} {{filename}}</a>
+                            <a
+                                href="{{{url}}}"
+                                aria-label="{{#str}} attachmentname, mod_forum, {{filename}} {{/str}}"
+                            >
+                                {{#pix}} {{icon}}, core {{/pix}} {{filename}}
+                            </a>
                             {{#urls.export}}
-                                <a href="{{{.}}}" title="{{#str}} addtoportfolio, core_portfolio {{/str}}">
+                                <a href="{{{.}}}" title="{{#str}} exportattachmentname, mod_forum, {{filename}} {{/str}}">
                                     {{#pix}} t/portfolioadd, core {{/pix}}
                                 </a>
                             {{/urls.export}}
 
                 {{$actions}}
                     {{^readonly}}
-                        <div class="d-flex justify-content-end">
+                        <div
+                            class="d-flex justify-content-end"
+                            data-region="post-actions-container"
+                            role="menubar"
+                            aria-label='{{#str}} postbyuser, mod_forum, {"post": "{{subject}}", "user": "{{author.fullname}}"} {{/str}}'
+                            aria-controls="p{{id}}"
+                        >
                             {{#capabilities}}
                                 {{#view}}
-                                    <a href="{{{urls.view}}}" class="btn btn-link">
+                                    <a
+                                        data-region="post-action"
+                                        href="{{{urls.view}}}"
+                                        class="btn btn-link"
+                                        title="{{#str}} permanentlinktopost, mod_forum {{/str}}"
+                                        role="menuitem"
+                                    >
                                         {{#str}} permalink, mod_forum {{/str}}
                                     </a>
                                 {{/view}}
                                 {{#controlreadstatus}}
                                     {{#unread}}
-                                        <a href="{{{urls.markasread}}}" class="btn btn-link">
+                                        <a
+                                            data-region="post-action"
+                                            href="{{{urls.markasread}}}"
+                                            class="btn btn-link"
+                                            role="menuitem"
+                                        >
                                             {{#str}} markread, mod_forum {{/str}}
                                         </a>
                                     {{/unread}}
                                     {{^unread}}
-                                        <a href="{{{urls.markasunread}}}" class="btn btn-link">
+                                        <a
+                                            data-region="post-action"
+                                            href="{{{urls.markasunread}}}"
+                                            class="btn btn-link"
+                                            role="menuitem"
+                                        >
                                             {{#str}} markunread, mod_forum {{/str}}
                                         </a>
                                     {{/unread}}
                                 {{/controlreadstatus}}
                                 {{#urls.viewparent}}
-                                    <a href="{{{.}}}" class="btn btn-link">
+                                    <a
+                                        data-region="post-action"
+                                        href="{{{.}}}"
+                                        class="btn btn-link"
+                                        title="{{#str}} permanentlinktoparentpost, mod_forum {{/str}}"
+                                        role="menuitem"
+                                    >
                                         {{#str}} parent, mod_forum {{/str}}
                                     </a>
                                 {{/urls.viewparent}}
                                 {{#edit}}
-                                    <a href="{{{urls.edit}}}" class="btn btn-link">
+                                    <a
+                                        data-region="post-action"
+                                        href="{{{urls.edit}}}"
+                                        class="btn btn-link"
+                                        role="menuitem"
+                                    >
                                         {{#str}} edit, mod_forum {{/str}}
                                     </a>
                                 {{/edit}}
                                 {{#split}}
-                                    <a href="{{{urls.split}}}" class="btn btn-link">
+                                    <a
+                                        data-region="post-action"
+                                        href="{{{urls.split}}}"
+                                        class="btn btn-link"
+                                        role="menuitem"
+                                    >
                                         {{#str}} prune, mod_forum {{/str}}
                                     </a>
                                 {{/split}}
                                 {{#delete}}
-                                    <a href="{{{urls.delete}}}" class="btn btn-link">
+                                    <a
+                                        data-region="post-action"
+                                        href="{{{urls.delete}}}"
+                                        class="btn btn-link"
+                                        role="menuitem"
+                                    >
                                         {{#str}} delete, mod_forum {{/str}}
                                     </a>
                                 {{/delete}}
                                 {{#reply}}
                                     {{$replyoutput}}
-                                        <a href="{{{urls.reply}}}" class="btn btn-link">
+                                        <a
+                                            data-region="post-action"
+                                            href="{{{urls.reply}}}"
+                                            class="btn btn-link"
+                                            role="menuitem"
+                                        >
                                             {{#str}} reply, mod_forum {{/str}}
                                         </a>
                                     {{/replyoutput}}
                                 {{/reply}}
                                 {{#export}}
-                                    <a href="{{{urls.export}}}" class="btn btn-link">
+                                    <a
+                                        data-region="post-action"
+                                        href="{{{urls.export}}}"
+                                        class="btn btn-link"
+                                        role="menuitem"
+                                    >
                                         {{#str}} addtoportfolio, core_portfolio {{/str}}
                                     </a>
                                 {{/export}}
index 526d190..82fa189 100644 (file)
     {
     }
 }}
-<div class="mb-2" data-post-id="{{id}}">
+<div
+    class="mb-2"
+    data-post-id="{{id}}"
+    data-region="post"
+    tabindex="-1"
+>
     <a href="{{{urls.viewisolated}}}">{{subject}}</a>
     {{^isdeleted}}
         <address class="d-inline-block mb-0">
index 78c21b0..2f083fa 100644 (file)
@@ -182,24 +182,18 @@ class mod_forum_vaults_post_testcase extends advanced_testcase {
         $post3 = $this->helper_reply_to_post($post1, $user);
         [$discussion2, $post4] = $this->helper_post_to_forum($forum, $user);
 
-        $entities = array_values($this->vault->get_from_discussion_ids($user, [$discussion1->id], false));
-        usort($entities, function($a, $b) {
-            return $a <=> $b;
-        });
+        $entities = $this->vault->get_from_discussion_ids($user, [$discussion1->id], false);
         $this->assertCount(3, $entities);
-        $this->assertEquals($post1->id, $entities[0]->get_id());
-        $this->assertEquals($post2->id, $entities[1]->get_id());
-        $this->assertEquals($post3->id, $entities[2]->get_id());
+        $this->assertArrayHasKey($post1->id, $entities); // Order is not guaranteed, so just verify element existence.
+        $this->assertArrayHasKey($post2->id, $entities);
+        $this->assertArrayHasKey($post3->id, $entities);
 
-        $entities = array_values($this->vault->get_from_discussion_ids($user, [$discussion1->id, $discussion2->id], false));
-        usort($entities, function($a, $b) {
-            return $a <=> $b;
-        });
+        $entities = $this->vault->get_from_discussion_ids($user, [$discussion1->id, $discussion2->id], false);
         $this->assertCount(4, $entities);
-        $this->assertEquals($post1->id, $entities[0]->get_id());
-        $this->assertEquals($post2->id, $entities[1]->get_id());
-        $this->assertEquals($post3->id, $entities[2]->get_id());
-        $this->assertEquals($post4->id, $entities[3]->get_id());
+        $this->assertArrayHasKey($post1->id, $entities); // Order is not guaranteed, so just verify element existence.
+        $this->assertArrayHasKey($post2->id, $entities);
+        $this->assertArrayHasKey($post3->id, $entities);
+        $this->assertArrayHasKey($post4->id, $entities);
     }
 
     /**
@@ -226,33 +220,31 @@ class mod_forum_vaults_post_testcase extends advanced_testcase {
         [$otherdiscussion, $otherpost] = $this->helper_post_to_forum($forum, $teacher);
 
         // The user is the author.
-        $entities = array_values($this->vault->get_from_discussion_ids($teacher, [$discussion->id, $otherdiscussion->id], true));
+        $entities = $this->vault->get_from_discussion_ids($teacher, [$discussion->id, $otherdiscussion->id], true);
         $this->assertCount(3, $entities);
-        $this->assertEquals($post->id, $entities[0]->get_id());
-        $this->assertEquals($reply->id, $entities[1]->get_id());
-        $this->assertEquals($otherpost->id, $entities[2]->get_id());
+        $this->assertArrayHasKey($post->id, $entities); // Order is not guaranteed, so just verify element existence.
+        $this->assertArrayHasKey($reply->id, $entities);
+        $this->assertArrayHasKey($otherpost->id, $entities);
 
         // The user is the intended recipient.
-        $entities = array_values($this->vault->get_from_discussion_ids($student, [$discussion->id, $otherdiscussion->id], false));
+        $entities = $this->vault->get_from_discussion_ids($student, [$discussion->id, $otherdiscussion->id], false);
         $this->assertCount(3, $entities);
-        $this->assertEquals($post->id, $entities[0]->get_id());
-        $this->assertEquals($reply->id, $entities[1]->get_id());
-        $this->assertEquals($otherpost->id, $entities[2]->get_id());
+        $this->assertArrayHasKey($post->id, $entities); // Order is not guaranteed, so just verify element existence.
+        $this->assertArrayHasKey($reply->id, $entities);
+        $this->assertArrayHasKey($otherpost->id, $entities);
 
         // The user is another teacher..
-        $entities = array_values(
-            $this->vault->get_from_discussion_ids($otherteacher, [$discussion->id, $otherdiscussion->id], true));
+        $entities = $this->vault->get_from_discussion_ids($otherteacher, [$discussion->id, $otherdiscussion->id], true);
         $this->assertCount(3, $entities);
-        $this->assertEquals($post->id, $entities[0]->get_id());
-        $this->assertEquals($reply->id, $entities[1]->get_id());
-        $this->assertEquals($otherpost->id, $entities[2]->get_id());
+        $this->assertArrayHasKey($post->id, $entities); // Order is not guaranteed, so just verify element existence.
+        $this->assertArrayHasKey($reply->id, $entities);
+        $this->assertArrayHasKey($otherpost->id, $entities);
 
         // The user is a different student.
-        $entities = array_values(
-            $this->vault->get_from_discussion_ids($otherstudent, [$discussion->id, $otherdiscussion->id], false));
+        $entities = $this->vault->get_from_discussion_ids($otherstudent, [$discussion->id, $otherdiscussion->id], false);
         $this->assertCount(2, $entities);
-        $this->assertEquals($post->id, $entities[0]->get_id());
-        $this->assertEquals($otherpost->id, $entities[1]->get_id());
+        $this->assertArrayHasKey($post->id, $entities); // Order is not guaranteed, so just verify element existence.
+        $this->assertArrayHasKey($otherpost->id, $entities);
     }
 
     /**
index 41da8fe..8c0abbe 100644 (file)
@@ -184,7 +184,7 @@ class repository_webdav extends repository {
         $mform->setType('webdav_port', PARAM_INT);
         $mform->addElement('text', 'webdav_user', get_string('webdav_user', 'repository_webdav'), array('size' => '40'));
         $mform->setType('webdav_user', PARAM_RAW_TRIMMED); // Not for us to clean.
-        $mform->addElement('password', 'webdav_password', get_string('webdav_password', 'repository_webdav'),
+        $mform->addElement('passwordunmask', 'webdav_password', get_string('webdav_password', 'repository_webdav'),
             array('size' => '40'));
     }
     public function supported_returntypes() {
index 5226db1..90030e2 100644 (file)
@@ -11,6 +11,8 @@ $drawer-offscreen-gutter: 20px !default;
     display: block;
     height: ($fixed-header-y + 10px); /* fixed header height*/
     margin-top: -($fixed-header-y + 10px); /* negative fixed header height */
+    width: 1px;
+    pointer-events: none;
 }
 
 .pagelayout-embedded :target {
index d18668f..2ea9cba 100644 (file)
@@ -12551,7 +12551,9 @@ span.editinstructions {
   height: 60px;
   /* fixed header height*/
   margin-top: -60px;
-  /* negative fixed header height */ }
+  /* negative fixed header height */
+  width: 1px;
+  pointer-events: none; }
 
 .pagelayout-embedded :target {
   padding-top: initial;
index e682d12..b0abac7 100644 (file)
@@ -12797,7 +12797,9 @@ span.editinstructions {
   height: 60px;
   /* fixed header height*/
   margin-top: -60px;
-  /* negative fixed header height */ }
+  /* negative fixed header height */
+  width: 1px;
+  pointer-events: none; }
 
 .pagelayout-embedded :target {
   padding-top: initial;
index 9c67dd7..bad8a94 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2019041300.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2019041300.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 
index fedfbc8..3126b60 100644 (file)
@@ -48,6 +48,14 @@ define('WEBSERVICE_AUTHMETHOD_SESSION_TOKEN', 2);
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class webservice {
+    /**
+     * Only update token last access once per this many seconds. (This constant controls update of
+     * the external tokens last access field. There is a similar define LASTACCESS_UPDATE_SECS
+     * which controls update of the web site last access fields.)
+     *
+     * @var int
+     */
+    const TOKEN_LASTACCESS_UPDATE_SECS = 60;
 
     /**
      * Authenticate user (used by download/upload file scripts)
@@ -209,11 +217,32 @@ class webservice {
         }
 
         // log token access
-        $DB->set_field('external_tokens', 'lastaccess', time(), array('id' => $token->id));
+        self::update_token_lastaccess($token);
 
         return array('user' => $user, 'token' => $token, 'service' => $service);
     }
 
+    /**
+     * Updates the last access time for a token.
+     *
+     * @param \stdClass $token Token object (must include id, lastaccess fields)
+     * @param int $time Time of access (0 = use current time)
+     * @throws dml_exception If database error
+     */
+    public static function update_token_lastaccess($token, int $time = 0) {
+        global $DB;
+
+        if (!$time) {
+            $time = time();
+        }
+
+        // Only update the field if it is a different time from previous request,
+        // so as not to waste database effort.
+        if ($time >= $token->lastaccess + self::TOKEN_LASTACCESS_UPDATE_SECS) {
+            $DB->set_field('external_tokens', 'lastaccess', $time, array('id' => $token->id));
+        }
+    }
+
     /**
      * Allow user to call a service
      *
@@ -1109,7 +1138,7 @@ abstract class webservice_server implements webservice_server_interface {
         $user = $DB->get_record('user', array('id'=>$token->userid), '*', MUST_EXIST);
 
         // log token access
-        $DB->set_field('external_tokens', 'lastaccess', time(), array('id'=>$token->id));
+        webservice::update_token_lastaccess($token);
 
         return $user;
 
index 5fd8680..59d741c 100644 (file)
@@ -136,6 +136,63 @@ class webservice_test extends advanced_testcase {
         }
     }
 
+    /**
+     * Tests update_token_lastaccess() function.
+     *
+     * @throws dml_exception
+     */
+    public function test_update_token_lastaccess() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        // Set current user.
+        $this->setAdminUser();
+
+        // Add a web service.
+        $webservice = new stdClass();
+        $webservice->name = 'Test web service';
+        $webservice->enabled = true;
+        $webservice->restrictedusers = false;
+        $webservice->component = 'moodle';
+        $webservice->timecreated = time();
+        $webservice->downloadfiles = true;
+        $webservice->uploadfiles = true;
+        $DB->insert_record('external_services', $webservice);
+
+        // Add token.
+        $tokenstr = external_create_service_token($webservice->name, context_system::instance()->id);
+        $token = $DB->get_record('external_tokens', ['token' => $tokenstr]);
+
+        // Trigger last access once (at current time).
+        webservice::update_token_lastaccess($token);
+
+        // Check last access.
+        $token = $DB->get_record('external_tokens', ['token' => $tokenstr]);
+        $this->assertLessThan(5, abs(time() - $token->lastaccess));
+
+        // Try setting it to +1 second. This should not update yet.
+        $before = (int)$token->lastaccess;
+        webservice::update_token_lastaccess($token, $before + 1);
+        $token = $DB->get_record('external_tokens', ['token' => $tokenstr]);
+        $this->assertEquals($before, $token->lastaccess);
+
+        // To -1000 seconds. This should not update.
+        webservice::update_token_lastaccess($token, $before - 1000);
+        $token = $DB->get_record('external_tokens', ['token' => $tokenstr]);
+        $this->assertEquals($before, $token->lastaccess);
+
+        // To +59 seconds. This should also not quite update.
+        webservice::update_token_lastaccess($token, $before + 59);
+        $token = $DB->get_record('external_tokens', ['token' => $tokenstr]);
+        $this->assertEquals($before, $token->lastaccess);
+
+        // Finally to +60 seconds, where it should update.
+        webservice::update_token_lastaccess($token, $before + 60);
+        $token = $DB->get_record('external_tokens', ['token' => $tokenstr]);
+        $this->assertEquals($before + 60, $token->lastaccess);
+    }
+
     /**
      * Utility method that tests the parameter type of a method info's input/output parameter.
      *