Merge branch 'MDL-59784-master' of git://github.com/andrewnicols/moodle
authorJun Pataleta <jun@moodle.com>
Wed, 23 Aug 2017 02:47:54 +0000 (10:47 +0800)
committerJun Pataleta <jun@moodle.com>
Wed, 23 Aug 2017 02:47:54 +0000 (10:47 +0800)
172 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/calendar.min.js
calendar/amd/build/drag_drop.min.js [new file with mode: 0644]
calendar/amd/build/events.min.js
calendar/amd/build/repository.min.js
calendar/amd/build/view_manager.min.js
calendar/amd/src/calendar.js
calendar/amd/src/drag_drop.js [new file with mode: 0644]
calendar/amd/src/events.js
calendar/amd/src/repository.js
calendar/amd/src/view_manager.js
calendar/classes/external/calendar_event_exporter.php
calendar/classes/external/event_exporter.php
calendar/classes/external/event_exporter_base.php
calendar/classes/external/month_exporter.php
calendar/classes/local/api.php
calendar/classes/local/event/mappers/event_mapper.php
calendar/event.php
calendar/externallib.php
calendar/lib.php
calendar/templates/event_summary_body.mustache
calendar/templates/month_detailed.mustache
calendar/tests/behat/behat_calendar.php
calendar/tests/behat/calendar.feature
calendar/tests/externallib_test.php
calendar/tests/local_api_test.php
course/classes/analytics/indicator/no_teacher.php
course/renderer.php
course/templates/activityinstance.mustache
course/templates/bulkactivitycompletion.mustache
course/templates/defaultactivitycompletion.mustache
enrol/manual/amd/build/quickenrolment.min.js
enrol/manual/amd/src/quickenrolment.js
lang/en/moodle.php
lib/amd/build/form-autocomplete.min.js
lib/amd/build/modal_save_cancel.min.js
lib/amd/src/form-autocomplete.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/services.php
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/form/form.js
lib/form/group.php
lib/form/templates/element-template.mustache
lib/form/tests/behat/hideif.feature [new file with mode: 0644]
lib/formslib.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/mod_form.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/core.scss
theme/boost/scss/moodle/undo.scss
theme/boost/templates/core_form/element-template-inline.mustache
theme/boost/templates/core_form/element-template.mustache
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 24a5810..7d3c291 100644 (file)
Binary files a/calendar/amd/build/calendar.min.js and b/calendar/amd/build/calendar.min.js differ
diff --git a/calendar/amd/build/drag_drop.min.js b/calendar/amd/build/drag_drop.min.js
new file mode 100644 (file)
index 0000000..249c6d6
Binary files /dev/null and b/calendar/amd/build/drag_drop.min.js differ
index ca56cb1..762a824 100644 (file)
Binary files a/calendar/amd/build/events.min.js and b/calendar/amd/build/events.min.js differ
index 8cc9d4b..a7c085d 100644 (file)
Binary files a/calendar/amd/build/repository.min.js and b/calendar/amd/build/repository.min.js differ
index 384490f..5cc674c 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js and b/calendar/amd/build/view_manager.min.js differ
index 570c8c6..3e49acb 100644 (file)
@@ -59,7 +59,9 @@ define([
     var SELECTORS = {
         ROOT: "[data-region='calendar']",
         EVENT_LINK: "[data-action='view-event']",
-        NEW_EVENT_BUTTON: "[data-action='new-event-button']"
+        NEW_EVENT_BUTTON: "[data-action='new-event-button']",
+        DAY_CONTENT: "[data-region='day-content']",
+        LOADING_ICON: '.loading-icon',
     };
 
     /**
@@ -155,6 +157,60 @@ define([
         }).fail(Notification.exception);
     };
 
+    /**
+     * Handler for the drag and drop move event. Provides a loading indicator
+     * while the request is sent to the server to update the event start date.
+     *
+     * Triggers a eventMoved calendar javascript event if the event was successfully
+     * updated.
+     *
+     * @param {event} e The calendar move event
+     * @param {object} eventElement The jQuery element with the event id
+     * @param {object} originElement The jQuery element for where the event is moving from
+     * @param {object} destinationElement The jQuery element for where the event is moving to
+     */
+    var handleMoveEvent = function(e, eventElement, originElement, destinationElement) {
+        var eventId = eventElement.attr('data-event-id');
+        var originTimestamp = originElement.attr('data-day-timestamp');
+        var destinationTimestamp = destinationElement.attr('data-day-timestamp');
+
+        // If the event has actually changed day.
+        if (originTimestamp != destinationTimestamp) {
+            Templates.render('core/loading', {})
+                .then(function(html, js) {
+                    // First we show some loading icons in each of the days being affected.
+                    originElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');
+                    destinationElement.find(SELECTORS.DAY_CONTENT).addClass('hidden');
+                    Templates.appendNodeContents(originElement, html, js);
+                    Templates.appendNodeContents(destinationElement, html, js);
+                    return;
+                })
+                .then(function() {
+                    // Send a request to the server to make the change.
+                    return CalendarRepository.updateEventStartDay(eventId, destinationTimestamp);
+                })
+                .then(function() {
+                    // If the update was successful then broadcast an event letting the calendar
+                    // know that an event has been moved.
+                    $('body').trigger(CalendarEvents.eventMoved, [eventElement, originElement, destinationElement]);
+                    return;
+                })
+                .always(function() {
+                    // Always remove the loading icons regardless of whether the update
+                    // request was successful or not.
+                    var originLoadingElement = originElement.find(SELECTORS.LOADING_ICON);
+                    var destinationLoadingElement = destinationElement.find(SELECTORS.LOADING_ICON);
+                    originElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');
+                    destinationElement.find(SELECTORS.DAY_CONTENT).removeClass('hidden');
+
+                    Templates.replaceNode(originLoadingElement, '', '');
+                    Templates.replaceNode(destinationLoadingElement, '', '');
+                    return;
+                })
+                .fail(Notification.exception);
+        }
+    };
+
     /**
      * Create the event form modal for creating new events and
      * editing existing events.
@@ -195,7 +251,7 @@ define([
             window.location.reload();
         });
         body.on(CalendarEvents.deleted, function() {
-            window.location.reload();
+            CalendarViewManager.reloadCurrentMonth();
         });
         body.on(CalendarEvents.updated, function() {
             window.location.reload();
@@ -204,6 +260,12 @@ define([
             // Action events needs to be edit directly on the course module.
             window.location.assign(url);
         });
+        // Handle the event fired by the drag and drop code.
+        body.on(CalendarEvents.moveEvent, handleMoveEvent);
+        // When an event is successfully moved we should updated the UI.
+        body.on(CalendarEvents.eventMoved, function() {
+            window.location.reload();
+        });
 
         eventFormModalPromise.then(function(modal) {
             // When something within the calendar tells us the user wants
diff --git a/calendar/amd/src/drag_drop.js b/calendar/amd/src/drag_drop.js
new file mode 100644 (file)
index 0000000..f2b17e6
--- /dev/null
@@ -0,0 +1,206 @@
+// 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/>.
+
+/**
+ * A javascript module to handle calendar drag and drop. This module
+ * unfortunately requires some state to be maintained because of the
+ * limitations of the HTML5 drag and drop API which means it can't
+ * be used multiple times with the current implementation.
+ *
+ * @module     core_calendar/drag_drop
+ * @class      drag_drop
+ * @package    core_calendar
+ * @copyright  2017 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+            'jquery',
+            'core_calendar/events'
+        ],
+        function(
+            $,
+            CalendarEvents
+        ) {
+
+    var SELECTORS = {
+        ROOT: "[data-region='calendar']",
+        DRAGGABLE: '[draggable="true"]',
+        DROP_ZONE: '[data-drop-zone="true"]',
+        WEEK: '[data-region="month-view-week"]',
+    };
+    var HOVER_CLASS = 'bg-primary';
+
+    // Unfortunately we are required to maintain some module
+    // level state due to the limitations of the HTML5 drag
+    // and drop API. Specifically the inability to pass data
+    // between the dragstate and dragover events handlers
+    // using the DataTransfer object in the event.
+
+    /** @var int eventId The event id being moved. */
+    var eventId = null;
+    /** @var int duration The number of days the event spans */
+    var duration = null;
+
+    /**
+     * Update the hover state for the event in the calendar to reflect
+     * which days the event will be moved to.
+     *
+     * This funciton supports events spanning multiple days and will
+     * recurse to highlight (or remove highlight) each of the days
+     * that the event will be moved to.
+     *
+     * For example: An event with a duration of 3 days will have
+     * 3 days highlighted when it's dragged elsewhere in the calendar.
+     * The current drag target and the 2 days following it (including
+     * wrapping to the next week if necessary).
+     *
+     * @param {string|object} target The drag target element
+     * @param {bool} hovered If the target is hovered or not
+     * @param {int} count How many days to highlight (default to duration)
+     */
+    var updateHoverState = function(target, hovered, count) {
+        var dropZone = $(target).closest(SELECTORS.DROP_ZONE);
+        if (typeof count === 'undefined') {
+            // This is how many days we need to highlight.
+            count = duration;
+        }
+
+        if (hovered) {
+            dropZone.addClass(HOVER_CLASS);
+        } else {
+            dropZone.removeClass(HOVER_CLASS);
+        }
+
+        count--;
+
+        // If we've still got days to highlight then we should
+        // find the next day.
+        if (count > 0) {
+            var nextDropZone = dropZone.next();
+
+            // If there are no more days in this week then we
+            // need to move down to the next week in the calendar.
+            if (!nextDropZone.length) {
+                var nextWeek = dropZone.closest(SELECTORS.WEEK).next();
+
+                if (nextWeek.length) {
+                    nextDropZone = nextWeek.children(SELECTORS.DROP_ZONE).first();
+                }
+            }
+
+            // If we found another day then let's recursively
+            // update it's hover state.
+            if (nextDropZone.length) {
+                updateHoverState(nextDropZone, hovered, count);
+            }
+        }
+    };
+
+    /**
+     * Set up the module level variables to track which event is being
+     * dragged and how many days it spans.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragstartHandler = function(e) {
+        var eventElement = $(e.target);
+
+        if (!eventElement.is('[data-event-id]')) {
+            eventElement = eventElement.find('[data-event-id]');
+        }
+
+        eventId = eventElement.attr('data-event-id');
+
+        var eventsSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
+        duration = $(eventsSelector).length;
+
+        e.dataTransfer.effectAllowed = "move";
+        e.dataTransfer.dropEffect = "move";
+        // Firefox requires a value to be set here or the drag won't
+        // work and the dragover handler won't fire.
+        e.dataTransfer.setData('text/plain', eventId);
+        e.dropEffect = "move";
+    };
+
+    /**
+     * Update the hover state of the target day element when
+     * the user is dragging an event over it.
+     *
+     * This will add a visual indicator to the calendar UI to
+     * indicate which day(s) the event will be moved to.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragoverHandler = function(e) {
+        e.preventDefault();
+        updateHoverState(e.target, true);
+    };
+
+    /**
+     * Update the hover state of the target day element that was
+     * previously dragged over but has is no longer a drag target.
+     *
+     * This will remove the visual indicator from the calendar UI
+     * that was added by the dragoverHandler.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dragleaveHandler = function(e) {
+        e.preventDefault();
+        updateHoverState(e.target, false);
+    };
+
+    /**
+     * Determines the event element, origin day, and destination day
+     * once the user drops the calendar event. These three bits of data
+     * are provided as the payload to the "moveEvent" calendar javascript
+     * event that is fired.
+     *
+     * This will remove the visual indicator from the calendar UI
+     * that was added by the dragoverHandler.
+     *
+     * @param {event} e The dragstart event
+     */
+    var dropHandler = function(e) {
+        e.preventDefault();
+
+        var eventElementSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]';
+        var eventElement = $(eventElementSelector);
+        var origin = eventElement.closest(SELECTORS.DROP_ZONE);
+        var destination = $(e.target).closest(SELECTORS.DROP_ZONE);
+
+        updateHoverState(e.target, false);
+        $('body').trigger(CalendarEvents.moveEvent, [eventElement, origin, destination]);
+    };
+
+    return {
+        /**
+         * Initialise the event handlers for the drag events.
+         */
+        init: function(root) {
+            root = $(root);
+
+            root.find(SELECTORS.DRAGGABLE).each(function(index, element) {
+                element.addEventListener('dragstart', dragstartHandler, true);
+            });
+
+            root.find(SELECTORS.DROP_ZONE).each(function(index, element) {
+                element.addEventListener('dragover', dragoverHandler, true);
+                element.addEventListener('dragleave', dragleaveHandler, true);
+                element.addEventListener('drop', dropHandler, true);
+            });
+        },
+    };
+});
index 465e337..95e0968 100644 (file)
@@ -29,6 +29,8 @@ define([], function() {
         updated: 'calendar-events:updated',
         editEvent: 'calendar-events:edit_event',
         editActionEvent: 'calendar-events:edit_action_event',
-        monthChanged: 'calendar-events:month_changed'
+        eventMoved: 'calendar-events:event_moved',
+        monthChanged: 'calendar-events:month_changed',
+        moveEvent: 'calendar-events:move_event'
     };
 });
