Merge branch 'MDL-62907-master' of https://github.com/sammarshallou/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Sun, 14 Apr 2019 16:12:56 +0000 (18:12 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Sun, 14 Apr 2019 16:12:56 +0000 (18:12 +0200)
170 files changed:
admin/settings/analytics.php
admin/settings/development.php
admin/tool/analytics/amd/build/model.min.js
admin/tool/analytics/amd/src/model.js
admin/tool/analytics/classes/output/form/edit_model.php
admin/tool/analytics/classes/output/helper.php
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/createmodel.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/analytics/templates/evaluation_mode_selection.mustache [deleted file]
admin/tool/analytics/templates/evaluation_options.mustache [new file with mode: 0644]
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/external/data_request_exporter.php
admin/tool/dataprivacy/classes/output/data_registry_page.php
admin/tool/dataprivacy/classes/output/data_requests_table.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/createdatarequest_form.php
admin/tool/dataprivacy/db/access.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/lib.php
admin/tool/dataprivacy/resubmitrequest.php
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/behat/datadelete.feature
admin/tool/dataprivacy/version.php
admin/tool/generator/classes/make_testplan_form.php
admin/tool/generator/classes/testplan_backend.php
admin/tool/generator/upgrade.txt [new file with mode: 0644]
admin/user/user_bulk_message.php
analytics/classes/local/time_splitting/base.php
analytics/classes/local/time_splitting/upcoming_periodic.php
analytics/classes/manager.php
analytics/tests/manager_test.php
analytics/tests/prediction_test.php
analytics/upgrade.txt
auth/email/tests/behat/signup.feature
backup/util/helper/async_helper.class.php
badges/criteria/award_criteria_courseset.php
blocks/course_list/block_course_list.php
blocks/html/lib.php
blog/classes/external/post_exporter.php
blog/tests/external_test.php
blog/upgrade.txt
cache/classes/loaders.php
calendar/lib.php
calendar/view.php
course/classes/category.php
course/classes/list_element.php
course/classes/management/helper.php
course/classes/management_renderer.php
course/classes/search/course.php
course/classes/search/customfield.php
course/completion_form.php
course/externallib.php
course/index.php
course/info.php
course/lib.php
course/management.php
course/renderer.php
course/search.php
course/tests/behat/course_browsing.feature [new file with mode: 0644]
course/tests/category_test.php
course/upgrade.txt
course/view.php
enrol/externallib.php
enrol/guest/classes/external.php
enrol/index.php
enrol/self/externallib.php
install/lang/ar/admin.php
install/lang/de_wp/langconfig.php [new file with mode: 0644]
install/lang/en_wp/langconfig.php [new file with mode: 0644]
install/lang/es_wp/langconfig.php [new file with mode: 0644]
install/lang/pt/admin.php
lang/en/admin.php
lang/en/error.php
lang/en/moodle.php
lang/en/role.php
lib/accesslib.php
lib/amd/build/form-course-selector.min.js
lib/amd/src/form-course-selector.js
lib/authlib.php
lib/classes/analytics/time_splitting/upcoming_3_days.php [new file with mode: 0644]
lib/classes/analytics/time_splitting/upcoming_fortnight.php [new file with mode: 0644]
lib/classes/component.php
lib/classes/lock/db_record_lock_factory.php
lib/classes/lock/file_lock_factory.php
lib/classes/lock/postgres_lock_factory.php
lib/classes/message/message.php
lib/datalib.php
lib/db/access.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/deprecatedlib.php
lib/filebrowser/file_info_context_coursecat.php
lib/filelib.php
lib/form/course.php
lib/messagelib.php
lib/mlbackend/php/classes/processor.php
lib/moodlelib.php
lib/navigationlib.php
lib/setuplib.php
lib/tests/component_test.php
lib/tests/lock_test.php
lib/upgrade.txt
lib/xhprof/readme_moodle.txt
lib/xhprof/xhprof_moodle.php
message/classes/api.php
message/classes/helper.php
message/externallib.php
message/lib.php
mod/book/lib.php
mod/book/tests/lib_test.php
mod/book/upgrade.txt
mod/data/classes/external/record_exporter.php
mod/data/tests/externallib_test.php
mod/data/upgrade.txt
mod/forum/backup/moodle2/backup_forum_stepslib.php
mod/forum/backup/moodle2/restore_forum_stepslib.php
mod/forum/classes/local/data_mappers/legacy/forum.php
mod/forum/classes/local/entities/forum.php
mod/forum/classes/local/factories/entity.php
mod/forum/classes/local/managers/capability.php
mod/forum/classes/local/renderers/discussion.php
mod/forum/classes/local/renderers/discussion_list.php
mod/forum/db/access.php
mod/forum/db/install.xml
mod/forum/db/upgrade.php
mod/forum/externallib.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/locallib.php
mod/forum/mod_form.php
mod/forum/tests/entities_forum_test.php
mod/forum/tests/externallib_test.php
mod/forum/tests/lib_test.php
mod/forum/tests/locallib_test.php [new file with mode: 0644]
mod/forum/upgrade.txt
mod/forum/version.php
mod/glossary/classes/external.php
mod/glossary/tests/external_test.php
mod/glossary/upgrade.txt
mod/lesson/backup/moodle2/backup_lesson_stepslib.php
mod/lesson/backup/moodle2/restore_lesson_stepslib.php
mod/lesson/classes/privacy/provider.php
mod/lesson/continue.php
mod/lesson/editpage.php
mod/lesson/essay.php
mod/lesson/index.php
mod/lesson/lang/en/lesson.php
mod/lesson/lib.php
mod/lesson/locallib.php
mod/lesson/pagetypes/essay.php
mod/lesson/pagetypes/numerical.php
mod/lesson/pagetypes/shortanswer.php
mod/lesson/report.php
mod/lesson/tests/behat/all_other_answers_catch.feature [new file with mode: 0644]
mod/lesson/tests/behat/wrong_answer_continue.feature [new file with mode: 0644]
mod/lesson/upgrade.txt
mod/wiki/classes/external.php
mod/wiki/tests/externallib_test.php
mod/wiki/upgrade.txt
search/classes/manager.php
tag/classes/external.php
tag/classes/external/tag_area_exporter.php [new file with mode: 0644]
tag/classes/external/tag_collection_exporter.php [new file with mode: 0644]
tag/classes/external/tag_item_exporter.php [new file with mode: 0644]
tag/classes/external/util.php [new file with mode: 0644]
tag/tests/external_test.php
version.php

index 5f43a52..dfbb1d6 100644 (file)
@@ -75,7 +75,7 @@ if ($hassiteconfig) {
             $defaultreader, $options));
 
         // Enable/disable time splitting methods.
