Merge branch 'MDL-59393-master-2' of git://github.com/ryanwyllie/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 23 Aug 2017 01:44:01 +0000 (09:44 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 23 Aug 2017 01:44:01 +0000 (09:44 +0800)
146 files changed:
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/analytics/templates/models_list.mustache
analytics/classes/calculable.php
analytics/classes/dataset_manager.php
analytics/classes/local/analyser/base.php
analytics/classes/local/analyser/by_course.php
analytics/classes/local/analyser/sitewide.php
analytics/classes/local/indicator/base.php
analytics/classes/local/time_splitting/base.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/tests/dataset_manager_test.php [new file with mode: 0644]
analytics/tests/fixtures/test_indicator_fullname.php
analytics/tests/fixtures/test_indicator_max.php
analytics/tests/fixtures/test_indicator_min.php
analytics/tests/fixtures/test_indicator_random.php
analytics/tests/fixtures/test_static_target_shortname.php
analytics/tests/fixtures/test_target_shortname.php
analytics/tests/prediction_test.php
badges/criteria/award_criteria_activity.php
badges/criteria/award_criteria_course.php
calendar/amd/build/view_manager.min.js
calendar/amd/src/view_manager.js
calendar/classes/external/event_exporter.php
calendar/templates/event_summary_body.mustache
calendar/tests/behat/behat_calendar.php
calendar/tests/behat/calendar.feature
course/classes/analytics/indicator/no_teacher.php
course/renderer.php
enrol/manual/amd/build/quickenrolment.min.js
enrol/manual/amd/src/quickenrolment.js
lang/en/moodle.php
lib/amd/build/addblockmodal.min.js
lib/amd/build/form-autocomplete.min.js
lib/amd/build/modal.min.js
lib/amd/build/modal_factory.min.js
lib/amd/build/modal_save_cancel.min.js
lib/amd/src/addblockmodal.js
lib/amd/src/form-autocomplete.js
lib/amd/src/modal.js
lib/amd/src/modal_factory.js
lib/amd/src/modal_save_cancel.js
lib/classes/analytics/indicator/any_access_after_end.php
lib/classes/analytics/indicator/any_access_before_start.php
lib/classes/analytics/indicator/any_write_action.php
lib/classes/analytics/indicator/read_actions.php
lib/classes/analytics/target/course_dropout.php
lib/classes/analytics/target/no_teaching.php
lib/classes/analytics/time_splitting/deciles.php
lib/classes/analytics/time_splitting/deciles_accum.php
lib/classes/analytics/time_splitting/no_splitting.php
lib/classes/analytics/time_splitting/quarters.php
lib/classes/analytics/time_splitting/quarters_accum.php
lib/classes/analytics/time_splitting/single_range.php
lib/db/install.xml
lib/db/upgrade.php
lib/editor/atto/plugins/managefiles/lang/en/atto_managefiles.php
lib/editor/atto/plugins/managefiles/lib.php
lib/editor/atto/plugins/managefiles/manage.php
lib/editor/atto/plugins/managefiles/manage_form.php
lib/filelib.php
lib/form/editor.php
lib/moodlelib.php
lib/outputrenderers.php
lib/tests/filelib_test.php
lib/tests/indicators_test.php
login/confirm.php
mnet/lib.php
mod/assign/classes/analytics/indicator/cognitive_depth.php
mod/assign/classes/analytics/indicator/social_breadth.php
mod/assign/lang/en/assign.php
mod/assign/locallib.php
mod/assign/submission/file/tests/behat/file_type_restriction.feature
mod/assign/submission/onlinetext/locallib.php
mod/book/classes/analytics/indicator/cognitive_depth.php
mod/book/classes/analytics/indicator/social_breadth.php
mod/book/lang/en/book.php
mod/chat/classes/analytics/indicator/cognitive_depth.php
mod/chat/classes/analytics/indicator/social_breadth.php
mod/chat/lang/en/chat.php
mod/choice/classes/analytics/indicator/cognitive_depth.php
mod/choice/classes/analytics/indicator/social_breadth.php
mod/choice/lang/en/choice.php
mod/data/classes/analytics/indicator/cognitive_depth.php
mod/data/classes/analytics/indicator/social_breadth.php
mod/data/lang/en/data.php
mod/feedback/classes/analytics/indicator/cognitive_depth.php
mod/feedback/classes/analytics/indicator/social_breadth.php
mod/feedback/lang/en/feedback.php
mod/folder/classes/analytics/indicator/cognitive_depth.php
mod/folder/classes/analytics/indicator/social_breadth.php
mod/folder/lang/en/folder.php
mod/forum/classes/analytics/indicator/cognitive_depth.php
mod/forum/classes/analytics/indicator/social_breadth.php
mod/forum/lang/en/forum.php
mod/forum/view.php
mod/glossary/classes/analytics/indicator/cognitive_depth.php
mod/glossary/classes/analytics/indicator/social_breadth.php
mod/glossary/lang/en/glossary.php
mod/imscp/classes/analytics/indicator/cognitive_depth.php
mod/imscp/classes/analytics/indicator/social_breadth.php
mod/imscp/lang/en/imscp.php
mod/label/classes/analytics/indicator/cognitive_depth.php
mod/label/classes/analytics/indicator/social_breadth.php
mod/label/lang/en/label.php
mod/lesson/classes/analytics/indicator/cognitive_depth.php
mod/lesson/classes/analytics/indicator/social_breadth.php
mod/lesson/lang/en/lesson.php
mod/lesson/locallib.php
mod/lti/classes/analytics/indicator/cognitive_depth.php
mod/lti/classes/analytics/indicator/social_breadth.php
mod/lti/lang/en/lti.php
mod/page/classes/analytics/indicator/cognitive_depth.php
mod/page/classes/analytics/indicator/social_breadth.php
mod/page/lang/en/page.php
mod/quiz/classes/analytics/indicator/cognitive_depth.php
mod/quiz/classes/analytics/indicator/social_breadth.php
mod/quiz/lang/en/quiz.php
mod/resource/classes/analytics/indicator/cognitive_depth.php
mod/resource/classes/analytics/indicator/social_breadth.php
mod/resource/lang/en/resource.php
mod/scorm/classes/analytics/indicator/cognitive_depth.php
mod/scorm/classes/analytics/indicator/social_breadth.php
mod/scorm/lang/en/scorm.php
mod/survey/classes/analytics/indicator/cognitive_depth.php
mod/survey/classes/analytics/indicator/social_breadth.php
mod/survey/lang/en/survey.php
mod/url/classes/analytics/indicator/cognitive_depth.php
mod/url/classes/analytics/indicator/social_breadth.php
mod/url/lang/en/url.php
mod/wiki/classes/analytics/indicator/cognitive_depth.php
mod/wiki/classes/analytics/indicator/social_breadth.php
mod/wiki/lang/en/wiki.php
mod/workshop/classes/analytics/indicator/cognitive_depth.php
mod/workshop/classes/analytics/indicator/social_breadth.php
mod/workshop/lang/en/workshop.php
theme/boost/scss/moodle/undo.scss
user/classes/analytics/indicator/user_profile_set.php
user/classes/analytics/indicator/user_track_forums.php
user/lib.php
user/tests/userlib_test.php
version.php
webservice/xmlrpc/lib.php
webservice/xmlrpc/tests/lib_test.php