index 5c95483..a8aa489 100644 (file)
@@ -103,9 +103,31 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
         return Ajax.call([request])[0];
     };
 
+    /**
+     * Change the start day for the given event id. The day timestamp
+     * only has to be any time during the target day because only the
+     * date information is extracted, the time of the day is ignored.
+     *
+     * @param {int} eventId The id of the event to update
+     * @param {int} dayTimestamp A timestamp for some time during the target day
+     * @return {promise}
+     */
+    var updateEventStartDay = function(eventId, dayTimestamp) {
+        var request = {
+            methodname: 'core_calendar_update_event_start_day',
+            args: {
+                eventId: eventId,
+                dayTimestamp: dayTimestamp
+            }
+        };
+
+        return Ajax.call([request])[0];
+    };
+
     return {
         getEventById: getEventById,
         deleteEvent: deleteEvent,
+        updateEventStartDay: updateEventStartDay,
         submitCreateUpdateForm: submitCreateUpdateForm,
         getCalendarMonthData: getCalendarMonthData
     };
index 6eb6269..10024f0 100644 (file)
@@ -47,31 +47,63 @@ define(['jquery', 'core/templates', 'core/notification', 'core_calendar/reposito
             });
         };
 
+        /**
+         * Refresh the month content.
+         *
+         * @param {Number} time The calendar time to be shown
+         * @param {Number} courseid The id of the course whose events are shown
+         * @return {promise}
+         */
+        var refreshMonthContent = function(time, courseid) {
+            return CalendarRepository.getCalendarMonthData(time, courseid)
+                .then(function(context) {
+                    return Templates.render('core_calendar/month_detailed', context);
+                })
+                .then(function(html, js) {
+                    return Templates.replaceNodeContents(SELECTORS.CALENDAR_MONTH_WRAPPER, html, js);
+                })
+                .fail(Notification.exception);
+        };
+
         /**
          * Handle changes to the current calendar view.
          *
          * @param {String} url The calendar url to be shown
          * @param {Number} time The calendar time to be shown
          * @param {Number} courseid The id of the course whose events are shown
+         * @return {promise}
          */
         var changeMonth = function(url, time, courseid) {
-            CalendarRepository.getCalendarMonthData(time, courseid)
-            .then(function(context) {
-                window.history.pushState({}, '', url);
-                return Templates.render('core_calendar/month_detailed', context);
-            })
-            .then(function(html, js) {
-                return Templates.replaceNodeContents(SELECTORS.CALENDAR_MONTH_WRAPPER, html, js);
-            })
-            .done(function() {
-                $('body').trigger(CalendarEvents.monthChanged, [time, courseid]);
-            })
-            .fail(Notification.exception);
+            return refreshMonthContent(time, courseid)
+                .then(function() {
+                    window.history.pushState({}, '', url);
+                    return arguments;
+                })
+                .then(function() {
+                    $('body').trigger(CalendarEvents.monthChanged, [time, courseid]);
+                    return arguments;
+                });
+        };
+
+        /**
+         * Reload the current month view data.
+         *
+         * @return {promise}
+         */
+        var reloadCurrentMonth = function() {
+            var root = $(SELECTORS.ROOT),
+                courseid = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('courseid'),
+                time = root.find(SELECTORS.CALENDAR_MONTH_WRAPPER).data('current-time');
+
+            return refreshMonthContent(time, courseid);
         };
 
         return {
             init: function() {
                 registerEventListeners(SELECTORS.ROOT);
-            }
+            },
+            reloadCurrentMonth: reloadCurrentMonth,
+            changeMonth: changeMonth,
+            refreshMonthContent: refreshMonthContent
         };
     });