-        $alltimesplittings = \core_analytics\manager::get_all_time_splittings();
+        $alltimesplittings = \core_analytics\manager::get_time_splitting_methods_for_evaluation(true);
 
         $timesplittingoptions = array();
         $timesplittingdefaults = array('\core\analytics\time_splitting\quarters_accum',
index 61d82c3..58493dd 100644 (file)
@@ -59,6 +59,8 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     $temp->add(new admin_setting_configcheckbox('profilingallowme', new lang_string('profilingallowme', 'admin'), new lang_string('profilingallowme_help', 'admin'), false));
     // Allow PROFILEALL/PROFILEALLSTOP GPC.
     $temp->add(new admin_setting_configcheckbox('profilingallowall', new lang_string('profilingallowall', 'admin'), new lang_string('profilingallowall_help', 'admin'), false));
+    $temp->add(new admin_setting_configtext('profilingslow', new lang_string('profilingslow', 'admin'),
+        new lang_string('profilingslow_help', 'admin'), 0, PARAM_FLOAT));
     // TODO: Allow to skip PHP functions (XHPROF_FLAGS_NO_BUILTINS)
     // TODO: Allow to skip call_user functions (ignored_functions array)
     // Specify the life time (in minutes) of profiling runs.
index 9e958db..fd1374c 100644 (file)
Binary files a/admin/tool/analytics/amd/build/model.min.js and b/admin/tool/analytics/amd/build/model.min.js differ
index 8d35e86..7a33345 100644 (file)
@@ -101,50 +101,53 @@ define(['jquery', 'core/str', 'core/log', 'core/notification', 'core/modal_facto
         },
 
         /**
-         * Displays a select-evaluation-mode choice.
+         * Displays evaluation mode and time-splitting method choices.
          *
          * @param  {String}  actionId
          * @param  {Boolean} trainedOnlyExternally
          */
-        selectEvaluationMode: function(actionId, trainedOnlyExternally) {
+        selectEvaluationOptions: function(actionId, trainedOnlyExternally, timeSplittingMethods) {
             $('[data-action-id="' + actionId + '"]').on('click', function(ev) {
                 ev.preventDefault();
 
                 var a = $(ev.currentTarget);
 
-                if (!trainedOnlyExternally) {
-                    // We can not evaluate trained models if the model was trained using data from this site.
-                    // Default to evaluate the model configuration if that is the case.
-                    window.location.href = a.attr('href');
-                    return;
-                }
-
                 var stringsPromise = Str.get_strings([
                     {
                         key: 'evaluatemodel',
                         component: 'tool_analytics'
                     }, {
-                        key: 'evaluationmode',
+                        key: 'evaluate',
                         component: 'tool_analytics'
                     }
                 ]);
                 var modalPromise = ModalFactory.create({type: ModalFactory.types.SAVE_CANCEL});
-                var bodyPromise = Templates.render('tool_analytics/evaluation_mode_selection', {});
+                var bodyPromise = Templates.render('tool_analytics/evaluation_options', {
+                    trainedexternally: trainedOnlyExternally,
+                    timesplittingmethods: timeSplittingMethods
+                });
 
                 $.when(stringsPromise, modalPromise).then(function(strings, modal) {
 
 
                     modal.getRoot().on(ModalEvents.hidden, modal.destroy.bind(modal));
 
-                    modal.setTitle(strings[1]);
-                    modal.setSaveButtonText(strings[0]);
+                    modal.setTitle(strings[0]);
+                    modal.setSaveButtonText(strings[1]);
                     modal.setBody(bodyPromise);
 
                     modal.getRoot().on(ModalEvents.save, function() {
+
+                        // Evaluation mode.
                         var evaluationMode = $("input[name='evaluationmode']:checked").val();
                         if (evaluationMode == 'trainedmodel') {
                             a.attr('href', a.attr('href') + '&mode=trainedmodel');
                         }
+
+                        // Selected time-splitting id.
+                        var timeSplittingMethod = $("#id-evaluation-timesplitting").val();
+                        a.attr('href', a.attr('href') + '&timesplitting=' + timeSplittingMethod);
+
                         window.location.href = a.attr('href');
                         return;
                     });
index 31d0f57..b529d50 100644 (file)
@@ -45,13 +45,14 @@ class edit_model extends \moodleform {
 
         $mform = $this->_form;
 
-        if ($this->_customdata['trainedmodel']) {
+        if ($this->_customdata['trainedmodel'] && $this->_customdata['staticmodel'] === false) {
             $message = get_string('edittrainedwarning', 'tool_analytics');
             $mform->addElement('html', $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING));
         }
 
         $mform->addElement('advcheckbox', 'enabled', get_string('enabled', 'tool_analytics'));
 
+        // Target.
         if (!empty($this->_customdata['targets'])) {
             $targets = array('' => '');
             foreach ($this->_customdata['targets'] as $classname => $target) {
@@ -64,42 +65,48 @@ class edit_model extends \moodleform {
             $mform->addRule('target', get_string('required'), 'required', null, 'client');
         }
 
-        $indicators = array();
-        foreach ($this->_customdata['indicators'] as $classname => $indicator) {
-            $optionname = \tool_analytics\output\helper::class_to_option($classname);
-            $indicators[$optionname] = $indicator->get_name();
+        // Indicators.
+        if (!$this->_customdata['staticmodel']) {
+            $indicators = array();
+            foreach ($this->_customdata['indicators'] as $classname => $indicator) {
+                $optionname = \tool_analytics\output\helper::class_to_option($classname);
+                $indicators[$optionname] = $indicator->get_name();
+            }
+            $options = array(
+                'multiple' => true
+            );
+            $mform->addElement('autocomplete', 'indicators', get_string('indicators', 'tool_analytics'), $indicators, $options);
+            $mform->setType('indicators', PARAM_ALPHANUMEXT);
+            $mform->addHelpButton('indicators', 'indicators', 'tool_analytics');
         }
-        $options = array(
-            'multiple' => true
-        );
-        $mform->addElement('autocomplete', 'indicators', get_string('indicators', 'tool_analytics'), $indicators, $options);
-        $mform->setType('indicators', PARAM_ALPHANUMEXT);
-        $mform->addHelpButton('indicators', 'indicators', 'tool_analytics');
 
+        // Time-splitting methods.
         $timesplittings = array('' => '');
         foreach ($this->_customdata['timesplittings'] as $classname => $timesplitting) {
             $optionname = \tool_analytics\output\helper::class_to_option($classname);
             $timesplittings[$optionname] = $timesplitting->get_name();
         }
-
         $mform->addElement('select', 'timesplitting', get_string('timesplittingmethod', 'analytics'), $timesplittings);
         $mform->addHelpButton('timesplitting', 'timesplittingmethod', 'analytics');
 
-        $defaultprocessor = \core_analytics\manager::get_predictions_processor_name(
-            \core_analytics\manager::get_predictions_processor()
-        );
-        $predictionprocessors = ['' => get_string('defaultpredictoroption', 'analytics', $defaultprocessor)];
-        foreach ($this->_customdata['predictionprocessors'] as $classname => $predictionsprocessor) {
-            if ($predictionsprocessor->is_ready() !== true) {
-                continue;
+        // Predictions processor.
+        if (!$this->_customdata['staticmodel']) {
+            $defaultprocessor = \core_analytics\manager::get_predictions_processor_name(
+                \core_analytics\manager::get_predictions_processor()
+            );
+            $predictionprocessors = ['' => get_string('defaultpredictoroption', 'analytics', $defaultprocessor)];
+            foreach ($this->_customdata['predictionprocessors'] as $classname => $predictionsprocessor) {
+                if ($predictionsprocessor->is_ready() !== true) {
+                    continue;
+                }
+                $optionname = \tool_analytics\output\helper::class_to_option($classname);
+                $predictionprocessors[$optionname] = \core_analytics\manager::get_predictions_processor_name($predictionsprocessor);
             }
-            $optionname = \tool_analytics\output\helper::class_to_option($classname);
-            $predictionprocessors[$optionname] = \core_analytics\manager::get_predictions_processor_name($predictionsprocessor);
-        }
 
-        $mform->addElement('select', 'predictionsprocessor', get_string('predictionsprocessor', 'analytics'),
-            $predictionprocessors);
-        $mform->addHelpButton('predictionsprocessor', 'predictionsprocessor', 'analytics');
+            $mform->addElement('select', 'predictionsprocessor', get_string('predictionsprocessor', 'analytics'),
+                $predictionprocessors);
+            $mform->addHelpButton('predictionsprocessor', 'predictionsprocessor', 'analytics');
+        }
 
         if (!empty($this->_customdata['id'])) {
             $mform->addElement('hidden', 'id', $this->_customdata['id']);
@@ -130,13 +137,15 @@ class edit_model extends \moodleform {
             }
         }
 
-        if (empty($data['indicators'])) {
-            $errors['indicators'] = get_string('errornoindicators', 'analytics');
-        } else {
-            foreach ($data['indicators'] as $indicator) {
-                $realindicatorname = \tool_analytics\output\helper::option_to_class($indicator);
-                if (\core_analytics\manager::is_valid($realindicatorname, '\core_analytics\local\indicator\base') === false) {
-                    $errors['indicators'] = get_string('errorinvalidindicator', 'analytics', $realindicatorname);
+        if (!$this->_customdata['staticmodel']) {
+            if (empty($data['indicators'])) {
+                $errors['indicators'] = get_string('errornoindicators', 'analytics');
+            } else {
+                foreach ($data['indicators'] as $indicator) {
+                    $realindicatorname = \tool_analytics\output\helper::option_to_class($indicator);
+                    if (\core_analytics\manager::is_valid($realindicatorname, '\core_analytics\local\indicator\base') === false) {
+                        $errors['indicators'] = get_string('errorinvalidindicator', 'analytics', $realindicatorname);
+                    }
                 }
             }
         }
index 861d679..e00ee0b 100644 (file)
@@ -45,7 +45,7 @@ class helper {
         // Form field is PARAM_ALPHANUMEXT and we are sending fully qualified class names
         // as option names, but replacing the backslash for a string that is really unlikely
         // to ever be part of a class name.
-        return str_replace('\\', '2015102400ouuu', $class);
+        return str_replace('\\', '__', $class);
     }
 
     /**
@@ -56,7 +56,7 @@ class helper {
      */
     public static function option_to_class($option) {
         // Really unlikely but yeah, I'm a bad booyyy.
-        return str_replace('2015102400ouuu', '\\', $option);
+        return str_replace('__', '\\', $option);
     }
 
     /**
index 5152b6d..b0431a3 100644 (file)
@@ -71,6 +71,17 @@ class models_list implements \renderable, \templatable {
             $onlycli = 1;
         }
 
+        // Evaluation options.
+        $timesplittingmethods = [
+            ['id' => 'all', 'text' => get_string('alltimesplittingmethods', 'tool_analytics')],
+        ];
+        foreach (\core_analytics\manager::get_time_splitting_methods_for_evaluation(true) as $timesplitting) {
+            $timesplittingmethods[] = [
+                'id' => \tool_analytics\output\helper::class_to_option($timesplitting->get_id()),
+                'text' => $timesplitting->get_name()->out(),
+            ];
+        }
+
         $data->models = array();
         foreach ($this->models as $model) {
             $modeldata = $model->export();
@@ -192,7 +203,16 @@ class models_list implements \renderable, \templatable {
                 $trainedonlyexternally = !$model->trained_locally() && $model->is_trained();
 
                 $actionid = 'evaluate-' . $model->get_id();
-                $PAGE->requires->js_call_amd('tool_analytics/model', 'selectEvaluationMode', [$actionid, $trainedonlyexternally]);
+
+                $modeltimesplittingmethods = $timesplittingmethods;
+                // Include the current time-splitting method as the default selection method the model already have one.
+                if ($model->get_model_obj()->timesplitting) {
+                    $currenttimesplitting = ['id' => 'current', 'text' => get_string('currenttimesplitting', 'tool_analytics')];
+                    array_unshift($modeltimesplittingmethods, $currenttimesplitting);
+                }
+
+                $evaluateparams = [$actionid, $trainedonlyexternally, $modeltimesplittingmethods];
+                $PAGE->requires->js_call_amd('tool_analytics/model', 'selectEvaluationOptions', $evaluateparams);
                 $urlparams['action'] = 'evaluate';
                 $url = new \moodle_url('model.php', $urlparams);
                 $icon = new \action_menu_link_secondary($url, new \pix_icon('i/calc', get_string('evaluate', 'tool_analytics')),
@@ -210,12 +230,10 @@ class models_list implements \renderable, \templatable {
             }
 
             // Edit model.
-            if (!$model->is_static()) {
-                $urlparams['action'] = 'edit';
-                $url = new \moodle_url('model.php', $urlparams);
-                $icon = new \action_menu_link_secondary($url, new \pix_icon('t/edit', get_string('edit')), get_string('edit'));
-                $actionsmenu->add($icon);
-            }
+            $urlparams['action'] = 'edit';
+            $url = new \moodle_url('model.php', $urlparams);
+            $icon = new \action_menu_link_secondary($url, new \pix_icon('t/edit', get_string('edit')), get_string('edit'));
+            $actionsmenu->add($icon);
 
             // Enable / disable.
             if ($model->is_enabled() || !empty($modeldata->timesplitting)) {
@@ -280,14 +298,17 @@ class models_list implements \renderable, \templatable {
                 $actionsmenu->add($icon);
             }
 
-            $actionid = 'delete-' . $model->get_id();
-            $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'delete']);
-            $urlparams['action'] = 'delete';
-            $url = new \moodle_url('model.php', $urlparams);
-            $icon = new \action_menu_link_secondary($url, new \pix_icon('t/delete',
-                get_string('delete', 'tool_analytics')), get_string('delete', 'tool_analytics'),
-                ['data-action-id' => $actionid]);
-            $actionsmenu->add($icon);
+            // Delete model.
+            if (!$model->is_static()) {
+                $actionid = 'delete-' . $model->get_id();
+                $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'delete']);
+                $urlparams['action'] = 'delete';
+                $url = new \moodle_url('model.php', $urlparams);
+                $icon = new \action_menu_link_secondary($url, new \pix_icon('t/delete',
+                    get_string('delete', 'tool_analytics')), get_string('delete', 'tool_analytics'),
+                    ['data-action-id' => $actionid]);
+                $actionsmenu->add($icon);
+            }
 
             $modeldata->actions = $actionsmenu->export_for_template($output);
 
index bf1701c..e5b9b92 100644 (file)
@@ -40,9 +40,10 @@ $targets = array_filter(\core_analytics\manager::get_all_targets(), function($ta
 
 $customdata = array(
     'trainedmodel' => false,
+    'staticmodel' => false,
     'targets' => $targets,
     'indicators' => \core_analytics\manager::get_all_indicators(),
-    'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods(),
+    'timesplittings' => \core_analytics\manager::get_all_time_splittings(),
     'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
 );
 $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
index b3a8ba4..34c1585 100644 (file)
@@ -24,6 +24,7 @@
 
 $string['accuracy'] = 'Accuracy';
 $string['allpredictions'] = 'All predictions';
+$string['alltimesplittingmethods'] = 'All time-splitting methods';
 $string['analysingsitedata'] = 'Analysing the site';
 $string['analyticmodels'] = 'Analytics models';
 $string['bettercli'] = 'Evaluating models and generating predictions may involve heavy processing. It is recommended to run these actions from the command line.';
@@ -36,6 +37,7 @@ $string['clienablemodel'] = 'You can enable the model by selecting a time-splitt
 $string['clievaluationandpredictions'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. You can allow these processes to be executed manually via the web interface by disabling the <a href="{$a}">\'onlycli\'</a> analytics setting.';
 $string['clievaluationandpredictionsnoadmin'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. It may be enabled by a site administrator.';
 $string['createmodel'] = 'Create model';
+$string['currenttimesplitting'] = 'Current time-splitting method';
 $string['delete'] = 'Delete';
 $string['deletemodelconfirmation'] = 'Are you sure you want to delete "{$a}"? These changes can not be reverted.';
 $string['disabled'] = 'Disabled';
@@ -46,7 +48,6 @@ $string['errorcantenablenotimesplitting'] = 'You need to select a time-splitting
 $string['errornoenabledandtrainedmodels'] = 'There are no enabled and trained models to predict.';
 $string['errornoenabledmodels'] = 'There are no enabled models to train.';
 $string['errornoexport'] = 'Only trained models can be exported';
-$string['errornostaticedit'] = 'Models based on assumptions cannot be edited.';
 $string['errornostaticevaluated'] = 'Models based on assumptions cannot be evaluated. They are always 100% correct according to how they were defined.';
 $string['errornostaticlog'] = 'Models based on assumptions cannot be evaluated because there is no performance log.';
 $string['erroronlycli'] = 'Execution only allowed via command line';
@@ -111,6 +112,7 @@ $string['predictionprocessfinished'] = 'Prediction process finished';
 $string['previouspage'] = 'Previous page';
 $string['samestartdate'] = 'Current start date is good';
 $string['sameenddate'] = 'Current end date is good';
+$string['selecttimesplittingforevaluation'] = 'Select the time-splitting method you want to use to evaluate the model configuration.';
 $string['target'] = 'Target';
 $string['target_help'] = 'The target is what the model will predict.';
 $string['target_link'] = 'Targets';
index ab2fe61..d70fad3 100644 (file)
@@ -103,21 +103,19 @@ switch ($action) {
     case 'delete':
         confirm_sesskey();
 
-        $model->delete();
+        if (!$model->is_static()) {
+            $model->delete();
+        }
         redirect($returnurl);
         break;
 
     case 'edit':
         confirm_sesskey();
 
-        if ($model->is_static()) {
-            echo $OUTPUT->header();
-            throw new moodle_exception('errornostaticedit', 'tool_analytics');
-        }
-
         $customdata = array(
             'id' => $model->get_id(),
             'trainedmodel' => $model->is_trained(),
+            'staticmodel' => $model->is_static(),
             'indicators' => $model->get_potential_indicators(),
             'timesplittings' => \core_analytics\manager::get_all_time_splittings(),
             'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
@@ -129,14 +127,22 @@ switch ($action) {
 
         } else if ($data = $mform->get_data()) {
 
-            // Converting option names to class names.
-            $indicators = array();
-            foreach ($data->indicators as $indicator) {
-                $indicatorclass = \tool_analytics\output\helper::option_to_class($indicator);
-                $indicators[] = \core_analytics\manager::get_indicator($indicatorclass);
-            }
             $timesplitting = \tool_analytics\output\helper::option_to_class($data->timesplitting);
-            $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor);
+
+            if (!$model->is_static()) {
+                // Converting option names to class names.
+                $indicators = array();
+                foreach ($data->indicators as $indicator) {
+                    $indicatorclass = \tool_analytics\output\helper::option_to_class($indicator);
+                    $indicators[] = \core_analytics\manager::get_indicator($indicatorclass);
+                }
+                $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor);
+            } else {
+                // These fields can not be modified.
+                $indicators = false;
+                $predictionsprocessor = false;
+            }
+
             $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
             redirect($returnurl);
         }
@@ -172,7 +178,19 @@ switch ($action) {
         $mode = optional_param('mode', false, PARAM_ALPHANUM);
         if ($mode == 'trainedmodel') {
             $options['mode'] = 'trainedmodel';
+        } else {
+
+            // All is the default in core_analytics\model::evaluate() as well.
+            $timesplitting = optional_param('timesplitting', 'all', PARAM_ALPHANUMEXT);
+            if ($timesplitting === 'current') {
+                $options['timesplitting'] = \core_analytics\manager::get_time_splitting($model->get_model_obj()->timesplitting);
+            } else if ($timesplitting !== 'all') {
+                $options['timesplitting'] = \core_analytics\manager::get_time_splitting(
+                    \tool_analytics\output\helper::option_to_class($timesplitting)
+                );
+            }
         }
+
         $results = $model->evaluate($options);
 
         // We reset the theme and the output as some indicators may be using external functions
diff --git a/admin/tool/analytics/templates/evaluation_mode_selection.mustache b/admin/tool/analytics/templates/evaluation_mode_selection.mustache
deleted file mode 100644 (file)
index e9b32ce..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-{{!
-    This file is part of Moodle - http://moodle.org/
-
-    Moodle is free software: you can redistribute it and/or modify
-    it under the terms of the GNU General Public License as published by
-    the Free Software Foundation, either version 3 of the License, or
-    (at your option) any later version.
-
-    Moodle is distributed in the hope that it will be useful,
-    but WITHOUT ANY WARRANTY; without even the implied warranty of
-    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-    GNU General Public License for more details.
-
-    You should have received a copy of the GNU General Public License
-    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @template tool_analytics/evaluation_mode_selector
-
-    Evaluation mode selector.
-
-    The purpose of this template is to render the evaluation mode radio button.
-
-    Classes required for JS:
-    * none
-
-    Data attributes required for JS:
-    * none
-
-    Example context (json):
-    {
-    }
-}}
-<div class="box mb-4">{{#str}} evaluationmodeinfo, tool_analytics {{/str}}</div>
-<div class="form-check">
-    <input class="form-check-input" type="radio" name="evaluationmode" id="id-mode-trainedmodel" value="trainedmodel" checked>
-    <label class="form-check-label" for="id-mode-trainedmodel">{{#str}} evaluationmodetrainedmodel, tool_analytics {{/str}}</label>
-</div>
-<div class="form-check">
-    <input class="form-check-input" type="radio" name="evaluationmode" id="id-mode-configuration" value="configuration">
-    <label class="form-check-label" for="id-mode-configuration">{{#str}} evaluationmodeconfiguration, tool_analytics {{/str}}</label>
-</div>
\ No newline at end of file
diff --git a/admin/tool/analytics/templates/evaluation_options.mustache b/admin/tool/analytics/templates/evaluation_options.mustache
new file mode 100644 (file)
index 0000000..9d77c13
--- /dev/null
@@ -0,0 +1,82 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template tool_analytics/evaluation_options
+
+    Evaluation selector.
+
+    The purpose of this template is to render the evaluation mode options.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Example context (json):
+    {
+        "trainedexternally": "1",
+        "timesplittingmethods": [
+            {
+                "id": "ou",
+                "name": "Quarters"
+            }, {
+                "id": "yeah",
+                "name": "Tenths"
+            }
+        ]
+    }
+}}
+
+{{#trainedexternally}}
+    <div class="box mb-4">{{#str}} evaluationmodeinfo, tool_analytics {{/str}}</div>
+
+    <div class="custom-control custom-radio">
+        <input class="custom-control-input" type="radio" name="evaluationmode" id="id-mode-trainedmodel" value="trainedmodel" checked>
+        <label class="custom-control-label" for="id-mode-trainedmodel">{{#str}} evaluationmodetrainedmodel, tool_analytics {{/str}}</label>
+    </div>
+
+    <div class="custom-control custom-radio">
+        <input class="custom-control-input" type="radio" name="evaluationmode" id="id-mode-configuration" value="configuration">
+        <label class="custom-control-label" for="id-mode-configuration">{{#str}} evaluationmodeconfiguration, tool_analytics {{/str}}</label>
+    </div>
+{{/trainedexternally}}
+
+{{! Hidden by default if #trainedexternally as the default option is trainedmodel in this case.}}
+<div id="id-evaluation-timesplitting-container" class="m-t-1 {{#trainedexternally}}hidden{{/trainedexternally}}">
+    {{#str}} selecttimesplittingforevaluation, tool_analytics {{/str}}
+    <div>
+        <select id="id-evaluation-timesplitting" name="timesplitting" class="custom-select m-t-1">
+            {{#timesplittingmethods}}
+                <option value="{{id}}">{{text}}</option>
+            {{/timesplittingmethods}}
+        </select>
+    </div>
+</div>
+
+
+{{#js}}
+    require(['jquery'], function($) {
+        $("input[name='evaluationmode']:radio").change(function() {
+            if ($(this).val() == 'configuration') {
+                $('#id-evaluation-timesplitting-container').show();
+            } else {
+                $('#id-evaluation-timesplitting-container').hide();
+            }
+        });
+    });
+{{/js}}
\ No newline at end of file
index 2c18ac7..80cb7ee 100644 (file)
@@ -614,6 +614,12 @@ class api {
             throw new moodle_exception('errorrequestnotwaitingforapproval', 'tool_dataprivacy');
         }
 
+        // Check if current user has permission to approve delete data request.
+        if ($request->get('type') == self::DATAREQUEST_TYPE_DELETE && !self::can_create_data_deletion_request_for_other()) {
+            throw new required_capability_exception(context_system::instance(),
+                'tool/dataprivacy:requestdeleteforotheruser', 'nopermissions', '');
+        }
+
         // Update the status and the DPO.
         $result = self::update_request_status($requestid, self::DATAREQUEST_STATUS_APPROVED, $USER->id);
 
@@ -653,6 +659,12 @@ class api {
             throw new moodle_exception('errorrequestnotwaitingforapproval', 'tool_dataprivacy');
         }
 
+        // Check if current user has permission to reject delete data request.
+        if ($request->get('type') == self::DATAREQUEST_TYPE_DELETE && !self::can_create_data_deletion_request_for_other()) {
+            throw new required_capability_exception(context_system::instance(),
+                'tool/dataprivacy:requestdeleteforotheruser', 'nopermissions', '');
+        }
+
         // Update the status and the DPO.
         return self::update_request_status($requestid, self::DATAREQUEST_STATUS_REJECTED, $USER->id);
     }
@@ -752,6 +764,48 @@ class api {
         return true;
     }
 
+    /**
+     * Check if user has permisson to create data deletion request for themselves.
+     *
+     * @param int|null $userid ID of the user.
+     * @return bool
+     * @throws coding_exception
+     */
+    public static function can_create_data_deletion_request_for_self(int $userid = null): bool {
+        global $USER;
+        $userid = $userid ?: $USER->id;
+        return has_capability('tool/dataprivacy:requestdelete', \context_user::instance($userid), $userid);
+    }
+
+    /**
+     * Check if user has permission to create data deletion request for another user.
+     *
+     * @param int|null $userid ID of the user.
+     * @return bool
+     * @throws coding_exception
+     * @throws dml_exception
+     */
+    public static function can_create_data_deletion_request_for_other(int $userid = null): bool {
+        global $USER;
+        $userid = $userid ?: $USER->id;
+        return has_capability('tool/dataprivacy:requestdeleteforotheruser', context_system::instance(), $userid);
+    }
+
+    /**
+     * Check if parent can create data deletion request for their children.
+     *
+     * @param int $userid ID of a user being requested.
+     * @param int|null $requesterid ID of a user making request.
+     * @return bool
+     * @throws coding_exception
+     */
+    public static function can_create_data_deletion_request_for_children(int $userid, int $requesterid = null): bool {
+        global $USER;
+        $requesterid = $requesterid ?: $USER->id;
+        return has_capability('tool/dataprivacy:makedatadeletionrequestsforchildren', \context_user::instance($userid),
+            $requesterid);
+    }
+
     /**
      * Checks whether a user can download a data request.
      *
index a309c8e..3c5ce09 100644 (file)
@@ -170,6 +170,10 @@ class data_request_exporter extends persistent_exporter {
                 $values['canreview'] = true;
                 // Whether the DPO can approve or deny the request.
                 $values['approvedeny'] = in_array($requesttype, [api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_TYPE_DELETE]);
+                // If the request's type is delete, check if user have permission to approve/deny it.
+                if ($requesttype == api::DATAREQUEST_TYPE_DELETE) {
+                    $values['approvedeny'] = api::can_create_data_deletion_request_for_other();
+                }
                 break;
             case api::DATAREQUEST_STATUS_APPROVED:
                 $values['statuslabelclass'] = 'badge-info';
index 7249adf..2f12f58 100644 (file)
@@ -234,10 +234,6 @@ class data_registry_page implements renderable, templatable {
 
             $coursecontext = \context_course::instance($course->id);
 
-            if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
-                continue;
-            }
-
             $coursenode = [
                 'text' => shorten_text(format_string($course->shortname, true, ['context' => $coursecontext])),
                 'contextid' => $coursecontext->id,
index b86e464..303c774 100644 (file)
@@ -116,9 +116,17 @@ class data_requests_table extends table_sql {
      *
      * @param stdClass $data The row data.
      * @return string
+     * @throws \moodle_exception
+     * @throws coding_exception
      */
     public function col_select($data) {
         if ($data->status == \tool_dataprivacy\api::DATAREQUEST_STATUS_AWAITING_APPROVAL) {
+            if ($data->type == \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE
+                && !api::can_create_data_deletion_request_for_other()) {
+                // Don't show checkbox if request's type is delete and user don't have permission.
+                return false;
+            }
+
             $stringdata = [
                 'username' => $data->foruser->fullname,
                 'requesttype' => \core_text::strtolower($data->typenameshort)
@@ -206,6 +214,7 @@ class data_requests_table extends table_sql {
 
         $requestid = $data->id;
         $status = $data->status;
+        $persistent = $this->datarequests[$requestid];
 
         // Prepare actions.
         $actions = [];
@@ -232,6 +241,11 @@ class data_requests_table extends table_sql {
                 }
                 break;
             case api::DATAREQUEST_STATUS_AWAITING_APPROVAL:
+                // Only show "Approve" and "Deny" button for deletion request if current user has permission.
+                if ($persistent->get('type') == api::DATAREQUEST_TYPE_DELETE &&
+                    !api::can_create_data_deletion_request_for_other()) {
+                    break;
+                }
                 // Approve.
                 $actiondata['data-action'] = 'approve';
                 $actiontext = get_string('approverequest', 'tool_dataprivacy');
@@ -253,9 +267,11 @@ class data_requests_table extends table_sql {
         }
 
         if ($this->manage) {
-            $persistent = $this->datarequests[$requestid];
             $canreset = $persistent->is_active() || empty($this->ongoingrequests[$data->foruser->id]->{$data->type});
             $canreset = $canreset && $persistent->is_resettable();
+            // Prevent re-submmit deletion request if current user don't have permission.
+            $canreset = $canreset && ($persistent->get('type') != api::DATAREQUEST_TYPE_DELETE ||
+                    api::can_create_data_deletion_request_for_other());
             if ($canreset) {
                 $reseturl = new moodle_url('/admin/tool/dataprivacy/resubmitrequest.php', [
                         'requestid' => $requestid,
index 12db2ea..b32dc15 100644 (file)
@@ -76,6 +76,19 @@ if ($data = $mform->get_data()) {
         }
     }
 
+    if ($data->type == \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE) {
+        if ($data->userid == $USER->id) {
+            if (!\tool_dataprivacy\api::can_create_data_deletion_request_for_self()) {
+                throw new moodle_exception('nopermissions', 'error', '',
+                    get_string('errorcannotrequestdeleteforself', 'tool_dataprivacy'));
+            }
+        } else if (!\tool_dataprivacy\api::can_create_data_deletion_request_for_other()
+            && !\tool_dataprivacy\api::can_create_data_deletion_request_for_children($data->userid)) {
+            throw new moodle_exception('nopermissions', 'error', '',
+                get_string('errorcannotrequestdeleteforother', 'tool_dataprivacy'));
+        }
+    }
+
     \tool_dataprivacy\api::create_data_request($data->userid, $data->type, $data->comments);
 
     if ($manage) {
@@ -93,7 +106,7 @@ $PAGE->set_title($title);
 echo $OUTPUT->header();
 echo $OUTPUT->heading($title);
 
-echo $OUTPUT->box_start();
+echo $OUTPUT->box_start('createrequestform');
 $mform->display();
 echo $OUTPUT->box_end();
 
index 4c94a8a..c91213c 100644 (file)
@@ -45,6 +45,7 @@ class tool_dataprivacy_data_request_form extends moodleform {
      * Form definition.
      *
      * @throws coding_exception
+     * @throws dml_exception
      */
     public function definition() {
         global $USER;
@@ -108,6 +109,24 @@ class tool_dataprivacy_data_request_form extends moodleform {
         // Action buttons.
         $this->add_action_buttons();
 
+        $shouldfreeze = false;
+        if ($this->manage) {
+            $shouldfreeze = !api::can_create_data_deletion_request_for_other();
+        } else {
+            $shouldfreeze = !api::can_create_data_deletion_request_for_self();
+            if ($shouldfreeze && !empty($useroptions)) {
+                foreach ($useroptions as $userid => $useroption) {
+                    if (api::can_create_data_deletion_request_for_children($userid)) {
+                        $shouldfreeze = false;
+                        break;
+                    }
+                }
+            }
+        }
+
+        if ($shouldfreeze) {
+            $mform->freeze('type');
+        }
     }
 
     /**
@@ -120,6 +139,7 @@ class tool_dataprivacy_data_request_form extends moodleform {
      * @throws dml_exception
      */
     public function validation($data, $files) {
+        global $USER;
         $errors = [];
 
         $validrequesttypes = [
@@ -134,6 +154,19 @@ class tool_dataprivacy_data_request_form extends moodleform {
             $errors['type'] = get_string('errorrequestalreadyexists', 'tool_dataprivacy');
         }
 
+        // Check if current user can create data deletion request.
+        $userid = $data['userid'];
+        if ($data['type'] == api::DATAREQUEST_TYPE_DELETE) {
+            if ($userid == $USER->id) {
+                if (!api::can_create_data_deletion_request_for_self()) {
+                    $errors['type'] = get_string('errorcannotrequestdeleteforself', 'tool_dataprivacy');
+                }
+            } else if (!api::can_create_data_deletion_request_for_other()
+                && !api::can_create_data_deletion_request_for_children($userid)) {
+                $errors['type'] = get_string('errorcannotrequestdeleteforother', 'tool_dataprivacy');
+            }
+        }
+
         return $errors;
     }
 }
index ad31867..d70f4a5 100644 (file)
@@ -34,6 +34,15 @@ $capabilities = [
         'archetypes' => []
     ],
 
+    // Capability for create new delete data request. Usually given to the site's Protection Officer.
+    'tool/dataprivacy:requestdeleteforotheruser' => [
+        'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS | RISK_DATALOSS,
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_SYSTEM,
+        'archetypes' => [],
+        'clonepermissionsfrom' => 'tool/dataprivacy:managedatarequests'
+    ],
+
     // Capability for managing the data registry. Usually given to the site's Data Protection Officer.
     'tool/dataprivacy:managedataregistry' => [
         'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS | RISK_DATALOSS,
@@ -50,6 +59,15 @@ $capabilities = [
         'archetypes' => []
     ],
 
+    // Capability for parents/guardians to make delete data requests on behalf of their children.
+    'tool/dataprivacy:makedatadeletionrequestsforchildren' => [
+        'riskbitmask' => RISK_SPAM | RISK_PERSONAL,
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_USER,
+        'archetypes' => [],
+        'clonepermissionsfrom' => 'tool/dataprivacy:makedatarequestsforchildren'
+    ],
+
     // Capability for users to download the results of their own data request.
     'tool/dataprivacy:downloadownrequest' => [
         'riskbitmask' => 0,
@@ -67,4 +85,14 @@ $capabilities = [
         'contextlevel' => CONTEXT_USER,
         'archetypes' => []
     ],
+
+    // Capability for users to create delete data request for their own.
+    'tool/dataprivacy:requestdelete' => [
+        'riskbitmask' => RISK_DATALOSS,
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_USER,
+        'archetypes' => [
+            'user' => CAP_ALLOW
+        ]
+    ]
 ];
index bb3fa70..5a0efd4 100644 (file)
@@ -70,6 +70,7 @@ $string['contextlevelname80'] = 'Blocks';
 $string['contextpurposecategorysaved'] = 'Purpose and category saved.';
 $string['contactdpoviaprivacypolicy'] = 'Please contact the privacy officer as described in the privacy policy.';
 $string['createcategory'] = 'Create data category';
+$string['createdeletedatarequest'] = 'Create data deletion request';
 $string['createnewdatarequest'] = 'Create a new data request';
 $string['createpurpose'] = 'Create data purpose';
 $string['creationauto'] = 'Automatically';
@@ -81,6 +82,9 @@ $string['dataprivacy:managedatarequests'] = 'Manage data requests';
 $string['dataprivacy:managedataregistry'] = 'Manage data registry';
 $string['dataprivacy:downloadownrequest'] = 'Download your own exported data';
 $string['dataprivacy:downloadallrequests'] = 'Download exported data for everyone';
+$string['dataprivacy:requestdeleteforotheruser'] = 'Request data deletion on behalf of another user';
+$string['dataprivacy:makedatadeletionrequestsforchildren'] = 'Request data deletion for minors';
+$string['dataprivacy:requestdelete'] = 'Request data deletion for yourself';
 $string['dataregistry'] = 'Data registry';
 $string['dataregistryinfo'] = 'The data registry enables categories (types of data) and purposes (the reasons for processing data) to be set for all content on the site - from users and courses down to activities and blocks. For each purpose, a retention period may be set. When a retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.';
 $string['dataretentionexplanation'] = 'This summary shows the default categories and purposes for retaining user data. Certain areas may have more specific categories and purposes than those listed here.';
@@ -124,6 +128,8 @@ $string['editpurposes'] = 'Edit purposes';
 $string['effectiveretentionperiodcourse'] = '{$a} (after the course end date)';
 $string['effectiveretentionperioduser'] = '{$a} (since the last time the user accessed the site)';
 $string['emailsalutation'] = 'Dear {$a},';
+$string['errorcannotrequestdeleteforself'] = 'You don\'t have permission to create deletion request for yourself.';
+$string['errorcannotrequestdeleteforother'] = 'You don\'t have permission to create deletion request for this user.';
 $string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
 $string['errorinvalidrequeststatus'] = 'Invalid request status!';
 $string['errorinvalidrequesttype'] = 'Invalid request type!';
index 8655f1c..c069301 100644 (file)
@@ -77,8 +77,9 @@ function tool_dataprivacy_myprofile_navigation(tree $tree, $user, $iscurrentuser
 
         // Check if the user has an ongoing data deletion request.
         $hasdeleterequest = \tool_dataprivacy\api::has_ongoing_request($user->id, \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE);
-        // Show data deletion link only if the user doesn't have an ongoing data deletion request.
-        if (!$hasdeleterequest) {
+        // Show data deletion link only if the user doesn't have an ongoing data deletion request and has permission
+        // to create data deletion request.
+        if (!$hasdeleterequest && \tool_dataprivacy\api::can_create_data_deletion_request_for_self()) {
             $deleteparams = ['type' => \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE];
             $deleteurl = new moodle_url('/admin/tool/dataprivacy/createdatarequest.php', $deleteparams);
             $deletenode = new core_user\output\myprofile\node('privacyandpolicies', 'requestdatadeletion',
index ac7520e..4f3ef55 100644 (file)
@@ -44,6 +44,11 @@ $stringparams = (object) [
     ];
 
 if (null !== $confirm && confirm_sesskey()) {
+    if ($originalrequest->get('type') == \tool_dataprivacy\api::DATAREQUEST_TYPE_DELETE
+        && !\tool_dataprivacy\api::can_create_data_deletion_request_for_other()) {
+        throw new required_capability_exception(context_system::instance(),
+            'tool/dataprivacy:requestdeleteforotheruser', 'nopermissions', '');
+    }
     $originalrequest->resubmit_request();
     redirect($manageurl, get_string('resubmittedrequest', 'tool_dataprivacy', $stringparams));
 }
index 8e60223..f95dedd 100644 (file)
@@ -2109,4 +2109,90 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
 
         return $request;
     }
+
+    /**
+     * Test user cannot create data deletion request for themselves if they don't have
+     * "tool/dataprivacy:requestdelete" capability.
+     *
+     * @throws coding_exception
+     */
+    public function test_can_create_data_deletion_request_for_self_no() {
+        $this->resetAfterTest();
+        $userid = $this->getDataGenerator()->create_user()->id;
+        $roleid = $this->getDataGenerator()->create_role();
+        assign_capability('tool/dataprivacy:requestdelete', CAP_PROHIBIT, $roleid, context_user::instance($userid));
+        role_assign($roleid, $userid, context_user::instance($userid));
+        $this->setUser($userid);
+        $this->assertFalse(api::can_create_data_deletion_request_for_self());
+    }
+
+    /**
+     * Test user can create data deletion request for themselves if they have
+     * "tool/dataprivacy:requestdelete" capability.
+     *
+     * @throws coding_exception
+     */
+    public function test_can_create_data_deletion_request_for_self_yes() {
+        $this->resetAfterTest();
+        $userid = $this->getDataGenerator()->create_user()->id;
+        $this->setUser($userid);
+        $this->assertTrue(api::can_create_data_deletion_request_for_self());
+    }
+
+    /**
+     * Test user cannot create data deletion request for another user if they
+     * don't have "tool/dataprivacy:requestdeleteforotheruser" capability.
+     *
+     * @throws coding_exception
+     * @throws dml_exception
+     */
+    public function test_can_create_data_deletion_request_for_other_no() {
+        $this->resetAfterTest();
+        $userid = $this->getDataGenerator()->create_user()->id;
+        $this->setUser($userid);
+        $this->assertFalse(api::can_create_data_deletion_request_for_other());
+    }
+
+    /**
+     * Test user can create data deletion request for another user if they
+     * don't have "tool/dataprivacy:requestdeleteforotheruser" capability.
+     *
+     * @throws coding_exception
+     */
+    public function test_can_create_data_deletion_request_for_other_yes() {
+        $this->resetAfterTest();
+        $userid = $this->getDataGenerator()->create_user()->id;
+        $roleid = $this->getDataGenerator()->create_role();
+        $contextsystem = context_system::instance();
+        assign_capability('tool/dataprivacy:requestdeleteforotheruser', CAP_ALLOW, $roleid, $contextsystem);
+        role_assign($roleid, $userid, $contextsystem);
+        $this->setUser($userid);
+        $this->assertTrue(api::can_create_data_deletion_request_for_other($userid));
+    }
+
+    /**
+     * Check parents can create data deletion request for their children but not others.
+     *
+     * @throws coding_exception
+     * @throws dml_exception
+     */
+    public function test_can_create_data_deletion_request_for_children() {
+        $this->resetAfterTest();
+
+        $parent = $this->getDataGenerator()->create_user();
+        $child = $this->getDataGenerator()->create_user();
+        $otheruser = $this->getDataGenerator()->create_user();
+
+        $contextsystem = \context_system::instance();
+        $parentrole = $this->getDataGenerator()->create_role();
+        assign_capability('tool/dataprivacy:makedatarequestsforchildren', CAP_ALLOW,
+            $parentrole, $contextsystem);
+        assign_capability('tool/dataprivacy:makedatadeletionrequestsforchildren', CAP_ALLOW,
+            $parentrole, $contextsystem);
+        role_assign($parentrole, $parent->id, \context_user::instance($child->id));
+
+        $this->setUser($parent);
+        $this->assertTrue(api::can_create_data_deletion_request_for_children($child->id));
+        $this->assertFalse(api::can_create_data_deletion_request_for_children($otheruser->id));
+    }
 }
index e6c7fde..5806102 100644 (file)
@@ -6,18 +6,24 @@ Feature: Data delete from the privacy API
 
   Background:
     Given the following "users" exist:
-      | username | firstname      | lastname |
-      | victim   | Victim User    | 1        |
-      | parent   | Long-suffering | Parent   |
+      | username       | firstname       | lastname |
+      | victim         | Victim User     | 1        |
+      | parent         | Long-suffering  | Parent   |
+      | privacyofficer | Privacy Officer | One      |
     And the following "roles" exist:
       | shortname | name  | archetype |
       | tired     | Tired |           |
     And the following "permission overrides" exist:
-      | capability                                   | permission | role  | contextlevel | reference |
-      | tool/dataprivacy:makedatarequestsforchildren | Allow      | tired | System       |           |
+      | capability                                           | permission | role    | contextlevel | reference |
+      | tool/dataprivacy:makedatarequestsforchildren         | Allow      | tired   | System       |           |
+      | tool/dataprivacy:makedatadeletionrequestsforchildren | Allow      | tired   | System       |           |
+      | tool/dataprivacy:managedatarequests                  | Allow      | manager | System       |           |
     And the following "role assigns" exist:
       | user   | role  | contextlevel | reference |
       | parent | tired | User         | victim    |
+    And the following "system role assigns" exist:
+      | user           | role    | contextlevel |
+      | privacyofficer | manager | User         |
     And the following config values are set as admin:
       | contactdataprotectionofficer | 1  | tool_dataprivacy |
     And the following data privacy "categories" exist:
@@ -26,6 +32,10 @@ Feature: Data delete from the privacy API
     And the following data privacy "purposes" exist:
       | name         | retentionperiod |
       | Site purpose | P10Y           |
+    And the following config values are set as admin:
+      | contactdataprotectionofficer | 1  | tool_dataprivacy |
+      | privacyrequestexpiry         | 55 | tool_dataprivacy |
+      | dporoles                     | 1  | tool_dataprivacy |
     And I set the site category and purpose to "Site category" and "Site purpose"
 
   @javascript
@@ -115,3 +125,112 @@ Feature: Data delete from the privacy API
     And I run all adhoc tasks
     And I reload the page
     And I should see "You don't have any personal data requests"
+
+  @javascript
+  Scenario: As a Privacy Officer, I cannot create data deletion request unless I have permission.
+    Given I log in as "privacyofficer"
+    And I navigate to "Users > Privacy and policies > Data requests" in site administration
+    And I follow "New request"
+    And I open the autocomplete suggestions list
+    And I click on "Victim User 1" item in the autocomplete list
+    Then I should see "Export all of my personal data"
+    And "Type" "select" should not be visible
+    And the following "permission overrides" exist:
+      | capability                                 | permission | role    | contextlevel | reference |
+      | tool/dataprivacy:requestdeleteforotheruser | Allow      | manager | System       |           |
+    And I reload the page
+    And I open the autocomplete suggestions list
+    And I click on "Victim User 1" item in the autocomplete list
+    And "Type" "select" should be visible
+
+  @javascript
+  Scenario: As a student, I cannot create data deletion request unless I have permission.
+    Given I log in as "victim"
+    And I follow "Profile" in the user menu
+    And I follow "Data requests"
+    And I follow "New request"
+    Then "Type" "select" should exist
+    And the following "permission overrides" exist:
+      | capability                     | permission | role | contextlevel | reference |
+      | tool/dataprivacy:requestdelete | Prevent    | user | System       |           |
+    And I reload the page
+    And I should see "Export all of my personal data"
+    And "Type" "select" should not exist
+
+  @javascript
+  Scenario: As a parent, I cannot create data deletion request unless I have permission.
+    Given I log in as "parent"
+    And the following "permission overrides" exist:
+      | capability                                           | permission | role  | contextlevel | reference |
+      | tool/dataprivacy:makedatadeletionrequestsforchildren | Prevent    | tired | System       | victim    |
+    And I follow "Profile" in the user menu
+    And I follow "Data requests"
+    And I follow "New request"
+    And I open the autocomplete suggestions list
+    And I click on "Victim User 1" item in the autocomplete list
+    And I set the field "Type" to "Delete all of my personal data"
+    And I press "Save changes"
+    And I should see "You don't have permission to create deletion request for this user."
+    And the following "permission overrides" exist:
+      | capability                                           | permission | role  | contextlevel | reference |
+      | tool/dataprivacy:makedatadeletionrequestsforchildren | Allow      | tired | System       | victim    |
+      | tool/dataprivacy:requestdelete                       | Prevent    | user  | System       |           |
+    And I open the autocomplete suggestions list
+    And I click on "Long-suffering Parent" item in the autocomplete list
+    And I press "Save changes"
+    And I should see "You don't have permission to create deletion request for yourself."
+
+  @javascript
+  Scenario: As a student, link to create data deletion should not be shown if I don't have permission.
+    Given the following "permission overrides" exist:
+      | capability                     | permission | role | contextlevel | reference |
+      | tool/dataprivacy:requestdelete | Prohibit   | user | System       |           |
+    When I log in as "victim"
+    And I follow "Profile" in the user menu
+    Then I should not see "Delete my account"
+
+  @javascript
+  Scenario: As a Privacy Officer, I cannot Approve to Deny deletion data request without permission.
+    Given the following "permission overrides" exist:
+      | capability                                 | permission | role    | contextlevel | reference |
+      | tool/dataprivacy:requestdeleteforotheruser | Allow      | manager | System       |           |
+    When I log in as "privacyofficer"
+    And I navigate to "Users > Privacy and policies > Data requests" in site administration
+    And I follow "New request"
+    And I open the autocomplete suggestions list
+    And I click on "Victim User 1" item in the autocomplete list
+    And I set the field "Type" to "Delete all of my personal data"
+    And I press "Save changes"
+    And the following "permission overrides" exist:
+      | capability                                 | permission | role    | contextlevel | reference |
+      | tool/dataprivacy:requestdeleteforotheruser | Prohibit   | manager | System       |           |
+    And I reload the page
+    Then ".selectrequests" "css_element" should not exist
+    And I open the action menu in "region-main" "region"
+    And I should not see "Approve request"
+    And I should not see "Deny request"
+    And I choose "View the request" in the open action menu
+    And "Approve" "button" should not exist
+    And "Deny" "button" should not exist
+
+  @javascript
+  Scenario: As a Privacy Officer, I cannot re-submit deletion data request without permission.
+    Given the following "permission overrides" exist:
+      | capability                                 | permission | role    | contextlevel | reference |
+      | tool/dataprivacy:requestdeleteforotheruser | Allow      | manager | System       |           |
+    When I log in as "privacyofficer"
+    And I navigate to "Users > Privacy and policies > Data requests" in site administration
+    And I follow "New request"
+    And I open the autocomplete suggestions list
+    And I click on "Victim User 1" item in the autocomplete list
+    And I set the field "Type" to "Delete all of my personal data"
+    And I press "Save changes"
+    And I open the action menu in "region-main" "region"
+    And I follow "Deny request"
+    And I press "Deny request"
+    And the following "permission overrides" exist:
+      | capability                                 | permission | role    | contextlevel | reference |
+      | tool/dataprivacy:requestdeleteforotheruser | Prohibit   | manager | System       |           |
+    And I reload the page
+    And I open the action menu in "region-main" "region"
+    Then I should not see "Resubmit as new request"
index 411f0ac..8089b41 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2019011500;
+$plugin->version   = 2019040800;
 $plugin->requires  = 2018112800;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';
index 15552d5..ee40e70 100644 (file)
@@ -47,8 +47,7 @@ class tool_generator_make_testplan_form extends moodleform {
             tool_generator_testplan_backend::get_size_choices());
         $mform->setDefault('size', tool_generator_testplan_backend::DEFAULT_SIZE);
 
-        $mform->addElement('select', 'courseid', get_string('targetcourse', 'tool_generator'),
-            tool_generator_testplan_backend::get_course_options());
+        $mform->addElement('course', 'courseid', get_string('targetcourse', 'tool_generator'));
 
         $mform->addElement('advcheckbox', 'updateuserspassword', get_string('updateuserspassword', 'tool_generator'));
         $mform->addHelpButton('updateuserspassword', 'updateuserspassword', 'tool_generator');
index 2e43b4c..6b3c2e4 100644 (file)
@@ -71,25 +71,6 @@ class tool_generator_testplan_backend extends tool_generator_backend {
         return $options;
     }
 
-    /**
-     * Gets the list of courses that can be used used to generate a test.
-     *
-     * @return array The list of options as courseid => name
-     */
-    public static function get_course_options() {
-        $courses = get_courses('all', 'c.sortorder ASC', 'c.id, c.shortname, c.fullname');
-        if (!$courses) {
-            print_error('error_nocourses', 'tool_generator');
-        }
-
-        $options = array();
-        unset($courses[1]);
-        foreach ($courses as $course) {
-            $options[$course->id] = $course->fullname . '(' . $course->shortname . ')';
-        }
-        return $options;
-    }
-
     /**
      * Getter for moodle-performance-comparison project URL.
      *
diff --git a/admin/tool/generator/upgrade.txt b/admin/tool/generator/upgrade.txt
new file mode 100644 (file)
index 0000000..4d5b19f
--- /dev/null
@@ -0,0 +1,6 @@
+This files describes API changes in core libraries and APIs,
+information provided here is intended especially for developers.
+
+=== 3.7 ===
+
+* Function tool_generator_testplan_backend::get_course_options() is removed, the 'course' form element is used instead.
index adf90ff..0759217 100644 (file)
@@ -4,7 +4,7 @@ require_once($CFG->libdir.'/adminlib.php');
 require_once($CFG->dirroot.'/message/lib.php');
 require_once('user_message_form.php');
 
-$msg     = optional_param('msg', '', PARAM_CLEANHTML);
+$msg     = optional_param('msg', '', PARAM_RAW);
 $confirm = optional_param('confirm', 0, PARAM_BOOL);
 
 admin_externalpage_setup('userbulk');
@@ -45,6 +45,7 @@ if ($msgform->is_cancelled()) {
     $options->para     = false;
     $options->newlines = true;
     $options->smiley   = false;
+    $options->trusted = trusttext_trusted(\context_system::instance());
 
     $msg = format_text($formdata->messagebody['text'], $formdata->messagebody['format'], $options);
 
index 3eef63c..00884ed 100644 (file)
@@ -275,6 +275,15 @@ abstract class base {
         return true;
     }
 
+    /**
+     * Is this method valid to evaluate prediction models?
+     *
+     * @return bool
+     */
+    public function valid_for_evaluation(): bool {
+        return true;
+    }
+
     /**
      * Validates the time splitting method ranges.
      *
index d8b2598..7cc4054 100644 (file)
@@ -59,4 +59,13 @@ abstract class upcoming_periodic extends periodic {
     public function cache_indicator_calculations(): bool {
         return false;
     }
+
+    /**
+     * Overriden as these time-splitting methods are based on future dates.
+     *
+     * @return bool
+     */
+    public function valid_for_evaluation(): bool {
+        return false;
+    }
 }
index 3e5bbb8..4a4eede 100644 (file)
@@ -287,23 +287,32 @@ class manager {
     }
 
     /**
-     * Returns the default time splitting methods for model evaluation.
+     * Returns the time-splitting methods for model evaluation.
      *
+     * @param  bool $all Return all the time-splitting methods that can potentially be used for evaluation or the default ones.
      * @return \core_analytics\local\time_splitting\base[]
      */
-    public static function get_time_splitting_methods_for_evaluation() {
+    public static function get_time_splitting_methods_for_evaluation(bool $all = false) {
 
-        if ($enabledtimesplittings = get_config('analytics', 'defaulttimesplittingsevaluation')) {
-            $enabledtimesplittings = array_flip(explode(',', $enabledtimesplittings));
+        if ($all === false) {
+            if ($enabledtimesplittings = get_config('analytics', 'defaulttimesplittingsevaluation')) {
+                $enabledtimesplittings = array_flip(explode(',', $enabledtimesplittings));
+            }
         }
 
         $timesplittings = self::get_all_time_splittings();
         foreach ($timesplittings as $key => $timesplitting) {
 
-            // We remove the ones that are not enabled. This also respects the default value (all methods enabled).
-            if (!empty($enabledtimesplittings) && !isset($enabledtimesplittings[$key])) {
+            if (!$timesplitting->valid_for_evaluation()) {
                 unset($timesplittings[$key]);
             }
+
+            if ($all === false) {
+                // We remove the ones that are not enabled. This also respects the default value (all methods enabled).
+                if (!empty($enabledtimesplittings) && !isset($enabledtimesplittings[$key])) {
+                    unset($timesplittings[$key]);
+                }
+            }
         }
         return $timesplittings;
     }
@@ -606,22 +615,7 @@ class manager {
         // Just in case...
         $element = clean_param($element, PARAM_ALPHANUMEXT);
 
-        // Core analytics classes (analytics subsystem should not contain uses of the analytics API).
-        $classes = \core_component::get_component_classes_in_namespace('core', 'analytics\\' . $element);
-
-        // Plugins.
-        foreach (\core_component::get_plugin_types() as $type => $unusedplugintypepath) {
-            foreach (\core_component::get_plugin_list($type) as $pluginname => $unusedpluginpath) {
-                $frankenstyle = $type . '_' . $pluginname;
-                $classes += \core_component::get_component_classes_in_namespace($frankenstyle, 'analytics\\' . $element);
-            }
-        }
-
-        // Core subsystems.
-        foreach (\core_component::get_core_subsystems() as $subsystemname => $unusedsubsystempath) {
-            $componentname = 'core_' . $subsystemname;
-            $classes += \core_component::get_component_classes_in_namespace($componentname, 'analytics\\' . $element);
-        }
+        $classes = \core_component::get_component_classes_in_namespace(null, 'analytics\\' . $element);
 
         return $classes;
     }
index f12af44..6d2cb69 100644 (file)
@@ -376,4 +376,31 @@ class analytics_manager_testcase extends advanced_testcase {
 
         $this->assertSame([], $repeated);
     }
+
+    /**
+     * test_get_time_splitting_methods description
+     * @return null
+     */
+    public function test_get_time_splitting_methods() {
+        $this->resetAfterTest(true);
+
+        $all = \core_analytics\manager::get_all_time_splittings();
+        $this->assertArrayHasKey('\core\analytics\time_splitting\upcoming_week', $all);
+        $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $all);
+
+        $allforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(true);
+        $this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $allforevaluation);
+        $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $allforevaluation);
+
+        $defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
+        $this->assertArrayNotHasKey('\core\analytics\time_splitting\upcoming_week', $defaultforevaluation);
+        $this->assertArrayHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
+
+        $sometimesplittings = '\core\analytics\time_splitting\single_range,' .
+            '\core\analytics\time_splitting\tenths';
+        set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics');
+
+        $defaultforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation(false);
+        $this->assertArrayNotHasKey('\core\analytics\time_splitting\quarters', $defaultforevaluation);
+    }
 }
index ec85d83..1382c97 100644 (file)
@@ -448,8 +448,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         $this->setAdminuser();
         set_config('enabled_stores', 'logstore_standard', 'tool_log');
 
-        $sometimesplittings = '\core\analytics\time_splitting\weekly,' .
-            '\core\analytics\time_splitting\single_range,' .
+        $sometimesplittings = '\core\analytics\time_splitting\single_range,' .
             '\core\analytics\time_splitting\quarters';
         set_config('defaulttimesplittingsevaluation', $sometimesplittings, 'analytics');
 
@@ -620,8 +619,6 @@ class core_analytics_prediction_testcase extends advanced_testcase {
                 'modelquality' => 'random',
                 'ncourses' => 50,
                 'expectedresults' => array(
-                    // The course duration is too much to be processed by in weekly basis.
-                    '\core\analytics\time_splitting\weekly' => \core_analytics\model::NO_DATASET,
                     '\core\analytics\time_splitting\single_range' => \core_analytics\model::LOW_SCORE,
                     '\core\analytics\time_splitting\quarters' => \core_analytics\model::LOW_SCORE,
                 )
@@ -630,8 +627,6 @@ class core_analytics_prediction_testcase extends advanced_testcase {
                 'modelquality' => 'perfect',
                 'ncourses' => 50,
                 'expectedresults' => array(
-                    // The course duration is too much to be processed by in weekly basis.
-                    '\core\analytics\time_splitting\weekly' => \core_analytics\model::NO_DATASET,
                     '\core\analytics\time_splitting\single_range' => \core_analytics\model::OK,
                     '\core\analytics\time_splitting\quarters' => \core_analytics\model::OK,
                 )
index 12aa5d9..31316d4 100644 (file)
@@ -24,6 +24,9 @@ information provided here is intended especially for developers.
       number of ranges.
     * Can now overwrite cache_indicator_calculations(). You should return false if the time frames generated
       by your time-splitting method are unique and / or can hardly be reused by further models.
+    * Can now overwrite valid_for_evaluation(). You can return false if the time-splitting method can not be
+      used to evaluate prediction models or if it does not make sense to evaluate prediction models with it,
+      as for example upcoming_periodic children classes.
     * \core_analytics\local\analyser\base::get_most_recent_prediction_range has been moved to
       \core_analytics\local\time_splitting\base::get_most_recent_prediction_range and it is not overwritable
       by time splitting methods.
index 7934510..234006c 100644 (file)
@@ -63,3 +63,36 @@ Feature: User must accept policy when logging in and signing up
     And I log in as "user1"
     And I open my profile in edit mode
     And the field "First name" matches value "User1"
+
+  Scenario Outline: Email validation during email registration
+    Given the following config values are set as admin:
+      | allowaccountssameemail | <allowsameemail> |
+      | registerauth           | email              |
+      | passwordpolicy         | 0                  |
+    And the following "users" exist:
+      | username | firstname | lastname | email          |
+      | s1       | John      | Doe      | s1@example.com |
+    And I am on site homepage
+    And I follow "Log in"
+    When I press "Create new account"
+    And I set the following fields to these values:
+      | Username      | s2      |
+      | Password      | test    |
+      | Email address | <email1> |
+      | Email (again) | <email2> |
+      | First name    | Jane    |
+      | Surname       | Doe     |
+    And I press "Create my new account"
+    Then I should <expect> "This email address is already registered. Perhaps you created an account in the past?"
+    And I should <expect2> "Invalid email address"
+
+    Examples:
+      | allowsameemail | email1         | email2         | expect  | expect2 |
+      | 0              | s1@example.com | s1@example.com | see     | not see |
+      | 0              | S1@EXAMPLE.COM | S1@EXAMPLE.COM | see     | not see |
+      | 0              | s1@example.com | S1@EXAMPLE.COM | see     | not see |
+      | 0              | s2@example.com | s1@example.com | not see | see     |
+      | 1              | s1@example.com | s1@example.com | not see | not see |
+      | 1              | S1@EXAMPLE.COM | S1@EXAMPLE.COM | not see | not see |
+      | 1              | s1@example.com | S1@EXAMPLE.COM | not see | not see |
+      | 1              | s1@example.com | s2@example.com | not see | see     |
index 3cf391f..c6ac5bb 100644 (file)
@@ -208,10 +208,13 @@ class async_helper  {
      * @return boolean $asyncpedning Is there a pending async operation.
      */
     public static function is_async_pending($id, $type, $operation) {
-        global $DB, $USER;
+        global $DB, $USER, $CFG;
         $asyncpending = false;
 
         // Only check for pending async operations if async mode is enabled.
+        require_once($CFG->dirroot . '/backup/util/interfaces/checksumable.class.php');
+        require_once($CFG->dirroot . '/backup/backup.class.php');
+
         if (self::is_async_enabled()) {
             $select = 'userid = ? AND itemid = ? AND type = ? AND operation = ? AND execution = ? AND status < ? AND status > ?';
             $params = array(
index f60715c..b798842 100644 (file)
@@ -78,22 +78,10 @@ class award_criteria_courseset extends award_criteria {
         require_once($CFG->dirroot . '/course/lib.php');
         $buttonarray = array();
 
-        // Get courses with enabled completion.
-        $courses = $DB->get_records('course', array('enablecompletion' => COMPLETION_ENABLED));
-        if (!empty($courses)) {
-            $list = core_course_category::make_categories_list();
-
-            $select = array();
-            $selected = array();
-            foreach ($courses as $c) {
-                $select[$c->id] = $list[$c->category] . ' / ' . format_string($c->fullname, true, array('context' => context_course::instance($c->id)));
-            }
-
-            if ($this->id !== 0) {
-                $selected = array_keys($this->params);
-            }
-            $settings = array('multiple' => 'multiple', 'size' => 20, 'style' => 'width:300px');
-            $mform->addElement('select', 'courses', get_string('addcourse', 'badges'), $select, $settings);
+        $hasselectablecourses = core_course_category::search_courses(['onlywithcompletion' => true], ['limit' => 1]);
+        if ($hasselectablecourses) {
+            $settings = array('multiple' => 'multiple', 'onlywithcompletion' => 1);
+            $mform->addElement('course', 'courses', get_string('addcourse', 'badges'), $settings);
             $mform->addRule('courses', get_string('requiredcourse', 'badges'), 'required');
             $mform->addHelpButton('courses', 'addcourse', 'badges');
 
@@ -104,6 +92,7 @@ class award_criteria_courseset extends award_criteria {
             $mform->addElement('hidden', 'addcourse', 'addcourse');
             $mform->setType('addcourse', PARAM_TEXT);
             if ($this->id !== 0) {
+                $selected = array_keys($this->params);
                 $mform->setDefault('courses', $selected);
             }
             $mform->setType('agg', PARAM_INT);
index 9d09dcb..b4aa2c1 100644 (file)
@@ -54,6 +54,11 @@ class block_course_list extends block_list {
            }
         }
 
+        $allcourselink =
+            (has_capability('moodle/course:update', context_system::instance())
+            || empty($CFG->block_course_list_hideallcourseslink)) &&
+            core_course_category::user_top();
+
         if (empty($CFG->disablemycourses) and isloggedin() and !isguestuser() and
           !(has_capability('moodle/course:update', context_system::instance()) and $adminseesall)) {    // Just print My Courses
             if ($courses = enrol_get_my_courses()) {
@@ -65,7 +70,7 @@ class block_course_list extends block_list {
                 }
                 $this->title = get_string('mycourses');
             /// If we can update any course of the view all isn't hidden, show the view all courses link
-                if (has_capability('moodle/course:update', context_system::instance()) || empty($CFG->block_course_list_hideallcourseslink)) {
+                if ($allcourselink) {
                     $this->content->footer = "<a href=\"$CFG->wwwroot/course/index.php\">".get_string("fulllistofcourses")."</a> ...";
                 }
             }
@@ -75,8 +80,9 @@ class block_course_list extends block_list {
             }
         }
 
-        $categories = core_course_category::get(0)->get_children();  // Parent = 0   ie top-level categories only
-        if ($categories) {   //Check we have categories
+        // User is not enrolled in any courses, show list of available categories or courses (if there is only one category).
+        $topcategory = core_course_category::top();
+        if ($topcategory->is_uservisible() && ($categories = $topcategory->get_children())) { // Check we have categories.
             if (count($categories) > 1 || (count($categories) == 1 && $DB->count_records('course') > 200)) {     // Just print top level category links
                 foreach ($categories as $category) {
                     $categoryname = $category->get_formatted_name();
@@ -84,13 +90,13 @@ class block_course_list extends block_list {
                     $this->content->items[]="<a $linkcss href=\"$CFG->wwwroot/course/index.php?categoryid=$category->id\">".$icon . $categoryname . "</a>";
                 }
             /// If we can update any course of the view all isn't hidden, show the view all courses link
-                if (has_capability('moodle/course:update', context_system::instance()) || empty($CFG->block_course_list_hideallcourseslink)) {
+                if ($allcourselink) {
                     $this->content->footer .= "<a href=\"$CFG->wwwroot/course/index.php\">".get_string('fulllistofcourses').'</a> ...';
                 }
                 $this->title = get_string('categories');
             } else {                          // Just print course names of single category
                 $category = array_shift($categories);
-                $courses = get_courses($category->id);
+                $courses = $category->get_courses();
 
                 if ($courses) {
                     foreach ($courses as $course) {
@@ -98,12 +104,12 @@ class block_course_list extends block_list {
                         $linkcss = $course->visible ? "" : " class=\"dimmed\" ";
 
                         $this->content->items[]="<a $linkcss title=\""
-                                   . format_string($course->shortname, true, array('context' => $coursecontext))."\" ".
+                                   . s($course->get_formatted_shortname())."\" ".
                                    "href=\"$CFG->wwwroot/course/view.php?id=$course->id\">"
-                                   .$icon. format_string(get_course_display_name_for_list($course), true, array('context' => context_course::instance($course->id))) . "</a>";
+                                   .$icon. $course->get_formatted_name() . "</a>";
                     }
                 /// If we can update any course of the view all isn't hidden, show the view all courses link
-                    if (has_capability('moodle/course:update', context_system::instance()) || empty($CFG->block_course_list_hideallcourseslink)) {
+                    if ($allcourselink) {
                         $this->content->footer .= "<a href=\"$CFG->wwwroot/course/index.php\">".get_string('fulllistofcourses').'</a> ...';
                     }
                     $this->get_remote_courses();
index a86b827..5fc988c 100644 (file)
@@ -48,9 +48,8 @@ function block_html_pluginfile($course, $birecord_or_cm, $context, $filearea, $a
         $parentcontext = $context->get_parent_context();
         if ($parentcontext->contextlevel === CONTEXT_COURSECAT) {
             // Check if category is visible and user can view this category.
-            $category = $DB->get_record('course_categories', array('id' => $parentcontext->instanceid), '*', MUST_EXIST);
-            if (!$category->visible) {
-                require_capability('moodle/category:viewhiddencategories', $parentcontext);
+            if (!core_course_category::get($parentcontext->instanceid, IGNORE_MISSING)) {
+                send_file_not_found();
             }
         } else if ($parentcontext->contextlevel === CONTEXT_USER && $parentcontext->instanceid != $USER->id) {
             // The block is in the context of a user, it is only visible to the user who it belongs to.
index 3d69299..e608c32 100644 (file)
@@ -29,6 +29,7 @@ use external_util;
 use external_files;
 use renderer_base;
 use context_system;
+use core_tag\external\tag_item_exporter;
 
 /**
  * Class for exporting a blog post (entry).
@@ -171,6 +172,12 @@ class post_exporter extends exporter {
                 'multiple' => true,
                 'optional' => true
             ),
+            'tags' => array(
+                'type' => tag_item_exporter::read_properties_definition(),
+                'description' => 'Tags.',
+                'multiple' => true,
+                'optional' => true,
+            ),
         );
     }
 
@@ -179,6 +186,12 @@ class post_exporter extends exporter {
 
         $values['summaryfiles'] = external_util::get_area_files($context->id, 'blog', 'post', $this->data->id);
         $values['attachmentfiles'] = external_util::get_area_files($context->id, 'blog', 'attachment', $this->data->id);
+        if ($this->data->module == 'blog_external') {
+            // For external blogs, the content field has the external blog id.
+            $values['tags'] = \core_tag\external\util::get_item_tags('core', 'blog_external', $this->data->content);
+        } else {
+            $values['tags'] = \core_tag\external\util::get_item_tags('core', 'post', $this->data->id);
+        }
 
         return $values;
     }
index f5822f4..5e23d5f 100644 (file)
@@ -108,6 +108,9 @@ class core_blog_external_testcase extends advanced_testcase {
         $result = core_blog\external::get_entries();
         $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
         $this->assertCount(1, $result['entries']);
+        $this->assertCount(1, $result['entries'][0]['tags']);
+        $this->assertEquals('tag1', $result['entries'][0]['tags'][0]['rawname']);
+
         $this->assertEquals($this->postid, $result['entries'][0]['id']);
     }
 
@@ -141,6 +144,9 @@ class core_blog_external_testcase extends advanced_testcase {
         $result = core_blog\external::get_entries();
         $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
         $this->assertCount(1, $result['entries']);
+        $this->assertCount(1, $result['entries'][0]['tags']);
+        $this->assertEquals('tag1', $result['entries'][0]['tags'][0]['rawname']);
+
         $this->assertEquals($this->postid, $result['entries'][0]['id']);
     }
 
@@ -331,6 +337,9 @@ class core_blog_external_testcase extends advanced_testcase {
         $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
         $this->assertCount(2, $result['entries']);
         $this->assertEquals(2, $result['totalentries']);
+        $this->assertCount(0, $result['entries'][0]['tags']);
+        $this->assertCount(1, $result['entries'][1]['tags']);
+        $this->assertEquals('tag1', $result['entries'][1]['tags'][0]['rawname']);
 
         $result = core_blog\external::get_entries(array(), 0, 1);
         $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
@@ -365,6 +374,8 @@ class core_blog_external_testcase extends advanced_testcase {
         $result = core_blog\external::get_entries(array(array('name' => 'courseid', 'value' => $this->courseid)));
         $result = external_api::clean_returnvalue(core_blog\external::get_entries_returns(), $result);
         $this->assertCount(1, $result['entries']);
+        $this->assertCount(1, $result['entries'][0]['tags']);
+        $this->assertEquals('tag1', $result['entries'][0]['tags'][0]['rawname']);
 
         // There is no entry associated with a wrong course.
         $result = core_blog\external::get_entries(array(array('name' => 'courseid', 'value' => $anothercourse->id)));
index 598a275..644ea08 100644 (file)
@@ -1,3 +1,9 @@
+This files describes API changes in /blog/* ,
+information provided here is intended especially for developers.
+
+=== 3.7 ===
+  * External function get_entries now returns an additional field "tags" returning the post tags.
+
 === 2.7 ===
 
 * blog_entry->add_associations() does not accept any params.
index 6b1337f..8cd7914 100644 (file)
@@ -1284,6 +1284,13 @@ class cache implements cache_loader {
             return -1;
         }
     }
+
+    /**
+     * Subclasses may support purging cache of all data belonging to the
+     * current user.
+     */
+    public function purge_current_user() {
+    }
 }
 
 /**
index 84b5ab3..12bc80b 100644 (file)
@@ -2352,8 +2352,11 @@ function calendar_get_default_courses($courseid = null, $fields = '*', $canmanag
         $fieldlist = explode(',', $fields);
 
         $prefixedfields = array_map(function($value) {
-            return 'c.' . trim($value);
+            return 'c.' . trim(strtolower($value));
         }, $fieldlist);
+        if (!in_array('c.visible', $prefixedfields) && !in_array('c.*', $prefixedfields)) {
+            $prefixedfields[] = 'c.visible';
+        }
         $courses = get_courses('all', 'c.shortname', implode(',', $prefixedfields));
     } else {
         $courses = enrol_get_users_courses($userid, true, $fields);
index 3fb1509..96f39f6 100644 (file)
@@ -83,6 +83,7 @@ $course = get_course($courseid);
 if ($courseid != SITEID && !empty($courseid)) {
     navigation_node::override_active_url(new moodle_url('/course/view.php', array('id' => $course->id)));
 } else if (!empty($categoryid)) {
+    core_course_category::get($categoryid); // Check that category exists and can be accessed.
     $PAGE->set_category_by_id($categoryid);
     navigation_node::override_active_url(new moodle_url('/course/index.php', array('categoryid' => $categoryid)));
 } else {
index c365857..ff6e641 100644 (file)
@@ -113,10 +113,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
     protected $theme = false;
 
     /** @var bool */
-    protected $fromcache;
-
-    /** @var bool */
-    protected $hasmanagecapability = null;
+    protected $fromcache = false;
 
     /**
      * Magic setter method, we do not want anybody to modify properties from the outside
@@ -232,17 +229,17 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      */
     public static function get($id, $strictness = MUST_EXIST, $alwaysreturnhidden = false, $user = null) {
         if (!$id) {
-            if (!isset(self::$coursecat0)) {
-                $record = new stdClass();
-                $record->id = 0;
-                $record->visible = 1;
-                $record->depth = 0;
-                $record->path = '';
-                $record->locked = 0;
-                self::$coursecat0 = new self($record);
+            // Top-level category.
+            if ($alwaysreturnhidden || self::top()->is_uservisible()) {
+                return self::top();
             }
-            return self::$coursecat0;
+            if ($strictness == MUST_EXIST) {
+                throw new moodle_exception('cannotviewcategory');
+            }
+            return null;
         }
+
+        // Try to get category from cache or retrieve from the DB.
         $coursecatrecordcache = cache::make('core', 'coursecatrecords');
         $coursecat = $coursecatrecordcache->get($id);
         if ($coursecat === false) {
@@ -253,14 +250,66 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
                 $coursecatrecordcache->set($id, $coursecat);
             }
         }
-        if ($coursecat && ($alwaysreturnhidden || $coursecat->is_uservisible($user))) {
-            return $coursecat;
-        } else {
+
+        if (!$coursecat) {
+            // Course category not found.
             if ($strictness == MUST_EXIST) {
                 throw new moodle_exception('unknowncategory');
             }
+            $coursecat = null;
+        } else if (!$alwaysreturnhidden && !$coursecat->is_uservisible($user)) {
+            // Course category is found but user can not access it.
+            if ($strictness == MUST_EXIST) {
+                throw new moodle_exception('cannotviewcategory');
+            }
+            $coursecat = null;
         }
-        return null;
+        return $coursecat;
+    }
+
+    /**
+     * Returns the pseudo-category representing the whole system (id=0, context_system)
+     *
+     * @return core_course_category
+     */
+    public static function top() {
+        if (!isset(self::$coursecat0)) {
+            $record = new stdClass();
+            $record->id = 0;
+            $record->visible = 1;
+            $record->depth = 0;
+            $record->path = '';
+            $record->locked = 0;
+            self::$coursecat0 = new self($record);
+        }
+        return self::$coursecat0;
+    }
+
+    /**
+     * Returns the top-most category for the current user
+     *
+     * Examples:
+     * 1. User can browse courses everywhere - return self::top() - pseudo-category with id=0
+     * 2. User does not have capability to browse courses on the system level but
+     *    has it in ONE course category - return this course category
+     * 3. User has capability to browse courses in two course categories - return self::top()
+     *
+     * @return core_course_category|null
+     */
+    public static function user_top() {
+        $children = self::top()->get_children();
+        if (count($children) == 1) {
+            // User has access to only one category on the top level. Return this category as "user top category".
+            return reset($children);
+        }
+        if (count($children) > 1) {
+            // User has access to more than one category on the top level. Return the top as "user top category".
+            // In this case user actually may not have capability 'moodle/course:browse' on the top level.
+            return self::top();
+        }
+        // User can not access any categories on the top level.
+        // TODO MDL-10965 find ANY/ALL categories in the tree where user has access to.
+        return self::get(0, IGNORE_MISSING);
     }
 
     /**
@@ -343,7 +392,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return core_course_category
      */
     public static function get_default() {
-        if ($visiblechildren = self::get(0)->get_children()) {
+        if ($visiblechildren = self::top()->get_children()) {
             $defcategory = reset($visiblechildren);
         } else {
             $toplevelcategories = self::get_tree(0);
@@ -358,6 +407,9 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * during {@link fix_course_sortorder()}
      */
     protected function restore() {
+        if (!$this->id) {
+            return;
+        }
         // Update all fields in the current object.
         $newrecord = self::get($this->id, MUST_EXIST, true);
         foreach (self::$coursecatfields as $key => $unused) {
@@ -419,7 +471,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         }
 
         if (empty($data->parent)) {
-            $parent = self::get(0);
+            $parent = self::top();
         } else {
             $parent = self::get($data->parent, MUST_EXIST, true);
         }
@@ -589,8 +641,48 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function is_uservisible($user = null) {
-        return !$this->id || $this->visible ||
-            has_capability('moodle/category:viewhiddencategories', $this->get_context(), $user);
+        return self::can_view_category($this, $user);
+    }
+
+    /**
+     * Checks if current user has access to the category
+     *
+     * @param stdClass|core_course_category $category
+     * @param int|stdClass $user The user id or object. By default (null) checks access for the current user.
+     * @return bool
+     */
+    public static function can_view_category($category, $user = null) {
+        if (!$category->id) {
+            return has_capability('moodle/course:browse', context_system::instance(), $user);
+        }
+        $context = context_coursecat::instance($category->id);
+        if (!$category->visible && !has_capability('moodle/category:viewhiddencategories', $context, $user)) {
+            return false;
+        }
+        return has_capability('moodle/course:browse', $context, $user);
+    }
+
+    /**
+     * Checks if current user can view course information or enrolment page.
+     *
+     * This method does not check if user is already enrolled in the course
+     *
+     * @param stdClass $course course object (must have 'id', 'visible' and 'category' fields)
+     * @param null|stdClass $user The user id or object. By default (null) checks access for the current user.
+     */
+    public static function can_view_course_info($course, $user = null) {
+        if ($course->id == SITEID) {
+            return true;
+        }
+        if (!$course->visible) {
+            $coursecontext = context_course::instance($course->id);
+            if (!has_capability('moodle/course:viewhiddencourses', $coursecontext, $user)) {
+                return false;
+            }
+        }
+        $categorycontext = isset($course->category) ? context_coursecat::instance($course->category) :
+            context_course::instance($course->id)->get_parent_context();
+        return has_capability('moodle/course:browse', $categorycontext, $user);
     }
 
     /**
@@ -680,12 +772,29 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * Returns number of ALL categories in the system regardless if
      * they are visible to current user or not
      *
+     * @deprecated since Moodle 3.7
      * @return int
      */
     public static function count_all() {
+        debugging('Method core_course_category::count_all() is deprecated. Please use ' .
+            'core_course_category::is_simple_site()', DEBUG_DEVELOPER);
         return self::get_tree('countall');
     }
 
+    /**
+     * Checks if the site has only one category and it is visible and available.
+     *
+     * In many situations we won't show this category at all
+     * @return bool
+     */
+    public static function is_simple_site() {
+        if (self::get_tree('countall') != 1) {
+            return false;
+        }
+        $default = self::get_default();
+        return $default->visible && $default->is_uservisible();
+    }
+
     /**
      * Retrieves number of records from course_categories table
      *
@@ -977,9 +1086,9 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      *
      * @param string $whereclause
      * @param array $params
-     * @param array $options may indicate that summary and/or coursecontacts need to be retrieved
+     * @param array $options may indicate that summary needs to be retrieved
      * @param bool $checkvisibility if true, capability 'moodle/course:viewhiddencourses' will be checked
-     *     on not visible courses
+     *     on not visible courses and 'moodle/course:browse' on all courses
      * @return array array of stdClass objects
      */
     protected static function get_course_records($whereclause, $params, $options, $checkvisibility = false) {
@@ -1002,29 +1111,21 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
                 array('contextcourse' => CONTEXT_COURSE) + $params);
 
         if ($checkvisibility) {
+            $mycourses = enrol_get_my_courses();
             // Loop through all records and make sure we only return the courses accessible by user.
             foreach ($list as $course) {
                 if (isset($list[$course->id]->hassummary)) {
                     $list[$course->id]->hassummary = strlen($list[$course->id]->hassummary) > 0;
                 }
-                if (empty($course->visible)) {
-                    // Load context only if we need to check capability.
-                    context_helper::preload_from_record($course);
-                    if (!has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) {
-                        unset($list[$course->id]);
-                    }
+                context_helper::preload_from_record($course);
+                $context = context_course::instance($course->id);
+                // Check that course is accessible by user.
+                if (!array_key_exists($course->id, $mycourses) && !self::can_view_course_info($course)) {
+                    unset($list[$course->id]);
                 }
             }
         }
 
-        // Preload course contacts if necessary.
-        if (!empty($options['coursecontacts'])) {
-            self::preload_course_contacts($list);
-        }
-        // Preload custom fields if necessary - saves DB queries later to do it for each course separately.
-        if (!empty($options['customfields'])) {
-            self::preload_custom_fields($list);
-        }
         return $list;
     }
 
@@ -1041,10 +1142,11 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         if (($invisibleids = $coursecatcache->get('ic'. $this->id)) === false) {
             // We never checked visible children before.
             $hidden = self::get_tree($this->id.'i');
+            $catids = self::get_tree($this->id);
             $invisibleids = array();
-            if ($hidden) {
+            if ($catids) {
                 // Preload categories contexts.
-                list($sql, $params) = $DB->get_in_or_equal($hidden, SQL_PARAMS_NAMED, 'id');
+                list($sql, $params) = $DB->get_in_or_equal($catids, SQL_PARAMS_NAMED, 'id');
                 $ctxselect = context_helper::get_preload_record_columns_sql('ctx');
                 $contexts = $DB->get_records_sql("SELECT $ctxselect FROM {context} ctx
                     WHERE ctx.contextlevel = :contextcoursecat AND ctx.instanceid ".$sql,
@@ -1052,9 +1154,10 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
                 foreach ($contexts as $record) {
                     context_helper::preload_from_record($record);
                 }
-                // Check that user has 'viewhiddencategories' capability for each hidden category.
-                foreach ($hidden as $id) {
-                    if (!has_capability('moodle/category:viewhiddencategories', context_coursecat::instance($id))) {
+                // Check access for each category.
+                foreach ($catids as $id) {
+                    $cat = (object)['id' => $id, 'visible' => in_array($id, $hidden) ? 0 : 1];
+                    if (!self::can_view_category($cat)) {
                         $invisibleids[] = $id;
                     }
                 }
@@ -1391,6 +1494,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      *     - blocklist - id of block (if we are searching for courses containing specific block0
      *     - modulelist - name of module (if we are searching for courses containing specific module
      *     - tagid - id of tag
+     *     - onlywithcompletion - set to true if we only need courses with completion enabled
      * @param array $options display options, same as in get_courses() except 'recursive' is ignored -
      *                       search is always category-independent
      * @param array $requiredcapabilities List of capabilities required to see return course.
@@ -1447,8 +1551,13 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         if (empty($search['blocklist']) && empty($search['modulelist']) && empty($search['tagid'])) {
             // Search courses that have specified words in their names/summaries.
             $searchterms = preg_split('|\s+|', trim($search['search']), 0, PREG_SPLIT_NO_EMPTY);
-
-            $courselist = get_courses_search($searchterms, 'c.sortorder ASC', 0, 9999999, $totalcount, $requiredcapabilities);
+            $searchcond = $searchcondparams = [];
+            if (!empty($search['onlywithcompletion'])) {
+                $searchcond = ['c.enablecompletion = :p1'];
+                $searchcondparams = ['p1' => 1];
+            }
+            $courselist = get_courses_search($searchterms, 'c.sortorder ASC', 0, 9999999, $totalcount,
+                $requiredcapabilities, $searchcond, $searchcondparams);
             self::sort_records($courselist, $sortfields);
             $coursecatcache->set($cachekey, array_keys($courselist));
             $coursecatcache->set($cntcachekey, $totalcount);
@@ -1602,10 +1711,9 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         $limit = !empty($options['limit']) ? $options['limit'] : null;
         $sortfields = !empty($options['sort']) ? $options['sort'] : array('sortorder' => 1);
 
-        // Check if this category is hidden.
-        // Also 0-category never has courses unless this is recursive call.
-        if (!$this->is_uservisible() || (!$this->id && !$recursive)) {
-            return array();
+        if (!$this->id && !$recursive) {
+            // There are no courses on system level unless we need recursive list.
+            return [];
         }
 
         $coursecatcache = cache::make('core', 'coursecat');
@@ -2402,7 +2510,9 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
                 $thislist = preg_split('|,|', $thislist, -1, PREG_SPLIT_NO_EMPTY);
             }
         } else if ($baselist !== false) {
-            $thislist = array_keys($baselist);
+            $thislist = array_keys(array_filter($baselist, function($el) {
+                return $el['name'] !== false;
+            }));
         }
 
         if ($baselist === false) {
@@ -2416,24 +2526,18 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
             $baselist = array();
             $thislist = array();
             foreach ($rs as $record) {
-                // If the category's parent is not visible to the user, it is not visible as well.
-                if (!$record->parent || isset($baselist[$record->parent])) {
-                    context_helper::preload_from_record($record);
-                    $context = context_coursecat::instance($record->id);
-                    if (!$record->visible && !has_capability('moodle/category:viewhiddencategories', $context)) {
-                        // No cap to view category, added to neither $baselist nor $thislist.
-                        continue;
-                    }
-                    $baselist[$record->id] = array(
-                        'name' => format_string($record->name, true, array('context' => $context)),
-                        'path' => $record->path
-                    );
-                    if (!empty($requiredcapability) && !has_all_capabilities($requiredcapability, $context)) {
-                        // No required capability, added to $baselist but not to $thislist.
-                        continue;
-                    }
-                    $thislist[] = $record->id;
+                context_helper::preload_from_record($record);
+                $context = context_coursecat::instance($record->id);
+                $canview = self::can_view_category($record);
+                $baselist[$record->id] = array(
+                    'name' => $canview ? format_string($record->name, true, array('context' => $context)) : false,
+                    'path' => $record->path
+                );
+                if (!$canview || (!empty($requiredcapability) && !has_all_capabilities($requiredcapability, $context))) {
+                    // No required capability, added to $baselist but not to $thislist.
+                    continue;
                 }
+                $thislist[] = $record->id;
             }
             $rs->close();
             $coursecatcache->set($basecachekey, $baselist);
@@ -2448,9 +2552,11 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
             $contexts = $DB->get_records_sql($sql, array('contextcoursecat' => CONTEXT_COURSECAT));
             $thislist = array();
             foreach (array_keys($baselist) as $id) {
-                context_helper::preload_from_record($contexts[$id]);
-                if (has_all_capabilities($requiredcapability, context_coursecat::instance($id))) {
-                    $thislist[] = $id;
+                if ($baselist[$id]['name'] !== false) {
+                    context_helper::preload_from_record($contexts[$id]);
+                    if (has_all_capabilities($requiredcapability, context_coursecat::instance($id))) {
+                        $thislist[] = $id;
+                    }
                 }
             }
             $coursecatcache->set($thiscachekey, join(',', $thislist));
@@ -2463,7 +2569,9 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
             if (!$excludeid || !in_array($excludeid, $path)) {
                 $namechunks = array();
                 foreach ($path as $parentid) {
-                    $namechunks[] = $baselist[$parentid]['name'];
+                    if (array_key_exists($parentid, $baselist) && $baselist[$parentid]['name'] !== false) {
+                        $namechunks[] = $baselist[$parentid]['name'];
+                    }
                 }
                 $names[$id] = join($separator, $namechunks);
             }
@@ -2529,7 +2637,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public static function can_create_top_level_category() {
-        return has_capability('moodle/category:manage', context_system::instance());
+        return self::top()->has_manage_capability();
     }
 
     /**
@@ -2550,10 +2658,10 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function has_manage_capability() {
-        if ($this->hasmanagecapability === null) {
-            $this->hasmanagecapability = has_capability('moodle/category:manage', $this->get_context());
+        if (!$this->is_uservisible()) {
+            return false;
         }
-        return $this->hasmanagecapability;
+        return has_capability('moodle/category:manage', $this->get_context());
     }
 
     /**
@@ -2561,7 +2669,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function parent_has_manage_capability() {
-        return has_capability('moodle/category:manage', get_category_or_system_context($this->parent));
+        return ($parent = $this->get_parent_coursecat()) && $parent->has_manage_capability();
     }
 
     /**
@@ -2595,7 +2703,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function can_change_sortorder() {
-        return $this->id && $this->get_parent_coursecat()->can_resort_subcategories();
+        return ($parent = $this->get_parent_coursecat()) && $parent->can_resort_subcategories();
     }
 
     /**
@@ -2603,7 +2711,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function can_create_course() {
-        return has_capability('moodle/course:create', $this->get_context());
+        return $this->is_uservisible() && has_capability('moodle/course:create', $this->get_context());
     }
 
     /**
@@ -2619,7 +2727,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function can_review_roles() {
-        return has_capability('moodle/role:assign', $this->get_context());
+        return $this->is_uservisible() && has_capability('moodle/role:assign', $this->get_context());
     }
 
     /**
@@ -2627,7 +2735,8 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function can_review_permissions() {
-        return has_any_capability(array(
+        return $this->is_uservisible() &&
+        has_any_capability(array(
             'moodle/role:assign',
             'moodle/role:safeoverride',
             'moodle/role:override',
@@ -2640,7 +2749,8 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function can_review_cohorts() {
-        return has_any_capability(array('moodle/cohort:view', 'moodle/cohort:manage'), $this->get_context());
+        return $this->is_uservisible() &&
+            has_any_capability(array('moodle/cohort:view', 'moodle/cohort:manage'), $this->get_context());
     }
 
     /**
@@ -2648,8 +2758,9 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function can_review_filters() {
-        return has_capability('moodle/filter:manage', $this->get_context()) &&
-               count(filter_get_available_in_context($this->get_context())) > 0;
+        return $this->is_uservisible() &&
+                has_capability('moodle/filter:manage', $this->get_context()) &&
+                count(filter_get_available_in_context($this->get_context())) > 0;
     }
 
     /**
@@ -2681,7 +2792,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
      * @return bool
      */
     public function can_restore_courses_into() {
-        return has_capability('moodle/restore:restorecourse', $this->get_context());
+        return $this->is_uservisible() && has_capability('moodle/restore:restorecourse', $this->get_context());
     }
 
     /**
@@ -2845,10 +2956,15 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
     /**
      * Returns the parent core_course_category object for this category.
      *
-     * @return core_course_category
+     * Only returns parent if it exists and is visible to the current user
+     *
+     * @return core_course_category|null
      */
     public function get_parent_coursecat() {
-        return self::get($this->parent);
+        if (!$this->id) {
+            return null;
+        }
+        return self::get($this->parent, IGNORE_MISSING);
     }
 
 
index 7d5eab4..5acafff 100644 (file)
@@ -415,14 +415,6 @@ class core_course_list_element implements IteratorAggregate {
         return context_course::instance($this->__get('id'));
     }
 
-    /**
-     * Returns true if this course is visible to the current user.
-     * @return bool
-     */
-    public function is_uservisible() {
-        return $this->visible || has_capability('moodle/course:viewhiddencourses', $this->get_context());
-    }
-
     /**
      * Returns true if the current user can review enrolments for this course.
      *
index 0befd64..39c2c5e 100644 (file)
@@ -403,12 +403,10 @@ class helper {
         $baseurl = new \moodle_url('/course/management.php', $params);
         $actions = array();
         // View.
-        if ($course->is_uservisible()) {
-            $actions['view'] = array(
-                'url' => new \moodle_url('/course/view.php', array('id' => $course->id)),
-                'string' => \get_string('view')
-            );
-        }
+        $actions['view'] = array(
+            'url' => new \moodle_url('/course/view.php', array('id' => $course->id)),
+            'string' => \get_string('view')
+        );
         // Edit.
         if ($course->can_edit()) {
             $actions['edit'] = array(
@@ -802,13 +800,14 @@ class helper {
             $searchcriteria = array('modulelist' => $modulelist);
         }
 
-        $courses = \core_course_category::get(0)->search_courses($searchcriteria, array(
+        $topcat = \core_course_category::top();
+        $courses = $topcat->search_courses($searchcriteria, array(
             'recursive' => true,
             'offset' => $page * $perpage,
             'limit' => $perpage,
             'sort' => array('fullname' => 1)
         ));
-        $totalcount = \core_course_category::get(0)->search_courses_count($searchcriteria, array('recursive' => true));
+        $totalcount = $topcat->search_courses_count($searchcriteria, array('recursive' => true));
 
         return array($courses, \count($courses), $totalcount);
     }
index 83ba88a..1f12b7f 100644 (file)
@@ -136,7 +136,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         $catatlevel[] = array_shift($selectedparents);
         $catatlevel = array_unique($catatlevel);
 
-        $listing = core_course_category::get(0)->get_children();
+        $listing = core_course_category::top()->get_children();
 
         $attributes = array(
                 'class' => 'ml-1 list-unstyled',
@@ -200,10 +200,10 @@ class core_course_management_renderer extends plugin_renderer_base {
                 'aria-expanded' => $isexpanded ? 'true' : 'false'
         );
         $text = $category->get_formatted_name();
-        if ($category->parent) {
+        if (($parent = $category->get_parent_coursecat()) && $parent->id) {
             $a = new stdClass;
             $a->category = $text;
-            $a->parentcategory = $category->get_parent_coursecat()->get_formatted_name();
+            $a->parentcategory = $parent->get_formatted_name();
             $textlabel = get_string('categorysubcategoryof', 'moodle', $a);
         }
         $courseicon = $this->output->pix_icon('i/course', get_string('courses'));
@@ -327,7 +327,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         $cancreatecategory = $category && $category->can_create_subcategory();
         $cancreatecategory = $cancreatecategory || core_course_category::can_create_top_level_category();
         if ($category === null) {
-            $category = core_course_category::get(0);
+            $category = core_course_category::top();
         }
 
         if ($cancreatecategory) {
@@ -459,8 +459,8 @@ class core_course_management_renderer extends plugin_renderer_base {
         }
         if (core_course_category::can_change_parent_any()) {
             $options = array();
-            if (has_capability('moodle/category:manage', context_system::instance())) {
-                $options[0] = core_course_category::get(0)->get_formatted_name();
+            if (core_course_category::top()->has_manage_capability()) {
+                $options[0] = core_course_category::top()->get_formatted_name();
             }
             $options += core_course_category::make_categories_list('moodle/category:manage');
             $select = html_writer::select(
index 27ba201..cce89ec 100644 (file)
@@ -113,9 +113,7 @@ class course extends \core_search\base {
             return \core_search\manager::ACCESS_DELETED;
         }
 
-        $coursecontext = \context_course::instance($course->id);
-
-        if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+        if (\core_course_category::can_view_course_info($course)) {
             return \core_search\manager::ACCESS_GRANTED;
         }
 
index 315281e..42bcf3d 100644 (file)
@@ -135,8 +135,7 @@ class customfield extends \core_search\base {
         if (!$course) {
             return \core_search\manager::ACCESS_DELETED;
         }
-        $coursecontext = \context_course::instance($course->id);
-        if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+        if (\core_course_category::can_view_course_info($course)) {
             return \core_search\manager::ACCESS_GRANTED;
         }
         return \core_search\manager::ACCESS_DENIED;
index 83647d9..f850101 100644 (file)
@@ -128,57 +128,29 @@ class course_completion_form extends moodleform {
         }
 
         // Get applicable courses (prerequisites).
-        $courses = $DB->get_records_sql("
-                SELECT DISTINCT c.id, c.category, c.fullname, cc.id AS selected
-                  FROM {course} c
-             LEFT JOIN {course_completion_criteria} cc ON cc.courseinstance = c.id AND cc.course = {$course->id}
-            INNER JOIN {course_completion_criteria} ccc ON ccc.course = c.id
-                 WHERE c.enablecompletion = ".COMPLETION_ENABLED."
-                       AND c.id <> {$course->id}");
-
-        if (!empty($courses)) {
-            // Get category list.
-            $list = core_course_category::make_categories_list();
-
-            // Get course list for select box.
-            $selectbox = array();
-            $selected = array();
-            foreach ($courses as $c) {
-                $selectbox[$c->id] = $list[$c->category] . ' / ' . format_string($c->fullname, true,
-                    array('context' => context_course::instance($c->id)));
-
-                // If already selected ...
-                if ($c->selected) {
-                    $selected[] = $c->id;
-                }
-            }
-
+        $selectedcourses = $DB->get_fieldset_sql("SELECT cc.courseinstance
+                  FROM {course_completion_criteria} cc WHERE cc.course = ?", [$course->id]);
+        $hasselectablecourses = core_course_category::search_courses(['onlywithcompletion' => true], ['limit' => 2]);
+        unset($hasselectablecourses[$course->id]);
+        if ($hasselectablecourses) {
             // Show multiselect box.
-            $mform->addElement('select', 'criteria_course', get_string('coursesavailable', 'completion'), $selectbox,
-                array('multiple' => 'multiple', 'size' => 6));
-
-            // Select current criteria.
-            $mform->setDefault('criteria_course', $selected);
-
-            // Explain list.
-            $mform->addElement('static', 'criteria_courses_explaination', '', get_string('coursesavailableexplaination', 'completion'));
+            $mform->addElement('course', 'criteria_course', get_string('coursesavailable', 'completion'),
+                array('multiple' => 'multiple', 'onlywithcompletion' => true, 'exclude' => $course->id));
+            $mform->setDefault('criteria_course', $selectedcourses);
 
-            if (count($courses) > 1) {
-                // Map aggregation methods to context-sensitive human readable dropdown menu.
-                $courseaggregationmenu = array();
-                foreach ($aggregation_methods as $methodcode => $methodname) {
-                    if ($methodcode === COMPLETION_AGGREGATION_ALL) {
-                        $courseaggregationmenu[COMPLETION_AGGREGATION_ALL] = get_string('courseaggregation_all', 'core_completion');
-                    } else if ($methodcode === COMPLETION_AGGREGATION_ANY) {
-                        $courseaggregationmenu[COMPLETION_AGGREGATION_ANY] = get_string('courseaggregation_any', 'core_completion');
-                    } else {
-                        $courseaggregationmenu[$methodcode] = $methodname;
-                    }
+            // Map aggregation methods to context-sensitive human readable dropdown menu.
+            $courseaggregationmenu = array();
+            foreach ($aggregation_methods as $methodcode => $methodname) {
+                if ($methodcode === COMPLETION_AGGREGATION_ALL) {
+                    $courseaggregationmenu[COMPLETION_AGGREGATION_ALL] = get_string('courseaggregation_all', 'core_completion');
+                } else if ($methodcode === COMPLETION_AGGREGATION_ANY) {
+                    $courseaggregationmenu[COMPLETION_AGGREGATION_ANY] = get_string('courseaggregation_any', 'core_completion');
+                } else {
+                    $courseaggregationmenu[$methodcode] = $methodname;
                 }
-                $mform->addElement('select', 'course_aggregation', get_string('courseaggregation', 'core_completion'), $courseaggregationmenu);
-                $mform->setDefault('course_aggregation', $completion->get_aggregation_method(COMPLETION_CRITERIA_TYPE_COURSE));
             }
-
+            $mform->addElement('select', 'course_aggregation', get_string('courseaggregation', 'core_completion'), $courseaggregationmenu);
+            $mform->setDefault('course_aggregation', $completion->get_aggregation_method(COMPLETION_CRITERIA_TYPE_COURSE));
         } else {
             $mform->addElement('static', 'nocourses', '', get_string('err_nocourses', 'completion'));
         }
index 97a42c2..0c6456b 100644 (file)
@@ -494,6 +494,10 @@ class core_course_external extends external_api {
                                                   'userid' => new external_value(PARAM_INT, 'User who added this content to moodle'),
                                                   'author' => new external_value(PARAM_TEXT, 'Content owner'),
                                                   'license' => new external_value(PARAM_TEXT, 'Content license'),
+                                                  'tags' => new external_multiple_structure(
+                                                       \core_tag\external\tag_item_exporter::get_read_structure(), 'Tags',
+                                                            VALUE_OPTIONAL
+                                                   ),
                                               )
                                           ), VALUE_DEFAULT, array()
                                       ),
@@ -1870,7 +1874,7 @@ class core_course_external extends external_api {
             if (!isset($excludedcats[$category->id])) {
 
                 // Final check to see if the category is visible to the user.
-                if ($category->visible or has_capability('moodle/category:viewhiddencategories', $context)) {
+                if (core_course_category::can_view_category($category)) {
 
                     $categoryinfo = array();
                     $categoryinfo['id'] = $category->id;
@@ -2373,6 +2377,8 @@ class core_course_external extends external_api {
                     'Optional list of required capabilities (used to filter the list)', VALUE_DEFAULT, array()
                 ),
                 'limittoenrolled' => new external_value(PARAM_BOOL, 'limit to enrolled courses', VALUE_DEFAULT, 0),
+                'onlywithcompletion' => new external_value(PARAM_BOOL, 'limit to courses where completion is enabled',
+                    VALUE_DEFAULT, 0),
             )
         );
     }
@@ -2469,6 +2475,7 @@ class core_course_external extends external_api {
      * @param int $perpage          Items per page
      * @param array $requiredcapabilities Optional list of required capabilities (used to filter the list).
      * @param int $limittoenrolled  Limit to only enrolled courses
+     * @param int onlywithcompletion Limit to only courses where completion is enabled
      * @return array of course objects and warnings
      * @since Moodle 3.0
      * @throws moodle_exception
@@ -2478,7 +2485,8 @@ class core_course_external extends external_api {
                                           $page=0,
                                           $perpage=0,
                                           $requiredcapabilities=array(),
-                                          $limittoenrolled=0) {
+                                          $limittoenrolled=0,
+                                          $onlywithcompletion=0) {
         global $CFG;
 
         $warnings = array();
@@ -2488,7 +2496,9 @@ class core_course_external extends external_api {
             'criteriavalue' => $criteriavalue,
             'page'          => $page,
             'perpage'       => $perpage,
-            'requiredcapabilities' => $requiredcapabilities
+            'requiredcapabilities' => $requiredcapabilities,
+            'limittoenrolled' => $limittoenrolled,
+            'onlywithcompletion' => $onlywithcompletion
         );
         $params = self::validate_parameters(self::search_courses_parameters(), $parameters);
         self::validate_context(context_system::instance());
@@ -2514,6 +2524,9 @@ class core_course_external extends external_api {
         // Prepare the search API options.
         $searchcriteria = array();
         $searchcriteria[$params['criterianame']] = $params['criteriavalue'];
+        if ($params['onlywithcompletion']) {
+            $searchcriteria['onlywithcompletion'] = true;
+        }
 
         $options = array();
         if ($params['perpage'] != 0) {
@@ -3235,14 +3248,18 @@ class core_course_external extends external_api {
             }
             // Get the public course information, even if we are not enrolled.
             $courseinlist = new core_course_list_element($course);
-            $coursesdata[$course->id] = self::get_course_public_information($courseinlist, $context);
 
             // Now, check if we have access to the course.
             try {
                 self::validate_context($context);
             } catch (Exception $e) {
+                // User can not access the course, check if they can see the public information about the course and return it.
+                if (core_course_category::can_view_course_info($course)) {
+                    $coursesdata[$course->id] = self::get_course_public_information($courseinlist, $context);
+                }
                 continue;
             }
+            $coursesdata[$course->id] = self::get_course_public_information($courseinlist, $context);
             // Return information for any user that can access the course.
             $coursefields = array('format', 'showgrades', 'newsitems', 'startdate', 'enddate', 'maxbytes', 'showreports', 'visible',
                 'groupmode', 'groupmodeforce', 'defaultgroupingid', 'enablecompletion', 'completionnotify', 'lang', 'theme',
index 536399e..28f6691 100644 (file)
@@ -29,39 +29,40 @@ require_once($CFG->dirroot. '/course/lib.php');
 $categoryid = optional_param('categoryid', 0, PARAM_INT); // Category id
 $site = get_site();
 
+if ($CFG->forcelogin) {
+    require_login();
+}
+
+$heading = $site->fullname;
 if ($categoryid) {
+    $category = core_course_category::get($categoryid); // This will validate access.
     $PAGE->set_category_by_id($categoryid);
     $PAGE->set_url(new moodle_url('/course/index.php', array('categoryid' => $categoryid)));
     $PAGE->set_pagetype('course-index-category');
-    // And the object has been loaded for us no need for another DB call
-    $category = $PAGE->category;
-} else {
-    // Check if there is only one category, if so use that.
-    if (core_course_category::count_all() == 1) {
-        $category = core_course_category::get_default();
-
-        $categoryid = $category->id;
+    $heading = $category->get_formatted_name();
+} else if ($category = core_course_category::user_top()) {
+    // Check if there is only one top-level category, if so use that.
+    $categoryid = $category->id;
+    $PAGE->set_url('/course/index.php');
+    if ($category->is_uservisible() && $categoryid) {
         $PAGE->set_category_by_id($categoryid);
-        $PAGE->set_pagetype('course-index-category');
+        $PAGE->set_context($category->get_context());
+        if (!core_course_category::is_simple_site()) {
+            $PAGE->set_url(new moodle_url('/course/index.php', array('categoryid' => $categoryid)));
+            $heading = $category->get_formatted_name();
+        }
     } else {
         $PAGE->set_context(context_system::instance());
     }
-
-    $PAGE->set_url('/course/index.php');
+    $PAGE->set_pagetype('course-index-category');
+} else {
+    throw new moodle_exception('cannotviewcategory');
 }
 
 $PAGE->set_pagelayout('coursecategory');
 $courserenderer = $PAGE->get_renderer('core', 'course');
 
-if ($CFG->forcelogin) {
-    require_login();
-}
-
-if ($categoryid && !$category->visible && !has_capability('moodle/category:viewhiddencategories', $PAGE->context)) {
-    throw new moodle_exception('unknowncategory');
-}
-
-$PAGE->set_heading($site->fullname);
+$PAGE->set_heading($heading);
 $content = $courserenderer->course_category($categoryid);
 
 echo $OUTPUT->header();
index 3eae743..6cb73c0 100644 (file)
@@ -48,8 +48,8 @@
     }
 
     $context = context_course::instance($course->id);
-    if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
-        print_error('coursehidden', '', $CFG->wwwroot .'/');
+    if (!core_course_category::can_view_course_info($course) && !is_enrolled($context, null, '', true)) {
+        print_error('cannotviewcategory', '', $CFG->wwwroot .'/');
     }
 
     $PAGE->set_course($course);
index 380a762..25af452 100644 (file)
@@ -762,7 +762,7 @@ function print_course_request_buttons($context) {
         echo $OUTPUT->single_button(new moodle_url('/course/request.php'), get_string('requestcourse'), 'get');
     }
     /// Print a button to manage pending requests
-    if ($context->contextlevel == CONTEXT_SYSTEM && has_capability('moodle/site:approvecourse', $context)) {
+    if (has_capability('moodle/site:approvecourse', $context)) {
         $disabled = !$DB->record_exists('course_request', array());
         echo $OUTPUT->single_button(new moodle_url('/course/pending.php'), get_string('coursespending'), 'get', array('disabled' => $disabled));
     }
index e104ac0..27784d1 100644 (file)
@@ -68,8 +68,9 @@ if ($courseid) {
 } else {
     $course = null;
     $courseid = null;
-    $category = core_course_category::get_default();
-    $categoryid = $category->id;
+    $topchildren = core_course_category::top()->get_children();
+    $category = reset($topchildren);
+    $categoryid = $category ? $category->id : 0;
     $context = context_coursecat::instance($category->id);
     $url->param('categoryid', $category->id);
 }
@@ -378,9 +379,9 @@ if ($action !== false && confirm_sesskey()) {
                     }
                     $categories = core_course_category::get_many($categoryids);
                 } else if ($for === 'allcategories') {
-                    if ($sortcategoriesby && core_course_category::get(0)->can_resort_subcategories()) {
+                    if ($sortcategoriesby && core_course_category::top()->can_resort_subcategories()) {
                         \core_course\management\helper::action_category_resort_subcategories(
-                            core_course_category::get(0), $sortcategoriesby);
+                            core_course_category::top(), $sortcategoriesby);
                     }
                     $categorieslist = core_course_category::make_categories_list('moodle/category:manage');
                     $categoryids = array_keys($categorieslist);
index 186ff5c..55f5ebe 100644 (file)
@@ -1582,35 +1582,38 @@ class core_course_renderer extends plugin_renderer_base {
      */
     public function course_category($category) {
         global $CFG;
-        $coursecat = core_course_category::get(is_object($category) ? $category->id : $category);
+        $usertop = core_course_category::user_top();
+        if (empty($category)) {
+            $coursecat = $usertop;
+        } else if (is_object($category) && $category instanceof core_course_category) {
+            $coursecat = $category;
+        } else {
+            $coursecat = core_course_category::get(is_object($category) ? $category->id : $category);
+        }
         $site = get_site();
         $output = '';
 
-        if (can_edit_in_category($coursecat->id)) {
+        if ($coursecat->can_create_course() || $coursecat->has_manage_capability()) {
             // Add 'Manage' button if user has permissions to edit this category.
             $managebutton = $this->single_button(new moodle_url('/course/management.php',
                 array('categoryid' => $coursecat->id)), get_string('managecourses'), 'get');
             $this->page->set_button($managebutton);
         }
-        if (!$coursecat->id) {
-            if (core_course_category::count_all() == 1) {
-                // There exists only one category in the system, do not display link to it
-                $coursecat = core_course_category::get_default();
-                $strfulllistofcourses = get_string('fulllistofcourses');
-                $this->page->set_title("$site->shortname: $strfulllistofcourses");
-            } else {
-                $strcategories = get_string('categories');
-                $this->page->set_title("$site->shortname: $strcategories");
-            }
+
+        if (core_course_category::is_simple_site()) {
+            // There is only one category in the system, do not display link to it.
+            $strfulllistofcourses = get_string('fulllistofcourses');
+            $this->page->set_title("$site->shortname: $strfulllistofcourses");
+        } else if (!$coursecat->id || !$coursecat->is_uservisible()) {
+            $strcategories = get_string('categories');
+            $this->page->set_title("$site->shortname: $strcategories");
         } else {
-            $title = $site->shortname;
-            if (core_course_category::count_all() > 1) {
-                $title .= ": ". $coursecat->get_formatted_name();
-            }
-            $this->page->set_title($title);
+            $strfulllistofcourses = get_string('fulllistofcourses');
+            $this->page->set_title("$site->shortname: $strfulllistofcourses");
 
             // Print the category selector
-            if (core_course_category::count_all() > 1) {
+            $categorieslist = core_course_category::make_categories_list();
+            if (count($categorieslist) > 1) {
                 $output .= html_writer::start_tag('div', array('class' => 'categorypicker'));
                 $select = new single_select(new moodle_url('/course/index.php'), 'categoryid',
                         core_course_category::make_categories_list(), $coursecat->id, null, 'switchcategory');
@@ -1644,13 +1647,13 @@ class core_course_renderer extends plugin_renderer_base {
         }
         $coursedisplayoptions['limit'] = $perpage;
         $catdisplayoptions['limit'] = $perpage;
-        if ($browse === 'courses' || !$coursecat->has_children()) {
+        if ($browse === 'courses' || !$coursecat->get_children_count()) {
             $coursedisplayoptions['offset'] = $page * $perpage;
             $coursedisplayoptions['paginationurl'] = new moodle_url($baseurl, array('browse' => 'courses'));
             $catdisplayoptions['nodisplay'] = true;
             $catdisplayoptions['viewmoreurl'] = new moodle_url($baseurl, array('browse' => 'categories'));
             $catdisplayoptions['viewmoretext'] = new lang_string('viewallsubcategories');
-        } else if ($browse === 'categories' || !$coursecat->has_courses()) {
+        } else if ($browse === 'categories' || !$coursecat->get_courses_count()) {
             $coursedisplayoptions['nodisplay'] = true;
             $catdisplayoptions['offset'] = $page * $perpage;
             $catdisplayoptions['paginationurl'] = new moodle_url($baseurl, array('browse' => 'categories'));
@@ -1670,24 +1673,23 @@ class core_course_renderer extends plugin_renderer_base {
 
         // Add action buttons
         $output .= $this->container_start('buttons');
-        $context = get_category_or_system_context($coursecat->id);
-        if (has_capability('moodle/course:create', $context)) {
-            // Print link to create a new course, for the 1st available category.
-            if ($coursecat->id) {
-                $url = new moodle_url('/course/edit.php', array('category' => $coursecat->id, 'returnto' => 'category'));
-            } else {
-                $url = new moodle_url('/course/edit.php', array('category' => $CFG->defaultrequestcategory, 'returnto' => 'topcat'));
+        if ($coursecat->is_uservisible()) {
+            $context = get_category_or_system_context($coursecat->id);
+            if (has_capability('moodle/course:create', $context)) {
+                // Print link to create a new course, for the 1st available category.
+                if ($coursecat->id) {
+                    $url = new moodle_url('/course/edit.php', array('category' => $coursecat->id, 'returnto' => 'category'));
+                } else {
+                    $url = new moodle_url('/course/edit.php',
+                        array('category' => $CFG->defaultrequestcategory, 'returnto' => 'topcat'));
+                }
+                $output .= $this->single_button($url, get_string('addnewcourse'), 'get');
             }
-            $output .= $this->single_button($url, get_string('addnewcourse'), 'get');
-        }
-        ob_start();
-        if (core_course_category::count_all() == 1) {
-            print_course_request_buttons(context_system::instance());
-        } else {
+            ob_start();
             print_course_request_buttons($context);
+            $output .= ob_get_contents();
+            ob_end_clean();
         }
-        $output .= ob_get_contents();
-        ob_end_clean();
         $output .= $this->container_end();
 
         return $output;
@@ -1828,7 +1830,7 @@ class core_course_renderer extends plugin_renderer_base {
         if (empty($displayoptions)) {
             $displayoptions = array();
         }
-        $showcategories = core_course_category::count_all() > 1;
+        $showcategories = !core_course_category::is_simple_site();
         $displayoptions += array('limit' => $CFG->coursesperpage, 'offset' => 0);
         $chelper = new coursecat_helper();
         $searchcriteria = array('tagid' => $tagid, 'ctx' => $ctx, 'rec' => $rec);
@@ -1950,15 +1952,15 @@ class core_course_renderer extends plugin_renderer_base {
         if (!empty($courses) || !empty($rcourses) || !empty($rhosts)) {
 
             $chelper = new coursecat_helper();
+            $totalcount = count($courses);
             if (count($courses) > $CFG->frontpagecourselimit) {
                 // There are more enrolled courses than we can display, display link to 'My courses'.
-                $totalcount = count($courses);
                 $courses = array_slice($courses, 0, $CFG->frontpagecourselimit, true);
                 $chelper->set_courses_display_options(array(
                         'viewmoreurl' => new moodle_url('/my/'),
                         'viewmoretext' => new lang_string('mycourses')
                     ));
-            } else {
+            } else if (core_course_category::top()->is_uservisible()) {
                 // All enrolled courses are displayed, display link to 'All courses' if there are more courses in system.
                 $chelper->set_courses_display_options(array(
                         'viewmoreurl' => new moodle_url('/course/index.php'),
@@ -2007,8 +2009,8 @@ class core_course_renderer extends plugin_renderer_base {
                     'viewmoretext' => new lang_string('fulllistofcourses')));
 
         $chelper->set_attributes(array('class' => 'frontpage-course-list-all'));
-        $courses = core_course_category::get(0)->get_courses($chelper->get_courses_display_options());
-        $totalcount = core_course_category::get(0)->get_courses_count($chelper->get_courses_display_options());
+        $courses = core_course_category::top()->get_courses($chelper->get_courses_display_options());
+        $totalcount = core_course_category::top()->get_courses_count($chelper->get_courses_display_options());
         if (!$totalcount && !$this->page->user_is_editing() && has_capability('moodle/course:create', context_system::instance())) {
             // Print link to create a new course, for the 1st available category.
             return $this->add_new_course_button();
@@ -2038,6 +2040,11 @@ class core_course_renderer extends plugin_renderer_base {
      */
     public function frontpage_combo_list() {
         global $CFG;
+        // TODO MDL-10965 improve.
+        $tree = core_course_category::top();
+        if (!$tree->get_children_count()) {
+            return '';
+        }
         $chelper = new coursecat_helper();
         $chelper->set_subcat_depth($CFG->maxcategorydepth)->
             set_categories_display_options(array(
@@ -2051,7 +2058,7 @@ class core_course_renderer extends plugin_renderer_base {
                         array('browse' => 'courses', 'page' => 1))
             ))->
             set_attributes(array('class' => 'frontpage-category-combo'));
-        return $this->coursecat_tree($chelper, core_course_category::get(0));
+        return $this->coursecat_tree($chelper, $tree);
     }
 
     /**
@@ -2061,6 +2068,11 @@ class core_course_renderer extends plugin_renderer_base {
      */
     public function frontpage_categories_list() {
         global $CFG;
+        // TODO MDL-10965 improve.
+        $tree = core_course_category::top();
+        if (!$tree->get_children_count()) {
+            return '';
+        }
         $chelper = new coursecat_helper();
         $chelper->set_subcat_depth($CFG->maxcategorydepth)->
                 set_show_courses(self::COURSECAT_SHOW_COURSES_COUNT)->
@@ -2070,7 +2082,7 @@ class core_course_renderer extends plugin_renderer_base {
                             array('browse' => 'categories', 'page' => 1))
                 ))->
                 set_attributes(array('class' => 'frontpage-category-names'));
-        return $this->coursecat_tree($chelper, core_course_category::get(0));
+        return $this->coursecat_tree($chelper, $tree);
     }
 
     /**
@@ -2379,6 +2391,9 @@ class core_course_renderer extends plugin_renderer_base {
      * @return string
      */
     protected function frontpage_part($skipdivid, $contentsdivid, $header, $contents) {
+        if (strval($contents) === '') {
+            return '';
+        }
         $output = html_writer::link('#' . $skipdivid,
             get_string('skipa', 'access', core_text::strtolower(strip_tags($header))),
             array('class' => 'skip-block skip'));
@@ -2438,10 +2453,8 @@ class core_course_renderer extends plugin_renderer_base {
 
                 case FRONTPAGEALLCOURSELIST:
                     $availablecourseshtml = $this->frontpage_available_courses();
-                    if (!empty($availablecourseshtml)) {
-                        $output .= $this->frontpage_part('skipavailablecourses', 'frontpage-available-course-list',
-                            get_string('availablecourses'), $availablecourseshtml);
-                    }
+                    $output .= $this->frontpage_part('skipavailablecourses', 'frontpage-available-course-list',
+                        get_string('availablecourses'), $availablecourseshtml);
                     break;
 
                 case FRONTPAGECATEGORYNAMES:
@@ -2705,7 +2718,7 @@ class coursecat_helper {
      * @return string|null
      */
     public function get_category_formatted_description($coursecat, $options = null) {
-        if ($coursecat->id && !empty($coursecat->description)) {
+        if ($coursecat->id && $coursecat->is_uservisible() && !empty($coursecat->description)) {
             if (!isset($coursecat->descriptionformat)) {
                 $descriptionformat = FORMAT_MOODLE;
             } else {
index e02bd24..9dcd112 100644 (file)
@@ -71,7 +71,8 @@ $strsearch = new lang_string("search");
 $strsearchresults = new lang_string("searchresults");
 $strnovalidcourses = new lang_string('novalidcourses');
 
-$PAGE->navbar->add($strcourses, new moodle_url('/course/index.php'));
+$courseurl = core_course_category::user_top() ? new moodle_url('/course/index.php') : null;
+$PAGE->navbar->add($strcourses, $courseurl);
 $PAGE->navbar->add($strsearch, new moodle_url('/course/search.php'));
 if (!empty($search)) {
     $PAGE->navbar->add(s($search));
diff --git a/course/tests/behat/course_browsing.feature b/course/tests/behat/course_browsing.feature
new file mode 100644 (file)
index 0000000..f55e58f
--- /dev/null
@@ -0,0 +1,106 @@
+@core @core_course
+Feature: Restricting access to course lists
+  In order to provide more targeted content
+  As a Moodle Administrator
+  I need to be able to give/revoke capabilities to view list of courses
+
+  Background:
+    Given the following "categories" exist:
+      | name | category | idnumber |
+      | Science category | 0 | SCI |
+      | English category | 0 | ENG |
+      | Other category   | 0 | MISC |
+    And the following "courses" exist:
+      | fullname   | shortname | category |
+      | Biology Y1 | BIO1      | SCI |
+      | Biology Y2 | BI02      | SCI |
+      | English Y1 | ENG1      | ENG |
+      | English Y2 | ENG2      | ENG |
+      | Humanities Y1 | HUM2   | MISC |
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | user0 | User | Z | user0@example.com |
+      | userb | User | B | userb@example.com |
+      | usere | User | E | usere@example.com |
+    Given the following "roles" exist:
+      | name            | shortname    | description      | archetype      |
+      | Category viewer | coursebrowse | My custom role 1 |                |
+    Given I log in as "admin"
+    And I set the following system permissions of "Authenticated user" role:
+      | capability | permission |
+      | moodle/course:browse | Prevent |
+    And I set the following system permissions of "Guest" role:
+      | capability | permission |
+      | moodle/course:browse | Prevent |
+    And I set the following system permissions of "Category viewer" role:
+      | capability | permission |
+      | moodle/course:browse | Allow |
+    And I am on site homepage
+    And I turn editing mode on
+    And I add the "Navigation" block if not present
+    And I log out
+    And the following "role assigns" exist:
+      | user  | role           | contextlevel | reference |
+      | usere | coursebrowse   | Category     | ENG       |
+      | userb | coursebrowse   | Category     | ENG       |
+      | userb | coursebrowse   | Category     | SCI       |
+
+  Scenario: Browse courses as a user without any browse capability
+    When I log in as "user0"
+    And I am on site homepage
+    Then I should not see "Available courses"
+    And "Courses" "link" should not exist in the "Navigation" "block"
+    And I log out
+
+  Scenario: Browse own courses as a user without any browse capability
+    Given the following "course enrolments" exist:
+      | user  | course | role |
+      | user0 | BIO1   | student |
+    When I log in as "user0"
+    And I am on site homepage
+    And I should see "Available courses"
+    And I should see "Biology Y1"
+    And "Courses" "link" should not exist in the "Navigation" "block"
+    And I log out
+
+  Scenario: Browse courses as a user who has access to only one category
+    When I log in as "usere"
+    And I am on site homepage
+    Then I should see "Available courses"
+    And I should see "English Y1"
+    And I should see "English Y2"
+    And I should not see "Biology"
+    And I should not see "Humanities"
+    And I click on "Courses" "link" in the "Navigation" "block"
+    And "English category" "text" should exist in the ".breadcrumb" "css_element"
+    And I should see "English Y1"
+    And I should see "English Y2"
+    And I should not see "Biology"
+    And I should not see "Humanities"
+    And I should not see "Other category"
+    And I follow "English Y2"
+    And I should see "You can not enrol yourself in this course."
+    And I log out
+
+  Scenario: Browse courses as a user who has access to several but not all categories
+    When I log in as "userb"
+    And I am on site homepage
+    Then I should see "Available courses"
+    And I should see "English Y1"
+    And I should see "English Y2"
+    And I should see "Biology"
+    And I should not see "Humanities"
+    And I click on "Courses" "link" in the "Navigation" "block"
+    And "category" "text" should not exist in the ".breadcrumb" "css_element"
+    And I should see "Science category"
+    And I should see "English category"
+    And I should not see "Other category"
+    And I follow "Science category"
+    And I should see "Biology Y2"
+    And I should not see "English Y1"
+    And the "Course categories" select box should contain "Science category"
+    And the "Course categories" select box should contain "English category"
+    And the "Course categories" select box should not contain "Other category"
+    And I follow "Biology Y1"
+    And I should see "You can not enrol yourself in this course."
+    And I log out
index 1dcd1ef..5489939 100644 (file)
@@ -418,14 +418,37 @@ class core_course_category_testcase extends advanced_testcase {
         // Dont assume there is just one. An add-on might create a category as part of the install.
         $numcategories = $DB->count_records('course_categories');
         $this->assertEquals($numcategories, core_course_category::count_all());
+        $this->assertDebuggingCalled('Method core_course_category::count_all() is deprecated. Please use ' .
+            'core_course_category::is_simple_site()', DEBUG_DEVELOPER);
         $category1 = core_course_category::create(array('name' => 'Cat1'));
         $category2 = core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id));
         $category3 = core_course_category::create(array('name' => 'Cat3', 'parent' => $category2->id, 'visible' => 0));
         // Now we've got three more.
         $this->assertEquals($numcategories + 3, core_course_category::count_all());
+        $this->assertDebuggingCalled('Method core_course_category::count_all() is deprecated. Please use ' .
+            'core_course_category::is_simple_site()', DEBUG_DEVELOPER);
         cache_helper::purge_by_event('changesincoursecat');
         // We should still have 4.
         $this->assertEquals($numcategories + 3, core_course_category::count_all());
+        $this->assertDebuggingCalled('Method core_course_category::count_all() is deprecated. Please use ' .
+            'core_course_category::is_simple_site()', DEBUG_DEVELOPER);
+    }
+
+    /**
+     * Test the is_simple_site function
+     */
+    public function test_is_simple_site() {
+        // By default site has one category and is considered simple.
+        $this->assertEquals(true, core_course_category::is_simple_site());
+        $default = core_course_category::get_default();
+        // When there is only one category but it is hidden, it is not a simple site.
+        $default->update(['visible' => 0]);
+        $this->assertEquals(false, core_course_category::is_simple_site());
+        $default->update(['visible' => 1]);
+        $this->assertEquals(true, core_course_category::is_simple_site());
+        // As soon as there is more than one category, site is not simple any more.
+        core_course_category::create(array('name' => 'Cat1'));
+        $this->assertEquals(false, core_course_category::is_simple_site());
     }
 
     /**
@@ -1007,7 +1030,7 @@ class core_course_category_testcase extends advanced_testcase {
 
         // Expecting to get an exception as this new user does not have the moodle/category:viewhiddencategories capability.
         $this->expectException('moodle_exception');
-        $this->expectExceptionMessage('unknowncategory');
+        $this->expectExceptionMessage(get_string('cannotviewcategory', 'error'));
         core_course_category::get($category2->id);
     }
 
@@ -1033,7 +1056,7 @@ class core_course_category_testcase extends advanced_testcase {
 
         $this->assertEquals($category1->id, core_course_category::get($category1->id, MUST_EXIST, false, $user2)->id);
         $this->expectException('moodle_exception');
-        $this->expectExceptionMessage('unknowncategory');
+        $this->expectExceptionMessage(get_string('cannotviewcategory', 'error'));
         core_course_category::get($category2->id, MUST_EXIST, false, $user2);
     }
 
index efa1b2f..090b88a 100644 (file)
@@ -6,6 +6,8 @@ information provided here is intended especially for developers.
  * External function core_course_external::get_course_contents new returns the following additional completiondata field:
    - valueused (indicates whether the completion state affects the availability of other content)
  * External function core_course_external::get_course_contents now returns a new contentsinfo field with summary files information.
+ * External function core_course_external::get_course_contents now returns an additional field "tags" returning the content tags.
+
 
 === 3.6 ===
 
index e1a3e3c..6d65bc9 100644 (file)
@@ -5,7 +5,6 @@
     require_once('../config.php');
     require_once('lib.php');
     require_once($CFG->libdir.'/completionlib.php');
-    require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
 
     $id          = optional_param('id', 0, PARAM_INT);
     $name        = optional_param('name', '', PARAM_TEXT);
     $PAGE->set_heading($course->fullname);
     echo $OUTPUT->header();
 
-if ($USER->editing == 1 && async_helper::is_async_pending($id, 'course', 'backup')) {
-    echo $OUTPUT->notification(get_string('pendingasyncedit', 'backup'), 'warning');
-}
+    if ($USER->editing == 1 && !empty($CFG->enableasyncbackup)) {
+
+        // MDL-65321 The backup libraries are quite heavy, only require the bare minimum.
+        require_once($CFG->dirroot . '/backup/util/helper/async_helper.class.php');
+
+        if (async_helper::is_async_pending($id, 'course', 'backup')) {
+            echo $OUTPUT->notification(get_string('pendingasyncedit', 'backup'), 'warning');
+        }
+    }
 
     if ($completion->is_enabled()) {
         // This value tracks whether there has been a dynamic change to the page.
index 9ba48d2..71608df 100644 (file)
@@ -876,8 +876,7 @@ class core_enrol_external extends external_api {
         self::validate_context(context_system::instance());
 
         $course = $DB->get_record('course', array('id' => $params['courseid']), '*', MUST_EXIST);
-        $context = context_course::instance($course->id);
-        if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
+        if (!core_course_category::can_view_course_info($course) && !can_access_course($course)) {
             throw new moodle_exception('coursehidden');
         }
 
index 656c295..65850b9 100644 (file)
@@ -75,8 +75,7 @@ class enrol_guest_external extends external_api {
         $enrolinstance = $DB->get_record('enrol', array('id' => $params['instanceid']), '*', MUST_EXIST);
 
         $course = $DB->get_record('course', array('id' => $enrolinstance->courseid), '*', MUST_EXIST);
-        $context = context_course::instance($course->id);
-        if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
+        if (!core_course_category::can_view_course_info($course) && !can_access_course($course)) {
             throw new moodle_exception('coursehidden');
         }
 
index dcc4d96..5a97c12 100644 (file)
@@ -61,6 +61,11 @@ if (\core\session\manager::is_loggedinas() and $USER->loginascontext->contextlev
     print_error('loginasnoenrol', '', $CFG->wwwroot.'/course/view.php?id='.$USER->loginascontext->instanceid);
 }
 
+// Check if user has access to the category where the course is located.
+if (!core_course_category::can_view_course_info($course) && !is_enrolled($context, $USER, '', true)) {
+    print_error('coursehidden', '', $CFG->wwwroot . '/');
+}
+
 // get all enrol forms available in this course
 $enrols = enrol_get_plugins(true);
 $enrolinstances = enrol_get_instances($course->id, true);
index 9006e62..5a2270e 100644 (file)
@@ -71,8 +71,7 @@ class enrol_self_external extends external_api {
 
         $enrolinstance = $DB->get_record('enrol', array('id' => $params['instanceid']), '*', MUST_EXIST);
         $course = $DB->get_record('course', array('id' => $enrolinstance->courseid), '*', MUST_EXIST);
-        $context = context_course::instance($course->id);
-        if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
+        if (!core_course_category::can_view_course_info($course) && !can_access_course($course)) {
             throw new moodle_exception('coursehidden');
         }
 
@@ -147,7 +146,7 @@ class enrol_self_external extends external_api {
         $context = context_course::instance($course->id);
         self::validate_context(context_system::instance());
 
-        if (!$course->visible and !has_capability('moodle/course:viewhiddencourses', $context)) {
+        if (!core_course_category::can_view_course_info($course)) {
             throw new moodle_exception('coursehidden');
         }
 
index e04c8d2..d8aa404 100644 (file)
@@ -42,3 +42,4 @@ $string['cliunknowoption'] = 'خيارات غير معروفة
 $string['cliyesnoprompt'] = 'ادخل (Y) تعني نعم أو (N) تعني لأ';
 $string['environmentrequireinstall'] = 'مطلوب تثبيته/تفيله';
 $string['environmentrequireversion'] = 'مطلوب الأصدار {$a->needed} ، وانت تستخدم الأصدار {$a->current}';
+$string['upgradekeyset'] = 'مفتاح التحديث (اتركها فارغة إن لم ترغب بتحديده)';
diff --git a/install/lang/de_wp/langconfig.php b/install/lang/de_wp/langconfig.php
new file mode 100644 (file)
index 0000000..5b94472
--- /dev/null
@@ -0,0 +1,34 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['parentlanguage'] = 'de';
+$string['thislanguage'] = 'Deutsch für Arbeitsplatz';
diff --git a/install/lang/en_wp/langconfig.php b/install/lang/en_wp/langconfig.php
new file mode 100644 (file)
index 0000000..60fdd46
--- /dev/null
@@ -0,0 +1,33 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['thislanguage'] = 'English for Workplace';
diff --git a/install/lang/es_wp/langconfig.php b/install/lang/es_wp/langconfig.php
new file mode 100644 (file)
index 0000000..9f05ce4
--- /dev/null
@@ -0,0 +1,34 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['parentlanguage'] = 'es';
+$string['thislanguage'] = 'Español para la Empresa';
index 1ea5f8c..f85be63 100644 (file)
@@ -35,7 +35,7 @@ $string['cliansweryes'] = 's';
 $string['cliincorrectvalueerror'] = 'Erro: o valor "{$a->value}" não é permitido para a opção "{$a->option}"';
 $string['cliincorrectvalueretry'] = 'Valor incorreto, por favor, tente novamente';
 $string['clitypevalue'] = 'introduza valor';
-$string['clitypevaluedefault'] = 'introduza valor, pressione a tecla Enter para usar o valor predefinido ({$a})';
+$string['clitypevaluedefault'] = 'introduza valor, pressione a tecla \'Enter\' para usar o valor predefinido ({$a})';
 $string['cliunknowoption'] = 'Opções desconhecidas: {$a}. Por favor, use a opção --help';
 $string['cliyesnoprompt'] = 'digite s (para sim) ou n (para não)';
 $string['environmentrequireinstall'] = 'deve estar instalada e ativa';
index 0e8f39b..9b43f46 100644 (file)
@@ -984,6 +984,8 @@ $string['profilingincluded'] = 'Profile these';
 $string['profilingincluded_help'] = 'List of (comma separated, absolute skipping wwwroot, callable) URLs that will be automatically profiled. Examples: /index.php, /course/view.php. Also accepts the * wildchar at any position. Examples: /mod/forum/*, /mod/*/view.php.';
 $string['profilinglifetime'] = 'Keep profiling runs';
 $string['profilinglifetime_help'] = 'Specify the time you want to keep information about old profiling runs. Older ones will be pruned periodically. Note that this excludes any profiling run marked as \'reference run\'.';
+$string['profilingslow'] = 'Profile slow pages';
+$string['profilingslow_help'] = 'By setting a minimum time in seconds all pages slower will be profiled. Only profiles which are slower than an existing profile for the same script will be kept. Set to 0 to disable. Note that this observes the exclude settings.';
 $string['protectusernames'] = 'Protect usernames';
 $string['proxybypass'] = 'Proxy bypass hosts';
 $string['proxyhost'] = 'Proxy host';
index d6c2df6..ef172e2 100644 (file)
@@ -169,6 +169,7 @@ $string['cannotuseadmin'] = 'You need to be an admin user to use this page';
 $string['cannotuseadminadminorteacher'] = 'You need to be a teacher or admin user to use this page';
 $string['cannotusepage'] = 'Only teachers and administrators can use this page';
 $string['cannotusepage2'] = 'Sorry, you may not use this page';
+$string['cannotviewcategory'] = 'You don\'t have permission to view courses here';
 $string['cannotviewprofile'] = 'You cannot view the profile of this user';
 $string['cannotviewreport'] = 'You cannot view this report';
 $string['cannotwritefile'] = 'Cannot write to file ({$a})';
index f531b32..46566af 100644 (file)
@@ -232,6 +232,7 @@ $string['categorycontents'] = 'Subcategories and courses';
 $string['categorycurrentcontents'] = 'Contents of {$a}';
 $string['categorydeleted'] = 'The category \'{$a}\' was deleted';
 $string['categoryduplicate'] = 'A category named \'{$a}\' already exists!';
+$string['categoryhidden'] = '(hidden)';
 $string['categorymodifiedcancel'] = 'Category was modified! Please cancel and try again.';
 $string['categoryname'] = 'Category name';
 $string['categorysubcategoryof'] = '{$a->category} - subcategory of {$a->parentcategory}';
@@ -2021,6 +2022,10 @@ $string['timesplitting:quartersaccum'] = 'Quarters accumulative';
 $string['timesplitting:quartersaccum_help'] = 'This time-splitting method divides the course into quarters (4 equal parts), with each prediction being based on the data of all previous quarters.';
 $string['timesplitting:singlerange'] = 'Single range';
 $string['timesplitting:singlerange_help'] = 'This time-splitting method considers the entire course as a single span.';
+$string['timesplitting:upcoming3days'] = 'Upcoming 3 days';
+$string['timesplitting:upcoming3days_help'] = 'This time-splitting method generates predictions every 3 days. The indicators calculations will be based on the upcoming 3 days.';
+$string['timesplitting:upcomingfortnight'] = 'Upcoming fortnight';
+$string['timesplitting:upcomingfortnight_help'] = 'This time-splitting method generates predictions every fortnight. The indicators calculations will be based on the upcoming fortnight.';
 $string['timesplitting:upcomingweek'] = 'Upcoming week';
 $string['timesplitting:upcomingweek_help'] = 'This time-splitting method generates predictions every week. The indicators calculations will be based on the upcoming week.';
 $string['thanks'] = 'Thanks';
index ef271e8..13b4c1b 100644 (file)
@@ -154,6 +154,7 @@ $string['confirmunassignyes'] = 'Remove';
 $string['confirmunassignno'] = 'Cancel';
 $string['context'] = 'Context';
 $string['course:activityvisibility'] = 'Hide/show activities';
+$string['course:browse'] = 'View list of courses where user is not enrolled';
 $string['course:bulkmessaging'] = 'Send a message to many people';
 $string['course:create'] = 'Create courses';
 $string['course:creategroupconversations'] = 'Create group conversations';
index 238feaf..e80ba19 100644 (file)
@@ -1948,6 +1948,11 @@ function can_access_course(stdClass $course, $user = null, $withcapability = '',
         return true;
     }
 
+    if (!core_course_category::can_view_course_info($course)) {
+        // No guest access if user does not have capability to browse courses.
+        return false;
+    }
+
     // if not enrolled try to gain temporary guest access
     $instances = $DB->get_records('enrol', array('courseid'=>$course->id, 'status'=>ENROL_INSTANCE_ENABLED), 'sortorder, id ASC');
     $enrols = enrol_get_plugins(true);
index d664f70..63f549a 100644 (file)
Binary files a/lib/amd/build/form-course-selector.min.js and b/lib/amd/build/form-course-selector.min.js differ
index 0914918..7da2ea6 100644 (file)
@@ -54,6 +54,7 @@ define(['core/ajax', 'jquery'], function(ajax, $) {
 
             var limittoenrolled = el.data('limittoenrolled');
             var includefrontpage = el.data('includefrontpage');
+            var onlywithcompletion = el.data('onlywithcompletion');
 
             // Build the query.
             var promises = null;
@@ -68,7 +69,8 @@ define(['core/ajax', 'jquery'], function(ajax, $) {
                 page: 0,
                 perpage: 100,
                 requiredcapabilities: requiredcapabilities,
-                limittoenrolled: limittoenrolled
+                limittoenrolled: limittoenrolled,
+                onlywithcompletion: onlywithcompletion
             };
 
             var calls = [{
index 6752ae4..33a193f 100644 (file)
@@ -979,15 +979,24 @@ function signup_validate_data($data, $files) {
     if (! validate_email($data['email'])) {
         $errors['email'] = get_string('invalidemail');
 
-    } else if ($DB->record_exists('user', array('email' => $data['email']))) {
-        $errors['email'] = get_string('emailexists') . ' ' .
-                get_string('emailexistssignuphint', 'moodle',
-                        html_writer::link(new moodle_url('/login/forgot_password.php'), get_string('emailexistshintlink')));
+    } else if (empty($CFG->allowaccountssameemail)) {
+        // Make a case-insensitive query for the given email address.
+        $select = $DB->sql_equal('email', ':email', false) . ' AND mnethostid = :mnethostid';
+        $params = array(
+            'email' => $data['email'],
+            'mnethostid' => $CFG->mnet_localhost_id,
+        );
+        // If there are other user(s) that already have the same email, show an error.
+        if ($DB->record_exists_select('user', $select, $params)) {
+            $forgotpasswordurl = new moodle_url('/login/forgot_password.php');
+            $forgotpasswordlink = html_writer::link($forgotpasswordurl, get_string('emailexistshintlink'));
+            $errors['email'] = get_string('emailexists') . ' ' . get_string('emailexistssignuphint', 'moodle', $forgotpasswordlink);
+        }
     }
     if (empty($data['email2'])) {
         $errors['email2'] = get_string('missingemail');
 
-    } else if ($data['email2'] != $data['email']) {
+    } else if (core_text::strtolower($data['email2']) != core_text::strtolower($data['email'])) {
         $errors['email2'] = get_string('invalidemail');
     }
     if (!isset($errors['email'])) {
diff --git a/lib/classes/analytics/time_splitting/upcoming_3_days.php b/lib/classes/analytics/time_splitting/upcoming_3_days.php
new file mode 100644 (file)
index 0000000..bb86d05
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Time splitting method that generates insights every three days and calculates indicators using upcoming dates.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates insights every three days and calculates indicators using upcoming dates.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class upcoming_3_days extends \core_analytics\local\time_splitting\upcoming_periodic {
+
+    /**
+     * The time splitting method name.
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:upcoming3days');
+    }
+
+    /**
+     * Once every three days.
+     * @return \DateInterval
+     */
+    public function periodicity() {
+        return new \DateInterval('P3D');
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/analytics/time_splitting/upcoming_fortnight.php b/lib/classes/analytics/time_splitting/upcoming_fortnight.php
new file mode 100644 (file)
index 0000000..0b6aeb7
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Time splitting method that generates insights every fortnight and calculates indicators using upcoming dates.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates insights every fortnight and calculates indicators using upcoming dates.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class upcoming_fortnight extends \core_analytics\local\time_splitting\upcoming_periodic {
+
+    /**
+     * The time splitting method name.
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:upcomingfortnight');
+    }
+
+    /**
+     * Every two weeks.
+     * @return \DateInterval
+     */
+    public function periodicity() {
+        return new \DateInterval('P2W');
+    }
+}
\ No newline at end of file
index 8cd7889..6250f3a 100644 (file)
@@ -923,32 +923,38 @@ $cache = '.var_export($cache, true).';
      *
      * e.g. get_component_classes_in_namespace('mod_forum', 'event')
      *
-     * @param string $component A valid moodle component (frankenstyle)
-     * @param string $namespace Namespace from the component name or empty if all $component namespace classes.
-     * @return array The full class name as key and the class path as value.
+     * @param string|null $component A valid moodle component (frankenstyle) or null if searching all components
+     * @param string $namespace Namespace from the component name or empty string if all $component classes.
+     * @return array The full class name as key and the class path as value, empty array if $component is `null`
+     * and $namespace is empty.
      */
-    public static function get_component_classes_in_namespace($component, $namespace = '') {
+    public static function get_component_classes_in_namespace($component = null, $namespace = '') {
 
-        $component = self::normalize_componentname($component);
+        $classes = array();
 
-        if ($namespace) {
+        // Only look for components if a component name is set or a namespace is set.
+        if (isset($component) || !empty($namespace)) {
 
-            // We will add them later.
-            $namespace = trim($namespace, '\\');
+            // If a component parameter value is set we only want to look in that component.
+            // Otherwise we want to check all components.
+            $component = (isset($component)) ? self::normalize_componentname($component) : '\w+';
+            if ($namespace) {
 
-            // We need add double backslashes as it is how classes are stored into self::$classmap.
-            $namespace = implode('\\\\', explode('\\', $namespace));
-            $namespace = $namespace . '\\\\';
-        }
+                // We will add them later.
+                $namespace = trim($namespace, '\\');
 
-        $regex = '|^' . $component . '\\\\' . $namespace . '|';
-        $it = new RegexIterator(new ArrayIterator(self::$classmap), $regex, RegexIterator::GET_MATCH, RegexIterator::USE_KEY);
+                // We need add double backslashes as it is how classes are stored into self::$classmap.
+                $namespace = implode('\\\\', explode('\\', $namespace));
+                $namespace = $namespace . '\\\\';
+            }
+            $regex = '|^' . $component . '\\\\' . $namespace . '|';
+            $it = new RegexIterator(new ArrayIterator(self::$classmap), $regex, RegexIterator::GET_MATCH, RegexIterator::USE_KEY);
 
-        // We want to be sure that they exist.
-        $classes = array();
-        foreach ($it as $classname => $classpath) {
-            if (class_exists($classname)) {
-                $classes[$classname] = $classpath;
+            // We want to be sure that they exist.
+            foreach ($it as $classname => $classpath) {
+                if (class_exists($classname)) {
+                    $classes[$classname] = $classpath;
+                }
             }
         }
 
index 3a6325c..dd2aa75 100644 (file)
@@ -146,7 +146,7 @@ class db_record_lock_factory implements lock_factory {
             $countparams = array('owner' => $token, 'resourcekey' => $resource);
             $result = $this->db->count_records('lock_db', $countparams);
             $locked = $result === 1;
-            if (!$locked) {
+            if (!$locked && $timeout > 0) {
                 usleep(rand(10000, 250000)); // Sleep between 10 and 250 milliseconds.
             }
             // Try until the giveup time.
index 5062de8..dd20e9b 100644 (file)
@@ -152,7 +152,7 @@ class file_lock_factory implements lock_factory {
             // Will block on windows. So sad.
             $wouldblock = false;
             $locked = flock($filehandle, LOCK_EX | LOCK_NB, $wouldblock);
-            if (!$locked && $wouldblock) {
+            if (!$locked && $wouldblock && $timeout > 0) {
                 usleep(rand(10000, 250000)); // Sleep between 10 and 250 milliseconds.
             }
             // Try until the giveup time.
index 5da94bc..87ae64b 100644 (file)
@@ -188,7 +188,7 @@ class postgres_lock_factory implements lock_factory {
         do {
             $result = $this->db->get_record_sql('SELECT pg_try_advisory_lock(:locktype, :token) AS locked', $params);
             $locked = $result->locked === 't';
-            if (!$locked) {
+            if (!$locked && $timeout > 0) {
                 usleep(rand(10000, 250000)); // Sleep between 10 and 250 milliseconds.
             }
             // Try until the giveup time.
index 27e2edf..ee2cd60 100644 (file)
@@ -122,6 +122,9 @@ class message {
     /** @var  int The time the message was created.*/
     private $timecreated;
 
+     /** @var boolean Mark trust content. */
+    private $fullmessagetrust;
+
     /** @var array a list of properties that is allowed for each message. */
     private $properties = array(
         'courseid',
@@ -144,7 +147,8 @@ class message {
         'savedmessageid',
         'attachment',
         'attachname',
-        'timecreated'
+        'timecreated',
+        'fullmessagetrust'
         );
 
     /** @var array property to store any additional message processor specific content */
index ad64468..64ea06a 100644 (file)
@@ -597,6 +597,9 @@ function get_course($courseid, $clone = true) {
  *            we are using distinct. You almost _NEVER_ need all the fields
  *            in such a large SELECT
  *
+ * Consider using core_course_category::get_courses()
+ * or core_course_category::search_courses() instead since they use caching.
+ *
  * @global object
  * @global object
  * @global object
@@ -643,91 +646,11 @@ function get_courses($categoryid="all", $sort="c.sortorder ASC", $fields="c.*")
         // loop throught them
         foreach ($courses as $course) {
             context_helper::preload_from_record($course);
-            if (isset($course->visible) && $course->visible <= 0) {
-                // for hidden courses, require visibility check
-                if (has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) {
-                    $visiblecourses [$course->id] = $course;
-                }
-            } else {
-                $visiblecourses [$course->id] = $course;
-            }
-        }
-    }
-    return $visiblecourses;
-}
-
-
-/**
- * Returns list of courses, for whole site, or category
- *
- * Similar to get_courses, but allows paging
- * Important: Using c.* for fields is extremely expensive because
- *            we are using distinct. You almost _NEVER_ need all the fields
- *            in such a large SELECT
- *
- * @global object
- * @global object
- * @global object
- * @uses CONTEXT_COURSE
- * @param string|int $categoryid Either a category id or 'all' for everything
- * @param string $sort A field and direction to sort by
- * @param string $fields The additional fields to return
- * @param int $totalcount Reference for the number of courses
- * @param string $limitfrom The course to start from
- * @param string $limitnum The number of courses to limit to
- * @return array Array of courses
- */
-function get_courses_page($categoryid="all", $sort="c.sortorder ASC", $fields="c.*",
-                          &$totalcount, $limitfrom="", $limitnum="") {
-    global $USER, $CFG, $DB;
-
-    $params = array();
-
-    $categoryselect = "";
-    if ($categoryid !== "all" && is_numeric($categoryid)) {
-        $categoryselect = "WHERE c.category = :catid";
-        $params['catid'] = $categoryid;
-    } else {
-        $categoryselect = "";
-    }
-
-    $ccselect = ', ' . context_helper::get_preload_record_columns_sql('ctx');
-    $ccjoin = "LEFT JOIN {context} ctx ON (ctx.instanceid = c.id AND ctx.contextlevel = :contextlevel)";
-    $params['contextlevel'] = CONTEXT_COURSE;
-
-    $totalcount = 0;
-    if (!$limitfrom) {
-        $limitfrom = 0;
-    }
-    $visiblecourses = array();
-
-    $sql = "SELECT $fields $ccselect
-              FROM {course} c
-              $ccjoin
-           $categoryselect
-          ORDER BY $sort";
-
-    // pull out all course matching the cat
-    $rs = $DB->get_recordset_sql($sql, $params);
-    // iteration will have to be done inside loop to keep track of the limitfrom and limitnum
-    foreach($rs as $course) {
-        context_helper::preload_from_record($course);
-        if ($course->visible <= 0) {
-            // for hidden courses, require visibility check
-            if (has_capability('moodle/course:viewhiddencourses', context_course::instance($course->id))) {
-                $totalcount++;
-                if ($totalcount > $limitfrom && (!$limitnum or count($visiblecourses) < $limitnum)) {
-                    $visiblecourses [$course->id] = $course;
-                }
-            }
-        } else {
-            $totalcount++;
-            if ($totalcount > $limitfrom && (!$limitnum or count($visiblecourses) < $limitnum)) {
+            if (core_course_category::can_view_course_info($course)) {
                 $visiblecourses [$course->id] = $course;
             }
         }
     }
-    $rs->close();
     return $visiblecourses;
 }
 
@@ -742,10 +665,12 @@ function get_courses_page($categoryid="all", $sort="c.sortorder ASC", $fields="c
  * @param int $recordsperpage The number of records per page
  * @param int $totalcount Passed in by reference.
  * @param array $requiredcapabilities Extra list of capabilities used to filter courses
- * @return object {@link $COURSE} records
+ * @param array $searchcond additional search conditions, for example ['c.enablecompletion = :p1']
+ * @param array $params named parameters for additional search conditions, for example ['p1' => 1]
+ * @return stdClass[] {@link $COURSE} records
  */
 function get_courses_search($searchterms, $sort, $page, $recordsperpage, &$totalcount,
-                            $requiredcapabilities = array()) {
+                            $requiredcapabilities = array(), $searchcond = [], $params = []) {
     global $CFG, $DB;
 
     if ($DB->sql_regex_supported()) {
@@ -753,8 +678,6 @@ function get_courses_search($searchterms, $sort, $page, $recordsperpage, &$total
         $NOTREGEXP = $DB->sql_regex(false);
     }
 
-    $searchcond = array();
-    $params     = array();
     $i = 0;
 
     // Thanks Oracle for your non-ansi concat and type limits in coalesce. MDL-29912
@@ -822,12 +745,13 @@ function get_courses_search($searchterms, $sort, $page, $recordsperpage, &$total
              WHERE $searchcond AND c.id <> ".SITEID."
           ORDER BY $sort";
 
+    $mycourses = enrol_get_my_courses();
     $rs = $DB->get_recordset_sql($sql, $params);
     foreach($rs as $course) {
         // Preload contexts only for hidden courses or courses we need to return.
         context_helper::preload_from_record($course);
         $coursecontext = context_course::instance($course->id);
-        if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+        if (!array_key_exists($course->id, $mycourses) && !core_course_category::can_view_course_info($course)) {
             continue;
         }
         if (!empty($requiredcapabilities)) {
index c7e7d73..bfb9dd5 100644 (file)
@@ -733,6 +733,16 @@ $capabilities = array(
         'clonepermissionsfrom' => 'moodle/category:update'
     ),
 
+    'moodle/course:browse' => array(
+
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'guest' => CAP_ALLOW,
+            'user' => CAP_ALLOW,
+        )
+    ),
+
     'moodle/category:viewhiddencategories' => array(
 
         'captype' => 'read',
index e187dea..519bb11 100644 (file)
         <FIELD NAME="fullmessagehtml" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="smallmessage" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="fullmessagetrust" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index ec4dd78..95a17be 100644 (file)
@@ -1525,6 +1525,7 @@ $functions = array(
         'description' => 'Gets tag index page for one tag and one tag area',
         'type' => 'read',
         'ajax' => true,
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
     'core_tag_get_tags' => array(
         'classname' => 'core_tag_external',
@@ -1540,6 +1541,34 @@ $functions = array(
         'type' => 'write',
         'ajax' => true,
     ),
+    'core_tag_get_tagindex_per_area' => array(
+        'classname' => 'core_tag_external',
+        'methodname' => 'get_tagindex_per_area',
+        'description' => 'Gets tag index page per different areas.',
+        'type' => 'read',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'core_tag_get_tag_areas' => array(
+        'classname' => 'core_tag_external',
+        'methodname' => 'get_tag_areas',
+        'description' => 'Retrieves existing tag areas.',
+        'type' => 'read',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'core_tag_get_tag_collections' => array(
+        'classname' => 'core_tag_external',
+        'methodname' => 'get_tag_collections',
+        'description' => 'Retrieves existing tag collections.',
+        'type' => 'read',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
+    'core_tag_get_tag_cloud' => array(
+        'classname' => 'core_tag_external',
+        'methodname' => 'get_tag_cloud',
+        'description' => 'Retrieves a tag cloud for the given collection and/or query search.',
+        'type' => 'read',
+        'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+    ),
     'core_update_inplace_editable' => array(
         'classname' => 'core_external',
         'methodname' => 'update_inplace_editable',
index 36ec2a9..8bf8063 100644 (file)
@@ -2973,5 +2973,20 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019040600.04);
     }
 
+    if ($oldversion < 2019041000.02) {
+
+        // Define field fullmessagetrust to be added to messages.
+        $table = new xmldb_table('messages');
+        $field = new xmldb_field('fullmessagetrust', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'timecreated');
+
+        // Conditionally launch add field fullmessagetrust.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019041000.02);
+    }
+
     return true;
 }
index 44e8b8b..5022e29 100644 (file)
@@ -3611,3 +3611,70 @@ function message_get_contact($contactid) {
 
     return \core_message\api::get_contact($USER->id, $contactid);
 }
+
+/**
+ * Returns list of courses, for whole site, or category
+ *
+ * Similar to get_courses, but allows paging
+ * Important: Using c.* for fields is extremely expensive because
+ *            we are using distinct. You almost _NEVER_ need all the fields
+ *            in such a large SELECT
+ *
+ * @deprecated since Moodle 3.7
+ * @todo The final deprecation of this function will take place in Moodle 41 - see MDL-65319.
+ *
+ * @param string|int $categoryid Either a category id or 'all' for everything
+ * @param string $sort A field and direction to sort by
+ * @param string $fields The additional fields to return
+ * @param int $totalcount Reference for the number of courses
+ * @param string $limitfrom The course to start from
+ * @param string $limitnum The number of courses to limit to
+ * @return array Array of courses
+ */
+function get_courses_page($categoryid="all", $sort="c.sortorder ASC", $fields="c.*",
+                          &$totalcount, $limitfrom="", $limitnum="") {
+    debugging('Function get_courses_page() is deprecated. Please use core_course_category::get_courses() ' .
+        'or core_course_category::search_courses()', DEBUG_DEVELOPER);
+    global $USER, $CFG, $DB;
+
+    $params = array();
+
+    $categoryselect = "";
+    if ($categoryid !== "all" && is_numeric($categoryid)) {
+        $categoryselect = "WHERE c.category = :catid";
+        $params['catid'] = $categoryid;
+    } else {
+        $categoryselect = "";
+    }
+
+    $ccselect = ', ' . context_helper::get_preload_record_columns_sql('ctx');
+    $ccjoin = "LEFT JOIN {context} ctx ON (ctx.instanceid = c.id AND ctx.contextlevel = :contextlevel)";
+    $params['contextlevel'] = CONTEXT_COURSE;
+
+    $totalcount = 0;
+    if (!$limitfrom) {
+        $limitfrom = 0;
+    }
+    $visiblecourses = array();
+
+    $sql = "SELECT $fields $ccselect
+              FROM {course} c
+              $ccjoin
+           $categoryselect
+          ORDER BY $sort";
+
+    // Pull out all course matching the cat.
+    $rs = $DB->get_recordset_sql($sql, $params);
+    // Iteration will have to be done inside loop to keep track of the limitfrom and limitnum.
+    foreach ($rs as $course) {
+        context_helper::preload_from_record($course);
+        if (core_course_category::can_view_course_info($course)) {
+            $totalcount++;
+            if ($totalcount > $limitfrom && (!$limitnum or count($visiblecourses) < $limitnum)) {
+                $visiblecourses [$course->id] = $course;
+            }
+        }
+    }
+    $rs->close();
+    return $visiblecourses;
+}
index d0b4746..f20b4af 100644 (file)
@@ -61,7 +61,7 @@ class file_info_context_coursecat extends file_info {
     public function get_file_info($component, $filearea, $itemid, $filepath, $filename) {
         global $DB;
 
-        if (!$this->category->visible and !has_capability('moodle/category:viewhiddencategories', $this->context)) {
+        if (!core_course_category::can_view_category($this->category)) {
             if (empty($component)) {
                 // we can not list the category contents, so try parent, or top system
                 if ($this->category->parent and $pc = $DB->get_record('course_categories', array('id'=>$this->category->parent))) {
@@ -101,7 +101,7 @@ class file_info_context_coursecat extends file_info {
             // No coursecat description area for "system".
             return null;
         }
-        if (!$this->category->visible and !has_capability('moodle/category:viewhiddencategories', $this->context)) {
+        if (!core_course_category::can_view_category($this->category)) {
             return null;
         }
         if (!has_capability('moodle/category:manage', $this->context)) {
@@ -228,8 +228,7 @@ class file_info_context_coursecat extends file_info {
 
         foreach ($coursecats as $id => &$category) {
             context_helper::preload_from_record($category);
-            $context = context_coursecat::instance($category->id);
-            if (!$category->visible && !has_capability('moodle/category:viewhiddencategories', $context)) {
+            if (!core_course_category::can_view_category($category)) {
                 $hiddencats[$id] = $coursecats[$id];
                 unset($coursecats[$id]);
             }
index 8e17525..1a3141b 100644 (file)
@@ -4626,11 +4626,8 @@ function file_pluginfile($relativepath, $forcedownload, $preview = null, $offlin
             }
 
             // Check if user can view this category.
-            if (!has_capability('moodle/category:viewhiddencategories', $context)) {
-                $coursecatvisible = $DB->get_field('course_categories', 'visible', array('id' => $context->instanceid));
-                if (!$coursecatvisible) {
-                    send_file_not_found();
-                }
+            if (!core_course_category::get($context->instanceid, IGNORE_MISSING)) {
+                send_file_not_found();
             }
 
             $filename = array_pop($args);
index d3f4a5c..b75d7d8 100644 (file)
@@ -71,6 +71,7 @@ class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
      *                       'requiredcapabilities' - array of capabilities. Uses ANY to combine them.
      *                       'limittoenrolled' - boolean Limits to enrolled courses.
      *                       'includefrontpage' - boolean Enables the frontpage to be selected.
+     *                       'onlywithcompletion' - only courses where completion is enabled
      */
     public function __construct($elementname = null, $elementlabel = null, $options = array()) {
         if (isset($options['multiple'])) {
@@ -107,6 +108,9 @@ class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
         if (!empty($options['includefrontpage'])) {
             $validattributes['data-includefrontpage'] = SITEID;
         }
+        if (!empty($options['onlywithcompletion'])) {
+            $validattributes['data-onlywithcompletion'] = 1;
+        }
 
         parent::__construct($elementname, $elementlabel, array(), $validattributes);
     }
@@ -146,12 +150,13 @@ class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
                 WHERE c.id ". $whereclause." ORDER BY c.sortorder";
         $list = $DB->get_records_sql($sql, array('contextcourse' => CONTEXT_COURSE) + $params);
 
+        $mycourses = enrol_get_my_courses(null, null, 0, array_keys($list));
         $coursestoselect = array();
         foreach ($list as $course) {
             context_helper::preload_from_record($course);
             $context = context_course::instance($course->id);
             // Make sure we can see the course.
-            if (!$course->visible && !has_capability('moodle/course:viewhiddencourses', $context)) {
+            if (!array_key_exists($course->id, $mycourses) && !core_course_category::can_view_course_info($course)) {
                 continue;
             }
             $label = format_string(get_course_display_name_for_list($course), true, ['context' => $context]);
index 95d97f2..2f3b417 100644 (file)
@@ -154,6 +154,22 @@ function message_send(\core\message\message $eventdata) {
         $tabledata->smallmessage = $eventdata->smallmessage;
         $tabledata->timecreated = time();
 
+        // The Trusted Content system.
+        // Texts created or uploaded by such users will be marked as trusted and will not be cleaned before display.
+        if (trusttext_active()) {
+            // Individual conversations are always in system context.
+            $messagecontext = \context_system::instance();
+            // We need to know the type of conversation and the contextid if it is a group conversation.
+            if ($conv = $DB->get_record('message_conversations', ['id' => $conversationid], 'id, type, contextid')) {
+                if ($conv->type == \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP && $conv->contextid) {
+                    $messagecontext = \context::instance_by_id($conv->contextid);
+                }
+            }
+            $tabledata->fullmessagetrust = trusttext_trusted($messagecontext);
+        } else {
+            $tabledata->fullmessagetrust = false;
+        }
+
         if ($messageid = message_handle_phpunit_redirection($eventdata, $table, $tabledata)) {
             return $messageid;
         }
index 016e86c..063c44d 100644 (file)
@@ -291,11 +291,15 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor
 
         // We need at least 2 samples belonging to each target.
         $counts = array_count_values($targets);
+        $ntargets = count(explode(',', $metadata['targetclasses']));
         foreach ($counts as $count) {
             if ($count < 2) {
                 $notenoughdata = true;
             }
         }
+        if ($ntargets > count($counts)) {
+            $notenoughdata = true;
+        }
         if (!empty($notenoughdata)) {
             $resultobj = new \stdClass();
             $resultobj->status = \core_analytics\model::NOT_ENOUGH_DATA;
index 88cdff0..1cc6caa 100644 (file)
@@ -2906,7 +2906,7 @@ function require_login($courseorid = null, $autologinguest = true, $cm = null, $
                     $USER->enrol['enrolled'][$course->id] = $until;
                     $access = true;
 
-                } else {
+                } else if (core_course_category::can_view_course_info($course)) {
                     $params = array('courseid' => $course->id, 'status' => ENROL_INSTANCE_ENABLED);
                     $instances = $DB->get_records('enrol', $params, 'sortorder, id ASC');
                     $enrols = enrol_get_plugins(true);
@@ -2941,6 +2941,16 @@ function require_login($courseorid = null, $autologinguest = true, $cm = null, $
                             }
                         }
                     }
+                } else {
+                    // User is not enrolled and is not allowed to browse courses here.
+                    if ($preventredirect) {
+                        throw new require_login_exception('Course is not available');
+                    }
+                    $PAGE->set_context(null);
+                    // We need to override the navigation URL as the course won't have been added to the navigation and thus
+                    // the navigation will mess up when trying to find it.
+                    navigation_node::override_active_url(new moodle_url('/'));
+                    notice(get_string('coursehidden'), $CFG->wwwroot .'/');
                 }
             }
         }
index a32d3ea..bae521f 100644 (file)
@@ -1298,6 +1298,9 @@ class global_navigation extends navigation_node {
         $this->rootnodes['currentcourse'] = $this->add(get_string('currentcourse'), null, self::TYPE_ROOTNODE, null, 'currentcourse');
         $this->rootnodes['mycourses'] = $this->add(get_string('mycourses'), null, self::TYPE_ROOTNODE, null, 'mycourses', new pix_icon('i/course', ''));
         $this->rootnodes['courses'] = $this->add(get_string('courses'), new moodle_url('/course/index.php'), self::TYPE_ROOTNODE, null, 'courses');
+        if (!core_course_category::user_top()) {
+            $this->rootnodes['courses']->hide();
+        }
         $this->rootnodes['users'] = $this->add(get_string('users'), null, self::TYPE_ROOTNODE, null, 'users');
 
         // We always load the frontpage course to ensure it is available without
@@ -1568,7 +1571,7 @@ class global_navigation extends navigation_node {
     protected function show_my_categories() {
         global $CFG;
         if ($this->showmycategories === null) {
-            $this->showmycategories = !empty($CFG->navshowmycoursecategories) && core_course_category::count_all() > 1;
+            $this->showmycategories = !empty($CFG->navshowmycoursecategories) && !core_course_category::is_simple_site();
         }
         return $this->showmycategories;
     }
@@ -1951,16 +1954,18 @@ class global_navigation extends navigation_node {
         if (array_key_exists($category->id, $this->addedcategories)) {
             return;
         }
-        $url = new moodle_url('/course/index.php', array('categoryid' => $category->id));
+        $canview = core_course_category::can_view_category($category);
+        $url = $canview ? new moodle_url('/course/index.php', array('categoryid' => $category->id)) : null;
         $context = context_coursecat::instance($category->id);
-        $categoryname = format_string($category->name, true, array('context' => $context));
+        $categoryname = $canview ? format_string($category->name, true, array('context' => $context)) :
+            get_string('categoryhidden');
         $categorynode = $parent->add($categoryname, $url, $nodetype, $categoryname, $category->id);
-        if (empty($category->visible)) {
-            if (has_capability('moodle/category:viewhiddencategories', context_system::instance())) {
-                $categorynode->hidden = true;
-            } else {
-                $categorynode->display = false;
-            }
+        if (!$canview) {
+            // User does not have required capabilities to view category.
+            $categorynode->display = false;
+        } else if (!$category->visible) {
+            // Category is hidden but user has capability to view hidden categories.
+            $categorynode->hidden = true;
         }
         $this->addedcategories[$category->id] = $categorynode;
     }
@@ -2606,10 +2611,10 @@ class global_navigation extends navigation_node {
 
         $coursecontext = context_course::instance($course->id);
 
-        if ($course->id != $SITE->id && !$course->visible) {
+        if ($coursetype != self::COURSE_MY && $coursetype != self::COURSE_CURRENT && $course->id != $SITE->id) {
             if (is_role_switched($course->id)) {
                 // user has to be able to access course in order to switch, let's skip the visibility test here
-            } else if (!has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+            } else if (!core_course_category::can_view_course_info($course)) {
                 return false;
             }
         }
@@ -3057,6 +3062,10 @@ class global_navigation extends navigation_node {
                 if (isset($this->addedcategories[$coursecat->id])) {
                     continue;
                 }
+                // Skip categories that are not visible.
+                if (!$coursecat->is_uservisible()) {
+                    continue;
+                }
 
                 // Get this course category's parent node.
                 $parent = null;
@@ -3209,7 +3218,7 @@ class global_navigation_for_ajax extends global_navigation {
                 }
                 require_course_login($course, true, null, false, true);
                 $this->page->set_context(context_course::instance($course->id));
-                $coursenode = $this->add_course($course);
+                $coursenode = $this->add_course($course, false, self::COURSE_CURRENT);
                 $this->add_course_essentials($coursenode, $course);
                 $this->load_course_sections($course, $coursenode);
                 break;
@@ -3221,7 +3230,7 @@ class global_navigation_for_ajax extends global_navigation {
                 $course = $DB->get_record_sql($sql, array($this->instanceid), MUST_EXIST);
                 require_course_login($course, true, null, false, true);
                 $this->page->set_context(context_course::instance($course->id));
-                $coursenode = $this->add_course($course);
+                $coursenode = $this->add_course($course, false, self::COURSE_CURRENT);
                 $this->add_course_essentials($coursenode, $course);
                 $this->load_course_sections($course, $coursenode, $course->sectionnumber);
                 break;
@@ -3236,7 +3245,7 @@ class global_navigation_for_ajax extends global_navigation {
                 $cm = $modinfo->get_cm($this->instanceid);
                 require_course_login($course, true, $cm, false, true);
                 $this->page->set_context(context_module::instance($cm->id));
-                $coursenode = $this->load_course($course);
+                $coursenode = $this->add_course($course, false, self::COURSE_CURRENT);
                 $this->load_course_sections($course, $coursenode, null, $cm);
                 $activitynode = $coursenode->find($cm->id, self::TYPE_ACTIVITY);
                 if ($activitynode) {
@@ -3597,15 +3606,16 @@ class navbar extends navigation_node {
 
         $categories = array();
         $cap = 'moodle/category:viewhiddencategories';
-        $showcategories = core_course_category::count_all() > 1;
+        $showcategories = !core_course_category::is_simple_site();
 
         if ($showcategories) {
             foreach ($this->page->categories as $category) {
-                if (!$category->visible && !has_capability($cap, get_category_or_system_context($category->parent))) {
+                $context = context_coursecat::instance($category->id);
+                if (!core_course_category::can_view_category($category)) {
                     continue;
                 }
                 $url = new moodle_url('/course/index.php', array('categoryid' => $category->id));
-                $name = format_string($category->name, true, array('context' => context_coursecat::instance($category->id)));
+                $name = format_string($category->name, true, array('context' => $context));
                 $categorynode = breadcrumb_navigation_node::create($name, $url, self::TYPE_CATEGORY, null, $category->id);
                 if (!$category->visible) {
                     $categorynode->hidden = true;
index 98a8666..dbfa05e 100644 (file)
@@ -1407,7 +1407,7 @@ function disable_output_buffering() {
  */
 function is_major_upgrade_required() {
     global $CFG;
-    $lastmajordbchanges = 2019032900.00;
+    $lastmajordbchanges = 2019041000.03;
 
     $required = empty($CFG->version);
     $required = $required || (float)$CFG->version < $lastmajordbchanges;
index b0b995a..da3d8a8 100644 (file)
@@ -473,7 +473,7 @@ class core_component_testcase extends advanced_testcase {
         $this->assertEquals(array(), array_keys($list));
     }
 
-    public function test_get_component_classes_int_namespace() {
+    public function test_get_component_classes_in_namespace() {
 
         // Unexisting.
         $this->assertCount(0, core_component::get_component_classes_in_namespace('core_unexistingcomponent', 'something'));
@@ -493,7 +493,7 @@ class core_component_testcase extends advanced_testcase {
         $this->assertCount(1, core_component::get_component_classes_in_namespace('auth_cas', 'task'));
         $this->assertCount(1, core_component::get_component_classes_in_namespace('auth_cas', '\\task'));
 
-        // Core as a component works, the funcion can normalise the component name.
+        // Core as a component works, the function can normalise the component name.
         $this->assertCount(7, core_component::get_component_classes_in_namespace('core', 'update'));
         $this->assertCount(7, core_component::get_component_classes_in_namespace('', 'update'));
         $this->assertCount(7, core_component::get_component_classes_in_namespace('moodle', 'update'));
@@ -507,6 +507,17 @@ class core_component_testcase extends advanced_testcase {
         // Without namespace it returns classes/ classes.
         $this->assertCount(3, core_component::get_component_classes_in_namespace('tool_mobile', ''));
         $this->assertCount(2, core_component::get_component_classes_in_namespace('tool_filetypes'));
+
+        // When no component is specified, classes are returned for the namespace in all components.
+        // (We don't assert exact amounts here as the count of `output` classes will change depending on plugins installed).
+        $this->assertGreaterThan(
+            count(\core_component::get_component_classes_in_namespace('core', 'output')),
+            count(\core_component::get_component_classes_in_namespace(null, 'output')));
+
+        // Without either a component or namespace it returns an empty array.
+        $this->assertEmpty(\core_component::get_component_classes_in_namespace());
+        $this->assertEmpty(\core_component::get_component_classes_in_namespace(null));
+        $this->assertEmpty(\core_component::get_component_classes_in_namespace(null, ''));
     }
 
     /**
index 17c8e57..695d3a1 100644 (file)
@@ -59,10 +59,31 @@ class lock_testcase extends advanced_testcase {
                     $lock2 = $lockfactory->get_lock('abc', 2);
                     $this->assertNotEmpty($lock2, 'Get a stacked lock');
                     $this->assertTrue($lock2->release(), 'Release a stacked lock');
+
+                    // This stacked lock should be gained almost instantly.
+                    $duration = -microtime(true);
+                    $lock3 = $lockfactory->get_lock('abc', 0);
+                    $duration += microtime(true);
+                    $lock3->release();
+                    $this->assertTrue($duration < 0.100, 'Lock should be gained almost instantly');
+
+                    // We should also assert that locks fail instantly if locked
+                    // from another process but this is hard to unit test.
+
                 } else {
-                    // This should timeout.
+                    // This should timeout after 2 seconds.
+                    $duration = -microtime(true);
                     $lock2 = $lockfactory->get_lock('abc', 2);
+                    $duration += microtime(true);
+                    $this->assertFalse($lock2, 'Cannot get a stacked lock');
+                    $this->assertTrue($duration > 1, 'Lock should timeout after more than 1 second');
+
+                    // This should timeout almost instantly.
+                    $duration = -microtime(true);
+                    $lock2 = $lockfactory->get_lock('abc', 0);
+                    $duration += microtime(true);
                     $this->assertFalse($lock2, 'Cannot get a stacked lock');
+                    $this->assertTrue($duration < 0.100, 'Lock should timeout almost instantly < 100ms');
                 }
             }
             // Release the lock.
index ab0d4e8..f1bbece 100644 (file)
@@ -26,6 +26,10 @@ attribute on forms to avoid collisions in forms loaded in AJAX requests.
   When the parameter is set to that constant, the function won't process file merging, keeping the original state of the file area.
 * Introduced new callback for plugin developers '<component>_pre_processor_message_send($procname, $proceventdata)':
   This will allow any plugin to manipulate messages or notifications before they are sent by a processor (email, mobile...)
+* New capability 'moodle/course:browse' in category context that controls whether user is able to browse list of courses
+  in this category. To work with list of courses use API methods in core_course_category and also 'course' form element.
+* It is possible to pass additional conditions to get_courses_search();
+  core_course_category::search_courses() now allows to search only among courses with completion enabled.
 
 === 3.6 ===
 
index c2daa35..8bd8bc5 100644 (file)
@@ -38,4 +38,6 @@ TODO:
 20110318 - MDL-26891 - Eloy Lafuente (stronk7): Implemented earlier profiling runs
 20130621 - MDL-39733 - Eloy Lafuente (stronk7): Export & import of profiling runs
 20160721 - MDL-55292 - Russell Smith (mr-russ): Add support for tideways profiler collection for PHP7
-20171002 - MDL-60313 - Marina Glancy (marinaglancy): Upgrade to 0.9.4 release; patched for PHP7.2
\ No newline at end of file
+20171002 - MDL-60313 - Marina Glancy (marinaglancy): Upgrade to 0.9.4 release; patched for PHP7.2
+20190314 - MDL-64543 - Brendan Heywood (brendanheywood): Add support for conditional slow profiling
+
index 82a1d1a..36548ea 100644 (file)
@@ -141,7 +141,13 @@ function profiling_start() {
         $profileauto = (mt_rand(1, $CFG->profilingautofrec) === 1);
     }
 
-    // See if the $script matches any of the included patterns
+    // Profile potentially slow pages.
+    $profileslow = false;
+    if (!empty($CFG->profilingslow) && !CLI_SCRIPT) {
+        $profileslow = true;
+    }
+
+    // See if the $script matches any of the included patterns.
     $included = empty($CFG->profilingincluded) ? '' : $CFG->profilingincluded;
     $profileincluded = profiling_string_matches($script, $included);
 
@@ -155,11 +161,19 @@ function profiling_start() {
     // Decide if profile by match must happen (only if profileauto is disabled)
     $profilematch = $profileincluded && !$profileexcluded && empty($CFG->profilingautofrec);
 
-    // If not auto, me, all, match have been detected, nothing to do
-    if (!$profileauto && !$profileme && !$profileall && !$profilematch) {
+    // Decide if slow profile has been excluded.
+    $profileslow = $profileslow && !$profileexcluded;
+
+    // If not auto, me, all, match have been detected, nothing to do.
+    if (!$profileauto && !$profileme && !$profileall && !$profilematch && !$profileslow) {
         return false;
     }
 
+    // If we have only been triggered by a *potentially* slow page then remember this for later.
+    if ((!$profileauto && !$profileme && !$profileall && !$profilematch) && $profileslow) {
+        $CFG->profilepotentialslowpage = microtime(true); // Neither $PAGE or $SESSION are guaranteed here.
+    }
+
     // Arrived here, the script is going to be profiled, let's do it
     $ignore = array('call_user_func', 'call_user_func_array');
     if (extension_loaded('tideways_xhprof')) {
@@ -217,6 +231,24 @@ function profiling_stop() {
         return false;
     }
 
+    // If we only profiled because it was potentially slow then...
+    if (!empty($CFG->profilepotentialslowpage)) {
+        $duration = microtime(true) - $CFG->profilepotentialslowpage;
+        if ($duration < $CFG->profilingslow) {
+            // Wasn't slow enough.
+            return false;
+        }
+
+        $sql = "SELECT max(totalexecutiontime)
+                  FROM {profiling}
+                 WHERE url = ?";
+        $slowest = $DB->get_field_sql($sql, array($script));
+        if (!empty($slowest) && $duration * 1000000 < $slowest) {
+            // Already have a worse profile stored.
+            return false;
+        }
+    }
+
     $run = new moodle_xhprofrun();
     $run->prepare_run($script);
     $runid = $run->save_run($data, null);
index ebbb9f0..5bcf9f1 100644 (file)
@@ -549,8 +549,8 @@ class api {
         $typesql = !is_null($type) ? " AND mc.type = :convtype " : "";
 
         $sql = "SELECT m.id as messageid, mc.id as id, mc.name as conversationname, mc.type as conversationtype, m.useridfrom,
-                       m.smallmessage, m.fullmessage, m.fullmessageformat, m.fullmessagehtml, m.timecreated, mc.component,
-                       mc.itemtype, mc.itemid, mc.contextid, mca.action as ismuted
+                       m.smallmessage, m.fullmessage, m.fullmessageformat, m.fullmessagetrust, m.fullmessagehtml, m.timecreated,
+                       mc.component, mc.itemtype, mc.itemid, mc.contextid, mca.action as ismuted
                   FROM {message_conversations} mc
             INNER JOIN {message_conversation_members} mcm
                     ON (mcm.conversationid = mc.id AND mcm.userid = :userid3)
@@ -1917,12 +1917,14 @@ class api {
         $eventdata->notification    = 0;
         $messageid = message_send($eventdata);
 
-        $messagerecord = $DB->get_record('messages', ['id' => $messageid], 'id, useridfrom, fullmessage, timecreated');
+        $messagerecord = $DB->get_record('messages', ['id' => $messageid], 'id, useridfrom, fullmessage,
+                timecreated, fullmessagetrust');
         $message = (object) [
             'id' => $messagerecord->id,
             'useridfrom' => $messagerecord->useridfrom,
             'text' => $messagerecord->fullmessage,
-            'timecreated' => $messagerecord->timecreated
+            'timecreated' => $messagerecord->timecreated,
+            'fullmessagetrust' => $messagerecord->fullmessagetrust
         ];
         return $message;
     }
index 80727c3..a1099b9 100644 (file)
@@ -58,7 +58,8 @@ class helper {
         $hash = self::get_conversation_hash([$userid, $otheruserid]);
 
         $sql = "SELECT m.id, m.useridfrom, m.subject, m.fullmessage, m.fullmessagehtml,
-                       m.fullmessageformat, m.smallmessage, m.timecreated, muaread.timecreated AS timeread
+                       m.fullmessageformat, m.fullmessagetrust, m.smallmessage, m.timecreated,
+                       mc.contextid, muaread.timecreated AS timeread
                   FROM {message_conversations} mc
             INNER JOIN {messages} m
                     ON m.conversationid = mc.id
@@ -131,7 +132,8 @@ class helper {
         global $DB;
 
         $sql = "SELECT m.id, m.useridfrom, m.subject, m.fullmessage, m.fullmessagehtml,
-                       m.fullmessageformat, m.smallmessage, m.timecreated, muaread.timecreated AS timeread
+                       m.fullmessageformat, m.fullmessagetrust, m.smallmessage, m.timecreated,
+                       mc.contextid, muaread.timecreated AS timeread
                   FROM {message_conversations} mc
             INNER JOIN {messages} m
                     ON m.conversationid = mc.id
index 9d10760..7535be9 100644 (file)
@@ -94,7 +94,8 @@ class core_message_external extends external_api {
                 $message['textformat']);
             $createdmessage->text = message_format_message_text((object) [
                 'smallmessage' => $createdmessage->text,
-                'fullmessageformat' => external_validate_format($message['textformat'])
+                'fullmessageformat' => external_validate_format($message['textformat']),
+                'fullmessagetrust' => $createdmessage->fullmessagetrust
             ]);
             $messages[] = $createdmessage;
         }
@@ -203,10 +204,6 @@ class core_message_external extends external_api {
             }
             if ($success) {
                 $resultmsg['msgid'] = $success;
-                $resultmsg['text'] = message_format_message_text((object) [
-                    'smallmessage' => $message['text'],
-                    'fullmessageformat' => external_validate_format($message['textformat'])
-                ]);
                 $resultmsg['timecreated'] = time();
                 $messageids[] = $success;
             } else {
@@ -221,11 +218,21 @@ class core_message_external extends external_api {
         }
 
         if (!empty($messageids)) {
-            $messagerecords = $DB->get_records_list('messages', 'id', $messageids, '', 'id, conversationid');
+            $messagerecords = $DB->get_records_list(
+                'messages',
+                'id',
+                $messageids,
+                '',
+                'id, conversationid, smallmessage, fullmessageformat, fullmessagetrust');
             $resultmessages = array_map(function($resultmessage) use ($messagerecords, $USER) {
                 $id = $resultmessage['msgid'];
                 $resultmessage['conversationid'] = isset($messagerecords[$id]) ? $messagerecords[$id]->conversationid : null;
                 $resultmessage['useridfrom'] = $USER->id;
+                $resultmessage['text'] = message_format_message_text((object) [
+                    'smallmessage' => $messagerecords[$id]->smallmessage,
+                    'fullmessageformat' => external_validate_format($messagerecords[$id]->fullmessageformat),
+                    'fullmessagetrust' => $messagerecords[$id]->fullmessagetrust
+                ]);
                 return $resultmessage;
             }, $resultmessages);
         }
index 759a50f..b98190f 100644 (file)
@@ -114,6 +114,7 @@ function message_format_message_text($message, $forcetexttohtml = false) {
     $options = new stdClass();
     $options->para = false;
     $options->blanktarget = true;
+    $options->trusted = isset($message->fullmessagetrust) ? $message->fullmessagetrust : false;
 
     $format = $message->fullmessageformat;
 
index f5bd20f..0b4887f 100644 (file)
@@ -611,6 +611,7 @@ function book_export_contents($cm, $baseurl) {
         $chapterindexfile['userid']       = null;
         $chapterindexfile['author']       = null;
         $chapterindexfile['license']      = null;
+        $chapterindexfile['tags']         = \core_tag\external\util::get_item_tags('mod_book', 'book_chapters', $chapter->id);
         $contents[] = $chapterindexfile;
 
         // Chapter files (images usually).
index 51fe25f..49f740a 100644 (file)
@@ -45,7 +45,8 @@ class mod_book_lib_testcase extends advanced_testcase {
     }
 
     public function test_export_contents() {
-        global $DB;
+        global $DB, $CFG;
+        require_once($CFG->dirroot . '/course/externallib.php');
 
         $user = $this->getDataGenerator()->create_user();
         $course = $this->getDataGenerator()->create_course(array('enablecomment' => 1));
@@ -57,7 +58,10 @@ class mod_book_lib_testcase extends advanced_testcase {
         $cm = get_coursemodule_from_id('book', $book->cmid);
 
         $bookgenerator = $this->getDataGenerator()->get_plugin_generator('mod_book');
-        $chapter1 = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 1));
+        $chapter1 = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 1,
+            'tags' => array('Cats', 'Dogs')));
+        $tag = core_tag_tag::get_by_name(0, 'Cats');
+
         $chapter2 = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 2));
         $subchapter = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 3, "subchapter" => 1));
         $chapter3 = $bookgenerator->create_chapter(array('bookid' => $book->id, "pagenum" => 4, "hidden" => 1));
@@ -71,11 +75,24 @@ class mod_book_lib_testcase extends advanced_testcase {
         $this->assertEquals('structure', $contents[0]['filename']);
         $this->assertEquals('index.html', $contents[1]['filename']);
         $this->assertEquals('Chapter 1', $contents[1]['content']);
+        $this->assertCount(2, $contents[1]['tags']);
+        $this->assertEquals('Cats', $contents[1]['tags'][0]['rawname']);
+        $this->assertEquals($tag->id, $contents[1]['tags'][0]['id']);
+        $this->assertEquals('Dogs', $contents[1]['tags'][1]['rawname']);
         $this->assertEquals('index.html', $contents[2]['filename']);
         $this->assertEquals('Chapter 2', $contents[2]['content']);
         $this->assertEquals('index.html', $contents[3]['filename']);
         $this->assertEquals('Chapter 3', $contents[3]['content']);
 
+        // Now, test the function via the external API.
+        $contents = core_course_external::get_course_contents($course->id, array());
+        $contents = external_api::clean_returnvalue(core_course_external::get_course_contents_returns(), $contents);
+        $this->assertEquals('book', $contents[0]['modules'][0]['modname']);
+        $this->assertEquals($cm->id, $contents[0]['modules'][0]['id']);
+        $this->assertCount(2, $contents[0]['modules'][0]['contents'][1]['tags']);
+        $this->assertEquals('Cats', $contents[0]['modules'][0]['contents'][1]['tags'][0]['rawname']);
+        $this->assertEquals('Dogs', $contents[0]['modules'][0]['contents'][1]['tags'][1]['rawname']);
+
         // Test empty book.
         $emptybook = $this->getDataGenerator()->create_module('book', array('course' => $course->id));
         $cm = get_coursemodule_from_id('book', $emptybook->cmid);
index 53f766d..1fb9028 100644 (file)
@@ -1,5 +1,9 @@
 This files describes API changes in the book code.
 
+=== 3.7 ===
+
+* book_export_contents() callback now returns tags information for every chapter.
+
 === 3.1 ===
 
 * The following functions, previously used (exclusively) by upgrade steps are not available
index 7fecd44..428b44d 100644 (file)
@@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die();
 use core\external\exporter;
 use renderer_base;
 use core_user;
+use core_tag\external\tag_item_exporter;
 
 /**
  * Class for exporting record data.
@@ -102,6 +103,12 @@ class record_exporter extends exporter {
                 'multiple' => true,
                 'optional' => true,
             ),
+            'tags' => array(
+                'type' => tag_item_exporter::read_properties_definition(),
+                'description' => 'Tags.',
+                'multiple' => true,
+                'optional' => true,
+            ),
         );
     }
 
@@ -128,6 +135,9 @@ class record_exporter extends exporter {
             }
             $values['contents'] = $contents;
         }
+
+        $values['tags'] = \core_tag\external\util::get_item_tags('mod_data', 'data_records', $this->data->id);
+
         return $values;
     }
 }
index 51ad2d4..f34f032 100644 (file)
@@ -412,9 +412,9 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         }
 
         $this->setUser($this->student1);
-        $entry11 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id);
+        $entry11 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id, ['Cats', 'Dogs']);
         $this->setUser($this->student2);
-        $entry12 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id);
+        $entry12 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id, ['Cats']);
         $entry13 = $generator->create_entry($this->database, $fieldcontents, $this->group1->id);
         // Entry not in group.
         $entry14 = $generator->create_entry($this->database, $fieldcontents, 0);
@@ -447,10 +447,13 @@ class mod_data_external_testcase extends externallib_advanced_testcase {
         $this->assertCount(3, $result['entries']);
         $this->assertEquals(3, $result['totalcount']);
         $this->assertEquals($entry11, $result['entries'][0]['id']);
+        $this->assertCount(2, $result['entries'][0]['tags']);
         $this->assertEquals($this->student1->id, $result['entries'][0]['userid']);
         $this->assertEquals($this->group1->id, $result['entries'][0]['groupid']);
         $this->assertEquals($this->database->id, $result['entries'][0]['dataid']);
         $this->assertEquals($entry12, $result['entries'][1]['id']);
+        $this->assertCount(1, $result['entries'][1]['tags']);
+        $this->assertEquals('Cats', $result['entries'][1]['tags'][0]['rawname']);
         $this->assertEquals($this->student2->id, $result['entries'][1]['userid']);
         $this->assertEquals($this->group1->id, $result['entries'][1]['groupid']);
         $this->assertEquals($this->database->id, $result['entries'][1]['dataid']);
index 28f7a44..e776b4d 100644 (file)
@@ -1,6 +1,9 @@
 This files describes API changes in /mod/data - plugins,
 information provided here is intended especially for developers.
 
+=== 3.7 ===
+* External functions get_entries, get_entry and search_entries now return an additional field "tags" containing the entry tags.
+
 === 3.4 ===
 * External function mod_data_external::search_entries() now returns the maxcount field: Total count of records that the user could
     see in the database (if all the search criterias were removed).
index 63f45d9..7bc891f 100644 (file)
@@ -39,7 +39,7 @@ class backup_forum_activity_structure_step extends backup_activity_structure_ste
         // Define each element separated
 
         $forum = new backup_nested_element('forum', array('id'), array(
-            'type', 'name', 'intro', 'introformat',
+            'type', 'name', 'intro', 'introformat', 'duedate', 'cutoffdate',
             'assessed', 'assesstimestart', 'assesstimefinish', 'scale',
             'maxbytes', 'maxattachments', 'forcesubscribe', 'trackingtype',
             'rsstype', 'rssarticles', 'timemodified', 'warnafter',
index 771fd41..855e25c 100644 (file)
@@ -62,6 +62,14 @@ class restore_forum_activity_structure_step extends restore_activity_structure_s
 
         // Any changes to the list of dates that needs to be rolled should be same during course restore and course reset.
         // See MDL-9367.
+        if (!isset($data->duedate)) {
+            $data->duedate = 0;
+        }
+        $data->duedate = $this->apply_date_offset($data->duedate);
+        if (!isset($data->cutoffdate)) {
+            $data->cutoffdate = 0;
+        }
+        $data->cutoffdate = $this->apply_date_offset($data->cutoffdate);
         $data->assesstimestart = $this->apply_date_offset($data->assesstimestart);
         $data->assesstimefinish = $this->apply_date_offset($data->assesstimefinish);
         if ($data->scale < 0) { // scale found, get mapping
index bc89b2c..97aa7a9 100644 (file)
@@ -69,7 +69,9 @@ class forum {
                 'completionreplies' => $forum->get_completion_replies(),
                 'completionposts' => $forum->get_completion_posts(),
                 'displaywordcount' => $forum->should_display_word_count(),
-                'lockdiscussionafter' => $forum->get_lock_discussions_after()
+                'lockdiscussionafter' => $forum->get_lock_discussions_after(),
+                'duedate' => $forum->get_due_date(),
+                'cutoffdate' => $forum->get_cutoff_date()
             ];
         }, $forums);
     }