index 36f1feb..c068b18 100644 (file)
@@ -66,6 +66,54 @@ class models_list implements \renderable, \templatable {
         foreach ($this->models as $model) {
             $modeldata = $model->export();
 
+            // Check if there is a help icon for the target to show.
+            $identifier = $modeldata->target->get_identifier();
+            $component = $modeldata->target->get_component();
+            if (get_string_manager()->string_exists($identifier . '_help', $component)) {
+                $helpicon = new \help_icon($identifier, $component);
+                $modeldata->targethelp = $helpicon->export_for_template($output);
+            } else {
+                // We really want to encourage developers to add help to their targets.
+                debugging("The target '{$modeldata->target}' should include a '{$identifier}_help' string to
+                    describe its purpose.", DEBUG_DEVELOPER);
+            }
+
+            // Check if there is a help icon for the indicators to show.
+            if (!empty($modeldata->indicators)) {
+                $indicators = array();
+                foreach ($modeldata->indicators as $ind) {
+                    // Create the indicator with the details we want for the context.
+                    $indicator = new \stdClass();
+                    $indicator->name = $ind->out();
+                    $identifier = $ind->get_identifier();
+                    $component = $ind->get_component();
+                    if (get_string_manager()->string_exists($identifier . '_help', $component)) {
+                        $helpicon = new \help_icon($identifier, $component);
+                        $indicator->help = $helpicon->export_for_template($output);
+                    } else {
+                        // We really want to encourage developers to add help to their indicators.
+                        debugging("The indicator '{$ind}' should include a '{$identifier}_help' string to
+                            describe its purpose.", DEBUG_DEVELOPER);
+                    }
+                    $indicators[] = $indicator;
+                }
+                $modeldata->indicators = $indicators;
+            }
+
+            // Check if there is a help icon for the time splitting method.
+            if (!empty($modeldata->timesplitting)) {
+                $identifier = $modeldata->timesplitting->get_identifier();
+                $component = $modeldata->timesplitting->get_component();
+                if (get_string_manager()->string_exists($identifier . '_help', $component)) {
+                    $helpicon = new \help_icon($identifier, $component);
+                    $modeldata->timesplittinghelp = $helpicon->export_for_template($output);
+                } else {
+                    // We really want to encourage developers to add help to their time splitting methods.
+                    debugging("The time splitting method '{$modeldata->timesplitting}' should include a '{$identifier}_help'
+                        string to describe its purpose.", DEBUG_DEVELOPER);
+                }
+            }
+
             // Model predictions list.
             if ($model->uses_insights()) {
                 $predictioncontexts = $model->get_predictions_contexts();
@@ -139,6 +187,7 @@ class models_list implements \renderable, \templatable {
                 $actionsmenu->add($icon);
             }
 
+            // Get predictions.
             if ($modeldata->enabled && !empty($modeldata->timesplitting)) {
                 $url = new \moodle_url('model.php', array('action' => 'getpredictions', 'id' => $model->get_id()));
                 $icon = new \action_menu_link_secondary($url, new \pix_icon('i/notifications',
@@ -154,6 +203,14 @@ class models_list implements \renderable, \templatable {
                 $actionsmenu->add($icon);
             }
 
+            // Export training data.
+            if (!$model->is_static() && $model->is_trained()) {
+                $url = new \moodle_url('model.php', array('action' => 'export', 'id' => $model->get_id()));
+                $icon = new \action_menu_link_secondary($url, new \pix_icon('i/export',
+                    get_string('exporttrainingdata', 'tool_analytics')), get_string('export', 'tool_analytics'));
+                $actionsmenu->add($icon);
+            }
+
             $modeldata->actions = $actionsmenu->export_for_template($output);
 
             $data->models[] = $modeldata;
index eba2a8f..4922a51 100644 (file)
@@ -36,13 +36,16 @@ $string['enabled'] = 'Enabled';
 $string['errorcantenablenotimesplitting'] = 'You need to select a time splitting method before enabling the model';
 $string['errornoenabledandtrainedmodels'] = 'There are not enabled and trained models to predict';
 $string['errornoenabledmodels'] = 'There are not enabled models to train';
+$string['errornoexport'] = 'Only trained models can be exported';
 $string['errornostaticedit'] = 'Models based on assumptions can not be edited';
 $string['errornostaticevaluated'] = 'Models based on assumptions can not be evaluated, they are always 100% correct according to how they were defined';
 $string['errornostaticlog'] = 'Models based on assumptions can not be evaluated, there is no preformance log';
+$string['errortrainingdataexport'] = 'The model training data could not be exported';
 $string['evaluate'] = 'Evaluate';
 $string['evaluatemodel'] = 'Evaluate model';
 $string['evaluationinbatches'] = 'The site contents are calculated and stored in batches, during evaluation you can stop the process at any moment, the next time you run it it will continue from the point you stopped it.';
-$string['trainandpredictmodel'] = 'Training model and calculating predictions';
+$string['export'] = 'Export';
+$string['exporttrainingdata'] = 'Export training data';
 $string['getpredictionsresultscli'] = 'Results using {$a->name} (id: {$a->id}) course duration splitting';
 $string['getpredictionsresults'] = 'Results using {$a->name} course duration splitting';
 $string['extrainfo'] = 'Info';
@@ -68,6 +71,7 @@ $string['predictionprocessfinished'] = 'Prediction process finished';
 $string['samestartdate'] = 'Current start date is good';
 $string['sameenddate'] = 'Current end date is good';
 $string['target'] = 'Target';
+$string['trainandpredictmodel'] = 'Training model and calculating predictions';
 $string['trainingprocessfinished'] = 'Training process finished';
 $string['trainingresults'] = 'Training results';
 $string['trainmodels'] = 'Train models';
index 9ef3c3d..daf62cf 100644 (file)
@@ -57,6 +57,9 @@ switch ($action) {
     case 'disable':
         $title = get_string('disable');
         break;
+    case 'export':
+        $title = get_string('export', 'tool_analytics');
+        break;
 
     default:
         throw new moodle_exception('errorunknownaction', 'analytics');
@@ -162,6 +165,22 @@ switch ($action) {
         $modellogstable = new \tool_analytics\output\model_logs('model-' . $model->get_id(), $model);
         echo $renderer->render_table($modellogstable);
         break;
+
+    case 'export':
+
+        if ($model->is_static() || !$model->is_trained()) {
+            throw new moodle_exception('errornoexport', 'tool_analytics');
+        }
+
+        $file = $model->get_training_data();
+        if (!$file) {
+            redirect(new \moodle_url('/admin/tool/analytics/index.php'), get_string('errortrainingdataexport', 'tool_analytics'),
+                null, \core\output\notification::NOTIFY_ERROR);
+        }
+
+        $filename = 'training-data.' . $model->get_id() . '.' . time() . '.csv';
+        send_file($file, $filename, null, 0, false, true);
+        break;
 }
 
 echo $OUTPUT->footer();
index f3d4fc8..49e4a91 100644 (file)
         "models": [
             {
                 "target": "Prevent devs at risk",
-                "enabled": 1,
-                "indicators": [
-                    "Indicator 1",
-                    "Indicator 2",
-                    "Indicator 3",
-                    "Indicator 4"
+                "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"}
+                            ]
+                        }
+                    }
                 ],
+                "enabled": 1,
+                "indicators": [{
+                    "name": "Indicator 1",
+                    "help": [{
+                            "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"}
+                                ]
+                            }
+                        }]
+                    },
+                    {
+                    "name": "Indicator 2",
+                    "help": [{
+                            "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": [
+                    {
+                        "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"}
+                            ]
+                        }
+                    }
+                ],
                 "noinsights": "No insights available yet"
             }
         ],
         <tbody>
         {{#models}}
             <tr>
-                <td>{{target}}</td>
+                <td>
+                    {{target}}
+                    {{#targethelp}}
+                        {{>core/help_icon}}
+                    {{/targethelp}}
+                </td>
                 <td>
                     {{#enabled}}
                         {{#pix}}i/checked, core, {{#str}}yes{{/str}}{{/pix}}
                 <td>
                     <ul>
                     {{#indicators}}
-                        <li>{{.}}</li>
+                        <li>
+                            {{name}}
+                            {{#help}}
+                                {{>core/help_icon}}
+                            {{/help}}
+                        </li>
                     {{/indicators}}
                     </ul>
                 </td>
                 <td>
-                    {{#timesplitting}}{{timesplitting}}{{/timesplitting}}{{^timesplitting}}{{#str}}notdefined, tool_analytics{{/str}}{{/timesplitting}}
+                    {{#timesplitting}}
+                        {{timesplitting}}
+                        {{#timesplittinghelp}}
+                            {{>core/help_icon}}
+                        {{/timesplittinghelp}}
+                    {{/timesplitting}}
+                    {{^timesplitting}}
+                        {{#str}}notdefined, tool_analytics{{/str}}
+                    {{/timesplitting}}
                 </td>
                 <td>
                     {{! models_list renderer is responsible of sending one or the other}}
index a3e3720..63ea392 100644 (file)
@@ -66,17 +66,15 @@ abstract class calculable {
     protected $sampledata = array();
 
     /**
-     * Returns a visible name for the indicator.
+     * Returns a lang_string object representing the name for the indicator or target.
      *
      * Used as column identificator.
      *
-     * Defaults to the indicator class name.
+     * If there is a corresponding '_help' string this will be shown as well.
      *
-     * @return string
+     * @return \lang_string
      */
-    public static function get_name() {
-        return '\\' . get_called_class();
-    }
+    public static abstract function get_name() : \lang_string;
 
     /**
      * The class id is the calculable class full qualified class name.
index a1438ea..4b457e8 100644 (file)
@@ -45,6 +45,11 @@ class dataset_manager {
      */
     const UNLABELLED_FILEAREA = 'unlabelled';
 
+    /**
+     * File area for exported datasets.
+     */
+    const EXPORT_FILEAREA = 'export';
+
     /**
      * Evaluation file file name.
      */
@@ -77,28 +82,35 @@ class dataset_manager {
     protected $evaluation;
 
     /**
-     * Labelled (true) or unlabelled data (false).
+     * The dataset filearea. Must be one of the self::*_FILEAREA options.
      *
-     * @var bool
+     * @var string
      */
-    protected $includetarget;
+    protected $filearea;
 
     /**
      * Constructor method.
      *
+     * @throws \coding_exception
      * @param int $modelid
      * @param int $analysableid
      * @param string $timesplittingid
+     * @param string $filearea
      * @param bool $evaluation
-     * @param bool $includetarget
      * @return void
      */
-    public function __construct($modelid, $analysableid, $timesplittingid, $evaluation = false, $includetarget = false) {
+    public function __construct($modelid, $analysableid, $timesplittingid, $filearea, $evaluation = false) {
+
+        if ($filearea !== self::EXPORT_FILEAREA && $filearea !== self::LABELLED_FILEAREA &&
+                $filearea !== self::UNLABELLED_FILEAREA) {
+            throw new \coding_exception('Invalid provided filearea');
+        }
+
         $this->modelid = $modelid;
         $this->analysableid = $analysableid;
         $this->timesplittingid = $timesplittingid;
         $this->evaluation = $evaluation;
-        $this->includetarget = $includetarget;
+        $this->filearea = $filearea;
     }
 
     /**
@@ -107,9 +119,11 @@ class dataset_manager {
      * @return bool Could we get the lock or not.
      */
     public function init_process() {
+
+        // Do not include $this->includetarget as we don't want the same analysable to be analysed for training
+        // and prediction at the same time.
         $lockkey = 'modelid:' . $this->modelid . '-analysableid:' . $this->analysableid .
-            '-timesplitting:' . self::clean_time_splitting_id($this->timesplittingid) .
-            '-includetarget:' . (int)$this->includetarget;
+            '-timesplitting:' . self::clean_time_splitting_id($this->timesplittingid);
 
         // Large timeout as processes may be quite long.
         $lockfactory = \core\lock\lock_config::get_lock_factory('core_analytics');
@@ -132,9 +146,10 @@ class dataset_manager {
 
         // Delete previous file if it exists.
         $fs = get_file_storage();
+
         $filerecord = [
             'component' => 'analytics',
-            'filearea' => self::get_filearea($this->includetarget),
+            'filearea' => $this->filearea,
             'itemid' => $this->modelid,
             'contextid' => \context_system::instance()->id,
             'filepath' => '/analysable/' . $this->analysableid . '/' . self::clean_time_splitting_id($this->timesplittingid) . '/',
@@ -217,7 +232,7 @@ class dataset_manager {
         $fs = get_file_storage();
 
         // Always evaluation.csv and labelled as it is an evaluation file.
-        $filearea = self::get_filearea(true);
+        $filearea = self::LABELLED_FILEAREA;
         $filename = self::get_filename(true);
         $filepath = '/analysable/' . $analysableid . '/' . self::clean_time_splitting_id($timesplittingid) . '/';
         return $fs->get_file(\context_system::instance()->id, 'analytics', $filearea, $modelid, $filepath, $filename);
@@ -231,11 +246,11 @@ class dataset_manager {
      * @param array  $files
      * @param int    $modelid
      * @param string $timesplittingid
+     * @param string $filearea
      * @param bool   $evaluation
-     * @param bool   $includetarget
      * @return \stored_file
      */
-    public static function merge_datasets(array $files, $modelid, $timesplittingid, $evaluation, $includetarget) {
+    public static function merge_datasets(array $files, $modelid, $timesplittingid, $filearea, $evaluation = false) {
 
         $tmpfilepath = make_request_directory() . DIRECTORY_SEPARATOR . 'tmpfile.csv';
 
@@ -297,7 +312,7 @@ class dataset_manager {
 
         $filerecord = [
             'component' => 'analytics',
-            'filearea' => self::get_filearea($includetarget),
+            'filearea' => $filearea,
             'itemid' => $modelid,
             'contextid' => \context_system::instance()->id,
             'filepath' => '/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/',
@@ -309,6 +324,37 @@ class dataset_manager {
         return $fs->create_file_from_pathname($filerecord, $tmpfilepath);
     }
 
+    /**
+     * Exports the model training data.
+     *
+     * @param int $modelid
+     * @param string $timesplittingid
+     * @return \stored_file|false
+     */
+    public static function export_training_data($modelid, $timesplittingid) {
+
+        $fs = get_file_storage();
+
+        $contextid = \context_system::instance()->id;
+        $filepath = '/timesplitting/' . self::clean_time_splitting_id($timesplittingid) . '/';
+
+        $files = $fs->get_directory_files($contextid, 'analytics', self::LABELLED_FILEAREA, $modelid,
+            $filepath, true, false);
+
+        // Discard evaluation files.
+        foreach ($files as $key => $file) {
+            if ($file->get_filename() === self::EVALUATION_FILENAME) {
+                unset($files[$key]);
+            }
+        }
+
+        if (empty($files)) {
+            return false;
+        }
+
+        return self::merge_datasets($files, $modelid, $timesplittingid, self::EXPORT_FILEAREA);
+    }
+
     /**
      * Returns the dataset file data structured by sampleids using the indicators and target column names.
      *
@@ -387,27 +433,9 @@ class dataset_manager {
             $filename = self::EVALUATION_FILENAME;
         } else {
             // Incremental time, the lock will make sure we don't have concurrency problems.
-            $filename = microtime(false) . '.csv';
+            $filename = microtime(true) . '.csv';
         }
 
         return $filename;
     }
-
-    /**
-     * Returns the file area to be used.
-     *
-     * @param bool $includetarget
-     * @return string
-     */
-    protected static function get_filearea($includetarget) {
-
-        if ($includetarget === true) {
-            $filearea = self::LABELLED_FILEAREA;
-        } else {
-            $filearea = self::UNLABELLED_FILEAREA;
-        }
-
-        return $filearea;
-    }
-
 }
index f0136f7..9bf10c8 100644 (file)
@@ -47,6 +47,13 @@ abstract class base {
      */
     protected $target;
 
+    /**
+     * A $this->$target copy loaded with the ongoing analysis analysable.
+     *
+     * @var \core_analytics\local\target\base
+     */
+    protected $analysabletarget;
+
     /**
      * The model indicators.
      *
@@ -255,11 +262,11 @@ abstract class base {
 
         // Target instances scope is per-analysable (it can't be lower as calculations run once per
         // analysable, not time splitting method nor time range).
-        $target = call_user_func(array($this->target, 'instance'));
+        $this->analysabletarget = call_user_func(array($this->target, 'instance'));
 
         // We need to check that the analysable is valid for the target even if we don't include targets
         // as we still need to discard invalid analysables for the target.
-        $result = $target->is_valid_analysable($analysable, $includetarget);
+        $result = $this->analysabletarget->is_valid_analysable($analysable, $includetarget);
         if ($result !== true) {
             $a = new \stdClass();
             $a->analysableid = $analysable->get_id();
@@ -291,11 +298,7 @@ abstract class base {
                 }
             }
 
-            if ($includetarget) {
-                $result = $this->process_time_splitting($timesplitting, $analysable, $target);
-            } else {
-                $result = $this->process_time_splitting($timesplitting, $analysable);
-            }
+            $result = $this->process_time_splitting($timesplitting, $analysable, $includetarget);
 
             if (!empty($result->file)) {
                 $files[$timesplitting->get_id()] = $result->file;
@@ -342,10 +345,10 @@ abstract class base {
      *
      * @param \core_analytics\local\time_splitting\base $timesplitting
      * @param \core_analytics\analysable $analysable
-     * @param \core_analytics\local\target\base|false $target
+     * @param bool $includetarget
      * @return \stdClass Results object.
      */
-    protected function process_time_splitting($timesplitting, $analysable, $target = false) {
+    protected function process_time_splitting($timesplitting, $analysable, $includetarget = false) {
 
         $result = new \stdClass();
 
@@ -372,7 +375,7 @@ abstract class base {
             return $result;
         }
 
-        if ($target) {
+        if ($includetarget) {
             // All ranges are used when we are calculating data for training.
             $ranges = $timesplitting->get_all_ranges();
         } else {
@@ -399,7 +402,7 @@ abstract class base {
             }
 
             // Only when processing data for predictions.
-            if ($target === false) {
+            if (!$includetarget) {
                 // We also filter out samples and ranges that have already been used for predictions.
                 $this->filter_out_prediction_samples_and_ranges($sampleids, $ranges, $timesplitting);
             }
@@ -417,8 +420,13 @@ abstract class base {
             }
         }
 
+        if (!empty($target)) {
+            $filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
+        } else {
+            $filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
+        }
         $dataset = new \core_analytics\dataset_manager($this->modelid, $analysable->get_id(), $timesplitting->get_id(),
-            $this->options['evaluation'], !empty($target));
+            $filearea, $this->options['evaluation']);
 
         // Flag the model + analysable + timesplitting as being analysed (prevent concurrent executions).
         if (!$dataset->init_process()) {
@@ -428,10 +436,9 @@ abstract class base {
             return $result;
         }
 
-        // Remove samples the target consider invalid. Note that we use $this->target, $target will be false
-        // during prediction, but we still need to discard samples the target considers invalid.
-        $this->target->add_sample_data($samplesdata);
-        $this->target->filter_out_invalid_samples($sampleids, $analysable, $target);
+        // Remove samples the target consider invalid.
+        $this->analysabletarget->add_sample_data($samplesdata);
+        $this->analysabletarget->filter_out_invalid_samples($sampleids, $analysable, $includetarget);
 
         if (!$sampleids) {
             $result->status = \core_analytics\model::NO_DATASET;
@@ -445,15 +452,15 @@ abstract class base {
             // indicator to calculate the sample.
             $this->indicators[$key]->add_sample_data($samplesdata);
         }
-        // Provide samples to the target instance (different than $this->target) $target is the new instance we get
-        // for each analysis in progress.
-        if ($target) {
-            $target->add_sample_data($samplesdata);
-        }
 
         // Here we start the memory intensive process that will last until $data var is
         // unset (until the method is finished basically).
-        $data = $timesplitting->calculate($sampleids, $this->get_samples_origin(), $this->indicators, $ranges, $target);
+        if ($includetarget) {
+            $data = $timesplitting->calculate($sampleids, $this->get_samples_origin(), $this->indicators, $ranges,
+                $this->analysabletarget);
+        } else {
+            $data = $timesplitting->calculate($sampleids, $this->get_samples_origin(), $this->indicators, $ranges);
+        }
 
         if (!$data) {
             $result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
@@ -472,7 +479,7 @@ abstract class base {
         if ($this->options['evaluation'] === false) {
             // Save the samples that have been already analysed so they are not analysed again in future.
 
-            if ($target) {
+            if ($includetarget) {
                 $this->save_train_samples($sampleids, $timesplitting, $file);
             } else {
                 $this->save_prediction_samples($sampleids, $ranges, $timesplitting);
index 424270e..e531bba 100644 (file)
@@ -111,8 +111,13 @@ abstract class by_course extends base {
             }
 
             // Merge all course files into one.
+            if ($includetarget) {
+                $filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
+            } else {
+                $filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
+            }
             $timesplittingfiles[$timesplittingid] = \core_analytics\dataset_manager::merge_datasets($files,
-                $this->modelid, $timesplittingid, $this->options['evaluation'], $includetarget);
+                $this->modelid, $timesplittingid, $filearea, $this->options['evaluation']);
         }
 
         return $timesplittingfiles;
index 1f21aca..9c6fb47 100644 (file)
@@ -57,8 +57,13 @@ abstract class sitewide extends base {
             }
 
             // We use merge but it is just a copy.
+            if ($includetarget) {
+                $filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
+            } else {
+                $filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
+            }
             $files[$timesplittingid] = \core_analytics\dataset_manager::merge_datasets(array($file), $this->modelid,
-                $timesplittingid, $this->options['evaluation'], $includetarget);
+                $timesplittingid, $filearea, $this->options['evaluation']);
         }
 
         return $files;
index 310dc55..30fdf38 100644 (file)
@@ -130,18 +130,25 @@ abstract class base extends \core_analytics\calculable {
      * @param string $samplesorigin
      * @param integer $starttime Limit the calculation to this timestart
      * @param integer $endtime Limit the calculation to this timeend
-     * @return array The format to follow is [userid] = int|float[]
+     * @param array $existingcalculations Existing calculations of this indicator, indexed by sampleid.
+     * @return array array[0] with format [sampleid] = int[]|float[], array[1] with format [sampleid] = int|float
      */
-    public function calculate($sampleids, $samplesorigin, $starttime = false, $endtime = false) {
+    public function calculate($sampleids, $samplesorigin, $starttime = false, $endtime = false, $existingcalculations = array()) {
 
         if (!PHPUNIT_TEST && CLI_SCRIPT) {
             echo '.';
         }
 
         $calculations = array();
+        $newcalculations = array();
         foreach ($sampleids as $sampleid => $unusedsampleid) {
 
-            $calculatedvalue = $this->calculate_sample($sampleid, $samplesorigin, $starttime, $endtime);
+            if (isset($existingcalculations[$sampleid])) {
+                $calculatedvalue = $existingcalculations[$sampleid];
+            } else {
+                $calculatedvalue = $this->calculate_sample($sampleid, $samplesorigin, $starttime, $endtime);
+                $newcalculations[$sampleid] = $calculatedvalue;
+            }
 
             if (!is_null($calculatedvalue) && ($calculatedvalue > self::MAX_VALUE || $calculatedvalue < self::MIN_VALUE)) {
                 throw new \coding_exception('Calculated values should be higher than ' . self::MIN_VALUE .
@@ -151,8 +158,8 @@ abstract class base extends \core_analytics\calculable {
             $calculations[$sampleid] = $calculatedvalue;
         }
 
-        $calculations = $this->to_features($calculations);
+        $features = $this->to_features($calculations);
 
-        return $calculations;
+        return array($features, $newcalculations);
     }
 }
index 23adfb1..109084e 100644 (file)
@@ -66,6 +66,11 @@ abstract class base {
      */
     protected static $indicators = [];
 
+    /**
+     * @var bool
+     */
+    protected $evaluation = false;
+
     /**
      * Define the time splitting methods ranges.
      *
@@ -77,14 +82,15 @@ abstract class base {
     abstract protected function define_ranges();
 
     /**
-     * The time splitting method name.
+     * Returns a lang_string object representing the name for the time splitting method.
      *
-     * It is very recommendable to overwrite this method as this name appears in the UI.
-     * @return string
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public function get_name() {
-        return $this->get_id();
-    }
+    public static abstract function get_name() : \lang_string;
 
     /**
      * Returns the time splitting method id.
@@ -95,6 +101,24 @@ abstract class base {
         return '\\' . get_class($this);
     }
 
+    /**
+     * Returns current evaluation value.
+     *
+     * @return bool
+     */
+    public function is_evaluating() {
+        return $this->evaluation;
+    }
+
+    /**
+     * Sets the evaluation flag.
+     *
+     * @param bool $evaluation
+     */
+    public function set_evaluating($evaluation) {
+        $this->evaluation = (bool)$evaluation;
+    }
+
     /**
      * Assigns the analysable and updates the time ranges according to the analysable start and end dates.
      *
@@ -195,10 +219,20 @@ abstract class base {
      * @return array
      */
     protected function calculate_indicators($sampleids, $samplesorigin, $indicators, $ranges) {
+        global $DB;
 
         $dataset = array();
 
+        // Faster to run 1 db query per range.
+        $existingcalculations = array();
+        foreach ($ranges as $rangeindex => $range) {
+            // Load existing calculations.
+            $existingcalculations[$rangeindex] = \core_analytics\manager::get_indicator_calculations($this->analysable,
+                $range['start'], $range['end'], $samplesorigin);
+        }
+
         // Fill the dataset samples with indicators data.
+        $newcalculations = array();
         foreach ($indicators as $indicator) {
 
             // Per-range calculations.
@@ -207,11 +241,17 @@ abstract class base {
                 // Indicator instances are per-range.
                 $rangeindicator = clone $indicator;
 
+                $prevcalculations = array();
+                if (!empty($existingcalculations[$rangeindex][$rangeindicator->get_id()])) {
+                    $prevcalculations = $existingcalculations[$rangeindex][$rangeindicator->get_id()];
+                }
+
                 // Calculate the indicator for each sample in this time range.
-                $calculated = $rangeindicator->calculate($sampleids, $samplesorigin, $range['start'], $range['end']);
+                list($samplesfeatures, $newindicatorcalculations) = $rangeindicator->calculate($sampleids,
+                    $samplesorigin, $range['start'], $range['end'], $prevcalculations);
 
-                // Copy the calculated data to the dataset.
-                foreach ($calculated as $analysersampleid => $calculatedvalues) {
+                // Copy the features data to the dataset.
+                foreach ($samplesfeatures as $analysersampleid => $features) {
 
                     $uniquesampleid = $this->append_rangeindex($analysersampleid, $rangeindex);
 
@@ -220,10 +260,48 @@ abstract class base {
                         $dataset[$uniquesampleid] = array();
                     }
 
-                    // Append the calculated indicator features at the end of the sample.
-                    $dataset[$uniquesampleid] = array_merge($dataset[$uniquesampleid], $calculatedvalues);
+                    // Append the features indicator features at the end of the sample.
+                    $dataset[$uniquesampleid] = array_merge($dataset[$uniquesampleid], $features);
+                }
+
+                if (!$this->is_evaluating()) {
+                    $timecreated = time();
+                    foreach ($newindicatorcalculations as $sampleid => $calculatedvalue) {
+                        // Prepare the new calculations to be stored into DB.
+
+                        $indcalc = new \stdClass();
+                        $indcalc->contextid = $this->analysable->get_context()->id;
+                        $indcalc->starttime = $range['start'];
+                        $indcalc->endtime = $range['end'];
+                        $indcalc->sampleid = $sampleid;
+                        $indcalc->sampleorigin = $samplesorigin;
+                        $indcalc->indicator = $rangeindicator->get_id();
+                        $indcalc->value = $calculatedvalue;
+                        $indcalc->timecreated = $timecreated;
+                        $newcalculations[] = $indcalc;
+                    }
                 }
             }
+
+            if (!$this->is_evaluating()) {
+                $batchsize = self::get_insert_batch_size();
+                if (count($newcalculations) > $batchsize) {
+                    // We don't want newcalculations array to grow too much as we already keep the
+                    // system memory busy storing $dataset contents.
+
+                    // Insert from the beginning.
+                    $remaining = array_splice($newcalculations, $batchsize);
+
+                    // Sorry mssql and oracle, this will be slow.
+                    $DB->insert_records('analytics_indicator_calc', $newcalculations);
+                    $newcalculations = $remaining;
+                }
+            }
+        }
+
+        if (!$this->is_evaluating() && $newcalculations) {
+            // Insert the remaining records.
+            $DB->insert_records('analytics_indicator_calc', $newcalculations);
         }
 
         return $dataset;
@@ -410,4 +488,32 @@ abstract class base {
             }
         }
     }
+
+    /**
+     * Returns the batch size used for insert_records.
+     *
+     * This method tries to find the best batch size without getting
+     * into dml internals. Maximum 1000 records to save memory.
+     *
+     * @return int
+     */
+    private static function get_insert_batch_size() {
+        global $DB;
+
+        // 500 is pgsql default so using 1000 is fine, no other db driver uses a hardcoded value.
+        if (empty($DB->dboptions['bulkinsertsize'])) {
+            return 1000;
+        }
+
+        $bulkinsert = $DB->dboptions['bulkinsertsize'];
+        if ($bulkinsert < 1000) {
+            return $bulkinsert;
+        }
+
+        while ($bulkinsert > 1000) {
+            $bulkinsert = round($bulkinsert / 2, 0);
+        }
+
+        return (int)$bulkinsert;
+    }
 }
index 80f80cd..fff2c50 100644 (file)
@@ -337,6 +337,32 @@ class manager {
         return $logstore;
     }
 
+    /**
+     * Returns this analysable calculations during the provided period.
+     *
+     * @param \core_analytics\analysable $analysable
+     * @param int $starttime
+     * @param int $endtime
+     * @param string $samplesorigin The samples origin as sampleid is not unique across models.
+     * @return array
+     */
+    public static function get_indicator_calculations($analysable, $starttime, $endtime, $samplesorigin) {
+        global $DB;
+
+        $params = array('starttime' => $starttime, 'endtime' => $endtime, 'contextid' => $analysable->get_context()->id,
+            'sampleorigin' => $samplesorigin);
+        $calculations = $DB->get_recordset('analytics_indicator_calc', $params, '', 'indicator, sampleid, value');
+
+        $existingcalculations = array();
+        foreach ($calculations as $calculation) {
+            if (empty($existingcalculations[$calculation->indicator])) {
+                $existingcalculations[$calculation->indicator] = array();
+            }
+            $existingcalculations[$calculation->indicator][$calculation->sampleid] = $calculation->value;
+        }
+        return $existingcalculations;
+    }
+
     /**
      * Returns the models with insights at the provided context.
      *
index aae6682..04a95f1 100644 (file)
@@ -293,6 +293,12 @@ class model {
             throw new \moodle_exception('errornotimesplittings', 'analytics');
         }
 
+        if (!empty($options['evaluation'])) {
+            foreach ($timesplittings as $timesplitting) {
+                $timesplitting->set_evaluating(true);
+            }
+        }
+
         $classname = $target->get_analyser_class();
         if (!class_exists($classname)) {
             throw new \coding_exception($classname . ' class does not exists');
@@ -1233,6 +1239,19 @@ class model {
             $limitfrom, $limitnum);
     }
 
+    /**
+     * Merges all training data files into one and returns it.
+     *
+     * @return \stored_file|false
+     */
+    public function get_training_data() {
+
+        \core_analytics\manager::check_can_manage_models();
+
+        $timesplittingid = $this->get_time_splitting()->get_id();
+        return \core_analytics\dataset_manager::export_training_data($this->get_id(), $timesplittingid);
+    }
+
     /**
      * Flag the provided file as used for training or prediction.
      *
diff --git a/analytics/tests/dataset_manager_test.php b/analytics/tests/dataset_manager_test.php
new file mode 100644 (file)
index 0000000..d36baf5
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for the dataset manager.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Unit tests for the dataset manager.
+ *
+ * @package   core_analytics
+ * @copyright 2017 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class dataset_manager_testcase extends advanced_testcase {
+
+    /**
+     * test_create_dataset
+     *
+     * @return
+     */
+    public function test_create_dataset() {
+        $this->resetAfterTest(true);
+
+        $sharedtoprows = array(
+            array('var1', 'var2'),
+            array('value1', 'value2'),
+            array('header1', 'header2')
+        );
+
+        $dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
+        $dataset1->init_process();
+        $dataset1data = array_merge($sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
+        $f1 = $dataset1->store($dataset1data);
+        $dataset1->close_process();
+
+        $f1contents = $f1->get_content();
+        $this->assertContains('yeah', $f1contents);
+        $this->assertContains('var1', $f1contents);
+        $this->assertContains('value1', $f1contents);
+        $this->assertContains('header1', $f1contents);
+    }
+
+    /**
+     * test_merge_datasets
+     *
+     * @return
+     */
+    public function test_merge_datasets() {
+        $this->resetAfterTest(true);
+
+        $sharedtoprows = array(
+            array('var1', 'var2'),
+            array('value1', 'value2'),
+            array('header1', 'header2')
+        );
+
+        $dataset1 = new \core_analytics\dataset_manager(1, 1, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
+        $dataset1->init_process();
+        $dataset1data = array_merge($sharedtoprows, array(array('yeah', 'yeah', 'yeah')));
+        $f1 = $dataset1->store($dataset1data);
+        $dataset1->close_process();
+
+        $dataset2 = new \core_analytics\dataset_manager(1, 2, 'whatever', \core_analytics\dataset_manager::LABELLED_FILEAREA, false);
+        $dataset2->init_process();
+        $dataset2data = array_merge($sharedtoprows, array(array('no', 'no', 'no')));
+        $f2 = $dataset2->store($dataset2data);
+        $dataset2->close_process();
+
+        $files = array($f1, $f2);
+        $merged = \core_analytics\dataset_manager::merge_datasets($files, 1, 'whatever',
+            \core_analytics\dataset_manager::LABELLED_FILEAREA);
+
+        $mergedfilecontents = $merged->get_content();
+        $this->assertContains('yeah', $mergedfilecontents);
+        $this->assertContains('no', $mergedfilecontents);
+        $this->assertContains('var1', $mergedfilecontents);
+        $this->assertContains('value1', $mergedfilecontents);
+        $this->assertContains('header1', $mergedfilecontents);
+    }
+}
index 2487407..761d40b 100644 (file)
@@ -33,6 +33,20 @@ defined('MOODLE_INTERNAL') || die();
  */
 class test_indicator_fullname extends \core_analytics\local\indicator\linear {
 
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('allowstealthmodules');
+    }
+
     /**
      * include_averages
      *
index 60102fd..ee506e4 100644 (file)
@@ -33,6 +33,20 @@ defined('MOODLE_INTERNAL') || die();
  */
 class test_indicator_max extends \core_analytics\local\indicator\binary {
 
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('allowstealthmodules');
+    }
+
     /**
      * calculate_sample
      *
index 1d11f59..ba3c33b 100644 (file)
@@ -33,6 +33,20 @@ defined('MOODLE_INTERNAL') || die();
  */
 class test_indicator_min extends \core_analytics\local\indicator\binary {
 
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('allowstealthmodules');
+    }
+
     /**
      * calculate_sample
      *
index a94be74..8b934dd 100644 (file)
@@ -33,6 +33,20 @@ defined('MOODLE_INTERNAL') || die();
  */
 class test_indicator_random extends \core_analytics\local\indicator\binary {
 
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('allowstealthmodules');
+    }
+
     /**
      * calculate_sample
      *
index 015cbcc..59a90bb 100644 (file)
@@ -38,6 +38,20 @@ require_once(__DIR__ . '/test_target_shortname.php');
  */
 class test_static_target_shortname extends test_target_shortname {
 
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('allowstealthmodules');
+    }
+
     /**
      * based_on_assumptions
      *
index 016a594..bd3be06 100644 (file)
@@ -33,6 +33,20 @@ defined('MOODLE_INTERNAL') || die();
  */
 class test_target_shortname extends \core_analytics\local\target\binary {
 
+    /**
+     * Returns a lang_string object representing the name for the indicator.
+     *
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        // Using a string that exists and contains a corresponding '_help' string.
+        return new \lang_string('allowstealthmodules');
+    }
+
     /**
      * predictions
      *
index c778a5c..1e21d8d 100644 (file)
@@ -107,10 +107,11 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      * @dataProvider provider_ml_training_and_prediction
      * @param string $timesplittingid
      * @param int $predictedrangeindex
+     * @param int $nranges
      * @param string $predictionsprocessorclass
      * @return void
      */
-    public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $predictionsprocessorclass) {
+    public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $nranges, $predictionsprocessorclass) {
         global $DB;
 
         $this->resetAfterTest(true);
@@ -153,6 +154,10 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         $this->assertEquals(1, $model->is_enabled());
         $this->assertEquals(1, $model->is_trained());
 
+        // 20 courses * the 3 model indicators * the number of time ranges of this time splitting method.
+        $indicatorcalc = 20 * 3 * $nranges;
+        $this->assertEquals($indicatorcalc, $DB->count_records('analytics_indicator_calc'));
+
         // 1 training file was created.
         $trainedsamples = $DB->get_records('analytics_train_samples', array('modelid' => $model->get_id()));
         $this->assertCount(1, $trainedsamples);
@@ -260,8 +265,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      */
     public function provider_ml_training_and_prediction() {
         $cases = array(
-            'no_splitting' => array('\core\analytics\time_splitting\no_splitting', 0),
-            'quarters' => array('\core\analytics\time_splitting\quarters', 3)
+            'no_splitting' => array('\core\analytics\time_splitting\no_splitting', 0, 1),
+            'quarters' => array('\core\analytics\time_splitting\quarters', 3, 4)
         );
 
         // We need to test all system prediction processors.
@@ -334,6 +339,28 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         get_log_manager(true);
     }
 
+    /**
+     * test_read_indicator_calculations
+     *
+     * @return void
+     */
+    public function test_read_indicator_calculations() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $starttime = 123;
+        $endtime = 321;
+        $sampleorigin = 'whatever';
+
+        $indicator = $this->getMockBuilder('test_indicator_max')->setMethods(['calculate_sample'])->getMock();
+        $indicator->expects($this->never())->method('calculate_sample');
+
+        $existingcalcs = array(111 => 1, 222 => 0.5);
+        $sampleids = array(111 => 111, 222 => 222);
+        list($values, $unused) = $indicator->calculate($sampleids, $sampleorigin, $starttime, $endtime, $existingcalcs);
+    }
+
     /**
      * provider_ml_test_evaluation
      *
index 171361b..8e9ed2e 100644 (file)
@@ -47,8 +47,13 @@ class award_criteria_activity extends award_criteria {
         parent::__construct($record);
 
         $this->course = $DB->get_record_sql('SELECT c.id, c.enablecompletion, c.cacherev, c.startdate
-                        FROM {badge} b INNER JOIN {course} c ON b.courseid = c.id
+                        FROM {badge} b LEFT JOIN {course} c ON b.courseid = c.id
                         WHERE b.id = :badgeid ', array('badgeid' => $this->badgeid), MUST_EXIST);
+
+        // If the course doesn't exist but we're sure the badge does (thanks to the LEFT JOIN), then use the site as the course.
+        if (empty($this->course->id)) {
+            $this->course = get_course(SITEID);
+        }
         $this->courseid = $this->course->id;
     }
 
index c8ae554..9f5b41a 100644 (file)
@@ -49,8 +49,13 @@ class award_criteria_course extends award_criteria {
         parent::__construct($record);
 
         $this->course = $DB->get_record_sql('SELECT c.id, c.enablecompletion, c.cacherev, c.startdate
-                        FROM {badge} b INNER JOIN {course} c ON b.courseid = c.id
+                        FROM {badge} b LEFT JOIN {course} c ON b.courseid = c.id
                         WHERE b.id = :badgeid ', array('badgeid' => $this->badgeid), MUST_EXIST);
+
+        // If the course doesn't exist but we're sure the badge does (thanks to the LEFT JOIN), then use the site as the course.
+        if (empty($this->course->id)) {
+            $this->course = get_course(SITEID);
+        }
         $this->courseid = $this->course->id;
     }
 
index 7866676..5cc674c 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js and b/calendar/amd/build/view_manager.min.js differ
index da1e454..10024f0 100644 (file)
@@ -77,9 +77,11 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
             return refreshMonthContent(time, courseid)
                 .then(function() {
                     window.history.pushState({}, '', url);
+                    return arguments;
                 })
                 .then(function() {
                     $('body').trigger(CalendarEvents.monthChanged, [time, courseid]);
+                    return arguments;
                 });
         };
 
index 82fc512..917f07e 100644 (file)
@@ -59,11 +59,17 @@ class event_exporter extends event_exporter_base {
             'null' => NULL_ALLOWED
         ];
         $values['isactionevent'] = ['type' => PARAM_BOOL];
+        $values['iscourseevent'] = ['type' => PARAM_BOOL];
+        $values['candelete'] = ['type' => PARAM_BOOL];
         $values['url'] = ['type' => PARAM_URL];
         $values['action'] = [
             'type' => event_action_exporter::read_properties_definition(),
             'optional' => true,
         ];
+        $values['editurl'] = [
+            'type' => PARAM_URL,
+            'optional' => true,
+        ];
         $values['groupname'] = [
             'type' => PARAM_RAW,
             'optional' => true,
@@ -83,10 +89,13 @@ class event_exporter extends event_exporter_base {
     protected function get_other_values(renderer_base $output) {
         $values = parent::get_other_values($output);
 
+        global $CFG;
+        require_once($CFG->dirroot.'/course/lib.php');
+
         $event = $this->event;
         $context = $this->related['context'];
         $values['isactionevent'] = false;
-
+        $values['iscourseevent'] = false;
         if ($moduleproxy = $event->get_course_module()) {
             $modulename = $moduleproxy->get('modname');
             $moduleid = $moduleproxy->get('id');
@@ -98,13 +107,13 @@ class event_exporter extends event_exporter_base {
             $params = array('update' => $moduleid, 'return' => true, 'sesskey' => sesskey());
             $editurl = new \moodle_url('/course/mod.php', $params);
             $values['editurl'] = $editurl->out(false);
+        } else if ($event->get_type() == 'course') {
+            $values['iscourseevent'] = true;
+            $url = \course_get_url($this->related['course'] ?: SITEID);
         } else {
             // TODO MDL-58866 We do not have any way to find urls for events outside of course modules.
-            global $CFG;
-            require_once($CFG->dirroot.'/course/lib.php');
             $url = \course_get_url($this->related['course'] ?: SITEID);
         }
-
         $values['url'] = $url->out(false);
 
         if ($event instanceof action_event_interface) {
index 376d2f1..e1c7205 100644 (file)
@@ -39,6 +39,9 @@
     {{/description}}
     <h4>{{#str}} eventtype, core_calendar {{/str}}</h4>
     {{eventtype}}
+    {{#iscourseevent}}
+        <a href="{{url}}">{{course.shortname}}</a>
+    {{/iscourseevent}}
     {{#source}}
         <div>{{{source}}}</div>
     {{/source}}
index c77b547..ef8fa66 100644 (file)
@@ -63,17 +63,18 @@ class behat_calendar extends behat_base {
         $eventname = $data->getRow(1);
         $eventname = $eventname[1];
 
-        // Click to create new event.
-        $this->execute("behat_general::i_wait_seconds", 1);
+        $this->execute("behat_general::wait_until_the_page_is_ready");
 
-        // Click to create new event.
-        $this->execute("behat_general::i_click_on", array(get_string('newevent', 'calendar'), "button"));
+        if ($this->running_javascript()) {
+            // Click to create new event.
+            $this->execute("behat_general::i_click_on", array(get_string('newevent', 'calendar'), "button"));
 
-        // Set form fields.
-        $this->execute("behat_forms::i_set_the_following_fields_to_these_values", $data);
+            // Set form fields.
+            $this->execute("behat_forms::i_set_the_following_fields_to_these_values", $data);
 
-        // Save event.
-        $this->execute("behat_forms::press_button", get_string('save'));
+            // Save event.
+            $this->execute("behat_forms::press_button", get_string('save'));
+        }
     }
 
     /**
index 291c83e..368a04d 100644 (file)
@@ -1,4 +1,4 @@
-@core @core_calendar @javascript
+@core @core_calendar
 Feature: Perform basic calendar functionality
   In order to ensure the calendar works as expected
   As an admin
@@ -26,11 +26,14 @@ Feature: Perform basic calendar functionality
       | user | group |
       | student1 | G1 |
       | teacher1 | G1 |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Calendar" block
+    And I log out
 
+  @javascript
   Scenario: Create a site event
     Given I log in as "admin"
-    And I am on "Course 1" course homepage with editing mode on
-    And I add the "Calendar" block
     And I create a calendar event with form data:
       | Type of event | site |
       | Event title | Really awesome event! |
@@ -44,91 +47,107 @@ Feature: Perform basic calendar functionality
     And I log in as "student2"
     And I follow "This month"
     And I should see "Really awesome event!"
-    And I log out
 
+  @javascript
   Scenario: Create a course event
-    Given I log in as "admin"
-    And I am on "Course 1" course homepage with editing mode on
-    And I add the "Calendar" block
-    And I log out
-    And I log in as "teacher1"
+    Given I log in as "teacher1"
     And I create a calendar event with form data:
       | Type of event | course |
       | Event title | Really awesome event! |
       | Description | Come join this awesome event, sucka! |
     And I log out
     And I log in as "student1"
-    And I am on "Course 1" course homepage
+    When I am on "Course 1" course homepage
     And I follow "This month"
-    And I should see "Really awesome event!"
+    And I click on "Really awesome event!" "link"
+    And "C1" "link" should exist in the ".modal-body" "css_element"
+    And I click on ".close" "css_element"
     And I log out
     And I log in as "student2"
     And I follow "This month"
-    And I should not see "Really awesome event!"
-    And I log out
+    Then I should not see "Really awesome event!"
 
+  @javascript
   Scenario: Create a group event
-    Given I log in as "admin"
-    And I am on "Course 1" course homepage with editing mode on
-    And I add the "Calendar" block
-    And I log out
-    And I log in as "teacher1"
+    Given I log in as "teacher1"
     And I create a calendar event with form data:
       | Type of event | group |
       | Event title | Really awesome event! |
       | Description | Come join this awesome event |
     And I log out
     And I log in as "student1"
-    And I am on "Course 1" course homepage
+    When I am on "Course 1" course homepage
     And I follow "This month"
-    And I follow "Really awesome event!"
+    Then I follow "Really awesome event!"
 
+  @javascript
   Scenario: Create a user event
-    Given I log in as "admin"
-    And I am on "Course 1" course homepage with editing mode on
-    And I add the "Calendar" block
+    Given I log in as "teacher1"
     And I create a calendar event with form data:
       | Type of event | user |
       | Event title | Really awesome event! |
       | Description | Come join this awesome event, sucka! |
     And I log out
     And I log in as "student1"
-    And I am on "Course 1" course homepage
+    When I am on "Course 1" course homepage
     And I follow "This month"
-    And I should not see "Really awesome event!"
+    Then I should not see "Really awesome event!"
 
+  @javascript
   Scenario: Delete an event
-    Given I log in as "admin"
-    And I am on "Course 1" course homepage with editing mode on
-    And I add the "Calendar" block
-    And I log out
-    And I log in as "teacher1"
+    Given I log in as "teacher1"
     And I create a calendar event with form data:
       | Type of event | user |
       | Event title | Really awesome event! |
       | Description | Come join this awesome event, sucka! |
     And I am on "Course 1" course homepage
-    And I follow "This month"
+    When I follow "This month"
     And I click on "Really awesome event!" "link"
     And I click on "Delete" "button"
     And I click on "Yes" "button"
     And I wait to be redirected
-    And I should not see "Really awesome event!"
+    Then I should not see "Really awesome event!"
 
+  @javascript
   Scenario: Edit an event
-    Given I log in as "admin"
-    And I am on "Course 1" course homepage with editing mode on
-    And I add the "Calendar" block
+    Given I log in as "teacher1"
     And I create a calendar event with form data:
       | Type of event | user |
       | Event title | Really awesome event! |
       | Description | Come join this awesome event, sucka! |
     And I am on "Course 1" course homepage
-    And I follow "This month"
+    When I follow "This month"
     And I click on "Really awesome event!" "link"
     And I click on "Edit" "button"
     And I set the following fields to these values:
       | Event title | Mediocre event :( |
       | Description | Wait, this event isn't that great. |
     And I press "Save"
-    And I should see "Mediocre event"
+    Then I should see "Mediocre event"
+
+  @javascript
+  Scenario: Module events editing
+    Given I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+    And the following "activities" exist:
+      | activity | course | idnumber | name          | intro                   | timeopen      | timeclose     |
+      | choice   | C1     | choice1  | Test choice 1 | Test choice description | ##yesterday## | ##tomorrow##  |
+    When I follow "This month"
+    Then I should see "Choice Test choice 1 open"
+    And I should see "Choice Test choice 1 close"
+    When I click on "Choice Test choice 1 open" "link"
+    Then "Delete" "button" should not exist
+    And I should see "Open event"
+    And "Edit" "button" should exist
+    When I click on "Edit" "button"
+    And I wait to be redirected
+    Then I should see "Updating: Choice"
+    And I am on "Course 1" course homepage
+    And I follow "This month"
+    When I click on "Choice Test choice 1 close" "link"
+    Then "Delete" "button" should not exist
+    And I should see "Close event"
+    And "Edit" "button" should exist
+    When I click on "Edit" "button"
+    And I wait to be redirected
+    Then I should see "Updating: Choice"
index 78e3597..99051c7 100644 (file)
@@ -43,12 +43,14 @@ class no_teacher extends \core_analytics\local\indicator\binary {
     protected $teacherroleids = null;
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:noteacher', 'moodle');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:noteacher', 'moodle');
     }
 
     /**
index a2d222b..b1ef9e9 100644 (file)
@@ -49,6 +49,14 @@ class core_course_renderer extends plugin_renderer_base {
      */
     protected $strings;
 
+    /**
+     * Whether a category content is being initially rendered with children. This is mainly used by the
+     * core_course_renderer::corsecat_tree() to render the appropriate action for the Expand/Collapse all link on
+     * page load.
+     * @var bool
+     */
+    protected $categoryexpandedonload = false;
+
     /**
      * Override the constructor so that we can initialise the string cache
      *
@@ -1511,6 +1519,8 @@ class core_course_renderer extends plugin_renderer_base {
             $classes[] = 'loaded';
             if (!empty($categorycontent)) {
                 $classes[] = 'with_children';
+                // Category content loaded with children.
+                $this->categoryexpandedonload = true;
             }
         }
 
@@ -1557,6 +1567,8 @@ class core_course_renderer extends plugin_renderer_base {
      * @return string
      */
     protected function coursecat_tree(coursecat_helper $chelper, $coursecat) {
+        // Reset the category expanded flag for this course category tree first.
+        $this->categoryexpandedonload = false;
         $categorycontent = $this->coursecat_category_content($chelper, $coursecat, 0);
         if (empty($categorycontent)) {
             return '';
@@ -1572,10 +1584,17 @@ class core_course_renderer extends plugin_renderer_base {
                 'collapseexpand',
             );
 
+            // Check if the category content contains subcategories with children's content loaded.
+            if ($this->categoryexpandedonload) {
+                $classes[] = 'collapse-all';
+                $linkname = get_string('collapseall');
+            } else {
+                $linkname = get_string('expandall');
+            }
+
             // Only show the collapse/expand if there are children to expand.
             $content .= html_writer::start_tag('div', array('class' => 'collapsible-actions'));
-            $content .= html_writer::link('#', get_string('expandall'),
-                    array('class' => implode(' ', $classes)));
+            $content .= html_writer::link('#', $linkname, array('class' => implode(' ', $classes)));
             $content .= html_writer::end_tag('div');
             $this->page->requires->strings_for_js(array('collapseall', 'expandall'), 'moodle');
         }
index 583e136..698752c 100644 (file)
Binary files a/enrol/manual/amd/build/quickenrolment.min.js and b/enrol/manual/amd/build/quickenrolment.min.js differ
index eedda35..600a894 100644 (file)
@@ -59,33 +59,41 @@ define(['core/templates',
     QuickEnrolment.prototype.initModal = function() {
         var triggerButtons = $('.enrolusersbutton.enrol_manual_plugin [type="submit"]');
 
-        var strparams = [
+        var stringsPromise = Str.get_strings([
             {key: 'enroluserscohorts', component: 'enrol_manual'},
-            {key: 'enrolusers', component: 'enrol_manual'}
-        ];
-
-        $.when(Str.get_strings(strparams)).then(function(strlist) {
-            var modalSaveChanges = strlist[0],
-                modalTitle = strlist[1];
-
-            return ModalFactory.create({
-                type: ModalFactory.types.SAVE_CANCEL,
-                title: modalTitle,
-                body: this.getBody()
-            }, triggerButtons).then(function(modal) {
-                this.modal = modal;
-                this.modal.setLarge();
-                this.modal.setSaveButtonText(modalSaveChanges);
-
-                // We want the reset the form every time it is opened.
-                this.modal.getRoot().on(ModalEvents.hidden, function() {
-                    this.modal.setBody(this.getBody());
-                }.bind(this));
-
-                this.modal.getRoot().on(ModalEvents.save, this.submitForm.bind(this));
-                this.modal.getRoot().on('submit', 'form', this.submitFormAjax.bind(this));
+            {key: 'enrolusers', component: 'enrol_manual'},
+        ]);
+
+        var titlePromise = stringsPromise.then(function(strings) {
+            return strings[1];
+        });
+
+        var buttonPromise = stringsPromise.then(function(strings) {
+            return strings[0];
+        });
+
+        return ModalFactory.create({
+            type: ModalFactory.types.SAVE_CANCEL,
+            large: true,
+            title: titlePromise,
+            body: this.getBody()
+        }, triggerButtons)
+        .then(function(modal) {
+            this.modal = modal;
+
+            this.modal.setSaveButtonText(buttonPromise);
+
+            // We want the reset the form every time it is opened.
+            this.modal.getRoot().on(ModalEvents.hidden, function() {
+                this.modal.setBody(this.getBody());
             }.bind(this));
-        }.bind(this)).fail(Notification.exception);
+
+            this.modal.getRoot().on(ModalEvents.save, this.submitForm.bind(this));
+            this.modal.getRoot().on('submit', 'form', this.submitFormAjax.bind(this));
+
+            return modal;
+        }.bind(this))
+        .fail(Notification.exception);
     };
 
     /**
index 7cced02..e0f3592 100644 (file)
@@ -1024,12 +1024,19 @@ $string['includesitefiles'] = 'Include site files used in this course';
 $string['includeuserfiles'] = 'Include user files';
 $string['increasesections'] = 'Increase the number of sections';
 $string['indicator:accessesafterend'] = 'Accesses after the end date';
+$string['indicator:accessesafterend_help'] = '';
 $string['indicator:accessesbeforestart'] = 'Accesses before the start date';
+$string['indicator:accessesbeforestart_help'] = '';
 $string['indicator:anywrite'] = 'Any write action';
+$string['indicator:anywrite_help'] = '';
 $string['indicator:completeduserprofile'] = 'User profile is completed';
+$string['indicator:completeduserprofile_help'] = '';
 $string['indicator:noteacher'] = 'There are no teachers';
+$string['indicator:noteacher_help'] = '';
 $string['indicator:readactions'] = 'Read actions amount';
+$string['indicator:readactions_help'] = '';
 $string['indicator:userforumstracking'] = 'User is tracking forums';
+$string['indicator:userforumstracking_help'] = '';
 $string['info'] = 'Information';
 $string['institution'] = 'Institution';
 $string['instudentview'] = 'in student view';
@@ -1887,7 +1894,9 @@ $string['tagalready'] = 'This tag already exists';
 $string['tagmanagement'] = 'Add/delete tags ...';
 $string['tags'] = 'Tags';
 $string['target:coursedropout'] = 'Students at risk of dropping out';
+$string['target:coursedropout_help'] = '';
 $string['target:noteachingactivity'] = 'No teaching';
+$string['target:noteachingactivity_help'] = '';
 $string['targetlabelstudentdropoutyes'] = 'Student at risk of dropping out';
 $string['targetlabelstudentdropoutno'] = 'Not at risk';
 $string['targetlabelteachingyes'] = 'Users with teaching capabilities have access to the course';
@@ -1905,13 +1914,21 @@ The list of available text editors is determined by the site administrator.';
 $string['texteditor'] = 'Use standard web forms';
 $string['textformat'] = 'Plain text format';
 $string['timesplitting:deciles'] = 'Deciles';
+$string['timesplitting:deciles_help'] = '';
 $string['timesplitting:decilesaccum'] = 'Deciles accumulative';
+$string['timesplitting:decilesaccum_help'] = '';
 $string['timesplitting:nosplitting'] = 'No time splitting';
+$string['timesplitting:nosplitting_help'] = '';
 $string['timesplitting:quarters'] = 'Quarters';
+$string['timesplitting:quarters_help'] = '';
 $string['timesplitting:quartersaccum'] = 'Quarters accumulative';
+$string['timesplitting:quartersaccum_help'] = '';
 $string['timesplitting:singlerange'] = 'Single range';
+$string['timesplitting:singlerange_help'] = '';
 $string['timesplitting:weekly'] = 'Weekly';
+$string['timesplitting:weekly_help'] = '';
 $string['timesplitting:weeklyaccum'] = 'Weekly accumulative';
+$string['timesplitting:weeklyaccum_help'] = '';
 $string['thanks'] = 'Thanks';
 $string['theme'] = 'Theme';
 $string['themes'] = 'Themes';
index 7cf5cb8..3467d8a 100644 (file)
Binary files a/lib/amd/build/addblockmodal.min.js and b/lib/amd/build/addblockmodal.min.js differ
index 233e46e..e0e9f5d 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index d1b6f8e..7566ff4 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index efa71d9..02e0604 100644 (file)
Binary files a/lib/amd/build/modal_factory.min.js and b/lib/amd/build/modal_factory.min.js differ
index 96737e8..e2d2ab7 100644 (file)
Binary files a/lib/amd/build/modal_save_cancel.min.js and b/lib/amd/build/modal_save_cancel.min.js differ
index 5da0bde..5aed323 100644 (file)
@@ -37,28 +37,36 @@ define(['jquery', 'core/modal_factory', 'core/templates', 'core/str', 'core/noti
             var addblocklink = $('[data-key=addblock]');
 
             // We need the fetch the names of the blocks. It was too much to send in the page.
-            Str.get_string('addblock').done(function(title) {
+            var titlerequests = context.blocks.map(function(blockName) {
+                return {
+                    key: 'pluginname',
+                    component: 'block_' + blockName,
+                };
+            });
 
-                var titlerequests = [];
-                $.each(context.blocks, function(index, key) {
-                    titlerequests[titlerequests.length] = {key: 'pluginname', component: 'block_' + key};
+            var bodyPromise = Str.get_strings(titlerequests)
+            .then(function(titles) {
+                return titles.map(function(title, index) {
+                    return {
+                        name: context.blocks[index],
+                        title: title,
+                    };
                 });
+            })
+            .then(function(blocks) {
+                context.blocks = blocks;
+                return Templates.render('core/add_block_body', context);
+            })
+            .fail(Notification.exception);
 
-                var blocks = [];
-                Str.get_strings(titlerequests).done(function(titles) {
-                    $.each(titles, function(index, title) {
-                        blocks[blocks.length] = {name: context.blocks[index], title: title};
-                    });
-
-                    context.blocks = blocks;
+            var titlePromise = Str.get_string('addblock')
+            .fail(Notification.exception);
 
-                    ModalFactory.create({
-                        title: title,
-                        body: Templates.render('core/add_block_body', context),
-                        type: 'CANCEL',
-                    }, addblocklink);
-                });
-            }).fail(Notification.exception);
+            ModalFactory.create({
+                title: titlePromise,
+                body: bodyPromise,
+                type: 'CANCEL',
+            }, addblocklink);
         }
     };
 });
index 17e7adf..12f44eb 100644 (file)
@@ -687,19 +687,40 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         });
         // Whenever the input field changes, update the suggestion list.
         if (options.showSuggestions) {
-            inputElement.on('input', function(e) {
-                var query = $(e.currentTarget).val();
-                var last = $(e.currentTarget).data('last-value');
-                // IE11 fires many more input events than required - even when the value has not changed.
-                // We need to only do this for real value changed events or the suggestions will be
-                // unclickable on IE11 (because they will be rebuilt before the click event fires).
-                // Note - because of this we cannot close the list when the query is empty or it will break
-                // on IE11.
-                if (last !== query) {
-                    updateSuggestions(options, state, query, originalSelect);
-                }
-                $(e.currentTarget).data('last-value', query);
-            });
+            // If this field uses ajax, set it up.
+            if (options.ajax) {
+                require([options.ajax], function(ajaxHandler) {
+                    var throttleTimeout = null;
+                    var handler = function(e) {
+                        updateAjax(e, options, state, originalSelect, ajaxHandler);
+                    };
+
+                    // For input events, we do not want to trigger many, many updates.
+                    var throttledHandler = function(e) {
+                        if (throttleTimeout !== null) {
+                            window.clearTimeout(throttleTimeout);
+                            throttleTimeout = null;
+                        }
+                        throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
+                    };
+                    // Trigger an ajax update after the text field value changes.
+                    inputElement.on("input", throttledHandler);
+                });
+            } else {
+                inputElement.on('input', function(e) {
+                    var query = $(e.currentTarget).val();
+                    var last = $(e.currentTarget).data('last-value');
+                    // IE11 fires many more input events than required - even when the value has not changed.
+                    // We need to only do this for real value changed events or the suggestions will be
+                    // unclickable on IE11 (because they will be rebuilt before the click event fires).
+                    // Note - because of this we cannot close the list when the query is empty or it will break
+                    // on IE11.
+                    if (last !== query) {
+                        updateSuggestions(options, state, query, originalSelect);
+                    }
+                    $(e.currentTarget).data('last-value', query);
+                });
+            }
         }
     };
 
@@ -804,31 +825,10 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                 // Add the event handlers.
                 addNavigation(options, state, originalSelect);
 
-                var inputElement = $(document.getElementById(state.inputId));
                 var suggestionsElement = $(document.getElementById(state.suggestionsId));
                 // Hide the suggestions by default.
                 suggestionsElement.hide().attr('aria-hidden', true);
 
-                // If this field uses ajax, set it up.
-                if (options.ajax) {
-                    require([options.ajax], function(ajaxHandler) {
-                        var throttleTimeout = null;
-                        var handler = function(e) {
-                            updateAjax(e, options, state, originalSelect, ajaxHandler);
-                        };
-
-                        // For input events, we do not want to trigger many, many updates.
-                        var throttledHandler = function(e) {
-                            if (throttleTimeout !== null) {
-                                window.clearTimeout(throttleTimeout);
-                                throttleTimeout = null;
-                            }
-                            throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
-                        };
-                        // Trigger an ajax update after the text field value changes.
-                        inputElement.on("input", throttledHandler);
-                    });
-                }
                 // Show the current values in the selection list.
                 updateSelectionList(options, state, originalSelect);
                 return true;
index 53ae0b7..fbc7d9a 100644 (file)
@@ -215,23 +215,26 @@ define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
     /**
      * Set the modal title element.
      *
+     * This method is overloaded to take either a string value for the title or a jQuery promise that is resolved with
+     * HTML most commonly from a Str.get_string call.
+     *
      * @method setTitle
-     * @param {string} value The title string
+     * @param {(string|object)} value The title string or jQuery promise which resolves to the title.
      */
     Modal.prototype.setTitle = function(value) {
         var title = this.getTitle();
-        title.html(value);
+
+        this.asyncSet(value, title.html.bind(title));
     };
 
     /**
      * Set the modal body element.
      *
-     * This method is overloaded to take either a string
-     * value for the body or a jQuery promise that is resolved with HTML and Javascript
-     * most commonly from a Templates.render call.
+     * This method is overloaded to take either a string value for the body or a jQuery promise that is resolved with
+     * HTML and Javascript most commonly from a Templates.render call.
      *
      * @method setBody
-     * @param {(string|object)} value The body string or jQuery promise
+     * @param {(string|object)} value The body string or jQuery promise which resolves to the body.
      */
     Modal.prototype.setBody = function(value) {
         var body = this.getBody();
@@ -595,5 +598,29 @@ define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
         }.bind(this));
     };
 
+    /**
+     * Set or resolve and set the value using the function.
+     *
+     * @method asyncSet
+     * @param {(string|object)} value The string or jQuery promise.
+     * @param {function} setFunction The setter
+     * @return {Promise}
+     */
+    Modal.prototype.asyncSet = function(value, setFunction) {
+        var p = value;
+        if (typeof value === 'string') {
+            p = $.Deferred();
+            p.resolve(value);
+        }
+
+        p.then(function(content) {
+            setFunction(content);
+
+            return;
+        });
+
+        return p;
+    };
+
     return Modal;
 });
index 678bad8..f9db208 100644 (file)
@@ -55,10 +55,10 @@ define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
      * is closed.
      *
      * @method setUpTrigger
-     * @param {object} modal The modal instance
+     * @param {Promise} modalPromise The modal instance
      * @param {object} triggerElement The jQuery element to open the modal
      */
-    var setUpTrigger = function(modal, triggerElement) {
+    var setUpTrigger = function(modalPromise, triggerElement) {
         if (typeof triggerElement != 'undefined') {
             if (Array.isArray(triggerElement)) {
                 var selector = triggerElement[1];
@@ -66,19 +66,31 @@ define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
 
                 CustomEvents.define(triggerElement, [CustomEvents.events.activate]);
                 triggerElement.on(CustomEvents.events.activate, selector, function(e, data) {
-                    modal.show();
+                    modalPromise.then(function(modal) {
+                        modal.show();
+
+                        return modal;
+                    });
                     data.originalEvent.preventDefault();
                 });
             } else {
                 CustomEvents.define(triggerElement, [CustomEvents.events.activate]);
                 triggerElement.on(CustomEvents.events.activate, function(e, data) {
-                    modal.show();
+                    modalPromise.then(function(modal) {
+                        modal.show();
+
+                        return modal;
+                    });
                     data.originalEvent.preventDefault();
                 });
             }
 
-            modal.getRoot().on(ModalEvents.hidden, function() {
-                triggerElement.focus();
+            modalPromise.then(function(modal) {
+                modal.getRoot().on(ModalEvents.hidden, function() {
+                    triggerElement.focus();
+                });
+
+                return modal;
             });
         }
     };
@@ -93,11 +105,10 @@ define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
      * @param {object} triggerElement The trigger HTML jQuery object
      * @return {object} Modal instance
      */
-    var createFromElement = function(registryConf, modalElement, triggerElement) {
+    var createFromElement = function(registryConf, modalElement) {
         modalElement = $(modalElement);
         var module = registryConf.module;
         var modal = new module(modalElement);
-        setUpTrigger(modal, triggerElement);
 
         return modal;
     };
@@ -115,12 +126,16 @@ define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
     var createFromType = function(registryConf, templateContext, triggerElement) {
         var templateName = registryConf.template;
 
-        return Templates.render(templateName, templateContext)
+        var modalPromise = Templates.render(templateName, templateContext)
             .then(function(html) {
                 var modalElement = $(html);
-                return createFromElement(registryConf, modalElement, triggerElement);
+                return createFromElement(registryConf, modalElement);
             })
             .fail(Notification.exception);
+
+        setUpTrigger(modalPromise, triggerElement);
+
+        return modalPromise;
     };
 
     /**
index 5949bb6..f62751c 100644 (file)
@@ -91,10 +91,15 @@ define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/m
     /**
      * Allows to overwrite the text of "Save changes" button.
      *
-     * @param {String} text
+     * This method is overloaded to take either a string value for the button title or a jQuery promise that is resolved with
+     * text most commonly from a Str.get_string call.
+     *
+     * @param {(String|object)} value The button text, or a jQuery promise which will resolve it
      */
-    ModalSaveCancel.prototype.setSaveButtonText = function(text) {
-        this.getFooter().find(SELECTORS.SAVE_BUTTON).text(text);
+    ModalSaveCancel.prototype.setSaveButtonText = function(value) {
+        var button = this.getFooter().find(SELECTORS.SAVE_BUTTON);
+
+        this.asyncSet(value, button.text.bind(button));
     };
 
     return ModalSaveCancel;
index 77adcc2..1cab863 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class any_access_after_end extends \core_analytics\local\indicator\binary {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:accessesafterend');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:accessesafterend');
     }
 
     /**
index e5cada0..4b30f55 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class any_access_before_start extends \core_analytics\local\indicator\binary {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:accessesbeforestart');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:accessesbeforestart');
     }
 
     /**
index 21aab2c..7a27d16 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class any_write_action extends \core_analytics\local\indicator\binary {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:anywrite');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:anywrite');
     }
 
     /**
index 77641fa..7b9ac39 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class read_actions extends \core_analytics\local\indicator\linear {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:readactions');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:readactions');
     }
 
     /**
index 8a021eb..855be38 100644 (file)
@@ -41,12 +41,14 @@ require_once($CFG->dirroot . '/completion/completion_completion.php');
 class course_dropout extends \core_analytics\local\target\binary {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('target:coursedropout');
+    public static function get_name() : \lang_string {
+        return new \lang_string('target:coursedropout');
     }
 
     /**
index 56f213c..21937c4 100644 (file)
@@ -45,12 +45,14 @@ class no_teaching extends \core_analytics\local\target\binary {
     }
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('target:noteachingactivity');
+    public static function get_name() : \lang_string {
+        return new \lang_string('target:noteachingactivity');
     }
 
     /**
index d984847..3e21df9 100644 (file)
@@ -36,12 +36,16 @@ defined('MOODLE_INTERNAL') || die();
 class deciles extends \core_analytics\local\time_splitting\equal_parts {
 
     /**
-     * get_name
+     * Returns a lang_string object representing the name for the time splitting method.
      *
-     * @return string
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public function get_name() {
-        return get_string('timesplitting:deciles');
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:deciles');
     }
 
     /**
index afd4028..b0ffc58 100644 (file)
@@ -36,12 +36,16 @@ defined('MOODLE_INTERNAL') || die();
 class deciles_accum extends \core_analytics\local\time_splitting\accumulative_parts {
 
     /**
-     * get_name
+     * Returns a lang_string object representing the name for the time splitting method.
      *
-     * @return string
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public function get_name() {
-        return get_string('timesplitting:decilesaccum');
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:decilesaccum');
     }
 
     /**
index 3a3a4eb..0d2e614 100644 (file)
@@ -40,12 +40,16 @@ defined('MOODLE_INTERNAL') || die();
 class no_splitting extends \core_analytics\local\time_splitting\base {
 
     /**
-     * get_name
+     * Returns a lang_string object representing the name for the time splitting method.
      *
-     * @return string
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public function get_name() {
-        return get_string('timesplitting:nosplitting');
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:nosplitting');
     }
 
     /**
index 91410d2..9245f2b 100644 (file)
@@ -36,12 +36,16 @@ defined('MOODLE_INTERNAL') || die();
 class quarters extends \core_analytics\local\time_splitting\equal_parts {
 
     /**
-     * get_name
+     * Returns a lang_string object representing the name for the time spliting method.
      *
-     * @return string
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public function get_name() {
-        return get_string('timesplitting:quarters');
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:quarters');
     }
 
     /**
index 7b07744..475c92d 100644 (file)
@@ -36,12 +36,16 @@ defined('MOODLE_INTERNAL') || die();
 class quarters_accum extends \core_analytics\local\time_splitting\accumulative_parts {
 
     /**
-     * get_name
+     * Returns a lang_string object representing the name for the time spliting method.
      *
-     * @return string
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public function get_name() {
-        return get_string('timesplitting:quartersaccum');
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:quartersaccum');
     }
 
     /**
index 6fd49b6..01d9d0d 100644 (file)
@@ -36,12 +36,16 @@ defined('MOODLE_INTERNAL') || die();
 class single_range extends \core_analytics\local\time_splitting\base {
 
     /**
-     * get_name
+     * Returns a lang_string object representing the name for the time spliting method.
      *
-     * @return string
+     * Used as column identificator.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public function get_name() {
-        return get_string('timesplitting:singlerange');
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:singlerange');
     }
 
     /**
index 768f7f2..399b613 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20170721" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20170801" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <INDEX NAME="modelidandfileidandaction" UNIQUE="false" FIELDS="modelid, fileid, action" COMMENT="Index on modelid and fileid and action"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="analytics_indicator_calc" COMMENT="Stored indicator calculations">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="starttime" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="endtime" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="sampleorigin" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="sampleid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="indicator" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="value" TYPE="number" LENGTH="10" NOTNULL="false" SEQUENCE="false" DECIMALS="2" COMMENT="The calculated value, it can be null."/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="starttime-endtime-contextid" UNIQUE="false" FIELDS="starttime, endtime, contextid"/>
+      </INDEXES>
+    </TABLE>
   </TABLES>
-</XMLDB>
\ No newline at end of file
+</XMLDB>
index 638044f..a3dd85d 100644 (file)
@@ -2308,5 +2308,50 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2017080700.01);
     }
 
+    if ($oldversion < 2017082200.00) {
+        $plugins = ['radius', 'fc', 'nntp', 'pam', 'pop3', 'imap'];
+
+        foreach ($plugins as $plugin) {
+            // Check to see if the plugin exists on disk.
+            // If it does not, remove the config for it.
+            if (!file_exists($CFG->dirroot . "/auth/{$plugin}/auth.php")) {
+                // Clean config.
+                unset_all_config_for_plugin("auth_{$plugin}");
+            }
+        }
+        upgrade_main_savepoint(true, 2017082200.00);
+    }
+
+    if ($oldversion < 2017082200.01) {
+
+        // Define table analytics_indicator_calc to be created.
+        $table = new xmldb_table('analytics_indicator_calc');
+
+        // Adding fields to table analytics_indicator_calc.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('starttime', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('endtime', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('contextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('sampleorigin', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('sampleid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('indicator', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('value', XMLDB_TYPE_NUMBER, '10, 2', null, null, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table analytics_indicator_calc.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
+
+        // Adding indexes to table analytics_indicator_calc.
+        $table->add_index('starttime-endtime-contextid', XMLDB_INDEX_NOTUNIQUE, array('starttime', 'endtime', 'contextid'));
+
+        // Conditionally launch create table for analytics_indicator_calc.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2017082200.01);
+    }
+
     return true;
 }
index 53db50b..f98243e 100644 (file)
@@ -29,4 +29,5 @@ $string['managefiles'] = 'Manage files';
 $string['missingfiles'] = 'Missing files';
 $string['pluginname'] = 'Manage files';
 $string['unusedfilesdesc'] = 'The following embedded files are not used in the text area:';
+$string['unusedfilesremovalnotice'] = 'Any unused files will be automatically deleted when saving changes.';
 $string['unusedfilesheader'] = 'Unused files';
index bce1bd4..b6ff2a1 100644 (file)
@@ -55,7 +55,8 @@ function atto_managefiles_params_for_js($elementid, $options, $fpoptions) {
 
     if (!$disabled) {
         $params['usercontext'] = context_user::instance($USER->id)->id;
-        foreach (array('itemid', 'context', 'areamaxbytes', 'maxbytes', 'subdirs', 'return_types') as $key) {
+        foreach (array('itemid', 'context', 'areamaxbytes', 'maxbytes', 'subdirs', 'return_types',
+                       'removeorphaneddrafts') as $key) {
             if (isset($options[$key])) {
                 if ($key === 'context' && is_object($options[$key])) {
                     // Just context id is enough.
index ff6922b..5dd39a4 100644 (file)
@@ -35,6 +35,7 @@ $return_types = optional_param('return_types', null, PARAM_INT);
 $areamaxbytes = optional_param('areamaxbytes', FILE_AREA_MAX_BYTES_UNLIMITED, PARAM_INT);
 $contextid = optional_param('context', SYSCONTEXTID, PARAM_INT);
 $elementid = optional_param('elementid', '', PARAM_TEXT);
+$removeorphaneddrafts = optional_param('removeorphaneddrafts', 0, PARAM_INT);
 
 $context = context::instance_by_id($contextid);
 if ($context->contextlevel == CONTEXT_MODULE) {
@@ -75,7 +76,7 @@ $options = array(
     'accepted_types' => $accepted_types,
     'areamaxbytes' => $areamaxbytes,
     'return_types' => $return_types,
-    'context' => $context,
+    'context' => $context
 );
 
 $usercontext = context_user::instance($USER->id);
@@ -87,8 +88,8 @@ foreach ($files as $file) {
 }
 
 $mform = new atto_managefiles_manage_form(null,
-    array('options' => $options, 'draftitemid' => $itemid, 'files' => $filenames, 'elementid' => $elementid),
-    'post', '', array('id' => 'atto_managefiles_manageform'));
+    array('options' => $options, 'draftitemid' => $itemid, 'files' => $filenames, 'elementid' => $elementid,
+        'removeorphaneddrafts' => $removeorphaneddrafts), 'post', '', array('id' => 'atto_managefiles_manageform'));
 
 if ($data = $mform->get_data()) {
     if (!empty($data->deletefile)) {
index 50bb8dd..a5569aa 100644 (file)
@@ -47,6 +47,7 @@ class atto_managefiles_manage_form extends moodleform {
         $elementid = $this->_customdata['elementid'];
         $options = $this->_customdata['options'];
         $files = $this->_customdata['files'];
+        $removeorphaneddrafts = $this->_customdata['removeorphaneddrafts'];
 
         $mform->addElement('header', 'filemanagerhdr', get_string('filemanager', 'atto_managefiles'));
 
@@ -69,6 +70,12 @@ class atto_managefiles_manage_form extends moodleform {
 
         $mform->addElement('filemanager', 'files_filemanager', '', null, $options);
 
+        // Let the user know that any drafts not referenced in the text will be removed automatically.
+        if ($removeorphaneddrafts) {
+            $mform->addElement('static', '', '',
+                html_writer::tag('div', get_string('unusedfilesremovalnotice', 'atto_managefiles')));
+        }
+
         $mform->addElement('header', 'missingfileshdr', get_string('missingfiles', 'atto_managefiles'));
         $mform->addElement('static', '', '',
             html_writer::tag('div',
index d2779ce..4b74d41 100644 (file)
@@ -237,6 +237,9 @@ function file_postupdate_standard_editor($data, $field, array $options, $context
     if (!isset($options['maxbytes'])) {
         $options['maxbytes'] = 0; // unlimited
     }
+    if (!isset($options['removeorphaneddrafts'])) {
+        $options['removeorphaneddrafts'] = false; // Don't remove orphaned draft files by default.
+    }
 
     if ($options['trusttext']) {
         $data->{$field.'trust'} = trusttext_trusted($context);
@@ -249,6 +252,10 @@ function file_postupdate_standard_editor($data, $field, array $options, $context
     if ($options['maxfiles'] == 0 or is_null($filearea) or is_null($itemid) or empty($editor['itemid'])) {
         $data->{$field} = $editor['text'];
     } else {
+        // Clean the user drafts area of any files not referenced in the editor text.
+        if ($options['removeorphaneddrafts']) {
+            file_remove_editor_orphaned_files($editor);
+        }
         $data->{$field} = file_save_draft_area_files($editor['itemid'], $context->id, $component, $filearea, $itemid, $options, $editor['text'], $options['forcehttps']);
     }
     $data->{$field.'format'} = $editor['format'];
@@ -792,6 +799,38 @@ function file_restore_source_field_from_draft_file($storedfile) {
     }
     return $storedfile;
 }
+
+/**
+ * Removes those files from the user drafts filearea which are not referenced in the editor text.
+ *
+ * @param stdClass $editor The online text editor element from the submitted form data.
+ */
+function file_remove_editor_orphaned_files($editor) {
+    global $CFG, $USER;
+
+    // Find those draft files included in the text, and generate their hashes.
+    $context = context_user::instance($USER->id);
+    $baseurl = $CFG->wwwroot . '/draftfile.php/' . $context->id . '/user/draft/' . $editor['itemid'] . '/';
+    $pattern = "/" . preg_quote($baseurl, '/') . "(.+?)[\?\"']/";
+    preg_match_all($pattern, $editor['text'], $matches);
+    $usedfilehashes = [];
+    foreach ($matches[1] as $matchedfilename) {
+        $matchedfilename = urldecode($matchedfilename);
+        $usedfilehashes[] = \file_storage::get_pathname_hash($context->id, 'user', 'draft', $editor['itemid'], '/',
+                                                             $matchedfilename);
+    }
+
+    // Now, compare the hashes of all draft files, and remove those which don't match used files.
+    $fs = get_file_storage();
+    $files = $fs->get_area_files($context->id, 'user', 'draft', $editor['itemid'], 'id', false);
+    foreach ($files as $file) {
+        $tmphash = $file->get_pathnamehash();
+        if (!in_array($tmphash, $usedfilehashes)) {
+            $file->delete();
+        }
+    }
+}
+
 /**
  * Saves files from a draft file area to a real one (merging the list of files).
  * Can rewrite URLs in some content at the same time if desired.
index 69a7812..d32527a 100644 (file)
@@ -58,7 +58,7 @@ class MoodleQuickForm_editor extends HTML_QuickForm_element implements templatab
     /** @var array options provided to initalize filepicker */
     protected $_options = array('subdirs' => 0, 'maxbytes' => 0, 'maxfiles' => 0, 'changeformat' => 0,
             'areamaxbytes' => FILE_AREA_MAX_BYTES_UNLIMITED, 'context' => null, 'noclean' => 0, 'trusttext' => 0,
-            'return_types' => 15, 'enable_filemanagement' => true);
+            'return_types' => 15, 'enable_filemanagement' => true, 'removeorphaneddrafts' => false);
     // 15 is $_options['return_types'] = FILE_INTERNAL | FILE_EXTERNAL | FILE_REFERENCE | FILE_CONTROLLED_LINK.
 
     /** @var array values for editor */
index 9e5ede0..bcf59ef 100644 (file)
@@ -9990,4 +9990,22 @@ class lang_string {
         $this->forcedstring = true;
         return array('forcedstring', 'string', 'lang');
     }
+
+    /**
+     * Returns the identifier.
+     *
+     * @return string
+     */
+    public function get_identifier() {
+        return $this->identifier;
+    }
+
+    /**
+     * Returns the component.
+     *
+     * @return string
+     */
+    public function get_component() {
+        return $this->component;
+    }
 }
index 57a2b12..a7c9f1f 100644 (file)
@@ -4185,12 +4185,12 @@ EOD;
             }
 
             // Only provide user information if the user is the current user, or a user which the current user can view.
-            $canviewdetails = false;
-            if ($user->id == $USER->id || user_can_view_profile($user)) {
-                $canviewdetails = true;
-            }
+            // When checking user_can_view_profile(), either:
+            // If the page context is course, check the course context (from the page object) or;
+            // If page context is NOT course, then check across all courses.
+            $course = ($this->page->context->contextlevel == CONTEXT_COURSE) ? $this->page->course : null;
 
-            if ($canviewdetails) {
+            if (user_can_view_profile($user, $course)) {
                 // Use the user's full name if the heading isn't set.
                 if (!isset($heading)) {
                     $heading = fullname($user);
index 87a37bd..cb40c8f 100644 (file)
@@ -1340,6 +1340,51 @@ EOF;
         $this->assertEquals(0, $fileinfo['foldercount']);   // No subdirectories inside the directory.
         $this->assertEquals($file->get_filesize(), $fileinfo['filesize_without_references']);
     }
+
+    /**
+     * Test confirming that draft files not referenced in the editor text are removed.
+     */
+    public function test_file_remove_editor_orphaned_files() {
+        global $USER, $CFG;
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        // Create three draft files.
+        $filerecord = ['filename'  => 'file1.png'];
+        $file = self::create_draft_file($filerecord);
+        $draftitemid = $file->get_itemid();
+
+        $filerecord['itemid'] = $draftitemid;
+
+        $filerecord['filename'] = 'file2.png';
+        self::create_draft_file($filerecord);
+
+        $filerecord['filename'] = 'file 3.png';
+        self::create_draft_file($filerecord);
+
+        // Confirm the user drafts area lists 3 files.
+        $fs = get_file_storage();
+        $usercontext = context_user::instance($USER->id);
+        $draftfiles = $fs->get_area_files($usercontext->id, 'user', 'draft', $draftitemid, 'itemid', 0);
+        $this->assertCount(3, $draftfiles);
+
+        // Now, spoof some editor text content, referencing 2 of the files; one requiring name encoding, one not.
+        $editor = [
+            'itemid' => $draftitemid,
+            'text' => '
+                <img src="'.$CFG->wwwroot.'/draftfile.php/'.$usercontext->id.'/user/draft/'.$draftitemid.'/file%203.png" alt="">
+                <img src="'.$CFG->wwwroot.'/draftfile.php/'.$usercontext->id.'/user/draft/'.$draftitemid.'/file1.png" alt="">'
+        ];
+
+        // Run the remove orphaned drafts function and confirm that only the referenced files remain in the user drafts.
+        $expected = ['file1.png', 'file 3.png']; // The drafts we expect will not be removed (are referenced in the online text).
+        file_remove_editor_orphaned_files($editor);
+        $draftfiles = $fs->get_area_files($usercontext->id, 'user', 'draft', $draftitemid, 'itemid', 0);
+        $this->assertCount(2, $draftfiles);
+        foreach ($draftfiles as $file) {
+            $this->assertContains($file->get_filename(), $expected);
+        }
+    }
 }
 
 /**
index 59058ed..79b9478 100644 (file)
@@ -80,13 +80,13 @@ class core_analytics_indicators_testcase extends advanced_testcase {
         $data[$user2->id]['user'] = $user2;
         $indicator->add_sample_data($data);
 
-        $values = $indicator->calculate($sampleids, 'notrelevanthere');
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere');
         $this->assertEquals($indicator::get_min_value(), $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
         \logstore_standard\event\unittest_executed::create(
             array('context' => $coursecontext, 'userid' => $user1->id))->trigger();
-        $values = $indicator->calculate($sampleids, 'notrelevanthere');
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere');
         $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
@@ -112,13 +112,13 @@ class core_analytics_indicators_testcase extends advanced_testcase {
         $data[$user2->id]['user'] = $user2;
         $indicator->add_sample_data($data);
 
-        $values = $indicator->calculate($sampleids, 'notrelevanthere');
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere');
         $this->assertEquals($indicator::get_min_value(), $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
         \logstore_standard\event\unittest_executed::create(
             array('context' => $coursecontext, 'userid' => $user1->id))->trigger();
-        $values = $indicator->calculate($sampleids, 'notrelevanthere');
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere');
         $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
@@ -141,7 +141,7 @@ class core_analytics_indicators_testcase extends advanced_testcase {
         $data[$user2->id]['user'] = $user2;
         $indicator->add_sample_data($data);
 
-        $values = $indicator->calculate($sampleids, 'user');
+        list($values, $unused) = $indicator->calculate($sampleids, 'user');
         $this->assertEquals($indicator::get_min_value(), $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
@@ -150,7 +150,7 @@ class core_analytics_indicators_testcase extends advanced_testcase {
 
         \logstore_standard\event\unittest_executed::create(
             array('context' => $coursecontext1, 'userid' => $user1->id))->trigger();
-        $values = $indicator->calculate($sampleids, 'user');
+        list($values, $unused) = $indicator->calculate($sampleids, 'user');
         $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
@@ -171,7 +171,7 @@ class core_analytics_indicators_testcase extends advanced_testcase {
 
         // Limited by time to avoid previous logs interfering as other logs
         // have been generated by the system.
-        $values = $indicator->calculate($sampleids, 'course', $beforecourseeventcreate);
+        list($values, $unused) = $indicator->calculate($sampleids, 'course', $beforecourseeventcreate);
         $this->assertEquals($indicator::get_max_value(), $values[$course1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$course2->id][0]);
 
@@ -197,21 +197,21 @@ class core_analytics_indicators_testcase extends advanced_testcase {
         $enddate = time() + (WEEKSECS * 2);
 
         $this->setAdminUser();
-        $values = $indicator->calculate($sampleids, 'user');
+        list($values, $unused) = $indicator->calculate($sampleids, 'user');
         $this->assertNull($values[$user1->id][0]);
         $this->assertNull($values[$user1->id][1]);
         $this->assertNull($values[$user1->id][0]);
         $this->assertNull($values[$user2->id][1]);
 
         // Zero score for 0 accesses.
-        $values = $indicator->calculate($sampleids, 'user', $startdate, $enddate);
+        list($values, $unused) = $indicator->calculate($sampleids, 'user', $startdate, $enddate);
         $this->assertEquals($indicator::get_min_value(), $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
         // 1/3 score for more than 0 accesses.
         \core\event\course_viewed::create(
             array('context' => $coursecontext, 'userid' => $user1->id))->trigger();
-        $values = $indicator->calculate($sampleids, 'user', $startdate, $enddate);
+        list($values, $unused) = $indicator->calculate($sampleids, 'user', $startdate, $enddate);
         $this->assertEquals(-0.33, $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
@@ -220,7 +220,7 @@ class core_analytics_indicators_testcase extends advanced_testcase {
             \core\event\course_viewed::create(
                 array('context' => $coursecontext, 'userid' => $user1->id))->trigger();
         }
-        $values = $indicator->calculate($sampleids, 'user', $startdate, $enddate);
+        list($values, $unused) = $indicator->calculate($sampleids, 'user', $startdate, $enddate);
         $this->assertEquals(0.33, $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
@@ -229,7 +229,7 @@ class core_analytics_indicators_testcase extends advanced_testcase {
             \core\event\course_viewed::create(
                 array('context' => $coursecontext, 'userid' => $user1->id))->trigger();
         }
-        $values = $indicator->calculate($sampleids, 'user', $startdate, $enddate);
+        list($values, $unused) = $indicator->calculate($sampleids, 'user', $startdate, $enddate);
         $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
index 04314e4..b46dec2 100644 (file)
@@ -24,7 +24,8 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-require('../config.php');
+require(__DIR__ . '/../config.php');
+require(__DIR__ . '/lib.php');
 require_once($CFG->libdir . '/authlib.php');
 
 $data = optional_param('data', '', PARAM_RAW);  // Formatted as:  secret/username
@@ -61,7 +62,7 @@ if (!empty($data) || (!empty($p) && !empty($s))) {
         echo $OUTPUT->header();
         echo $OUTPUT->box_start('generalbox centerpara boxwidthnormal boxaligncenter');
         echo "<p>".get_string("alreadyconfirmed")."</p>\n";
-        echo $OUTPUT->single_button("$CFG->wwwroot/course/", get_string('courses'));
+        echo $OUTPUT->single_button(core_login_get_return_url(), get_string('courses'));
         echo $OUTPUT->box_end();
         echo $OUTPUT->footer();
         exit;
@@ -78,16 +79,6 @@ if (!empty($data) || (!empty($p) && !empty($s))) {
             complete_user_login($user);
 
             \core\session\manager::apply_concurrent_login_limit($user->id, session_id());
-
-            // Check where to go, $redirect has a higher preference.
-            if (empty($redirect) and !empty($SESSION->wantsurl) ) {
-                $redirect = $SESSION->wantsurl;
-                unset($SESSION->wantsurl);
-            }
-
-            if (!empty($redirect)) {
-                redirect($redirect);
-            }
         }
 
         $PAGE->navbar->add(get_string("confirmed"));
@@ -97,7 +88,7 @@ if (!empty($data) || (!empty($p) && !empty($s))) {
         echo $OUTPUT->box_start('generalbox centerpara boxwidthnormal boxaligncenter');
         echo "<h3>".get_string("thanks").", ". fullname($USER) . "</h3>\n";
         echo "<p>".get_string("confirmed")."</p>\n";
-        echo $OUTPUT->single_button("$CFG->wwwroot/course/", get_string('courses'));
+        echo $OUTPUT->single_button(core_login_get_return_url(), get_string('continue'));
         echo $OUTPUT->box_end();
         echo $OUTPUT->footer();
         exit;
index 6e27a0a..c6c8209 100644 (file)
@@ -56,7 +56,10 @@ function mnet_get_public_key($uri, $application=null) {
         $application = $DB->get_record('mnet_application', array('name'=>'moodle'));
     }
 
-    $rq = xmlrpc_encode_request('system/keyswap', array($CFG->wwwroot, $mnet->public_key, $application->name), array("encoding" => "utf-8"));
+    $rq = xmlrpc_encode_request('system/keyswap', array($CFG->wwwroot, $mnet->public_key, $application->name), array(
+        'encoding' => 'utf-8',
+        'escaping' => 'markup',
+    ));
     $ch = curl_init($uri . $application->xmlrpc_server_url);
 
     curl_setopt($ch, CURLOPT_TIMEOUT, 60);
index a499bd6..98aa899 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthassign', 'mod_assign');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_assign');
     }
 
     /**
index 1ff1fda..7874285 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthassign', 'mod_assign');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_assign');
     }
 
     /**
index ec4d368..921c54c 100644 (file)
@@ -266,8 +266,10 @@ $string['groupsnone'] = 'There are no groups in this course';
 $string['hideshow'] = 'Hide/Show';
 $string['hiddenuser'] = 'Participant ';
 $string['inactiveoverridehelp'] = '* Student does not have the correct group or role to attempt the assignment';
-$string['indicator:cognitivedepthassign'] = 'Assignment cognitive';
-$string['indicator:socialbreadthassign'] = 'Assignment social';
+$string['indicator:cognitivedepth'] = 'Assignment cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Assignment social';
+$string['indicator:socialbreadth_help'] = '';
 $string['instructionfiles'] = 'Instruction files';
 $string['introattachments'] = 'Additional files';
 $string['introattachments_help'] = 'Additional files for use in the assignment, such as answer templates, may be added. Download links for the files will then be displayed on the assignment page under the description.';
index 003229b..fab4a6b 100644 (file)
@@ -2889,7 +2889,12 @@ class assign {
             $item = $this->get_submission($submissionid);
 
             // Check permissions.
-            $this->require_view_submission($item->userid);
+            if (empty($item->userid)) {
+                // Group submission.
+                $this->require_view_group_submission($item->groupid);
+            } else {
+                $this->require_view_submission($item->userid);
+            }
             $o .= $this->get_renderer()->render(new assign_header($this->get_instance(),
                                                               $this->get_context(),
                                                               $this->show_intro(),
@@ -2944,11 +2949,17 @@ class assign {
      * @param assign_plugin $plugin - The assignment plugin
      */
     public function download_rewrite_pluginfile_urls($text, $user, $plugin) {
-        $groupmode = groups_get_activity_groupmode($this->get_course_module());
+        // The groupname prefix for the urls doesn't depend on the group mode of the assignment instance.
+        // Rather, it should be determined by checking the group submission settings of the instance,
+        // which is what download_submission() does when generating the file name prefixes.
         $groupname = '';
-        if ($groupmode) {
-            $groupid = groups_get_activity_group($this->get_course_module(), true);
-            $groupname = groups_get_group_name($groupid).'-';
+        if ($this->get_instance()->teamsubmission) {
+            $submissiongroup = $this->get_submission_group($user->id);
+            if ($submissiongroup) {
+                $groupname = $submissiongroup->name . '-';
+            } else {
+                $groupname = get_string('defaultteam', 'assign') . '-';
+            }
         }
 
         if ($this->is_blind_marking()) {
@@ -2961,10 +2972,14 @@ class assign {
             $prefix = clean_filename($prefix . '_' . $this->get_uniqueid_for_user($user->id) . '_');
         }
 
-        $subtype = $plugin->get_subtype();
-        $type = $plugin->get_type();
-        $prefix = $prefix . $subtype . '_' . $type . '_';
-
+        // Only prefix files if downloadasfolders user preference is NOT set.
+        if (!get_user_preferences('assign_downloadasfolders', 1)) {
+            $subtype = $plugin->get_subtype();
+            $type = $plugin->get_type();
+            $prefix = $prefix . $subtype . '_' . $type . '_';
+        } else {
+            $prefix = "";
+        }
         $result = str_replace('@@PLUGINFILE@@/', $prefix, $text);
 
         return $result;
@@ -3084,6 +3099,18 @@ class assign {
         return '';
     }
 
+    /**
+     * Throw an error if the permissions to view this users' group submission are missing.
+     *
+     * @param int $groupid Group id.
+     * @throws required_capability_exception
+     */
+    public function require_view_group_submission($groupid) {
+        if (!$this->can_view_group_submission($groupid)) {
+            throw new required_capability_exception($this->context, 'mod/assign:viewgrades', 'nopermission', '');
+        }
+    }
+
     /**
      * Throw an error if the permissions to view this users submission are missing.
      *
@@ -3172,7 +3199,9 @@ class assign {
         $groupname = '';
         if ($groupmode) {
             $groupid = groups_get_activity_group($this->get_course_module(), true);
-            $groupname = groups_get_group_name($groupid).'-';
+            if (!empty($groupid)) {
+                $groupname = groups_get_group_name($groupid) . '-';
+            }
         }
 
         // Construct the zip file name.
index cab3f8f..6086b13 100644 (file)
@@ -35,10 +35,13 @@ Feature: In an assignment, limit submittable file types
     And I press "Save and display"
     And I navigate to "Edit settings" in current page administration
     And the field "Accepted file types" matches value "image/png,spreadsheet"
-    And I set the field "Accepted file types" to "image"
+    And I set the field "Accepted file types" to ""
+    And I press "Choose"
+    And I set the field "Image files" to "1"
+    And I press "Save changes"
     And I press "Save and display"
     And I navigate to "Edit settings" in current page administration
-    Then I should see "Image files"
+    Then the field "Accepted file types" matches value "image"
 
   @javascript @_file_upload
   Scenario: Uploading permitted file types for an assignment
index 5ec7c6d..17e342b 100644 (file)
@@ -169,12 +169,13 @@ class assign_submission_onlinetext extends assign_submission_plugin {
      * @return array
      */
     private function get_edit_options() {
-         $editoroptions = array(
-           'noclean' => false,
-           'maxfiles' => EDITOR_UNLIMITED_FILES,
-           'maxbytes' => $this->assignment->get_course()->maxbytes,
-           'context' => $this->assignment->get_context(),
-           'return_types' => (FILE_INTERNAL | FILE_EXTERNAL | FILE_CONTROLLED_LINK)
+        $editoroptions = array(
+            'noclean' => false,
+            'maxfiles' => EDITOR_UNLIMITED_FILES,
+            'maxbytes' => $this->assignment->get_course()->maxbytes,
+            'context' => $this->assignment->get_context(),
+            'return_types' => (FILE_INTERNAL | FILE_EXTERNAL | FILE_CONTROLLED_LINK),
+            'removeorphaneddrafts' => true // Whether or not to remove any draft files which aren't referenced in the text.
         );
         return $editoroptions;
     }
index 37d3461..705a537 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthbook', 'mod_book');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_book');
     }
 
     /**
index bbe6772..45ec925 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthbook', 'mod_book');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_book');
     }
 
     /**
index fdae839..c3f3056 100644 (file)
@@ -55,8 +55,10 @@ $string['eventchapterupdated'] = 'Chapter updated';
 $string['eventchapterviewed'] = 'Chapter viewed';
 $string['editchapter'] = 'Edit chapter "{$a}"';
 $string['hidechapter'] = 'Hide chapter "{$a}"';
-$string['indicator:cognitivedepthbook'] = 'Book cognitive';
-$string['indicator:socialbreadthbook'] = 'Book social';
+$string['indicator:cognitivedepth'] = 'Book cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Book social';
+$string['indicator:socialbreadth_help'] = '';
 $string['movechapterup'] = 'Move chapter up "{$a}"';
 $string['movechapterdown'] = 'Move chapter down "{$a}"';
 $string['search:activity'] = 'Book - resource information';
index aa74afd..4cfaa82 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthchat', 'mod_chat');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_chat');
     }
 
     /**
index e751c4a..4d92426 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthchat', 'mod_chat');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_chat');
     }
 
     /**
index 881911f..6fd468d 100644 (file)
@@ -67,8 +67,10 @@ $string['entermessage'] = "Enter your message";
 $string['eventmessagesent'] = 'Message sent';
 $string['eventsessionsviewed'] = 'Sessions viewed';
 $string['idle'] = 'Idle';
-$string['indicator:cognitivedepthchat'] = 'Chat cognitive';
-$string['indicator:socialbreadthchat'] = 'Chat social';
+$string['indicator:cognitivedepth'] = 'Chat cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Chat social';
+$string['indicator:socialbreadth_help'] = '';
 $string['inputarea'] = 'Input area';
 $string['invalidid'] = 'Could not find that chat room!';
 $string['list_all_sessions'] = 'List all sessions.';
index 050f71e..f325b02 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthchoice', 'mod_choice');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_choice');
     }
 
     /**
index ff48e80..0afd601 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthchoice', 'mod_choice');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_choice');
     }
 
     /**
index 1ad15e1..63f1e7b 100644 (file)
@@ -71,8 +71,10 @@ $string['chooseaction'] = 'Choose an action ...';
 $string['chooseoption'] = 'Choose: {$a}';
 $string['description'] = 'Description';
 $string['includeinactive'] = 'Include responses from inactive/suspended users';
-$string['indicator:cognitivedepthchoice'] = 'Choice cognitive';
-$string['indicator:socialbreadthchoice'] = 'Choice social';
+$string['indicator:cognitivedepth'] = 'Choice cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Choice social';
+$string['indicator:socialbreadth_help'] = '';
 $string['limit'] = 'Limit';
 $string['limitno'] = 'Limit {no}';
 $string['limitanswers'] = 'Limit the number of responses allowed';
index 3380ce0..29b6fbb 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthdata', 'mod_data');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_data');
     }
 
     /**
index 3156cfe..0c2da04 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthdata', 'mod_data');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_data');
     }
 
     /**
index ef3984a..4438acc 100644 (file)
@@ -203,8 +203,10 @@ $string['importsuccess'] = 'The preset has been successfully applied.';
 $string['includeapproval'] = 'Include approval status';
 $string['includetime'] = 'Include time added/modified';
 $string['includeuserdetails'] = 'Include user details';
-$string['indicator:cognitivedepthdata'] = 'Database cognitive';
-$string['indicator:socialbreadthdata'] = 'Database social';
+$string['indicator:cognitivedepth'] = 'Database cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Database social';
+$string['indicator:socialbreadth_help'] = '';
 $string['insufficiententries'] = 'more entries needed to view this database';
 $string['intro'] = 'Description';
 $string['invalidaccess'] = 'This page was not accessed correctly';
index 7e39604..93bb252 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthfeedback', 'mod_feedback');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_feedback');
     }
 
     /**
index 67e8f60..0ad198e 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthfeedback', 'mod_feedback');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_feedback');
     }
 
     /**
index 825176c..4afc33f 100644 (file)
@@ -139,8 +139,10 @@ $string['downloadresponseas'] = 'Download all responses as:';
 $string['importfromthisfile'] = 'Import from this file';
 $string['import_questions'] = 'Import questions';
 $string['import_successfully'] = 'Import successfully';
-$string['indicator:cognitivedepthfeedback'] = 'Feedback cognitive';
-$string['indicator:socialbreadthfeedback'] = 'Feedback social';
+$string['indicator:cognitivedepth'] = 'Feedback cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Feedback social';
+$string['indicator:socialbreadth_help'] = '';
 $string['info'] = 'Information';
 $string['infotype'] = 'Information type';
 $string['insufficient_responses_for_this_group'] = 'There are insufficient responses for this group';
index ba81418..b3f49da 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthfolder', 'mod_folder');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_folder');
     }
 
     /**
index 9d5d821..932e162 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthfolder', 'mod_folder');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_folder');
     }
 
     /**
index c5b9c41..1d68fb3 100644 (file)
@@ -33,8 +33,10 @@ $string['folder:addinstance'] = 'Add a new folder';
 $string['folder:managefiles'] = 'Manage files in folder module';
 $string['folder:view'] = 'View folder content';
 $string['foldercontent'] = 'Files and subfolders';
-$string['indicator:cognitivedepthfolder'] = 'Folder cognitive';
-$string['indicator:socialbreadthfolder'] = 'Folder social';
+$string['indicator:cognitivedepth'] = 'Folder cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Folder social';
+$string['indicator:socialbreadth_help'] = '';
 $string['modulename'] = 'Folder';
 $string['modulename_help'] = 'The folder module enables a teacher to display a number of related files inside a single folder, reducing scrolling on the course page. A zipped folder may be uploaded and unzipped for display, or an empty folder created and files uploaded into it.
 
index 026f4a7..8696dfb 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthforum', 'mod_forum');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_forum');
     }
 
     /**
index 7cac14a..a7508b2 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthforum', 'mod_forum');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_forum');
     }
 
     /**
index 0fc9234..2160367 100644 (file)
@@ -269,8 +269,10 @@ $string['forum:viewsubscribers'] = 'View subscribers';
 $string['generalforum'] = 'Standard forum for general use';
 $string['generalforums'] = 'General forums';
 $string['hiddenforumpost'] = 'Hidden forum post';
-$string['indicator:cognitivedepthforum'] = 'Forum cognitive';
-$string['indicator:socialbreadthforum'] = 'Forum social';
+$string['indicator:cognitivedepth'] = 'Forum cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Forum social';
+$string['indicator:socialbreadth_help'] = '';
 $string['inforum'] = 'in {$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';
index b5a7863..de2f2a8 100644 (file)
     $PAGE->add_body_class('forumtype-'.$forum->type);
     $PAGE->set_heading($course->fullname);
 
-/// Some capability checks.
+    // Some capability checks.
+    $courselink = new moodle_url('/course/view.php', ['id' => $cm->course]);
+
     if (empty($cm->visible) and !has_capability('moodle/course:viewhiddenactivities', $context)) {
-        notice(get_string("activityiscurrentlyhidden"));
+        notice(get_string("activityiscurrentlyhidden"), $courselink);
     }
 
     if (!has_capability('mod/forum:viewdiscussion', $context)) {
-        notice(get_string('noviewdiscussionspermission', 'forum'));
+        notice(get_string('noviewdiscussionspermission', 'forum'), $courselink);
     }
 
     // Mark viewed and trigger the course_module_viewed event.
index 8aa13a2..f7f1b2c 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthglossary', 'mod_glossary');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_glossary');
     }
 
     /**
index 95d0571..b1e64b7 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthglossary', 'mod_glossary');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_glossary');
     }
 
     /**
index bbff437..2dff272 100644 (file)
@@ -215,8 +215,10 @@ $string['importedentries'] = 'Imported entries';
 $string['importentries'] = 'Import entries';
 $string['importentriesfromxml'] = 'Import entries from XML file';
 $string['includegroupbreaks'] = 'Include group breaks';
-$string['indicator:cognitivedepthglossary'] = 'Glossary cognitive';
-$string['indicator:socialbreadthglossary'] = 'Glossary social';
+$string['indicator:cognitivedepth'] = 'Glossary cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Glossary social';
+$string['indicator:socialbreadth_help'] = '';
 $string['isglobal'] = 'Is this glossary global?';
 $string['isglobal_help'] = 'A global glossary has entries which are linked to from throughout the site, rather than only in the course that the glossary is in. Only administrators can set a glossary as global.';
 $string['letter'] = 'letter';
index bf72034..39fa772 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthimscp', 'mod_imscp');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_imscp');
     }
 
     /**
index 7b6a788..953875f 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthimscp', 'mod_imscp');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_imscp');
     }
 
     /**
index f2cbb03..5b83b0f 100644 (file)
@@ -28,8 +28,10 @@ $string['contentheader'] = 'Content';
 $string['deploymenterror'] = 'Content package error!';
 $string['keepold'] = 'Archive packages';
 $string['keepoldexplain'] = 'How many packages should be archived?';
-$string['indicator:cognitivedepthimscp'] = 'IMS content packages\' cognitive';
-$string['indicator:socialbreadthimscp'] = 'IMS content packages\' social';
+$string['indicator:cognitivedepth'] = 'IMS content packages\' cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'IMS content packages\' social';
+$string['indicator:socialbreadth_help'] = '';
 $string['imscp:addinstance'] = 'Add a new IMS content package';
 $string['imscp:view'] = 'View IMS content';
 $string['modulename'] = 'IMS content package';
index 0c1d365..3daa74b 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthlabel', 'mod_label');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_label');
     }
 
     /**
index d2a0955..18afd3f 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthlabel', 'mod_label');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_label');
     }
 
     /**
index 0c2a13f..1c4ee35 100644 (file)
@@ -31,8 +31,10 @@ $string['dndresizeheight'] = 'Resize drag and drop height';
 $string['dndresizewidth'] = 'Resize drag and drop width';
 $string['dnduploadlabel'] = 'Add media to course page';
 $string['dnduploadlabeltext'] = 'Add a label to the course page';
-$string['indicator:cognitivedepthlabel'] = 'Label cognitive';
-$string['indicator:socialbreadthlabel'] = 'Label social';
+$string['indicator:cognitivedepth'] = 'Label cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Label social';
+$string['indicator:socialbreadth_help'] = '';
 $string['label:addinstance'] = 'Add a new label';
 $string['label:view'] = 'View label';
 $string['labeltext'] = 'Label text';
index 17f7122..012d420 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthlesson', 'mod_lesson');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_lesson');
     }
 
     /**
index 0f1294c..83fd27a 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthlesson', 'mod_lesson');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_lesson');
     }
 
     /**
index 3ba28b1..df331c5 100644 (file)
@@ -256,8 +256,10 @@ $string['importquestions'] = 'Import questions';
 $string['importquestions_help'] = 'This feature enables questions in a variety of formats to be imported via text file.';
 $string['inactiveoverridehelp'] = '* Student does not have the correct group or role to view/attempt the lesson';
 $string['insertedpage'] = 'Inserted page';
-$string['indicator:cognitivedepthlesson'] = 'Lesson cognitive';
-$string['indicator:socialbreadthlesson'] = 'Lesson social';
+$string['indicator:cognitivedepth'] = 'Lesson cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Lesson social';
+$string['indicator:socialbreadth_help'] = '';
 $string['invalidfile'] = 'Invalid file';
 $string['invalidid'] = 'No course module ID or lesson ID were passed';
 $string['invalidlessonid'] = 'lesson ID was incorrect';
index 8256004..1253979 100644 (file)
@@ -704,10 +704,12 @@ function lesson_get_overview_report_table_and_data(lesson $lesson, $currentgroup
         list($esql, $params) = get_enrolled_sql($context, '', $currentgroup, true);
         list($sort, $sortparams) = users_order_by_sql('u');
 
+        $extrafields = get_extra_user_fields($context);
+
         $params['a1lessonid'] = $lesson->id;
         $params['b1lessonid'] = $lesson->id;
         $params['c1lessonid'] = $lesson->id;
-        $ufields = user_picture::fields('u');
+        $ufields = user_picture::fields('u', $extrafields);
         $sql = "SELECT DISTINCT $ufields
                 FROM {user} u
                 JOIN (
@@ -895,16 +897,35 @@ function lesson_get_overview_report_table_and_data(lesson $lesson, $currentgroup
 
     $table = new html_table();
 
+    $headers = [get_string('name')];
+
+    foreach ($extrafields as $field) {
+        $headers[] = get_user_field_name($field);
+    }
+
+    $headers [] = get_string('attempts', 'lesson');
+
     // Set up the table object.
     if ($data->lessonscored) {
-        $table->head = array(get_string('name'), get_string('attempts', 'lesson'), get_string('highscore', 'lesson'));
-    } else {
-        $table->head = array(get_string('name'), get_string('attempts', 'lesson'));
+        $headers [] = get_string('highscore', 'lesson');
+    }
+
+    $colcount = count($headers);
+
+    $table->head = $headers;
+
+    $table->align = [];
+    $table->align = array_pad($table->align, $colcount, 'center');
+    $table->align[$colcount - 1] = 'left';
+
+    if ($data->lessonscored) {
+        $table->align[$colcount - 2] = 'left';
     }
-    $table->align = array('center', 'left', 'left');
-    $table->wrap = array('nowrap', 'nowrap', 'nowrap');
+
+    $table->wrap = [];
+    $table->wrap = array_pad($table->wrap, $colcount, 'nowrap');
+
     $table->attributes['class'] = 'standardtable generaltable';
-    $table->size = array(null, '70%', null);
 
     // print out the $studentdata array
     // going through each student that has attempted the lesson, so, each student should have something to be displayed
@@ -991,14 +1012,21 @@ function lesson_get_overview_report_table_and_data(lesson $lesson, $currentgroup
             }
             // get line breaks in after each attempt
             $attempts = implode("<br />\n", $attempts);
+            $row = [$studentname];
+
+            foreach ($extrafields as $field) {
+                $row[] = $student->$field;
+            }
+
+            $row[] = $attempts;
 
             if ($data->lessonscored) {
                 // Add the grade if the lesson is graded.
-                $table->data[] = array($studentname, $attempts, $bestgrade . "%");
-            } else {
-                // This lesson does not have a grade.
-                $table->data[] = array($studentname, $attempts);
+                $row[] = $bestgrade."%";
             }
+
+            $table->data[] = $row;
+
             // Add the student data.
             $dataforstudent->id = $student->id;
             $dataforstudent->fullname = $studentname;
index 247d0d8..bcfe466 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthlti', 'mod_lti');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_lti');
     }
 
     /**
index f7b80d9..24e7c5f 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthlti', 'mod_lti');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_lti');
     }
 
     /**
index d9d62c4..552723b 100644 (file)
@@ -214,8 +214,10 @@ $string['icon_url'] = 'Icon URL';
 $string['icon_url_help'] = 'The icon URL allows the icon that shows up in the course listing for this activity to be modified. Instead of using the default
 LTI icon, an icon which conveys the type of activity may be specified.';
 $string['id'] = 'id';
-$string['indicator:socialbreadthlti'] = 'LTI social';
-$string['indicator:cognitivedepthlti'] = 'LTI cognitive';
+$string['indicator:cognitivedepth'] = 'LTI cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'LTI social';
+$string['indicator:socialbreadth_help'] = '';
 $string['invalidid'] = 'LTI ID was incorrect';
 $string['launch_in_moodle'] = 'Launch tool in moodle';
 $string['launch_in_popup'] = 'Launch tool in a pop-up';
index c5c0ba6..b988204 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthpage', 'mod_page');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_page');
     }
 
     /**
index 3fccfe6..852d348 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthpage', 'mod_page');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_page');
     }
 
     /**
index 59fe3ab..0d97e85 100644 (file)
@@ -30,8 +30,10 @@ $string['createpage'] = 'Create a new page resource';
 $string['displayoptions'] = 'Available display options';
 $string['displayselect'] = 'Display';
 $string['displayselectexplain'] = 'Select display type.';
-$string['indicator:cognitivedepthpage'] = 'Page cognitive';
-$string['indicator:socialbreadthpage'] = 'Page social';
+$string['indicator:cognitivedepth'] = 'Page cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Page social';
+$string['indicator:socialbreadth_help'] = '';
 $string['legacyfiles'] = 'Migration of old course file';
 $string['legacyfilesactive'] = 'Active';
 $string['legacyfilesdone'] = 'Finished';
index e47ffdb..86d5155 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthquiz', 'mod_quiz');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_quiz');
     }
 
     /**
index 6259a4e..b110426 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthquiz', 'mod_quiz');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_quiz');
     }
 
     /**
index 4580a84..3b55ce8 100644 (file)
@@ -431,8 +431,10 @@ $string['importmax10error'] = 'There is an error in the question. You may not ha
 $string['importquestions'] = 'Import questions from file';
 $string['inactiveoverridehelp'] = '* Student does not have the correct group or role to attempt the quiz';
 $string['incorrect'] = 'Incorrect';
-$string['indicator:cognitivedepthquiz'] = 'Quiz cognitive';
-$string['indicator:socialbreadthquiz'] = 'Quiz social';
+$string['indicator:cognitivedepth'] = 'Quiz cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Quiz social';
+$string['indicator:socialbreadth_help'] = '';
 $string['indivresp'] = 'Responses of individuals to each item';
 $string['info'] = 'Info';
 $string['infoshort'] = 'i';
index 25bc096..28b4a66 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthresource', 'mod_resource');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_resource');
     }
 
     /**
index 9637e46..dd62403 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthresource', 'mod_resource');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_resource');
     }
 
     /**
index 3d730b2..786b1dd 100644 (file)
@@ -60,8 +60,10 @@ $string['filterfilesexplain'] = 'Select type of file content filtering, please n
 $string['filtername'] = 'Resource names auto-linking';
 $string['forcedownload'] = 'Force download';
 $string['framesize'] = 'Frame height';
-$string['indicator:cognitivedepthresource'] = 'File cognitive';
-$string['indicator:socialbreadthresource'] = 'File social';
+$string['indicator:cognitivedepth'] = 'File cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'File social';
+$string['indicator:socialbreadth_help'] = '';
 $string['legacyfiles'] = 'Migration of old course file';
 $string['legacyfilesactive'] = 'Active';
 $string['legacyfilesdone'] = 'Finished';
index 82d43b5..2ea656c 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthscorm', 'mod_scorm');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_scorm');
     }
 
     /**
index d431652..c45096a 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthscorm', 'mod_scorm');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_scorm');
     }
 
     /**
index 0c9f38b..3dff5f9 100644 (file)
@@ -196,8 +196,10 @@ $string['highestattempt'] = 'Highest attempt';
 $string['chooseapacket'] = 'Choose or update a package';
 $string['identifier'] = 'Question identifier';
 $string['incomplete'] = 'Incomplete';
-$string['indicator:cognitivedepthscorm'] = 'SCORM cognitive';
-$string['indicator:socialbreadthscorm'] = 'SCORM social';
+$string['indicator:cognitivedepth'] = 'SCORM cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'SCORM social';
+$string['indicator:socialbreadth_help'] = '';
 $string['info'] = 'Info';
 $string['interactions'] = 'Interactions';
 $string['masteryoverride'] = 'Mastery score overrides status';
index 9e8e5e1..b991e1b 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthsurvey', 'mod_survey');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_survey');
     }
 
     /**
index 483458b..b1bc1fd 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthsurvey', 'mod_survey');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_survey');
     }
 
     /**
index 46d4918..f23d7b5 100644 (file)
@@ -206,8 +206,10 @@ $string['guestsnotallowed'] = 'Only enrolled users are able to submit surveys';
 $string['howlong'] = 'How long did this survey take you to complete?';
 $string['howlongoptions'] = 'under 1 min,1-2 min,2-3 min,3-4 min,4-5-min,5-10 min,more than 10';
 $string['ifoundthat'] = 'I found that';
-$string['indicator:cognitivedepthsurvey'] = 'Survey cognitive';
-$string['indicator:socialbreadthsurvey'] = 'Survey social';
+$string['indicator:cognitivedepth'] = 'Survey cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Survey social';
+$string['indicator:socialbreadth_help'] = '';
 $string['introtext'] = 'Introduction text';
 $string['invalidsurveyid'] = 'Survey ID was incorrect';
 $string['invalidtmptid'] = 'Invalid template id';
index 9c68162..46c256b 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthurl', 'mod_url');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_url');
     }
 
     /**
index 48c6f0b..d4038a8 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthurl', 'mod_url');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_url');
     }
 
     /**
index f14e918..b92c843 100644 (file)
@@ -45,8 +45,10 @@ $string['externalurl'] = 'External URL';
 $string['framesize'] = 'Frame height';
 $string['invalidstoredurl'] = 'Cannot display this resource, URL is invalid.';
 $string['chooseavariable'] = 'Choose a variable...';
-$string['indicator:cognitivedepthurl'] = 'URL cognitive';
-$string['indicator:socialbreadthurl'] = 'URL social';
+$string['indicator:cognitivedepth'] = 'URL cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'URL social';
+$string['indicator:socialbreadth_help'] = '';
 $string['invalidurl'] = 'Entered URL is invalid';
 $string['modulename'] = 'URL';
 $string['modulename_help'] = 'The URL module enables a teacher to provide a web link as a course resource. Anything that is freely available online, such as documents or images, can be linked to; the URL doesn’t have to be the home page of a website. The URL of a particular web page may be copied and pasted or a teacher can use the file picker and choose a link from a repository such as Flickr, YouTube or Wikimedia (depending upon which repositories are enabled for the site).
index fd4a47a..c84a33a 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthwiki', 'mod_wiki');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_wiki');
     }
 
     /**
index a02018d..44269f3 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthwiki', 'mod_wiki');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_wiki');
     }
 
     /**
index 4b6a208..2aae820 100644 (file)
@@ -107,8 +107,10 @@ $string['incorrectdeleteversions'] = "Page versions provided for deletion are in
 $string['incorrectpageid'] = 'Page ID is incorrect.';
 $string['incorrectsubwikiid'] = 'Subwiki ID is incorrect.';
 $string['incorrectwikiid'] = 'Wiki ID is incorrect.';
-$string['indicator:cognitivedepthwiki'] = 'Wiki cognitive';
-$string['indicator:socialbreadthwiki'] = 'Wiki social';
+$string['indicator:cognitivedepth'] = 'Wiki cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Wiki social';
+$string['indicator:socialbreadth_help'] = '';
 $string['insertcomment'] = 'Insert comment';
 $string['insertimage'] = 'Insert an image...';
 $string['insertimage_help'] = 'This drop-down list will insert an image to the wiki editor. If you need to add more images to the wiki, please use "Files" tab.';
index e55d0a5..90f64c8 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class cognitive_depth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:cognitivedepthworkshop', 'mod_workshop');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:cognitivedepth', 'mod_workshop');
     }
 
     /**
index dca70fd..0f5d748 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class social_breadth extends activity_base {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:socialbreadthworkshop', 'mod_workshop');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:socialbreadth', 'mod_workshop');
     }
 
     /**
index ddaadc5..3b61476 100644 (file)
@@ -175,8 +175,10 @@ $string['gradingsettings'] = 'Grading settings';
 $string['groupnoallowed'] = 'You are not allowed to access any group in this workshop';
 $string['chooseuser'] = 'Choose user...';
 $string['iamsure'] = 'Yes, I am sure';
-$string['indicator:cognitivedepthworkshop'] = 'Workshop cognitive';
-$string['indicator:socialbreadthworkshop'] = 'Workshop social';
+$string['indicator:cognitivedepth'] = 'Workshop cognitive';
+$string['indicator:cognitivedepth_help'] = '';
+$string['indicator:socialbreadth'] = 'Workshop social';
+$string['indicator:socialbreadth_help'] = '';
 $string['info'] = 'Info';
 $string['instructauthors'] = 'Instructions for submission';
 $string['instructreviewers'] = 'Instructions for assessment';
index 982e6bf..c9e8049 100644 (file)
@@ -138,3 +138,12 @@ ol {
     max-height: calc(100vh - #{$navbar-height});
     overflow-y: auto;
 }
+
+// Dont allow z-index creep anywhere.
+.page-item {
+    &.active .page-link {
+        @include plain-hover-focus {
+            z-index: inherit;
+        }
+    }
+}
index f9a6aa7..c41921d 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class user_profile_set extends \core_analytics\local\indicator\linear {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:completeduserprofile');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:completeduserprofile');
     }
 
     /**
index 29b4e76..68ebe9e 100644 (file)
@@ -36,12 +36,14 @@ defined('MOODLE_INTERNAL') || die();
 class user_track_forums extends \core_analytics\local\indicator\binary {
 
     /**
-     * get_name
+     * Returns the name.
      *
-     * @return string
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
      */
-    public static function get_name() {
-        return get_string('indicator:userforumstracking');
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:userforumstracking');
     }
 
     /**
index b8b4a63..7aafa36 100644 (file)
@@ -1153,19 +1153,21 @@ function user_can_view_profile($user, $course = null, $usercontext = null) {
     }
 
     if (isset($course)) {
-        $sharedcourses = array($course);
+        $userscourses = array($course);
     } else {
-        $sharedcourses = enrol_get_shared_courses($USER->id, $user->id, true);
+        // This returns context information, so we can preload below.
+        $userscourses = enrol_get_all_users_courses($user->id);
     }
 
-    if (empty($sharedcourses)) {
+    if (empty($userscourses)) {
         return false;
     }
 
-    foreach ($sharedcourses as $sharedcourse) {
-        $coursecontext = context_course::instance($sharedcourse->id);
+    foreach ($userscourses as $userscourse) {
+        context_helper::preload_from_record($userscourse);
+        $coursecontext = context_course::instance($userscourse->id);
         if (has_capability('moodle/user:viewdetails', $coursecontext)) {
-            if (!groups_user_groups_visible($sharedcourse, $user->id)) {
+            if (!groups_user_groups_visible($userscourse, $user->id)) {
                 // Not a member of the same group.
                 continue;
             }
index 3e88074..dc4b69d 100644 (file)
@@ -624,6 +624,40 @@ class core_userliblib_testcase extends advanced_testcase {
         foreach ($users as $user) {
             $this->assertTrue(user_can_view_profile($user));
         }
+
+        // Testing non-shared courses where capabilities are met, using system role overrides.
+        $CFG->forceloginforprofiles = $tempcfg;
+        $course4 = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->enrol_user($user1->id, $course4->id);
+
+        // Assign a manager role at the system context.
+        $managerrole = $DB->get_record('role', array('shortname' => 'manager'));
+        $user9 = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->role_assign($managerrole->id, $user9->id);
+
+        // Make sure viewalldetails and viewdetails are overridden to 'prevent' (i.e. can be overridden at a lower context).
+        $systemcontext = context_system::instance();
+        assign_capability('moodle/user:viewdetails', CAP_PREVENT, $managerrole->id, $systemcontext, true);
+        assign_capability('moodle/user:viewalldetails', CAP_PREVENT, $managerrole->id, $systemcontext, true);
+        $systemcontext->mark_dirty();
+
+        // And override these to 'Allow' in a specific course.
+        $course4context = context_course::instance($course4->id);
+        assign_capability('moodle/user:viewalldetails', CAP_ALLOW, $managerrole->id, $course4context, true);
+        assign_capability('moodle/user:viewdetails', CAP_ALLOW, $managerrole->id, $course4context, true);
+        $course4context->mark_dirty();
+
+        // The manager now shouldn't have viewdetails in the system or user context.
+        $this->setUser($user9);
+        $user1context = context_user::instance($user1->id);
+        $this->assertFalse(has_capability('moodle/user:viewdetails', $systemcontext));
+        $this->assertFalse(has_capability('moodle/user:viewdetails', $user1context));
+
+        // Confirm that user_can_view_profile() returns true for $user1 when called without $course param. It should find $course1.
+        $this->assertTrue(user_can_view_profile($user1));
+
+        // Confirm this also works when restricting scope to just that course.
+        $this->assertTrue(user_can_view_profile($user1, $course4));
     }
 
     /**
index b63e633..13128ed 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2017082100.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2017082300.00;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 
index 6d48200..7f5e288 100644 (file)
@@ -14,7 +14,6 @@
 // You should have received a copy of the GNU General Public License
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
-
 /**
  * Moodle XML-RPC library
  *
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+defined('MOODLE_INTERNAL') || die();
+
 /**
  * Moodle XML-RPC client
  *
- * It has been implemented for unit testing purpose (all protocols have similar client)
- *
  * @package    webservice_xmlrpc
  * @copyright  2010 Jerome Mouneyrac
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -73,15 +72,7 @@ class webservice_xmlrpc_client {
             $this->serverurl->param('wstoken', $this->token);
         }
 
-        // Set output options.
-        $outputoptions = array(
-            'encoding' => 'utf-8'
-        );
-
-        // Encode the request.
-        // See MDL-53962 - needed for backwards compatibility on <= 3.0
-        $params = array_values($params);
-        $request = xmlrpc_encode_request($functionname, $params, $outputoptions);
+        $request = $this->encode_request($functionname, $params);
 
         // Set the headers.
         $headers = array(
@@ -102,4 +93,24 @@ class webservice_xmlrpc_client {
 
         return $result;
     }
+
+    /**
+     * Generates XML for a method request.
+     *
+     * @param string $functionname Name of the method to call.
+     * @param mixed $params Method parameters compatible with the method signature.
+     * @return string
+     */
+    protected function encode_request($functionname, $params) {
+
+        $outputoptions = array(
+            'encoding' => 'utf-8',
+            'escaping' => 'markup',
+        );
+
+        // See MDL-53962 - needed for backwards compatibility on <= 3.0.
+        $params = array_values($params);
+
+        return xmlrpc_encode_request($functionname, $params, $outputoptions);
+    }
 }
index 8adc438..6a7c7fe 100644 (file)
@@ -88,6 +88,27 @@ class webservice_xmlrpc_test extends advanced_testcase {
         $this->expectException('moodle_exception');
         $client->call('testfunction');
     }
+
+    /**
+     * Test the XML-RPC request encoding.
+     */
+    public function test_encode_request() {
+
+        $client = new webservice_xmlrpc_client_mock('/webservice/xmlrpc/server.php', 'anytoken');
+
+        // Encode the request with the proper encoding and escaping options.
+        $xml = $client->encode_request('do_it', ['foo' => '<bar>ŠČŘŽÝÁÍÉ</bar>']);
+
+        // Assert that decoding with explicit encoding will work. This appeared
+        // to fail if the markup escaping was not set.
+        $this->assertEquals(['<bar>ŠČŘŽÝÁÍÉ</bar>'], xmlrpc_decode($xml, 'UTF-8'));
+
+        // Our experiments show that even with default/implicit encoding,
+        // requests encoded with markup escaping set are also decoded
+        // correctly. This is known to be used in some servers so we test it
+        // here, too.
+        $this->assertEquals(['<bar>ŠČŘŽÝÁÍÉ</bar>'], xmlrpc_decode($xml));
+    }
 }
 
 /**
@@ -137,4 +158,15 @@ class webservice_xmlrpc_client_mock extends webservice_xmlrpc_client {
 
         return $result;
     }
+
+    /**
+     * Allows to test the request encoding.
+     *
+     * @param string $functionname Name of the method to call.
+     * @param mixed $params Method parameters compatible with the method signature.
+     * @return string
+     */
+    public function encode_request($functionname, $params) {
+        return parent::encode_request($functionname, $params);
+    }
 }