index 3186390..7ba5805 100644 (file)
@@ -44,16 +44,11 @@ class calendar_event_exporter extends event_exporter_base {
      * @return array
      */
     protected static function define_other_properties() {
-        return [
-            'url' => ['type' => PARAM_URL],
-            'icon' => [
-                'type' => event_icon_exporter::read_properties_definition(),
-            ],
-            'course' => [
-                'type' => course_summary_exporter::read_properties_definition(),
-                'optional' => true,
-            ]
-        ];
+
+        $values = parent::define_other_properties();
+        $values['url'] = ['type' => PARAM_URL];
+
+        return $values;
     }
 
     /**
index 7e4bb7c..917f07e 100644 (file)
@@ -51,7 +51,6 @@ class event_exporter extends event_exporter_base {
 
         $values = parent::define_other_properties();
 
-        $values['canedit'] = ['type' => PARAM_BOOL];
         $values['displayeventsource'] = ['type' => PARAM_BOOL];
         $values['subscription'] = [
             'type' => PARAM_RAW,
@@ -60,12 +59,23 @@ 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,
+            'default' => null,
+            'null' => NULL_ALLOWED
+        ];
 
         return $values;
     }
@@ -79,12 +89,13 @@ class event_exporter extends event_exporter_base {
     protected function get_other_values(renderer_base $output) {
         $values = parent::get_other_values($output);
 
-        $event = $this->event;
-        $legacyevent = container::get_event_mapper()->from_event_to_legacy_event($event);
+        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');
@@ -96,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) {
@@ -119,14 +130,11 @@ class event_exporter extends event_exporter_base {
             $values['course'] = $coursesummaryexporter->export($output);
         }
 
-        $values['canedit'] = calendar_edit_event_allowed($legacyevent);
-        $values['candelete'] = calendar_delete_event_allowed($legacyevent);
-
         // Handle event subscription.
         $values['subscription'] = null;
         $values['displayeventsource'] = false;
-        if (!empty($legacyevent->subscriptionid)) {
-            $subscription = calendar_get_subscription($legacyevent->subscriptionid);
+        if ($event->get_subscription()) {
+            $subscription = calendar_get_subscription($event->get_subscription()->get('id'));
             if (!empty($subscription) && $CFG->calendar_showicalsource) {
                 $values['displayeventsource'] = true;
                 $subscriptiondata = new \stdClass();
@@ -138,6 +146,11 @@ class event_exporter extends event_exporter_base {
             }
         }
 
+        if ($group = $event->get_group()) {
+            $values['groupname'] = format_string($group->get('name'), true,
+                ['context' => \context_course::instance($event->get_course()->get('id'))]);
+        }
+
         return $values;
     }
 }
index bc176d3..cc7ef38 100644 (file)
@@ -26,7 +26,10 @@ namespace core_calendar\external;
 
 defined('MOODLE_INTERNAL') || die();
 
+require_once($CFG->dirroot . "/calendar/lib.php");
+
 use \core\external\exporter;
+use \core_calendar\local\event\container;
 use \core_calendar\local\event\entities\event_interface;
 use \core_calendar\local\event\entities\action_event_interface;
 use \core_course\external\course_summary_exporter;
@@ -153,18 +156,19 @@ class event_exporter_base extends exporter {
      */
     protected static function define_other_properties() {
         return [
-            'url' => ['type' => PARAM_URL],
             'icon' => [
                 'type' => event_icon_exporter::read_properties_definition(),
             ],
-            'action' => [
-                'type' => event_action_exporter::read_properties_definition(),
-                'optional' => true,
-            ],
             'course' => [
                 'type' => course_summary_exporter::read_properties_definition(),
                 'optional' => true,
-            ]
+            ],
+            'canedit' => [
+                'type' => PARAM_BOOL
+            ],
+            'candelete' => [
+                'type' => PARAM_BOOL
+            ],
         ];
     }
 
@@ -177,37 +181,21 @@ class event_exporter_base extends exporter {
     protected function get_other_values(renderer_base $output) {
         $values = [];
         $event = $this->event;
+        $legacyevent = container::get_event_mapper()->from_event_to_legacy_event($event);
         $context = $this->related['context'];
-        if ($moduleproxy = $event->get_course_module()) {
-            $modulename = $moduleproxy->get('modname');
-            $moduleid = $moduleproxy->get('id');
-            $url = new \moodle_url(sprintf('/mod/%s/view.php', $modulename), ['id' => $moduleid]);
-        } 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);
-        }
         $timesort = $event->get_times()->get_sort_time()->getTimestamp();
         $iconexporter = new event_icon_exporter($event, ['context' => $context]);
 
-        $values['url'] = $url->out(false);
         $values['icon'] = $iconexporter->export($output);
 
-        if ($event instanceof action_event_interface) {
-            $actionrelated = [
-                'context' => $context,
-                'event' => $event
-            ];
-            $actionexporter = new event_action_exporter($event->get_action(), $actionrelated);
-            $values['action'] = $actionexporter->export($output);
-        }
-
         if ($course = $this->related['course']) {
             $coursesummaryexporter = new course_summary_exporter($course, ['context' => $context]);
             $values['course'] = $coursesummaryexporter->export($output);
         }
 
+        $values['canedit'] = calendar_edit_event_allowed($legacyevent, true);
+        $values['candelete'] = calendar_delete_event_allowed($legacyevent);
+
         return $values;
     }
 
index cf1d1bd..9b99498 100644 (file)
@@ -112,6 +112,9 @@ class month_exporter extends exporter {
             'nextperiod' => [
                 'type' => PARAM_INT,
             ],
+            'time' => [
+                'type' => PARAM_INT,
+            ]
         ];
     }
 
@@ -131,6 +134,7 @@ class month_exporter extends exporter {
             'navigation' => $this->get_navigation($output),
             'weeks' => $this->get_weeks($output),
             'daynames' => $this->get_day_names($output),
+            'time' => $this->calendar->time
         ];
     }
 
index e7bddfd..9f80509 100644 (file)
@@ -26,6 +26,8 @@ namespace core_calendar\local;
 
 defined('MOODLE_INTERNAL') || die();
 
+use core_calendar\local\event\container;
+use core_calendar\local\event\entities\event_interface;
 use core_calendar\local\event\exceptions\limit_invalid_parameter_exception;
 
 /**
@@ -214,4 +216,30 @@ class api {
 
         return $return;
     }
+
+    /**
+     * Change the start day for an event. Only the date will be
+     * modified, the time of day for the event will be left as is.
+     *
+     * @param event_interface $event The existing event to modify
+     * @param DateTimeInterface $startDate The new date to use for the start day
+     * @return event_interface The new event with updated start date
+     */
+    public static function update_event_start_day(
+        event_interface $event,
+        \DateTimeInterface $startDate
+    ) {
+        $mapper = container::get_event_mapper();
+        $legacyevent = $mapper->from_event_to_legacy_event($event);
+        $starttime = $event->get_times()->get_start_time()->setDate(
+            $startDate->format('Y'),
+            $startDate->format('n'),
+            $startDate->format('j')
+        );
+
+        // This function does our capability checks.
+        $legacyevent->update((object) ['timestart' => $starttime->getTimestamp()]);
+
+        return $mapper->from_legacy_event_to_event($legacyevent);
+    }
 }
index 8e544dd..1561e02 100644 (file)
@@ -89,8 +89,16 @@ class event_mapper implements event_mapper_interface {
     public function from_event_to_legacy_event(event_interface $event) {
         $action = ($event instanceof action_event_interface) ? $event->get_action() : null;
         $timeduration = $event->get_times()->get_end_time()->getTimestamp() - $event->get_times()->get_start_time()->getTimestamp();
+        $properties = $this->from_event_to_stdclass($event);
 
-        return new \calendar_event($this->from_event_to_stdclass($event));
+        // Normalise for the legacy event because it wants zero rather than null.
+        $properties->courseid = empty($properties->courseid) ? 0 : $properties->courseid;
+        $properties->groupid = empty($properties->groupid) ? 0 : $properties->groupid;
+        $properties->userid = empty($properties->userid) ? 0 : $properties->userid;
+        $properties->modulename = empty($properties->modulename) ? 0 : $properties->modulename;
+        $properties->instance = empty($properties->instance) ? 0 : $properties->instance;
+
+        return new \calendar_event($properties);
     }
 
     public function from_event_to_stdclass(event_interface $event) {
index 66c6a6f..dad5fd5 100644 (file)
@@ -114,7 +114,7 @@ $formoptions = new stdClass;
 if ($eventid !== 0) {
     $title = get_string('editevent', 'calendar');
     $event = calendar_event::load($eventid);
-    if (!calendar_edit_event_allowed($event)) {
+    if (!calendar_edit_event_allowed($event, true)) {
         print_error('nopermissions');
     }
     $event->action = $action;
index b8ba417..ba62951 100644 (file)
@@ -811,6 +811,10 @@ class core_calendar_external extends external_api {
                 $properties = $legacyevent->properties(true);
             }
 
+            if (!calendar_edit_event_allowed($legacyevent, true)) {
+                print_error('nopermissiontoupdatecalendar');
+            }
+
             $legacyevent->update($properties);
 
             $eventmapper = event_container::get_event_mapper();
@@ -906,4 +910,84 @@ class core_calendar_external extends external_api {
     public static function get_calendar_monthly_view_returns() {
         return \core_calendar\external\month_exporter::get_read_structure();
     }
+
+    /**
+     * Returns description of method parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function update_event_start_day_parameters() {
+        return new external_function_parameters(
+            [
+                'eventId' => new external_value(PARAM_INT, 'Id of event to be updated', VALUE_REQUIRED),
+                'dayTimestamp' => new external_value(PARAM_INT, 'Timestamp for the new start day', VALUE_REQUIRED),
+            ]
+        );
+    }
+
+    /**
+     * Change the start day for the given calendar event to the day that
+     * corresponds with the provided timestamp.
+     *
+     * The timestamp only needs to be anytime within the desired day as only
+     * the date data is extracted from it.
+     *
+     * The event's original time of day is maintained, only the date is shifted.
+     *
+     * @param int $eventId Id of event to be updated
+     * @param int $dayTimestamp Timestamp for the new start day
+     * @return  array
+     */
+    public static function update_event_start_day($eventId, $dayTimestamp) {
+        global $USER, $PAGE;
+
+        // Parameter validation.
+        $params = self::validate_parameters(self::update_event_start_day_parameters(), [
+            'eventId' => $eventId,
+            'dayTimestamp' => $dayTimestamp,
+        ]);
+
+        $vault = event_container::get_event_vault();
+        $mapper = event_container::get_event_mapper();
+        $event = $vault->get_event_by_id($eventId);
+
+        if (!$event) {
+            throw new \moodle_exception('Unable to find event with id ' . $eventId);
+        }
+
+        $legacyevent = $mapper->from_event_to_legacy_event($event);
+
+        if (!calendar_edit_event_allowed($legacyevent, true)) {
+            print_error('nopermissiontoupdatecalendar');
+        }
+
+        self::validate_context($legacyevent->context);
+
+        $newdate = usergetdate($dayTimestamp);
+        $startdatestring = implode('-', [$newdate['year'], $newdate['mon'], $newdate['mday']]);
+        $startdate = new DateTimeImmutable($startdatestring);
+        $event = local_api::update_event_start_day($event, $startdate);
+        $cache = new events_related_objects_cache([$event]);
+        $relatedobjects = [
+            'context' => $cache->get_context($event),
+            'course' => $cache->get_course($event),
+        ];
+        $exporter = new event_exporter($event, $relatedobjects);
+        $renderer = $PAGE->get_renderer('core_calendar');
+
+        return array('event' => $exporter->export($renderer));
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     */
+    public static function update_event_start_day_returns() {
+        return new external_single_structure(
+            array(
+                'event' => event_exporter::get_read_structure()
+            )
+        );
+    }
 }
index bb61015..30cbaa4 100644 (file)
@@ -2435,9 +2435,10 @@ function calendar_set_filters(array $courseeventsfrom, $ignorefilters = false) {
  * Return the capability for editing calendar event.
  *
  * @param calendar_event $event event object
+ * @param bool $manualedit is the event being edited manually by the user
  * @return bool capability to edit event
  */
-function calendar_edit_event_allowed($event) {
+function calendar_edit_event_allowed($event, $manualedit = false) {
     global $USER, $DB;
 
     // Must be logged in.
@@ -2450,6 +2451,12 @@ function calendar_edit_event_allowed($event) {
         return false;
     }
 
+    if ($manualedit && !empty($event->modulename)) {
+        // A user isn't allowed to directly edit an event generated
+        // by a module.
+        return false;
+    }
+
     // You cannot edit URL based calendar subscription events presently.
     if (!empty($event->subscriptionid)) {
         if (!empty($event->subscription->url)) {
index e5301bd..fa3e699 100644 (file)
@@ -23,7 +23,9 @@
     {
         "timestart": 1490320388,
         "description": "An random event description",
-        "eventtype": "open"
+        "eventtype": "User",
+        "source": "Ical imported",
+        "groupname": "Group 1"
     }
 }}
 <div data-region="summary-modal-container" data-event-id="{{id}}" data-event-title="{{name}}"
     {{/description}}
     <h4>{{#str}} eventtype, core_calendar {{/str}}</h4>
     {{eventtype}}
+    {{#iscourseevent}}
+        <a href="{{url}}">{{course.shortname}}</a>
+    {{/iscourseevent}}
     {{#source}}
         <div>{{{source}}}</div>
     {{/source}}
+    {{#groupname}}
+        <div><a href="{{url}}">{{{course.shortname}}}</a>, {{{groupname}}}</div>
+    {{/groupname}}
 </div>
index 106d81b..19b12ff 100644 (file)
@@ -31,7 +31,7 @@
     {
     }
 }}
-<span class="calendarwrapper" data-courseid="{{courseid}}">
+<span id="month-detailed-{{uniqid}}" class="calendarwrapper" data-courseid="{{courseid}}" data-current-time="{{time}}">
     {{> core_calendar/month_header }}
     {{> core_calendar/month_navigation }}
     <table class="calendarmonth calendartable card-deck m-b-0">
@@ -46,7 +46,7 @@
         </thead>
         <tbody>
     {{#weeks}}
-            <tr>
+            <tr data-region="month-view-week">
                 {{#prepadding}}
                     <td class="dayblank">&nbsp;</td>
                 {{/prepadding}}
@@ -56,7 +56,9 @@
                             }}{{#isweekend}} weekend{{/isweekend}}{{!
                             }}{{#durationevents.0}} duration{{/durationevents.0}}{{!
                             }}{{#durationevents}} duration_{{.}}{{/durationevents}}{{!
-                        }}">
+                        }}"
+                        data-day-timestamp="{{timestamp}}"
+                        data-drop-zone="true">
                         <div class="hidden-sm-down text-xs-center">
                             {{#events.0}}
                                 <a href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
                                 {{mday}}
                             {{/events.0}}
                             {{#events.0}}
-                                <ul>
-                                    {{#events}}
+                                <div data-region="day-content">
+                                    <ul>
+                                        {{#events}}
                                         {{#underway}}
                                             <li class="events-underway">[{{name}}]</li>
                                         {{/underway}}
                                         {{^underway}}
-                                            <li class="calendar_event_{{eventtype}}">
+                                            <li class="calendar_event_{{eventtype}}"
+                                                {{#canedit}}
+                                                    draggable="true"
+                                                    data-drag-type="move"
+                                                {{/canedit}}>
+
                                                 <a data-action="view-event" data-event-id="{{id}}" href="{{url}}">{{name}}</a>
                                             </li>
                                         {{/underway}}
-                                    {{/events}}
-                                </ul>
+                                        {{/events}}
+                                    </ul>
+                                </div>
                             {{/events.0}}
                         </div>
                         <div class="hidden-md-up hidden-desktop">
@@ -84,7 +93,9 @@
                                 <a href="{{viewdaylink}}" class="day" title="{{viewdaylinktitle}}">{{mday}}</a>
                             {{/events.0}}
                             {{^events.0}}
-                                {{mday}}
+                                <div data-region="day-content">
+                                    {{mday}}
+                                </div>
                             {{/events.0}}
                         </div>
                     </td>
         </tbody>
     </table>
 </span>
+{{#js}}
+require(['jquery', 'core_calendar/drag_drop'], function($, DragDrop) {
+    var root = $('#month-detailed-{{uniqid}}');
+    DragDrop.init(root);
+});
+{{/js}}
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 6f2ad09..bbf9f37 100644 (file)
@@ -1290,4 +1290,128 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
         $this->expectException('moodle_exception');
         core_calendar_external::delete_calendar_events($params);
     }
+
+    /**
+     * Updating the event start day should change the date value but leave
+     * the time of day unchanged.
+     */
+    public function test_update_event_start_day() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        assign_capability('moodle/calendar:manageownentries', CAP_ALLOW, $roleid, $context, true);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = $this->create_calendar_event(
+            'Test event',
+            $user->id,
+            'user',
+            0,
+            null,
+            [
+                'courseid' => 0,
+                'timestart' => $originalStartTime->getTimestamp()
+            ]
+        );
+
+        $result = core_calendar_external::update_event_start_day($event->id, $newStartDate->getTimestamp());
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::update_event_start_day_returns(),
+            $result
+        );
+
+        $this->assertEquals($expected->getTimestamp(), $result['event']['timestart']);
+    }
+
+    /**
+     * A user should not be able to edit an event that they don't have
+     * capabilities for.
+     */
+    public function test_update_event_start_day_no_permission() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        assign_capability('moodle/calendar:manageownentries', CAP_ALLOW, $roleid, $context, true);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = $this->create_calendar_event(
+            'Test event',
+            $user->id,
+            'user',
+            0,
+            null,
+            [
+                'courseid' => 0,
+                'timestart' => $originalStartTime->getTimestamp()
+            ]
+        );
+
+        assign_capability('moodle/calendar:manageownentries', CAP_PROHIBIT, $roleid, $context, true);
+        $this->expectException('moodle_exception');
+        $result = core_calendar_external::update_event_start_day($event->id, $newStartDate->getTimestamp());
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::update_event_start_day_returns(),
+            $result
+        );
+    }
+
+    /**
+     * A user should not be able to update a module event.
+     */
+    public function test_update_event_start_day_module_event() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $course = $generator->create_course();
+        $moduleinstance = $generator->get_plugin_generator('mod_assign')
+                                    ->create_instance(['course' => $course->id]);
+        $roleid = $generator->create_role();
+        $context = \context_course::instance($course->id);
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        $generator->enrol_user($user->id, $course->id);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = $this->create_calendar_event(
+            'Test event',
+            $user->id,
+            'user',
+            0,
+            null,
+            [
+                'modulename' => 'assign',
+                'instance' => $moduleinstance->id,
+                'courseid' => $course->id,
+                'timestart' => $originalStartTime->getTimestamp()
+            ]
+        );
+
+        assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true);
+        $this->expectException('moodle_exception');
+        $result = core_calendar_external::update_event_start_day($event->id, $newStartDate->getTimestamp());
+        $result = external_api::clean_returnvalue(
+            core_calendar_external::update_event_start_day_returns(),
+            $result
+        );
+    }
 }
index 5e4b16a..8e8ad27 100644 (file)
@@ -26,6 +26,8 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once(__DIR__ . '/helpers.php');
 
+use \core_calendar\local\event\container;
+
 /**
  * Class contaning unit tests for the calendar local API.
  *
@@ -858,4 +860,72 @@ class core_calendar_local_api_testcase extends advanced_testcase {
         $events = calendar_get_legacy_events($timestart, $timeend, true, true, true);
         $this->assertCount(3, $events);
     }
+
+    /**
+     * Setting the start date on the calendar event should update the date
+     * of the event but should leave the time of day unchanged.
+     */
+    public function test_update_event_start_day_updates_date() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+        $mapper = container::get_event_mapper();
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+        assign_capability('moodle/calendar:manageownentries', CAP_ALLOW, $roleid, $context, true);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = create_event([
+            'name' => 'Test event',
+            'userid' => $user->id,
+            'eventtype' => 'user',
+            'repeats' => 0,
+            'timestart' => $originalStartTime->getTimestamp(),
+        ]);
+        $event = $mapper->from_legacy_event_to_event($event);
+
+        $newEvent = \core_calendar\local\api::update_event_start_day($event, $newStartDate);
+        $actual = $newEvent->get_times()->get_start_time();
+
+        $this->assertEquals($expected->getTimestamp(), $actual->getTimestamp());
+    }
+
+    /**
+     * A user should not be able to update the start date of the event
+     * that they don't have the capabilities to modify.
+     */
+    public function test_update_event_start_day_no_permission() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $roleid = $generator->create_role();
+        $context = \context_system::instance();
+        $originalStartTime = new DateTimeImmutable('2017-01-1T15:00:00+08:00');
+        $newStartDate = new DateTimeImmutable('2018-02-2T10:00:00+08:00');
+        $expected = new DateTimeImmutable('2018-02-2T15:00:00+08:00');
+        $mapper = container::get_event_mapper();
+
+        $generator->role_assign($roleid, $user->id, $context->id);
+
+        $this->setUser($user);
+        $this->resetAfterTest(true);
+
+        $event = create_event([
+            'name' => 'Test event',
+            'userid' => $user->id,
+            'eventtype' => 'user',
+            'repeats' => 0,
+            'timestart' => $originalStartTime->getTimestamp(),
+        ]);
+        $event = $mapper->from_legacy_event_to_event($event);
+
+        assign_capability('moodle/calendar:manageownentries', CAP_PROHIBIT, $roleid, $context, true);
+        $this->expectException('moodle_exception');
+        $newEvent = \core_calendar\local\api::update_event_start_day($event, $newStartDate);
+    }
 }
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 8b1fd6c..1ed95a8 100644 (file)
         <div>
             {{#canmanage}}
             <label class="accesshide" for="selectactivity_{{cmid}}">{{#str}}select, completion{{/str}} {{modname}}</label>
-            <input type="checkbox" id="selectactivity_{{cmid}}" class="m-r-1" name="cmid[]" data-section="{{sectionnumber}}" value="{{cmid}}" aria-label="{{#str}}checkactivity, completion, {{modname}}{{/str}}">
+            <input type="checkbox" id="selectactivity_{{cmid}}" class="m-r-1" name="cmid[]" data-section="{{sectionnumber}}" value="{{cmid}}" aria-label="{{#str}}checkactivity, completion, {{{modname}}}{{/str}}">
             {{/canmanage}}
             <a href="{{url}}">
             <img src="{{icon}}" class="iconlarge activityicon" alt=" " role="presentation" />
-            <span class="instancename">{{modname}}</span>
+            <span class="instancename">{{{modname}}}</span>
             </a>
         </div>
     </div>
index 57fafd9..e40bca8 100644 (file)
@@ -64,8 +64,8 @@
                 <div class="m-b-1">
                     <div class="row m-b-1 row-fluid">
                         <div class="col-sm-12">
-                            <input type="checkbox" data-section-master="{{sectionnumber}}" class="m-r-1" aria-label="{{#str}}checkallsection, completion, {{name}}{{/str}}">
-                            <h3 class="d-inline-block">{{name}}</h3>
+                            <input type="checkbox" data-section-master="{{sectionnumber}}" class="m-r-1" aria-label="{{#str}}checkallsection, completion, {{{name}}}{{/str}}">
+                            <h3 class="d-inline-block">{{{name}}}</h3>
                         </div>
                     </div>
                     {{> core_course/activityinstance}}
index db10908..066370f 100644 (file)
@@ -63,9 +63,9 @@
                 <div class="row m-b-1 row-fluid">
                     <div class="col-xs-6 span6">
                         <label class="accesshide" for="modtype_{{id}}">{{#str}}select, core_completion{{/str}} {{formattedname}}</label>
-                        <input id="modtype_{{id}}" type="checkbox" class="m-r-1" name="modids[]" value="{{id}}" aria-label="{{#str}}checkactivity, completion, {{formattedname}}{{/str}}">
+                        <input id="modtype_{{id}}" type="checkbox" class="m-r-1" name="modids[]" value="{{id}}" aria-label="{{#str}}checkactivity, completion, {{{formattedname}}}{{/str}}">
                         <img class="iconlarge activityicon" src="{{icon}}" alt=" " role="presentation" />
-                        <span>{{formattedname}}</span>
+                        <span>{{{formattedname}}}</span>
                     </div>
                     <div class="activity-completionstatus col-xs-6 span6">
                         <div class="col-sm-1 span1 p-l-0">
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 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 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 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 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 e37d54e..b1f29b6 100644 (file)
@@ -68,6 +68,15 @@ $functions = array(
         'ajax' => true,
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+    'core_calendar_update_event_start_day' => array(
+        'classname' => 'core_calendar_external',
+        'methodname' => 'update_event_start_day',
+        'description' => 'Update the start day (but not time) for an event.',
+        'classpath' => 'calendar/externallib.php',
+        'type' => 'write',
+        'capabilities' => 'moodle/calendar:manageentries, moodle/calendar:manageownentries, moodle/calendar:managegroupentries',
+        'ajax' => true,
+    ),
     'core_calendar_create_calendar_events' => array(
         'classname' => 'core_calendar_external',
         'methodname' => 'create_calendar_events',
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 38e518b..4fc331f 100644 (file)
@@ -69,8 +69,10 @@ if (typeof M.form.dependencyManager === 'undefined') {
                 names[i] = new Y.NodeList();
                 for (var condition in conditions) {
                     for (var value in conditions[condition]) {
-                        for (var ei in conditions[condition][value]) {
-                            names[conditions[condition][value][ei]] = new Y.NodeList();
+                        for (var hide in conditions[condition][value]) {
+                            for (var ei in conditions[condition][value][hide]) {
+                                names[conditions[condition][value][hide][ei]] = new Y.NodeList();
+                            }
                         }
                     }
                 }
@@ -83,6 +85,13 @@ if (typeof M.form.dependencyManager === 'undefined') {
                     names[name].push(node);
                 }
             });
+            // Locate any groups with the given name.
+            this.get('form').all('.fitem').each(function(node) {
+                var name = node.getData('groupname');
+                if (name && ({}).hasOwnProperty.call(names, name)) {
+                    names[name].push(node);
+                }
+            });
             this._nameCollections = names;
         },
 
@@ -118,7 +127,7 @@ if (typeof M.form.dependencyManager === 'undefined') {
             var dependencies = this.get('dependencies'),
                 tohide = {},
                 tolock = {},
-                condition, value, lock, hide,
+                condition, value, isHide, lock, hide,
                 checkfunction, result, elements;
             if (!({}).hasOwnProperty.call(dependencies, dependon)) {
                 return true;
@@ -126,26 +135,28 @@ if (typeof M.form.dependencyManager === 'undefined') {
             elements = this.elementsByName(dependon);
             for (condition in dependencies[dependon]) {
                 for (value in dependencies[dependon][condition]) {
-                    checkfunction = '_dependency' + condition[0].toUpperCase() + condition.slice(1);
-                    if (Y.Lang.isFunction(this[checkfunction])) {
-                        result = this[checkfunction].apply(this, [elements, value, e]);
-                    } else {
-                        result = this._dependencyDefault(elements, value, e);
-                    }
-                    lock = result.lock || false;
-                    hide = result.hide || false;
-                    for (var ei in dependencies[dependon][condition][value]) {
-                        var eltolock = dependencies[dependon][condition][value][ei];
-                        if (({}).hasOwnProperty.call(tohide, eltolock)) {
-                            tohide[eltolock] = tohide[eltolock] || hide;
+                    for (isHide in dependencies[dependon][condition][value]) {
+                        checkfunction = '_dependency' + condition[0].toUpperCase() + condition.slice(1);
+                        if (Y.Lang.isFunction(this[checkfunction])) {
+                            result = this[checkfunction].apply(this, [elements, value, (isHide === "1"), e]);
                         } else {
-                            tohide[eltolock] = hide;
+                            result = this._dependencyDefault(elements, value, (isHide === "1"), e);
                         }
+                        lock = result.lock || false;
+                        hide = result.hide || false;
+                        for (var ei in dependencies[dependon][condition][value][isHide]) {
+                            var eltolock = dependencies[dependon][condition][value][isHide][ei];
+                            if (({}).hasOwnProperty.call(tohide, eltolock)) {
+                                tohide[eltolock] = tohide[eltolock] || hide;
+                            } else {
+                                tohide[eltolock] = hide;
+                            }
 
-                        if (({}).hasOwnProperty.call(tolock, eltolock)) {
-                            tolock[eltolock] = tolock[eltolock] || lock;
-                        } else {
-                            tolock[eltolock] = lock;
+                            if (({}).hasOwnProperty.call(tolock, eltolock)) {
+                                tolock[eltolock] = tolock[eltolock] || lock;
+                            } else {
+                                tolock[eltolock] = lock;
+                            }
                         }
                     }
                 }
@@ -260,8 +271,13 @@ if (typeof M.form.dependencyManager === 'undefined') {
         _hideElement: function(name, hidden) {
             var els = this.elementsByName(name);
             els.each(function(node) {
-                var e = node.ancestor('.fitem');
+                var e = node.ancestor('.fitem', true);
                 if (e) {
+                    if (hidden) {
+                        e.setAttribute('hidden', 'hidden');
+                    } else {
+                        e.removeAttribute('hidden');
+                    }
                     e.setStyles({
                         display: (hidden) ? 'none' : ''
                     });
@@ -291,7 +307,7 @@ if (typeof M.form.dependencyManager === 'undefined') {
 
             return false;
         },
-        _dependencyNotchecked: function(elements, value) {
+        _dependencyNotchecked: function(elements, value, isHide) {
             var lock = false;
             elements.each(function() {
                 if (this.getAttribute('type').toLowerCase() == 'hidden' &&
@@ -306,10 +322,10 @@ if (typeof M.form.dependencyManager === 'undefined') {
             });
             return {
                 lock: lock,
-                hide: false
+                hide: isHide ? lock : false
             };
         },
-        _dependencyChecked: function(elements, value) {
+        _dependencyChecked: function(elements, value, isHide) {
             var lock = false;
             elements.each(function() {
                 if (this.getAttribute('type').toLowerCase() == 'hidden' &&
@@ -324,20 +340,20 @@ if (typeof M.form.dependencyManager === 'undefined') {
             });
             return {
                 lock: lock,
-                hide: false
+                hide: isHide ? lock : false
             };
         },
-        _dependencyNoitemselected: function(elements, value) {
+        _dependencyNoitemselected: function(elements, value, isHide) {
             var lock = false;
             elements.each(function() {
                 lock = lock || this.get('selectedIndex') == -1;
             });
             return {
                 lock: lock,
-                hide: false
+                hide: isHide ? lock : false
             };
         },
-        _dependencyEq: function(elements, value) {
+        _dependencyEq: function(elements, value, isHide) {
             var lock = false;
             var hiddenVal = false;
             var options, v, selected, values;
@@ -391,7 +407,7 @@ if (typeof M.form.dependencyManager === 'undefined') {
             });
             return {
                 lock: lock,
-                hide: false
+                hide: isHide ? lock : false
             };
         },
         /**
@@ -402,7 +418,7 @@ if (typeof M.form.dependencyManager === 'undefined') {
          * @returns {{lock: boolean, hide: boolean}}
          * @private
          */
-        _dependencyIn: function(elements, values) {
+        _dependencyIn: function(elements, values, isHide) {
             // A pipe (|) is used as a value separator
             // when multiple values have to be passed on at the same time.
             values = values.split('|');
@@ -458,7 +474,7 @@ if (typeof M.form.dependencyManager === 'undefined') {
             });
             return {
                 lock: lock,
-                hide: false
+                hide: isHide ? lock : false
             };
         },
         _dependencyHide: function(elements, value) {
@@ -467,7 +483,7 @@ if (typeof M.form.dependencyManager === 'undefined') {
                 hide: true
             };
         },
-        _dependencyDefault: function(elements, value, ev) {
+        _dependencyDefault: function(elements, value, isHide) {
             var lock = false,
                 hiddenVal = false,
                 values
@@ -521,7 +537,7 @@ if (typeof M.form.dependencyManager === 'undefined') {
             });
             return {
                 lock: lock,
-                hide: false
+                hide: isHide ? lock : false
             };
         }
     }, {
index da9359d..f4f241f 100644 (file)
@@ -213,6 +213,7 @@ class MoodleQuickForm_group extends HTML_QuickForm_group implements templatable
             $i++;
         }
 
+        $context['groupname'] = $name;
         $context['elements'] = $elements;
         return $context;
     }
index 9463064..cc1195a 100644 (file)
@@ -46,7 +46,7 @@
     }
 
 }}
-<div id="fitem_{{ element.id }}" class="fitem fitem_{{ element.type }}">
+<div id="fitem_{{ element.id }}" class="fitem fitem_{{ element.type }}" {{#element.groupname}}data-groupname="{{.}}"{{/element.groupname}}>
     <div class="fitemtitle">
       <label for="{{element.id}}">{{{ label }}}</label>
         {{{ helpbutton }}}
diff --git a/lib/form/tests/behat/hideif.feature b/lib/form/tests/behat/hideif.feature
new file mode 100644 (file)
index 0000000..04027ba
--- /dev/null
@@ -0,0 +1,29 @@
+@core @javascript @core_form
+Feature: hideIf functionality in forms
+  For forms including hideIf functions
+  As a user
+  If I trigger the hideIf condition then the form elements will be hidden
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I turn editing mode on
+
+  Scenario: When 'eq' hideIf conditions are not met, the relevant elements are shown
+    When I add a "Assignment" to section "1"
+    And I expand all fieldsets
+    And I set the field "Students submit in groups" to "Yes"
+    Then I should see "Require group to make submission"
+    And I should see "Require all group members submit"
+    And I should see "Grouping for student groups"
+
+  Scenario: When 'eq' hideIf conditions are met, the relevant elements are hidden
+    When I add a "Assignment" to section "1"
+    And I expand all fieldsets
+    And I set the field "Students submit in groups" to "No"
+    Then I should not see "Require group to make submission"
+    And I should not see "Require all group members to submit"
+    And I should not see "Grouping for student groups"
index 965b0a4..66527b6 100644 (file)
@@ -1412,6 +1412,11 @@ class MoodleQuickForm extends HTML_QuickForm_DHTMLRulesTableless {
     /** @var array dependent state for the element/'s */
     var $_dependencies = array();
 
+    /**
+     * @var array elements that will become hidden based on another element
+     */
+    protected $_hideifs = array();
+
     /** @var array Array of buttons that if pressed do not result in the processing of the form. */
     var $_noSubmitButtons=array();
 
@@ -1460,6 +1465,16 @@ class MoodleQuickForm extends HTML_QuickForm_DHTMLRulesTableless {
      */
     protected $clientvalidation = false;
 
+    /**
+     * Is this a 'disableIf' dependency ?
+     */
+    const DEP_DISABLE = 0;
+
+    /**
+     * Is this a 'hideIf' dependency?
+     */
+    const DEP_HIDE = 1;
+
     /**
      * Class constructor - same parameters as HTML_QuickForm_DHTMLRulesTableless
      *
@@ -2426,8 +2441,7 @@ require(["core/event", "jquery"], function(Event, $) {
             foreach ($conditions as $condition=>$values) {
                 $result[$dependentOn][$condition] = array();
                 foreach ($values as $value=>$dependents) {
-                    $result[$dependentOn][$condition][$value] = array();
-                    $i = 0;
+                    $result[$dependentOn][$condition][$value][self::DEP_DISABLE] = array();
                     foreach ($dependents as $dependent) {
                         $elements = $this->_getElNamesRecursive($dependent);
                         if (empty($elements)) {
@@ -2438,7 +2452,29 @@ require(["core/event", "jquery"], function(Event, $) {
                             if ($element == $dependentOn) {
                                 continue;
                             }
-                            $result[$dependentOn][$condition][$value][] = $element;
+                            $result[$dependentOn][$condition][$value][self::DEP_DISABLE][] = $element;
+                        }
+                    }
+                }
+            }
+        }
+        foreach ($this->_hideifs as $dependenton => $conditions) {
+            $result[$dependenton] = array();
+            foreach ($conditions as $condition => $values) {
+                $result[$dependenton][$condition] = array();
+                foreach ($values as $value => $dependents) {
+                    $result[$dependenton][$condition][$value][self::DEP_HIDE] = array();
+                    foreach ($dependents as $dependent) {
+                        $elements = $this->_getElNamesRecursive($dependent);
+                        if (!in_array($dependent, $elements)) {
+                            // Always want to hide the main element, even if it contains sub-elements as well.
+                            $elements[] = $dependent;
+                        }
+                        foreach ($elements as $element) {
+                            if ($element == $dependenton) {
+                                continue;
+                            }
+                            $result[$dependenton][$condition][$value][self::DEP_HIDE][] = $element;
                         }
                     }
                 }
@@ -2533,6 +2569,40 @@ require(["core/event", "jquery"], function(Event, $) {
         $this->_dependencies[$dependentOn][$condition][$value][] = $elementName;
     }
 
+    /**
+     * Adds a dependency for $elementName which will be hidden if $condition is met.
+     * If $condition = 'notchecked' (default) then the condition is that the $dependentOn element
+     * is not checked. If $condition = 'checked' then the condition is that the $dependentOn element
+     * is checked. If $condition is something else (like "eq" for equals) then it is checked to see if the value
+     * of the $dependentOn element is $condition (such as equal) to $value.
+     *
+     * When working with multiple selects, the dependentOn has to be the real name of the select, meaning that
+     * it will most likely end up with '[]'. Also, the value should be an array of required values, or a string
+     * containing the values separated by pipes: array('red', 'blue') or 'red|blue'.
+     *
+     * @param string $elementname the name of the element which will be hidden
+     * @param string $dependenton the name of the element whose state will be checked for condition
+     * @param string $condition the condition to check
+     * @param mixed $value used in conjunction with condition.
+     */
+    public function hideIf($elementname, $dependenton, $condition = 'notchecked', $value = '1') {
+        // Multiple selects allow for a multiple selection, we transform the array to string here as
+        // an array cannot be used as a key in an associative array.
+        if (is_array($value)) {
+            $value = implode('|', $value);
+        }
+        if (!array_key_exists($dependenton, $this->_hideifs)) {
+            $this->_hideifs[$dependenton] = array();
+        }
+        if (!array_key_exists($condition, $this->_hideifs[$dependenton])) {
+            $this->_hideifs[$dependenton][$condition] = array();
+        }
+        if (!array_key_exists($value, $this->_hideifs[$dependenton][$condition])) {
+            $this->_hideifs[$dependenton][$condition][$value] = array();
+        }
+        $this->_hideifs[$dependenton][$condition][$value][] = $elementname;
+    }
+
     /**
      * Registers button as no submit button
      *
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 7f88c07..ffb9a15 100644 (file)
@@ -148,12 +148,12 @@ class mod_assign_mod_form extends moodleform_mod {
             'preventsubmissionnotingroup',
             'assign');
         $mform->setType('preventsubmissionnotingroup', PARAM_BOOL);
-        $mform->disabledIf('preventsubmissionnotingroup', 'teamsubmission', 'eq', 0);
+        $mform->hideIf('preventsubmissionnotingroup', 'teamsubmission', 'eq', 0);
 
         $name = get_string('requireallteammemberssubmit', 'assign');
         $mform->addElement('selectyesno', 'requireallteammemberssubmit', $name);
         $mform->addHelpButton('requireallteammemberssubmit', 'requireallteammemberssubmit', 'assign');
-        $mform->disabledIf('requireallteammemberssubmit', 'teamsubmission', 'eq', 0);
+        $mform->hideIf('requireallteammemberssubmit', 'teamsubmission', 'eq', 0);
         $mform->disabledIf('requireallteammemberssubmit', 'submissiondrafts', 'eq', 0);
 
         $groupings = groups_get_all_groupings($assignment->get_course()->id);
@@ -166,7 +166,7 @@ class mod_assign_mod_form extends moodleform_mod {
         $name = get_string('teamsubmissiongroupingid', 'assign');
         $mform->addElement('select', 'teamsubmissiongroupingid', $name, $options);
         $mform->addHelpButton('teamsubmissiongroupingid', 'teamsubmissiongroupingid', 'assign');
-        $mform->disabledIf('teamsubmissiongroupingid', 'teamsubmission', 'eq', 0);
+        $mform->hideIf('teamsubmissiongroupingid', 'teamsubmission', 'eq', 0);
         if ($assignment->has_submissions_or_grades()) {
             $mform->freeze('teamsubmissiongroupingid');
         }
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');
     }
 
     /**