Merge branch 'MDL-63665-master' of git://github.com/rezaies/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Mon, 29 Oct 2018 00:42:50 +0000 (08:42 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Mon, 29 Oct 2018 00:42:50 +0000 (08:42 +0800)
194 files changed:
admin/settings/analytics.php
admin/settings/moodleservices.php [new file with mode: 0644]
admin/settings/server.php
admin/settings/top.php
admin/templates/setting_description.mustache [new file with mode: 0644]
admin/tool/analytics/classes/output/form/edit_model.php
admin/tool/analytics/model.php
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/data_registry.php
admin/tool/dataprivacy/classes/form/context_instance.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/lib.php
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/behat/datadelete.feature
admin/tool/dataprivacy/tests/behat/dataexport.feature
admin/tool/dataprivacy/tests/behat/manage_categories.feature
admin/tool/dataprivacy/tests/behat/manage_data_requests.feature
admin/tool/dataprivacy/tests/behat/manage_defaults.feature
admin/tool/dataprivacy/tests/behat/manage_purposes.feature
admin/tool/dataprivacy/tests/data_registry_test.php [new file with mode: 0644]
admin/tool/mobile/settings.php
admin/tool/policy/tests/behat/acceptances.feature
admin/tool/policy/tests/behat/managepolicies.feature
admin/tool/task/classes/run_from_cli.php [new file with mode: 0644]
admin/tool/task/lang/en/tool_task.php
admin/tool/task/renderer.php
admin/tool/task/schedule_task.php
admin/tool/task/tests/behat/run_task_now.feature [deleted file]
admin/tool/usertours/tests/behat/tour_accessibility.feature
analytics/classes/manager.php
analytics/classes/model.php
analytics/tests/prediction_test.php
blocks/classes/external.php
blocks/html/block_html.php
blocks/moodleblock.class.php
blocks/myoverview/amd/build/repository.min.js
blocks/myoverview/amd/build/view.min.js
blocks/myoverview/amd/build/view_nav.min.js
blocks/myoverview/amd/src/repository.js
blocks/myoverview/amd/src/view.js
blocks/myoverview/amd/src/view_nav.js
blocks/myoverview/block_myoverview.php
blocks/myoverview/classes/output/main.php
blocks/myoverview/classes/privacy/provider.php
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/lang/en/deprecated.txt [new file with mode: 0644]
blocks/myoverview/lib.php [new file with mode: 0644]
blocks/myoverview/templates/course-action-menu.mustache [new file with mode: 0644]
blocks/myoverview/templates/courses-view.mustache
blocks/myoverview/templates/favourite-icon.mustache [new file with mode: 0644]
blocks/myoverview/templates/main.mustache
blocks/myoverview/templates/nav-display-selector.mustache
blocks/myoverview/templates/nav-grouping-selector.mustache
blocks/myoverview/templates/nav-sort-selector.mustache
blocks/myoverview/templates/placeholder-course.mustache
blocks/myoverview/templates/view-cards.mustache
blocks/myoverview/templates/view-list.mustache
blocks/myoverview/templates/view-summary.mustache
blocks/myoverview/tests/behat/block_myoverview_dashboard.feature
blocks/myoverview/tests/behat/block_myoverview_favourite.feature [new file with mode: 0644]
blocks/myoverview/tests/privacy_test.php [new file with mode: 0644]
blocks/tests/externallib_test.php
blocks/upgrade.txt
config-dist.php
course/classes/external/course_summary_exporter.php
course/externallib.php
course/lib.php
course/tests/behat/course_category_management_listing.feature
course/tests/behat/course_change_visibility.feature
course/tests/behat/course_creation.feature
course/tests/behat/course_resort.feature
favourites/classes/local/service/user_favourite_service.php
favourites/tests/service_test.php
files/converter/googledrive/classes/privacy/provider.php
grade/tests/behat/grade_category_validation.feature
grade/tests/behat/grade_grade_minmax_change.feature
grade/tests/behat/grade_item_validation.feature
grade/tests/behat/grade_scales_aggregation.feature
group/group_form.php
group/lib.php
group/tests/lib_test.php
lang/en/admin.php
lang/en/analytics.php
lang/en/cache.php
lang/en/group.php
lang/en/message.php
lib/adminlib.php
lib/amd/build/dragdrop.min.js [new file with mode: 0644]
lib/amd/build/pending.min.js [new file with mode: 0644]
lib/amd/build/templates.min.js
lib/amd/src/autoscroll.js
lib/amd/src/dragdrop.js [new file with mode: 0644]
lib/amd/src/pending.js [new file with mode: 0644]
lib/amd/src/templates.js
lib/behat/lib.php
lib/classes/message/message.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/plugininfo/mlbackend.php
lib/classes/update/code_manager.php
lib/db/caches.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/javascript-static.js
lib/messagelib.php
lib/phpunit/bootstrap.php
message/classes/api.php
message/classes/helper.php
message/classes/tests/helper.php [new file with mode: 0644]
message/classes/time_last_message_between_users.php
message/externallib.php
message/index.php
message/tests/api_test.php
message/tests/externallib_test.php
message/upgrade.txt
mnet/service/enrol/classes/privacy/provider.php
mnet/service/enrol/tests/privacy_test.php
mod/assign/feedback/editpdf/tests/behat/annotate_pdf.feature
mod/assign/feedback/editpdf/tests/behat/view_previous_annotations.feature
mod/assign/tests/behat/edit_student_submission.feature
mod/assign/tests/behat/grant_extension.feature
mod/assign/tests/behat/prevent_submission_changes.feature
mod/assign/tests/behat/reopen_locked_submission.feature
mod/lti/service/gradebookservices/classes/privacy/provider.php
mod/lti/service/memberships/classes/privacy/provider.php
mod/quiz/tests/behat/editing_repaginate.feature
mod/quiz/tests/behat/editing_section_headings.feature
pix/i/moremenu.png [new file with mode: 0644]
pix/i/moremenu.svg [new file with mode: 0644]
pix/i/star.png [new file with mode: 0644]
pix/i/star.svg [new file with mode: 0644]
question/category_class.php
question/format.php
question/format/xml/tests/fixtures/nested_categories_with_questions.xml
question/format/xml/tests/qformat_xml_import_export_test.php
question/type/ddmarker/amd/build/form.min.js [new file with mode: 0644]
question/type/ddmarker/amd/build/question.min.js [new file with mode: 0644]
question/type/ddmarker/amd/build/shapes.min.js [new file with mode: 0644]
question/type/ddmarker/amd/src/form.js [new file with mode: 0644]
question/type/ddmarker/amd/src/question.js [new file with mode: 0644]
question/type/ddmarker/amd/src/shapes.js [new file with mode: 0644]
question/type/ddmarker/edit_ddmarker_form.php
question/type/ddmarker/lang/en/qtype_ddmarker.php
question/type/ddmarker/renderer.php
question/type/ddmarker/shapes.php
question/type/ddmarker/styles.css
question/type/ddmarker/tests/behat/preview.feature
question/type/ddmarker/tests/helper.php
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-dd/moodle-qtype_ddmarker-dd-debug.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-dd/moodle-qtype_ddmarker-dd-min.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-dd/moodle-qtype_ddmarker-dd.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-form/moodle-qtype_ddmarker-form-debug.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-form/moodle-qtype_ddmarker-form-min.js [deleted file]
question/type/ddmarker/yui/build/moodle-qtype_ddmarker-form/moodle-qtype_ddmarker-form.js [deleted file]
question/type/ddmarker/yui/src/ddmarker/build.json [deleted file]
question/type/ddmarker/yui/src/ddmarker/js/ddmarker.js [deleted file]
question/type/ddmarker/yui/src/ddmarker/meta/ddmarker.json [deleted file]
question/type/ddmarker/yui/src/form/build.json [deleted file]
question/type/ddmarker/yui/src/form/js/form.js [deleted file]
question/type/ddmarker/yui/src/form/meta/form.json [deleted file]
question/type/ddwtos/amd/build/ddwtos.min.js [new file with mode: 0644]
question/type/ddwtos/amd/src/ddwtos.js [new file with mode: 0644]
question/type/ddwtos/renderer.php
question/type/ddwtos/styles.css
question/type/ddwtos/tests/behat/preview.feature
question/type/ddwtos/yui/build/moodle-qtype_ddwtos-dd/moodle-qtype_ddwtos-dd-debug.js [deleted file]
question/type/ddwtos/yui/build/moodle-qtype_ddwtos-dd/moodle-qtype_ddwtos-dd-min.js [deleted file]
question/type/ddwtos/yui/build/moodle-qtype_ddwtos-dd/moodle-qtype_ddwtos-dd.js [deleted file]
question/type/ddwtos/yui/src/ddwtos/build.json [deleted file]
question/type/ddwtos/yui/src/ddwtos/js/ddwtos.js [deleted file]
question/type/ddwtos/yui/src/ddwtos/meta/ddwtos.json [deleted file]
question/type/gapselect/rendererbase.php
report/stats/classes/privacy/provider.php
report/stats/tests/privacy_test.php
search/engine/simpledb/classes/privacy/provider.php
search/engine/simpledb/tests/privacy_test.php
search/engine/solr/classes/privacy/provider.php
tag/classes/privacy/provider.php
tag/tests/privacy_test.php
theme/boost/amd/build/aria.min.js
theme/boost/amd/src/aria.js
theme/boost/scss/moodle/blocks.scss
theme/boost/style/moodle.css
theme/boost/templates/core/custom_menu_item.mustache
theme/boost/templates/core_admin/setting_description.mustache [new file with mode: 0644]
theme/boost/templates/navbar.mustache
theme/boost/tests/behat/contextmenu.feature
theme/boost/tests/behat/regionmainsettingsmenu.feature
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/less/moodle/bs4-compat.less
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/block_myoverview/course-action-menu.mustache [new file with mode: 0644]
version.php

index b42a252..d58124c 100644 (file)
@@ -37,8 +37,8 @@ if ($hassiteconfig) {
             $predictors[$fullclassname] = new lang_string('pluginname', $pluginname);
         }
         $settings->add(new \core_analytics\admin_setting_predictor('analytics/predictionsprocessor',
-            new lang_string('predictionsprocessor', 'analytics'), new lang_string('predictionsprocessor_help', 'analytics'),
-            '\mlbackend_php\processor', $predictors)
+            new lang_string('defaultpredictionsprocessor', 'analytics'), new lang_string('predictionsprocessor_help', 'analytics'),
+            \core_analytics\manager::default_mlbackend(), $predictors)
         );
 
         // Log store.
diff --git a/admin/settings/moodleservices.php b/admin/settings/moodleservices.php
new file mode 100644 (file)
index 0000000..2f34cfd
--- /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/>.
+
+/**
+ * This file gives information about Moodle Services
+ *
+ * @package    core
+ * @copyright  2018 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+if ($hassiteconfig) {
+
+    // Create Moodle Services information.
+    $moodleservices->add(new admin_setting_heading('moodleservicesintro', '',
+        new lang_string('moodleservices_help', 'admin')));
+
+    // Moodle Partners information.
+    if (empty($CFG->disableserviceads_partner)) {
+        $moodleservices->add(new admin_setting_heading('moodlepartners',
+            new lang_string('moodlepartners', 'admin'),
+            new lang_string('moodlepartners_help', 'admin')));
+    }
+
+    // Moodle app information.
+    $moodleservices->add(new admin_setting_heading('moodleapp',
+        new lang_string('moodleapp', 'admin'),
+        new lang_string('moodleapp_help', 'admin')));
+
+    // Branded Moodle app information.
+    if (empty($CFG->disableserviceads_branded)) {
+        $moodleservices->add(new admin_setting_heading('moodlebrandedapp',
+            new lang_string('moodlebrandedapp', 'admin'),
+            new lang_string('moodlebrandedapp_help', 'admin')));
+    }
+}
+
+
index c0d57c3..f4c24c0 100644 (file)
@@ -7,7 +7,8 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
 
 // "systempaths" settingpage
 $temp = new admin_settingpage('systempaths', new lang_string('systempaths','admin'));
-
+$temp->add(new admin_setting_configexecutable('pathtophp', new lang_string('pathtophp', 'admin'),
+    new lang_string('configpathtophp', 'admin'), ''));
 $temp->add(new admin_setting_configexecutable('pathtodu', new lang_string('pathtodu', 'admin'), new lang_string('configpathtodu', 'admin'), ''));
 $temp->add(new admin_setting_configexecutable('aspellpath', new lang_string('aspellpath', 'admin'), new lang_string('edhelpaspellpath'), ''));
 $temp->add(new admin_setting_configexecutable('pathtodot', new lang_string('pathtodot', 'admin'), new lang_string('pathtodot_help', 'admin'), ''));
index 17621cb..102b758 100644 (file)
@@ -15,6 +15,11 @@ $ADMIN->add('root', new admin_externalpage('registrationmoodleorg', new lang_str
  // hidden upgrade script
 $ADMIN->add('root', new admin_externalpage('upgradesettings', new lang_string('upgradesettings', 'admin'), "$CFG->wwwroot/$CFG->admin/upgradesettings.php", 'moodle/site:config', true));
 
+// Adding Moodle Services information page.
+$moodleservices = new admin_settingpage('moodleservices', new lang_string('moodleservices',
+    'admin'));
+$ADMIN->add('root', $moodleservices);
+
 if ($hassiteconfig) {
     $optionalsubsystems = new admin_settingpage('optionalsubsystems', new lang_string('advancedfeatures', 'admin'));
     $ADMIN->add('root', $optionalsubsystems);
diff --git a/admin/templates/setting_description.mustache b/admin/templates/setting_description.mustache
new file mode 100644 (file)
index 0000000..c6d0e57
--- /dev/null
@@ -0,0 +1,45 @@
+{{!
+    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 core_admin/setting_description
+
+    Admin setting description template.
+
+    Context variables required for this template:
+    * labelfor - id of the form element
+    * title - Setting title
+    * name - Setting name
+
+    Example context (json):
+    {
+        "title": "Setting title",
+        "name": "Name",
+        "description": "Description goes here"
+    }
+}}
+{{!
+    Setting description.
+}}
+<div class="form-item form-horizontal clearfix">
+    <div class="form-label">
+        <label>
+            {{{title}}}
+        </label>
+        <span class="form-shortname ">{{{name}}}</span>
+    </div>
+    <div class="controls felement fstatic">{{{description}}}</div>
+</div>
\ No newline at end of file
index 66270b2..91fb876 100644 (file)
@@ -72,6 +72,22 @@ class edit_model extends \moodleform {
         $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;
+            }
+            $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('hidden', 'id', $this->_customdata['id']);
         $mform->setType('id', PARAM_INT);
 
index 58f1129..b70fc24 100644 (file)
@@ -110,7 +110,8 @@ switch ($action) {
             'id' => $model->get_id(),
             'model' => $model,
             'indicators' => $model->get_potential_indicators(),
-            'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods()
+            'timesplittings' => \core_analytics\manager::get_enabled_time_splitting_methods(),
+            'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
         );
         $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
 
@@ -126,7 +127,8 @@ switch ($action) {
                 $indicators[] = \core_analytics\manager::get_indicator($indicatorclass);
             }
             $timesplitting = \tool_analytics\output\helper::option_to_class($data->timesplitting);
-            $model->update($data->enabled, $indicators, $timesplitting);
+            $predictionsprocessor = \tool_analytics\output\helper::option_to_class($data->predictionsprocessor);
+            $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
             redirect(new \moodle_url('/admin/tool/analytics/index.php'));
         }
 
@@ -137,6 +139,7 @@ switch ($action) {
         $callable = array('\tool_analytics\output\helper', 'class_to_option');
         $modelobj->indicators = array_map($callable, json_decode($modelobj->indicators));
         $modelobj->timesplitting = \tool_analytics\output\helper::class_to_option($modelobj->timesplitting);
+        $modelobj->predictionsprocessor = \tool_analytics\output\helper::class_to_option($modelobj->predictionsprocessor);
         $mform->set_data($modelobj);
         $mform->display();
         break;
index aef98de..e73a34c 100644 (file)
@@ -914,7 +914,7 @@ class api {
      * @param int $forcedvalue Use this categoryid value as if this was this context instance category.
      * @return category|false
      */
-    public static function get_effective_context_category(\context $context, $forcedvalue=false) {
+    public static function get_effective_context_category(\context $context, $forcedvalue = false) {
         if (!data_registry::defaults_set()) {
             return false;
         }
@@ -941,15 +941,14 @@ class api {
      * Returns the effective category given a context level.
      *
      * @param int $contextlevel
-     * @param int $forcedvalue Use this categoryid value as if this was this context level category.
      * @return category|false
      */
-    public static function get_effective_contextlevel_category($contextlevel, $forcedvalue=false) {
+    public static function get_effective_contextlevel_category($contextlevel) {
         if (!data_registry::defaults_set()) {
             return false;
         }
 
-        return data_registry::get_effective_contextlevel_value($contextlevel, 'category', $forcedvalue);
+        return data_registry::get_effective_contextlevel_value($contextlevel, 'category');
     }
 
     /**
index d10ab83..50681ba 100644 (file)
@@ -39,18 +39,6 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class data_registry {
-
-    /**
-     * @var array Inheritance between context levels.
-     */
-    private static $contextlevelinheritance = [
-        CONTEXT_USER => [CONTEXT_SYSTEM],
-        CONTEXT_COURSECAT => [CONTEXT_SYSTEM],
-        CONTEXT_COURSE => [CONTEXT_COURSECAT, CONTEXT_SYSTEM],
-        CONTEXT_MODULE => [CONTEXT_COURSE, CONTEXT_COURSECAT, CONTEXT_SYSTEM],
-        CONTEXT_BLOCK => [CONTEXT_COURSE, CONTEXT_COURSECAT, CONTEXT_SYSTEM],
-    ];
-
     /**
      * Returns purpose and category var names from a context class name
      *
@@ -83,7 +71,6 @@ class data_registry {
      * @return int[]|false[]
      */
     public static function get_defaults($contextlevel, $pluginname = '') {
-
         $classname = \context_helper::get_class_for_level($contextlevel);
         list($purposevar, $categoryvar) = self::var_names_from_context($classname, $pluginname);
 
@@ -104,10 +91,10 @@ class data_registry {
         }
 
         if (empty($purposeid)) {
-            $purposeid = false;
+            $purposeid = context_instance::NOTSET;
         }
         if (empty($categoryid)) {
-            $categoryid = false;
+            $categoryid = context_instance::NOTSET;
         }
 
         return [$purposeid, $categoryid];
@@ -190,69 +177,92 @@ class data_registry {
      * @return persistent|false It return a 'purpose' instance or a 'category' instance, depending on $element
      */
     public static function get_effective_context_value(\context $context, $element, $forcedvalue = false) {
+        global $DB;
 
         if ($element !== 'purpose' && $element !== 'category') {
             throw new coding_exception('Only \'purpose\' and \'category\' are supported.');
         }
         $fieldname = $element . 'id';
 
-        if (empty($forcedvalue)) {
-            $instance = context_instance::get_record_by_contextid($context->id, false);
-
-            if (!$instance) {
-                // If the instance does not have a value defaults to not set, so we grab the context level default as its value.
-                $instancevalue = context_instance::NOTSET;
-            } else {
-                $instancevalue = $instance->get($fieldname);
-            }
+        if (!empty($forcedvalue) && ($forcedvalue === context_instance::INHERIT)) {
+            // Do not include the current context when calculating the value.
+            // This has the effect that an inheritted value is calculated.
+            $parentcontextids = $context->get_parent_context_ids(false);
+        } else if (!empty($forcedvalue) && ($forcedvalue !== context_instance::NOTSET)) {
+            return self::get_element_instance($element, $forcedvalue);
         } else {
-            $instancevalue = $forcedvalue;
+            // Fetch all parent contexts, including self.
+            $parentcontextids = $context->get_parent_context_ids(true);
         }
+        list($insql, $inparams) = $DB->get_in_or_equal($parentcontextids, SQL_PARAMS_NAMED);
+        $inparams['contextmodule'] = CONTEXT_MODULE;
 
-        // Not set.
-        if ($instancevalue == context_instance::NOTSET) {
-
-            // The effective value varies depending on the context level.
-            if ($context->contextlevel == CONTEXT_USER) {
-                // Use the context level value as we don't allow people to set specific instances values.
+        if ('purpose' === $element) {
+             $elementjoin = 'LEFT JOIN {tool_dataprivacy_purpose} ele ON ctxins.purposeid = ele.id';
+             $elementfields = purpose::get_sql_fields('ele', 'ele');
+        } else {
+             $elementjoin = 'LEFT JOIN {tool_dataprivacy_category} ele ON ctxins.categoryid = ele.id';
+             $elementfields = category::get_sql_fields('ele', 'ele');
+        }
+        $contextfields = \context_helper::get_preload_record_columns_sql('ctx');
+        $fields = implode(', ', ['ctx.id', 'm.name AS modname', $contextfields, $elementfields]);
+
+        $sql = "SELECT $fields
+                  FROM {context} ctx
+             LEFT JOIN {tool_dataprivacy_ctxinstance} ctxins ON ctx.id = ctxins.contextid
+             LEFT JOIN {course_modules} cm ON ctx.contextlevel = :contextmodule AND ctx.instanceid = cm.id
+             LEFT JOIN {modules} m ON m.id = cm.module
+             {$elementjoin}
+                 WHERE ctx.id {$insql}
+              ORDER BY ctx.path DESC";
+        $contextinstances = $DB->get_records_sql($sql, $inparams);
+
+        // Check whether this context is a user context, or a child of a user context.
+        // All children of a User context share the same context and cannot be set individually.
+        foreach ($contextinstances as $record) {
+            \context_helper::preload_from_record($record);
+            $parent = \context::instance_by_id($record->id, false);
+
+            if ($parent->contextlevel == CONTEXT_USER) {
+                // Use the context level value for the user.
                 return self::get_effective_contextlevel_value(CONTEXT_USER, $element);
             }
+        }
 
-            $parents = $context->get_parent_contexts(true);
-            foreach ($parents as $parent) {
-                if ($parent->contextlevel == CONTEXT_USER) {
-                    // Use the context level value as we don't allow people to set specific instances values.
-                    return self::get_effective_contextlevel_value(CONTEXT_USER, $element);
-                }
+        foreach ($contextinstances as $record) {
+            $parent = \context::instance_by_id($record->id, false);
+
+            $checkcontextlevel = false;
+            if (empty($record->eleid)) {
+                $checkcontextlevel = true;
             }
 
-            // Check if we need to pass the plugin name of an activity.
-            $forplugin = '';
-            if ($context->contextlevel == CONTEXT_MODULE) {
-                list($course, $cm) = get_course_and_cm_from_cmid($context->instanceid);
-                $forplugin = $cm->modname;
+            if (!empty($forcedvalue) && context_instance::NOTSET === $forcedvalue) {
+                $checkcontextlevel = true;
             }
-            // Use the default context level value.
-            list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category(
-                $context->contextlevel, false, false, $forplugin
-            );
 
-            return self::get_element_instance($element, $$fieldname);
-        }
+            if ($checkcontextlevel) {
+                // Check for a value at the contextlevel
+                $forplugin = empty($record->modname) ? '' : $record->modname;
+                list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category(
+                        $parent->contextlevel, false, false, $forplugin);
 
-        // Specific value for this context instance.
-        if ($instancevalue != context_instance::INHERIT) {
-            return self::get_element_instance($element, $instancevalue);
-        }
+                $instancevalue = $$fieldname;
 
-        // This context is using inherited so let's return the parent effective value.
-        $parentcontext = $context->get_parent_context();
-        if (!$parentcontext) {
-            return false;
+                if (context_instance::NOTSET !== $instancevalue && context_instance::INHERIT !== $instancevalue) {
+                    // There is an actual value. Return it.
+                    return self::get_element_instance($element, $instancevalue);
+                }
+            } else {
+                $elementclass = "\\tool_dataprivacy\\{$element}";
+                $instance = new $elementclass(null, $elementclass::extract_record($record, 'ele'));
+                $instance->validate();
+
+                return $instance;
+            }
         }
 
-        // The forced value should not be transmitted to parent contexts.
-        return self::get_effective_context_value($parentcontext, $element);
+        throw new coding_exception('Something went wrong, system defaults should be set and we should already have a value.');
     }
 
     /**
@@ -264,11 +274,9 @@ class data_registry {
      *
      * @param int $contextlevel
      * @param string $element 'category' or 'purpose'
-     * @param int $forcedvalue Use this value as if this was this context level purpose.
      * @return \tool_dataprivacy\purpose|false
      */
-    public static function get_effective_contextlevel_value($contextlevel, $element, $forcedvalue = false) {
-
+    public static function get_effective_contextlevel_value($contextlevel, $element) {
         if ($element !== 'purpose' && $element !== 'category') {
             throw new coding_exception('Only \'purpose\' and \'category\' are supported.');
         }
@@ -279,39 +287,15 @@ class data_registry {
                 'have a purpose or a category.');
         }
 
-        if ($forcedvalue === false) {
-            $instance = contextlevel::get_record_by_contextlevel($contextlevel, false);
-            if (!$instance) {
-                // If the context level does not have a value defaults to not set, so we grab the context level default as
-                // its value.
-                $instancevalue = context_instance::NOTSET;
-            } else {
-                $instancevalue = $instance->get($fieldname);
-            }
-        } else {
-            $instancevalue = $forcedvalue;
-        }
+        list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category($contextlevel);
 
-        // Not set -> Use the default context level value.
-        if ($instancevalue == context_instance::NOTSET) {
-            list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category($contextlevel);
+        // Note: The $$fieldname points to either $purposeid, or $categoryid.
+        if (context_instance::NOTSET !== $$fieldname && context_instance::INHERIT !== $$fieldname) {
+            // There is a specific value set.
             return self::get_element_instance($element, $$fieldname);
         }
 
-        // Specific value for this context instance.
-        if ($instancevalue != context_instance::INHERIT) {
-            return self::get_element_instance($element, $instancevalue);
-        }
-
-        if ($contextlevel == CONTEXT_SYSTEM) {
-            throw new coding_exception('Something went wrong, system defaults should be set and we should already have a value.');
-        }
-
-        // If we reach this point is that we are inheriting so get the parent context level and repeat.
-        $parentcontextlevel = reset(self::$contextlevelinheritance[$contextlevel]);
-
-        // Forced value are intentionally not passed as the force value should only affect the immediate context level.
-        return self::get_effective_contextlevel_value($parentcontextlevel, $element);
+        throw new coding_exception('Something went wrong, system defaults should be set and we should already have a value.');
     }
 
     /**
@@ -320,13 +304,13 @@ class data_registry {
      * @param int $contextlevel
      * @param int|bool $forcedpurposevalue Use this value as if this was this context level purpose.
      * @param int|bool $forcedcategoryvalue Use this value as if this was this context level category.
-     * @param string $activity The plugin name of the activity.
+     * @param string $component The name of the component to check.
      * @return int[]
      */
     public static function get_effective_default_contextlevel_purpose_and_category($contextlevel, $forcedpurposevalue = false,
-                                                                                   $forcedcategoryvalue = false, $activity = '') {
-
-        list($purposeid, $categoryid) = self::get_defaults($contextlevel, $activity);
+                                                                                   $forcedcategoryvalue = false, $component = '') {
+        // Get the defaults for this context level.
+        list($purposeid, $categoryid) = self::get_defaults($contextlevel, $component);
 
         // Honour forced values.
         if ($forcedpurposevalue) {
@@ -336,37 +320,19 @@ class data_registry {
             $categoryid = $forcedcategoryvalue;
         }
 
-        // Not set == INHERIT for defaults.
-        if ($purposeid == context_instance::INHERIT || $purposeid == context_instance::NOTSET) {
-            $purposeid = false;
-        }
-        if ($categoryid == context_instance::INHERIT || $categoryid == context_instance::NOTSET) {
-            $categoryid = false;
-        }
-
-        if ($contextlevel != CONTEXT_SYSTEM && ($purposeid === false || $categoryid === false)) {
-            foreach (self::$contextlevelinheritance[$contextlevel] as $parent) {
+        if ($contextlevel == CONTEXT_USER) {
+            // Only user context levels inherit from a parent context level.
+            list($parentpurposeid, $parentcategoryid) = self::get_defaults(CONTEXT_SYSTEM);
 
-                list($parentpurposeid, $parentcategoryid) = self::get_defaults($parent);
-                // Not set == INHERIT for defaults.
-                if ($parentpurposeid == context_instance::INHERIT || $parentpurposeid == context_instance::NOTSET) {
-                    $parentpurposeid = false;
-                }
-                if ($parentcategoryid == context_instance::INHERIT || $parentcategoryid == context_instance::NOTSET) {
-                    $parentcategoryid = false;
-                }
-
-                if ($purposeid === false && $parentpurposeid) {
-                    $purposeid = $parentpurposeid;
-                }
+            if (context_instance::INHERIT == $purposeid || context_instance::NOTSET == $purposeid) {
+                $purposeid = $parentpurposeid;
+            }
 
-                if ($categoryid === false && $parentcategoryid) {
-                    $categoryid = $parentcategoryid;
-                }
+            if (context_instance::INHERIT == $categoryid || context_instance::NOTSET == $categoryid) {
+                $categoryid = $parentcategoryid;
             }
         }
 
-        // They may still be false, but we return anyway.
         return [$purposeid, $categoryid];
     }
 
@@ -379,7 +345,6 @@ class data_registry {
      * @return \core\persistent
      */
     private static function get_element_instance($element, $id) {
-
         if ($element !== 'purpose' && $element !== 'category') {
             throw new coding_exception('No other elements than purpose and category are allowed');
         }
index bd1204f..c5cd3c9 100644 (file)
@@ -145,12 +145,12 @@ class context_instance extends \core\form\persistent {
             $persistent->set('contextid', $context->id);
         }
 
-        $purposeoptions = \tool_dataprivacy\output\data_registry_page::purpose_options(
-            api::get_purposes()
-        );
-        $categoryoptions = \tool_dataprivacy\output\data_registry_page::category_options(
-            api::get_categories()
-        );
+        $purposes = [];
+        foreach (api::get_purposes() as $purpose) {
+            $purposes[$purpose->get('id')] = $purpose;
+        }
+        $purposeoptions = \tool_dataprivacy\output\data_registry_page::purpose_options($purposes);
+        $categoryoptions = \tool_dataprivacy\output\data_registry_page::category_options(api::get_categories());
 
         $customdata = [
             'context' => $context,
@@ -168,9 +168,14 @@ class context_instance extends \core\form\persistent {
                 $context);
 
             $customdata['purposeretentionperiods'] = [];
-            foreach ($purposeoptions as $optionvalue => $unused) {
-                // Get the effective purpose if $optionvalue would be the selected value.
-                $purpose = api::get_effective_context_purpose($context, $optionvalue);
+            foreach (array_keys($purposeoptions) as $optionvalue) {
+
+                if (isset($purposes[$optionvalue])) {
+                    $purpose = $purposes[$optionvalue];
+                } else {
+                    // Get the effective purpose if $optionvalue would be the selected value.
+                    $purpose = api::get_effective_context_purpose($context, $optionvalue);
+                }
 
                 $retentionperiod = self::get_retention_display_text(
                     $purpose,
index c0ea7ac..12db2ea 100644 (file)
@@ -27,8 +27,9 @@ require_once('lib.php');
 require_once('createdatarequest_form.php');
 
 $manage = optional_param('manage', 0, PARAM_INT);
+$requesttype = optional_param('type', \tool_dataprivacy\api::DATAREQUEST_TYPE_EXPORT, PARAM_INT);
 
-$url = new moodle_url('/admin/tool/dataprivacy/createdatarequest.php', ['manage' => $manage]);
+$url = new moodle_url('/admin/tool/dataprivacy/createdatarequest.php', ['manage' => $manage, 'type' => $requesttype]);
 
 $PAGE->set_url($url);
 
@@ -58,6 +59,7 @@ if (!$manage && !\tool_dataprivacy\api::can_contact_dpo()) {
 }
 
 $mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage)]);
+$mform->set_data(['type' => $requesttype]);
 
 // Data request cancelled.
 if ($mform->is_cancelled()) {
index 448435f..64c094a 100644 (file)
@@ -93,6 +93,7 @@ $string['deletedefaults'] = 'Delete defaults: {$a}';
 $string['deletedefaultsconfirmation'] = 'Are you sure you want to delete the default category and purpose for {$a} modules?';
 $string['deleteexpiredcontextstask'] = 'Delete expired contexts';
 $string['deleteexpireddatarequeststask'] = 'Delete files from completed data requests that have expired';
+$string['deletemyaccount'] = 'Delete my account';
 $string['deletepurpose'] = 'Delete purpose';
 $string['deletepurposetext'] = 'Are you sure you want to delete the purpose \'{$a}\'?';
 $string['defaultssaved'] = 'Defaults saved';
index a9712d1..276850e 100644 (file)
@@ -63,6 +63,28 @@ function tool_dataprivacy_myprofile_navigation(tree $tree, $user, $iscurrentuser
         $node = new core_user\output\myprofile\node('privacyandpolicies', 'datarequests',
             get_string('datarequests', 'tool_dataprivacy'), null, $url);
         $category->add_node($node);
+
+        // Check if the user has an ongoing data export request.
+        $hasexportrequest = \tool_dataprivacy\api::has_ongoing_request($user->id, \tool_dataprivacy\api::DATAREQUEST_TYPE_EXPORT);
+        // Show data export link only if the user doesn't have an ongoing data export request.
+        if (!$hasexportrequest) {
+            $exportparams = ['type' => \tool_dataprivacy\api::DATAREQUEST_TYPE_EXPORT];
+            $exporturl = new moodle_url('/admin/tool/dataprivacy/createdatarequest.php', $exportparams);
+            $exportnode = new core_user\output\myprofile\node('privacyandpolicies', 'requestdataexport',
+                get_string('requesttypeexport', 'tool_dataprivacy'), null, $exporturl);
+            $category->add_node($exportnode);
+        }
+
+        // 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) {
+            $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',
+                get_string('deletemyaccount', 'tool_dataprivacy'), null, $deleteurl);
+            $category->add_node($deletenode);
+        }
     }
 
     $summaryurl = new moodle_url('/admin/tool/dataprivacy/summary.php');
index a290b27..f0617d9 100644 (file)
@@ -1150,11 +1150,6 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertEquals($purposes[0]->get('id'), $purposeid);
         $this->assertEquals(false, $categoryid);
 
-        // Course inherits from system if not defined.
-        list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_COURSE);
-        $this->assertEquals($purposes[0]->get('id'), $purposeid);
-        $this->assertEquals(false, $categoryid);
-
         // Course defined values should have preference.
         list($purposevar, $categoryvar) = data_registry::var_names_from_context(
             \context_helper::get_class_for_level(CONTEXT_COURSE)
@@ -1168,159 +1163,293 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
 
         // Context level defaults are also allowed to be set to 'inherit'.
         set_config($purposevar, context_instance::INHERIT, 'tool_dataprivacy');
+    }
 
-        list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_COURSE);
-        $this->assertEquals($purposes[0]->get('id'), $purposeid);
-        $this->assertEquals($categories[0]->get('id'), $categoryid);
+    /**
+     * Ensure that when nothing is configured, all values return false.
+     */
+    public function test_get_effective_contextlevel_unset() {
+        // Before setup, get_effective_contextlevel_purpose will return false.
+        $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
+        $this->assertFalse(api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM));
 
-        list($purposeid, $categoryid) = data_registry::get_effective_default_contextlevel_purpose_and_category(CONTEXT_MODULE);
-        $this->assertEquals($purposes[0]->get('id'), $purposeid);
-        $this->assertEquals($categories[0]->get('id'), $categoryid);
+        $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_USER));
+        $this->assertFalse(api::get_effective_contextlevel_purpose(CONTEXT_USER));
     }
 
-    public function test_get_effective_contextlevel_category() {
+    /**
+     * Ensure that when nothing is configured, all values return false.
+     */
+    public function test_get_effective_context_unset() {
         // Before setup, get_effective_contextlevel_purpose will return false.
-        $this->assertFalse(api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
+        $this->assertFalse(api::get_effective_context_category(\context_system::instance()));
+        $this->assertFalse(api::get_effective_context_purpose(\context_system::instance()));
+    }
+
+    /**
+     * Ensure that fetching the effective value for context levels is only available to system, and user context levels.
+     *
+     * @dataProvider invalid_effective_contextlevel_provider
+     * @param   int $contextlevel
+     */
+    public function test_set_contextlevel_invalid_contextlevels($contextlevel) {
+
+        $this->expectException(coding_exception::class);
+        api::set_contextlevel((object) [
+                'contextlevel' => $contextlevel,
+            ]);
+
     }
 
     /**
      * Test effective contextlevel return.
      */
     public function test_effective_contextlevel() {
-        $this->setAdminUser();
-
         $this->resetAfterTest();
 
-        // Before setup, get_effective_contextlevel_purpose will return false.
-        $this->assertFalse(api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM));
+        // Set the initial purpose and category.
+        $purpose1 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']);
+        $category1 = api::create_category((object)['name' => 'a']);
+        api::set_contextlevel((object)[
+            'contextlevel' => CONTEXT_SYSTEM,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
 
-        list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
+        $this->assertEquals($purpose1, api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM));
+        $this->assertEquals($category1, api::get_effective_contextlevel_category(CONTEXT_SYSTEM));
 
-        // Set the system context level to purpose 1.
-        $record = (object)[
-            'contextlevel' => CONTEXT_SYSTEM,
-            'purposeid' => $purposes[1]->get('id'),
-            'categoryid' => $categories[1]->get('id'),
-        ];
-        api::set_contextlevel($record);
+        // The user context inherits from the system context when not set.
+        $this->assertEquals($purpose1, api::get_effective_contextlevel_purpose(CONTEXT_USER));
+        $this->assertEquals($category1, api::get_effective_contextlevel_category(CONTEXT_USER));
 
-        $purpose = api::get_effective_contextlevel_purpose(CONTEXT_SYSTEM);
-        $this->assertEquals($purposes[1]->get('id'), $purpose->get('id'));
+        // Forcing the behaviour to inherit will have the same result.
+        api::set_contextlevel((object) [
+                'contextlevel' => CONTEXT_USER,
+                'purposeid' => context_instance::INHERIT,
+                'categoryid' => context_instance::INHERIT,
+            ]);
+        $this->assertEquals($purpose1, api::get_effective_contextlevel_purpose(CONTEXT_USER));
+        $this->assertEquals($category1, api::get_effective_contextlevel_category(CONTEXT_USER));
 
-        // Value 'not set' will get the default value for the context level. For context level defaults
-        // both 'not set' and 'inherit' result in inherit, so the parent context (system) default
-        // will be retrieved.
-        $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
-        $this->assertEquals($purposes[1]->get('id'), $purpose->get('id'));
+        // Setting specific values will override the inheritance behaviour.
+        $purpose2 = api::create_purpose((object)['name' => 'p2', 'retentionperiod' => 'PT2H', 'lawfulbases' => 'gdpr_art_6_1_a']);
+        $category2 = api::create_category((object)['name' => 'b']);
+        // Set the system context level to purpose 1.
+        api::set_contextlevel((object) [
+                'contextlevel' => CONTEXT_USER,
+                'purposeid' => $purpose2->get('id'),
+                'categoryid' => $category2->get('id'),
+            ]);
 
-        // The behaviour forcing an inherit from context system should result in the same effective
-        // purpose.
-        $record->purposeid = context_instance::INHERIT;
-        $record->contextlevel = CONTEXT_USER;
-        api::set_contextlevel($record);
-        $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
-        $this->assertEquals($purposes[1]->get('id'), $purpose->get('id'));
+        $this->assertEquals($purpose2, api::get_effective_contextlevel_purpose(CONTEXT_USER));
+        $this->assertEquals($category2, api::get_effective_contextlevel_category(CONTEXT_USER));
+    }
 
-        $record->purposeid = $purposes[2]->get('id');
-        $record->contextlevel = CONTEXT_USER;
-        api::set_contextlevel($record);
+    /**
+     * Ensure that fetching the effective value for context levels is only available to system, and user context levels.
+     *
+     * @dataProvider invalid_effective_contextlevel_provider
+     * @param   int $contextlevel
+     */
+    public function test_effective_contextlevel_invalid_contextlevels($contextlevel) {
+        $this->resetAfterTest();
 
-        $purpose = api::get_effective_contextlevel_purpose(CONTEXT_USER);
-        $this->assertEquals($purposes[2]->get('id'), $purpose->get('id'));
+        $purpose1 = api::create_purpose((object)['name' => 'p1', 'retentionperiod' => 'PT1H', 'lawfulbases' => 'gdpr_art_6_1_a']);
+        $category1 = api::create_category((object)['name' => 'a']);
+        api::set_contextlevel((object)[
+            'contextlevel' => CONTEXT_SYSTEM,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
 
-        // Only system and user allowed.
         $this->expectException(coding_exception::class);
-        $record->contextlevel = CONTEXT_COURSE;
-        $record->purposeid = $purposes[1]->get('id');
-        api::set_contextlevel($record);
+        api::get_effective_contextlevel_purpose($contextlevel);
     }
 
     /**
-     * Test effective context purposes and categories.
-     *
-     * @return null
+     * Data provider for invalid contextlevel fetchers.
      */
-    public function test_effective_context() {
+    public function invalid_effective_contextlevel_provider() {
+        return [
+            [CONTEXT_COURSECAT],
+            [CONTEXT_COURSE],
+            [CONTEXT_MODULE],
+            [CONTEXT_BLOCK],
+        ];
+    }
+
+    /**
+     * Ensure that context inheritance works up the context tree.
+     */
+    public function test_effective_context_inheritance() {
         $this->resetAfterTest();
 
-        $this->setAdminUser();
+        $systemdata = $this->create_and_set_purpose_for_contextlevel('PT1S', CONTEXT_SYSTEM);
+
+        /*
+         * System
+         * - Cat
+         *   - Subcat
+         *     - Course
+         *       - Forum
+         * - User
+         *   - User block
+         */
+        $cat = $this->getDataGenerator()->create_category();
+        $subcat = $this->getDataGenerator()->create_category(['parent' => $cat->id]);
+        $course = $this->getDataGenerator()->create_course(['category' => $subcat->id]);
+        $forum = $this->getDataGenerator()->create_module('forum', ['course' => $course->id]);
+        list(, $forumcm) = get_course_and_cm_from_instance($forum->id, 'forum');
 
-        list($purposes, $categories, $courses, $modules) = $this->add_purposes_and_categories();
+        $user = $this->getDataGenerator()->create_user();
 
-        // Define system defaults (all context levels below will inherit).
-        list($purposevar, $categoryvar) = data_registry::var_names_from_context(
-            \context_helper::get_class_for_level(CONTEXT_SYSTEM)
-        );
-        set_config($purposevar, $purposes[0]->get('id'), 'tool_dataprivacy');
-        set_config($categoryvar, $categories[0]->get('id'), 'tool_dataprivacy');
+        $contextsystem = \context_system::instance();
+        $contextcat = \context_coursecat::instance($cat->id);
+        $contextsubcat = \context_coursecat::instance($subcat->id);
+        $contextcourse = \context_course::instance($course->id);
+        $contextforum = \context_module::instance($forumcm->id);
+        $contextuser = \context_user::instance($user->id);
+
+        // Initially everything is set to Inherit.
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextforum));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextuser));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcat));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextforum));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextuser));
+
+        // When actively set, user will use the specified value.
+        $userdata = $this->create_and_set_purpose_for_contextlevel('PT1S', CONTEXT_USER);
+
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextforum));
+        $this->assertEquals($userdata->purpose, api::get_effective_context_purpose($contextuser));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcat));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextforum));
+        $this->assertEquals($userdata->category, api::get_effective_context_category($contextuser));
+
+        // Set a context for the top category.
+        $catpurpose = new purpose(0, (object) [
+                'name' => 'Purpose',
+                'retentionperiod' => 'P1D',
+                'lawfulbases' => 'gdpr_art_6_1_a',
+            ]);
+        $catpurpose->save();
+        $catcategory = new category(0, (object) ['name' => 'Category']);
+        $catcategory->save();
+        api::set_context_instance((object) [
+                'contextid' => $contextcat->id,
+                'purposeid' => $catpurpose->get('id'),
+                'categoryid' => $catcategory->get('id'),
+            ]);
 
-        // Define course defaults.
-        list($purposevar, $categoryvar) = data_registry::var_names_from_context(
-            \context_helper::get_class_for_level(CONTEXT_COURSE)
-        );
-        set_config($purposevar, $purposes[1]->get('id'), 'tool_dataprivacy');
-        set_config($categoryvar, $categories[1]->get('id'), 'tool_dataprivacy');
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextforum));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextforum));
+
+        // Set a context for the sub category.
+        $subcatpurpose = new purpose(0, (object) [
+                'name' => 'Purpose',
+                'retentionperiod' => 'P1D',
+                'lawfulbases' => 'gdpr_art_6_1_a',
+            ]);
+        $subcatpurpose->save();
+        $subcatcategory = new category(0, (object) ['name' => 'Category']);
+        $subcatcategory->save();
+        api::set_context_instance((object) [
+                'contextid' => $contextsubcat->id,
+                'purposeid' => $subcatpurpose->get('id'),
+                'categoryid' => $subcatcategory->get('id'),
+            ]);
 
-        $course0context = \context_course::instance($courses[0]->id);
-        $course1context = \context_course::instance($courses[1]->id);
-        $mod0context = \context_module::instance($modules[0]->cmid);
-        $mod1context = \context_module::instance($modules[1]->cmid);
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextforum));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextforum));
+
+        // Set a context for the course.
+        $coursepurpose = new purpose(0, (object) [
+                'name' => 'Purpose',
+                'retentionperiod' => 'P1D',
+                'lawfulbases' => 'gdpr_art_6_1_a',
+            ]);
+        $coursepurpose->save();
+        $coursecategory = new category(0, (object) ['name' => 'Category']);
+        $coursecategory->save();
+        api::set_context_instance((object) [
+                'contextid' => $contextcourse->id,
+                'purposeid' => $coursepurpose->get('id'),
+                'categoryid' => $coursecategory->get('id'),
+            ]);
 
-        // Set course instance values.
-        $record = (object)[
-            'contextid' => $course0context->id,
-            'purposeid' => $purposes[1]->get('id'),
-            'categoryid' => $categories[2]->get('id'),
-        ];
-        api::set_context_instance($record);
-        $category = api::get_effective_context_category($course0context);
-        $this->assertEquals($record->categoryid, $category->get('id'));
-
-        // Module instances get the context level default if nothing specified.
-        $category = api::get_effective_context_category($mod0context);
-        $this->assertEquals($categories[1]->get('id'), $category->get('id'));
-
-        // Module instances get the parent context category if they inherit.
-        $record->contextid = $mod0context->id;
-        $record->categoryid = context_instance::INHERIT;
-        api::set_context_instance($record);
-        $category = api::get_effective_context_category($mod0context);
-        $this->assertEquals($categories[2]->get('id'), $category->get('id'));
-
-        // The $forcedvalue param allows us to override the actual value (method php-docs for more info).
-        $category = api::get_effective_context_category($mod0context, $categories[1]->get('id'));
-        $this->assertEquals($categories[1]->get('id'), $category->get('id'));
-        $category = api::get_effective_context_category($mod0context, $categories[0]->get('id'));
-        $this->assertEquals($categories[0]->get('id'), $category->get('id'));
-
-        // Module instances get the parent context category if they inherit; in
-        // this case the parent context category is not set so it should use the
-        // context level default (see 'Define course defaults' above).
-        $record->contextid = $mod1context->id;
-        $record->categoryid = context_instance::INHERIT;
-        api::set_context_instance($record);
-        $category = api::get_effective_context_category($mod1context);
-        $this->assertEquals($categories[1]->get('id'), $category->get('id'));
-
-        // User instances use the value set at user context level instead of the user default.
-
-        // User defaults to cat 0 and user context level to 1.
-        list($purposevar, $categoryvar) = data_registry::var_names_from_context(
-            \context_helper::get_class_for_level(CONTEXT_USER)
-        );
-        set_config($purposevar, $purposes[0]->get('id'), 'tool_dataprivacy');
-        set_config($categoryvar, $categories[0]->get('id'), 'tool_dataprivacy');
-        $usercontextlevel = (object)[
-            'contextlevel' => CONTEXT_USER,
-            'purposeid' => $purposes[1]->get('id'),
-            'categoryid' => $categories[1]->get('id'),
-        ];
-        api::set_contextlevel($usercontextlevel);
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($coursepurpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($coursepurpose, api::get_effective_context_purpose($contextforum));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($coursecategory, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($coursecategory, api::get_effective_context_category($contextforum));
+
+        // Set a context for the forum.
+        $forumpurpose = new purpose(0, (object) [
+                'name' => 'Purpose',
+                'retentionperiod' => 'P1D',
+                'lawfulbases' => 'gdpr_art_6_1_a',
+            ]);
+        $forumpurpose->save();
+        $forumcategory = new category(0, (object) ['name' => 'Category']);
+        $forumcategory->save();
+        api::set_context_instance((object) [
+                'contextid' => $contextforum->id,
+                'purposeid' => $forumpurpose->get('id'),
+                'categoryid' => $forumcategory->get('id'),
+            ]);
 
-        $newuser = $this->getDataGenerator()->create_user();
-        $usercontext = \context_user::instance($newuser->id);
-        $category = api::get_effective_context_category($usercontext);
-        $this->assertEquals($categories[1]->get('id'), $category->get('id'));
+        $this->assertEquals($systemdata->purpose, api::get_effective_context_purpose($contextsystem));
+        $this->assertEquals($catpurpose, api::get_effective_context_purpose($contextcat));
+        $this->assertEquals($subcatpurpose, api::get_effective_context_purpose($contextsubcat));
+        $this->assertEquals($coursepurpose, api::get_effective_context_purpose($contextcourse));
+        $this->assertEquals($forumpurpose, api::get_effective_context_purpose($contextforum));
+
+        $this->assertEquals($systemdata->category, api::get_effective_context_category($contextsystem));
+        $this->assertEquals($catcategory, api::get_effective_context_category($contextcat));
+        $this->assertEquals($subcatcategory, api::get_effective_context_category($contextsubcat));
+        $this->assertEquals($coursecategory, api::get_effective_context_category($contextcourse));
+        $this->assertEquals($forumcategory, api::get_effective_context_category($contextforum));
     }
 
     /**
@@ -1388,7 +1517,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
 
         $user = $this->getDataGenerator()->create_user();
         $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - YEARSECS]);
@@ -1404,7 +1533,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
 
         $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
 
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
         api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED);
 
         $requests = contextlist_context::get_records();
@@ -1420,7 +1549,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
 
         $user = $this->getDataGenerator()->create_user();
         $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
@@ -1436,7 +1565,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
 
         $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
 
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
         api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED);
 
         $requests = contextlist_context::get_records();
@@ -1452,7 +1581,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
 
         $user = $this->getDataGenerator()->create_user();
         $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
@@ -1468,7 +1597,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
 
         $request = api::create_data_request($user->id, api::DATAREQUEST_TYPE_DELETE);
 
-        $purposes->course->set('protected', 0)->save();
+        $purposes->course->purpose->set('protected', 0)->save();
         api::add_request_contexts_with_status($collection, $request->get('id'), contextlist_context::STATUS_APPROVED);
 
         $requests = contextlist_context::get_records();
@@ -1482,7 +1611,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $purposes = $this->setup_basics('PT1H', 'PT1H', 'PT1H');
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
 
         $user = $this->getDataGenerator()->create_user();
         $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time() - YEARSECS]);
@@ -1508,7 +1637,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
             ]);
         $rcl->save();
 
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
         $collection = api::get_approved_contextlist_collection_for_request($request);
 
         $this->assertCount(1, $collection);
@@ -1524,7 +1653,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
 
         $user = $this->getDataGenerator()->create_user();
         $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
@@ -1550,7 +1679,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
             ]);
         $rcl->save();
 
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
         $collection = api::get_approved_contextlist_collection_for_request($request);
 
         $this->assertCount(0, $collection);
@@ -1566,7 +1695,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $purposes = $this->setup_basics('PT1H', 'PT1H', 'P1Y');
-        $purposes->course->set('protected', 1)->save();
+        $purposes->course->purpose->set('protected', 1)->save();
 
         $user = $this->getDataGenerator()->create_user();
         $course = $this->getDataGenerator()->create_course(['startdate' => time() - YEARSECS, 'enddate' => time()]);
@@ -1592,7 +1721,7 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
             ]);
         $rcl->save();
 
-        $purposes->course->set('protected', 0)->save();
+        $purposes->course->purpose->set('protected', 0)->save();
         $collection = api::get_approved_contextlist_collection_for_request($request);
 
         $this->assertCount(1, $collection);
@@ -1893,9 +2022,8 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
      *
      * @param   string  $retention
      * @param   int     $contextlevel
-     * @return  purpose
      */
-    protected function create_and_set_purpose_for_contextlevel(string $retention, int $contextlevel) : purpose {
+    protected function create_and_set_purpose_for_contextlevel(string $retention, int $contextlevel) {
         $purpose = new purpose(0, (object) [
             'name' => 'Test purpose ' . rand(1, 1000),
             'retentionperiod' => $retention,
@@ -1920,6 +2048,9 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
             set_config($purposevar, $purpose->get('id'), 'tool_dataprivacy');
         }
 
-        return $purpose;
+        return (object) [
+            'purpose' => $purpose,
+            'category' => $cat,
+        ];
     }
 }
index c6454bd..f16a006 100644 (file)
@@ -38,7 +38,7 @@ Feature: Data delete from the privacy API
     And I run all adhoc tasks
     And I reload the page
     And I should see "Awaiting approval" in the "Victim User 1" "table_row"
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And I follow "Approve request"
     And I press "Approve request"
     And I should see "Approved" in the "Victim User 1" "table_row"
@@ -67,7 +67,7 @@ Feature: Data delete from the privacy API
     And I log out
     And I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And I follow "Approve request"
     And I press "Approve request"
 
@@ -105,7 +105,7 @@ Feature: Data delete from the privacy API
     And I log out
     And I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And I follow "Approve request"
     And I press "Approve request"
 
index 50c58bc..7f87c10 100644 (file)
@@ -34,21 +34,21 @@ Feature: Data export from the privacy API
     And I run all adhoc tasks
     And I reload the page
     And I should see "Awaiting approval" in the "Victim User 1" "table_row"
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And I follow "Approve request"
     And I press "Approve request"
     And I should see "Approved" in the "Victim User 1" "table_row"
     And I run all adhoc tasks
     And I reload the page
     And I should see "Download ready" in the "Victim User 1" "table_row"
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And following "Download" should download between "1" and "100000" bytes
     And the following config values are set as admin:
       | privacyrequestexpiry | 1 | tool_dataprivacy |
     And I wait "1" seconds
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
     And I should see "Expired" in the "Victim User 1" "table_row"
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And I should not see "Download"
 
   @javascript
@@ -67,7 +67,7 @@ Feature: Data export from the privacy API
     And I log out
     And I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And I follow "Approve request"
     And I press "Approve request"
 
@@ -79,7 +79,7 @@ Feature: Data export from the privacy API
     And I run all adhoc tasks
     And I reload the page
     And I should see "Download ready" in the "Export all of my personal data" "table_row"
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And following "Download" should download between "1" and "100000" bytes
 
     And the following config values are set as admin:
@@ -107,7 +107,7 @@ Feature: Data export from the privacy API
     And I log out
     And I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And I follow "Approve request"
     And I press "Approve request"
 
@@ -119,7 +119,7 @@ Feature: Data export from the privacy API
     And I run all adhoc tasks
     And I reload the page
     And I should see "Download ready" in the "Victim User 1" "table_row"
-    And I follow "Actions"
+    And I open the action menu in "Victim User 1" "table_row"
     And following "Download" should download between "1" and "100000" bytes
 
     And the following config values are set as admin:
index 9952bbb..fb230d7 100644 (file)
@@ -7,7 +7,7 @@ Feature: Manage data categories
   Background:
     Given I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data registry" in site administration
-    And I click on "Edit" "link"
+    And I open the action menu in "region-main" "region"
     And I choose "Categories" in the open action menu
     And I press "Add category"
     And I set the field "Name" to "Category 1"
@@ -17,7 +17,7 @@ Feature: Manage data categories
     And I should see "Category 1 description" in the "Category 1" "table_row"
 
   Scenario: Update a data category
-    Given I click on "Actions" "link" in the "Category 1" "table_row"
+    Given I open the action menu in "Category 1" "table_row"
     And I choose "Edit" in the open action menu
     And I set the field "Name" to "Category 1 edited"
     And I set the field "Description" to "Category 1 description edited"
@@ -26,7 +26,7 @@ Feature: Manage data categories
     And I should see "Category 1 description edited" in the "List of data categories" "table"
 
   Scenario: Delete a data category
-    Given I click on "Actions" "link" in the "Category 1" "table_row"
+    Given I open the action menu in "Category 1" "table_row"
     And I choose "Delete" in the open action menu
     And I should see "Delete category"
     And I should see "Are you sure you want to delete the category 'Category 1'?"
index 9eb2a8a..cd1bc2a 100644 (file)
@@ -35,7 +35,7 @@ Feature: Manage data requests
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
     Then I should see "Hi PO!" in the "John Doe" "table_row"
     And I should see "Dear Mr. Privacy Officer" in the "Jane Doe" "table_row"
-    And I click on "Actions" "link" in the "John Doe" "table_row"
+    And I open the action menu in "John Doe" "table_row"
     And I should see "View the request"
     And I should see "Mark as complete"
     And I choose "View the request" in the open action menu
@@ -43,17 +43,17 @@ Feature: Manage data requests
     And I press "Mark as complete"
     And I wait until the page is ready
     And I should see "Complete" in the "John Doe" "table_row"
-    And I click on "Actions" "link" in the "John Doe" "table_row"
+    And I open the action menu in "John Doe" "table_row"
     And I should see "View the request"
     But I should not see "Mark as complete"
     And I press key "27" in ".moodle-actionmenu" "css_element"
-    And I click on "Actions" "link" in the "Jane Doe" "table_row"
+    And I open the action menu in "Jane Doe" "table_row"
     And I choose "Mark as complete" in the open action menu
     And I should see "Do you really want to mark this user enquiry as complete?"
     And I press "Mark as complete"
     And I wait until the page is ready
     And I should see "Complete" in the "Jane Doe" "table_row"
-    And I click on "Actions" "link" in the "Jane Doe" "table_row"
+    And I open the action menu in "Jane Doe" "table_row"
     And I should see "View the request"
     But I should not see "Mark as complete"
 
index a6287d5..0bb5e75 100644 (file)
@@ -32,12 +32,11 @@ Feature: Manage data registry defaults
       | Purpose 2    | P5Y            |
     And I set the site category and purpose to "Site category" and "Site purpose"
 
+  # Setting a default for course categories should apply to everything beneath that category.
   Scenario: Set course category data registry defaults
-    Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
-    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    Given I navigate to "Users > Privacy and policies > Data registry" in site administration
     And I click on "Set defaults" "link"
     And I should see "Inherit"
-    And I should not see "Add a new module default"
     And I press "Edit"
     And I set the field "Category" to "Category 1"
     And I set the field "Purpose" to "Purpose 1"
@@ -47,27 +46,91 @@ Feature: Manage data registry defaults
     And I navigate to "Users > Privacy and policies > Data registry" in site administration
     And I click on "Science and technology" "link"
     And I wait until the page is ready
-    And the field "categoryid" matches value "Category 2"
+    And the field "categoryid" matches value "Not set (use the default value)"
+    And the field "purposeid" matches value "Not set (use the default value)"
+    And I should see "3 years"
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+
+  # When Setting a default for course categories, and overriding a specific category, only that category and its
+  # children will be overridden.
+  # If any child is a course category, it will get the default.
+  Scenario: Set course category data registry defaults with override
+    Given I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    And I press "Save changes"
+    And I should see "Category 1"
+    And I should see "Purpose 1"
+    And I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
+    When I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    Then the field "categoryid" matches value "Category 2"
+    And the field "purposeid" matches value "Purpose 2"
+    And I should see "5 years"
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    # Physics 101 is also a category, so it will get the category default.
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+
+  # When overriding a specific category, only that category and its children will be overridden.
+  Scenario: Set course category data registry defaults with override
+    Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
+    When I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    Then the field "categoryid" matches value "Category 2"
     And the field "purposeid" matches value "Purpose 2"
     And I should see "5 years"
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    # Physics 101 is also a category, so it will get the category default.
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
 
+  # Resetting instances removes custom values.
   Scenario: Set course category data registry defaults with override
     Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
     And I navigate to "Users > Privacy and policies > Data registry" in site administration
     And I click on "Set defaults" "link"
-    And I should see "Inherit"
-    And I should not see "Add a new module default"
     And I press "Edit"
     And I set the field "Category" to "Category 1"
     And I set the field "Purpose" to "Purpose 1"
-    And I click on "Reset instances with custom values" "checkbox"
-    When I press "Save changes"
-    Then I should see "Category 1"
+    When I click on "Reset instances with custom values" "checkbox"
+    And I press "Save changes"
+    And I should see "Category 1"
     And I should see "Purpose 1"
     And I navigate to "Users > Privacy and policies > Data registry" in site administration
     And I click on "Science and technology" "link"
     And I wait until the page is ready
-    And the field "categoryid" matches value "Not set (use the default value)"
+    Then the field "categoryid" matches value "Not set (use the default value)"
     And the field "purposeid" matches value "Not set (use the default value)"
     And I should see "3 years"
 
@@ -94,6 +157,12 @@ Feature: Manage data registry defaults
     And the field "categoryid" matches value "Category 2"
     And the field "purposeid" matches value "Purpose 2"
     And I should see "5 years (after the course end date)"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "5 years"
 
   Scenario: Set course data registry defaults with override
     Given I set the category and purpose for the course "Physics 101" to "Category 2" and "Purpose 2"
@@ -119,6 +188,12 @@ Feature: Manage data registry defaults
     And the field "categoryid" matches value "Not set (use the default value)"
     And the field "purposeid" matches value "Not set (use the default value)"
     And I should see "3 years (after the course end date)"
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And I should see "3 years"
 
   Scenario: Set module level data registry defaults
     Given I set the category and purpose for the "assign1" "assign" in course "Physics 101" to "Category 2" and "Purpose 2"
index c6c5908..ff02925 100644 (file)
@@ -7,7 +7,7 @@ Feature: Manage data storage purposes
   Background:
     Given I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data registry" in site administration
-    And I click on "Edit" "link"
+    And I open the action menu in "region-main" "region"
     And I choose "Purposes" in the open action menu
     And I press "Add purpose"
     And I set the field "Name" to "Purpose 1"
@@ -29,7 +29,7 @@ Feature: Manage data storage purposes
     And "Purpose 1 Purpose 1 description" row "5" column of "List of data purposes" table should contain "No"
 
   Scenario: Update a data storage purpose
-    Given I click on "Actions" "link" in the "Purpose 1" "table_row"
+    Given I open the action menu in "Purpose 1" "table_row"
     And I choose "Edit" in the open action menu
     And I set the field "Name" to "Purpose 1 edited"
     And I set the field "Description" to "Purpose 1 description edited"
@@ -48,7 +48,7 @@ Feature: Manage data storage purposes
     And "Purpose 1 edited Purpose 1 description edited" row "5" column of "List of data purposes" table should not contain "No"
 
   Scenario: Delete a data storage purpose
-    Given I click on "Actions" "link" in the "Purpose 1" "table_row"
+    Given I open the action menu in "Purpose 1" "table_row"
     And I choose "Delete" in the open action menu
     And I should see "Delete purpose"
     And I should see "Are you sure you want to delete the purpose 'Purpose 1'?"
diff --git a/admin/tool/dataprivacy/tests/data_registry_test.php b/admin/tool/dataprivacy/tests/data_registry_test.php
new file mode 100644 (file)
index 0000000..b565f72
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for the data_registry class.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \tool_dataprivacy\data_registry;
+
+/**
+ * Unit tests for the data_registry class.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_dataprivacy_dataregistry_testcase extends advanced_testcase {
+
+    /**
+     * Ensure that the get_effective_context_value only errors if provided an inappropriate element.
+     *
+     * This test is not great because we only test a limited set of values. This is a fault of the underlying API.
+     */
+    public function test_get_effective_context_value_invalid_element() {
+        $this->expectException(coding_exception::class);
+        data_registry::get_effective_context_value(\context_system::instance(), 'invalid');
+    }
+
+    /**
+     * Ensure that the get_effective_contextlevel_value only errors if provided an inappropriate element.
+     *
+     * This test is not great because we only test a limited set of values. This is a fault of the underlying API.
+     */
+    public function test_get_effective_contextlevel_value_invalid_element() {
+        $this->expectException(coding_exception::class);
+        data_registry::get_effective_contextlevel_value(\context_system::instance(), 'invalid');
+    }
+}
index 125a72a..5a313ed 100644 (file)
@@ -73,6 +73,14 @@ if ($hassiteconfig) {
         $temp->add(new admin_setting_configtext('mobilecssurl', new lang_string('mobilecssurl', 'tool_mobile'),
                     new lang_string('configmobilecssurl', 'tool_mobile'), '', PARAM_URL));
 
+        // Reference to Branded Mobile App.
+        if (empty($CFG->disableserviceads_branded)) {
+            $temp->add(new admin_setting_description('moodlebrandedappreference',
+                new lang_string('moodlebrandedapp', 'admin'),
+                new lang_string('moodlebrandedappreference', 'admin')
+            ));
+        }
+
         $temp->add(new admin_setting_heading('tool_mobile/smartappbanners',
                     new lang_string('smartappbanners', 'tool_mobile'), ''));
 
index 4780a58..da4749f 100644 (file)
@@ -7,6 +7,8 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
   Background:
     Given the following config values are set as admin:
       | sitepolicyhandler | tool_policy |
+    # This is required for now to prevent the overflow region affecting the action menus.
+    And I change window size to "large"
     And the following policies exist:
       | Name                | Revision | Content    | Summary     | Status   |
       | This site policy    |          | full text2 | short text2 | active   |
@@ -98,7 +100,6 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
   Scenario: View acceptances made by users on their own, multiple policies
     Given I log in as "admin"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "This privacy policy" "table_row"
     And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
     And I press "Continue"
     And I log out
@@ -136,7 +137,6 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
   Scenario: Agree on behalf of another user as a manager, multiple policies, javascript off
     Given I log in as "admin"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "This privacy policy" "table_row"
     And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
     And I press "Continue"
     And I set the following system permissions of "Manager" role:
@@ -169,7 +169,7 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
   Scenario: Agree on behalf of another user as a manager, multiple policies, javascript on
     Given I log in as "admin"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "This privacy policy" "table_row"
+    And I click on "Actions" "link_or_button" in the "This privacy policy" "table_row"
     And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
     And I press "Activate"
     And I set the following system permissions of "Manager" role:
@@ -265,7 +265,7 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
   Scenario: Bulk agree on behalf of another users as a manager, multiple policies, javascript on
     Given I log in as "admin"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "This privacy policy" "table_row"
+    And I click on "Actions" "link_or_button" in the "This privacy policy" "table_row"
     And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
     And I press "Activate"
     And I set the following system permissions of "Manager" role:
index 7cd2a0e..60cf7a7 100644 (file)
@@ -64,7 +64,6 @@ Feature: Manage policies
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And "Draft" "field" should not exist
     And "Active" "field" should not exist
@@ -87,7 +86,6 @@ Feature: Manage policies
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And I set the field "Version" to "v2"
     And I press "Save as draft"
@@ -106,7 +104,6 @@ Feature: Manage policies
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And I set the field "Name" to "Policy2"
     And I set the field "Version" to "v2"
@@ -116,7 +113,6 @@ Feature: Manage policies
       | Policy2 Site policy, All users, Compulsory  | Active        | v2         | 0 of 4 (0%) |
     And I should not see "Policy1"
     And I should not see "v1"
-    And I open the action menu in "Policy2" "table_row"
     And I click on "View previous versions" "link" in the "Policy2" "table_row"
     And I should see "Policy2 previous versions"
     And I should not see "v2"
@@ -131,7 +127,6 @@ Feature: Manage policies
       | Policy1    | v1       | full text2 | short text2 | draft    |
     And I log in as "manager"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And I set the field "Version" to "v2"
     And "Draft" "field" should exist
@@ -144,7 +139,6 @@ Feature: Manage policies
       | Name                                        | Policy status | Version    | Agreements   |
       | Policy1 Site policy, All users, Compulsory  | Draft         | v2         | N/A          |
     And I should not see "v1"
-    And I open the action menu in "Policy1" "table_row"
     And "View previous versions" "link" should not exist
     And I log out
 
@@ -154,7 +148,6 @@ Feature: Manage policies
       | Policy1    | v1       | full text2 | short text2 | draft    |
     And I log in as "manager"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Edit" "link" in the "Policy1" "table_row"
     And I set the field "Version" to "v2"
     And I set the field "Active" to "1"
@@ -163,7 +156,6 @@ Feature: Manage policies
       | Name                                        | Policy status | Version    | Agreements   |
       | Policy1 Site policy, All users, Compulsory  | Active        | v2         | 0 of 4 (0%)  |
     And I should not see "v1"
-    And I open the action menu in "Policy1" "table_row"
     And "View previous versions" "link" should not exist
     And I log out
 
@@ -173,14 +165,12 @@ Feature: Manage policies
       | Policy1    | v1       | full text2 | short text2 | draft    |
     And I log in as "manager"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Set status to \"Active\"" "link" in the "Policy1" "table_row"
     Then I should see "All users will be required to agree to this new policy version to be able to use the site."
     And I press "Continue"
     And the following should exist in the "tool-policy-managedocs-wrapper" table:
       | Name                                        | Policy status | Version    | Agreements   |
       | Policy1 Site policy, All users, Compulsory  | Active        | v1         | 0 of 4 (0%)  |
-    And I open the action menu in "Policy1" "table_row"
     And "View previous versions" "link" should not exist
     And I log out
 
@@ -193,14 +183,12 @@ Feature: Manage policies
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Set status to \"Inactive\"" "link" in the "Policy1" "table_row"
     Then I should see "You are about to inactivate policy"
     And I press "Continue"
     And the following should exist in the "tool-policy-managedocs-wrapper" table:
       | Name                                        | Policy status | Version    | Agreements   |
       | Policy1 Site policy, All users, Compulsory  | Inactive      | v1         | 1 of 4 (25%) |
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Create a new draft" "link" in the "Policy1" "table_row"
     And I set the field "Version" to "v2"
     And I set the field "Name" to "Policy2"
@@ -216,7 +204,6 @@ Feature: Manage policies
       | Policy2 Site policy, All users, Compulsory  | Draft         | v2         | N/A          |
     And I should not see "v1"
     And I should not see "Policy1"
-    And I open the action menu in "Policy2" "table_row"
     And I click on "View previous versions" "link" in the "Policy2" "table_row"
     And I should see "Policy2 previous versions"
     And the following should exist in the "tool-policy-managedocs-wrapper" table:
@@ -234,10 +221,8 @@ Feature: Manage policies
     And I set the field "I agree to the Policy1" to "1"
     And I press "Next"
     And I navigate to "Users > Privacy and policies > Manage policies" in site administration
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Set status to \"Inactive\"" "link" in the "Policy1" "table_row"
     And I press "Continue"
-    And I open the action menu in "Policy1" "table_row"
     And I click on "Create a new draft" "link" in the "Policy1" "table_row"
     And I set the field "Version" to "v2"
     And I set the field "Name" to "Policy2"
@@ -248,7 +233,6 @@ Feature: Manage policies
       | Policy2 Site policy, All users, Compulsory  | Active        | v2         | 0 of 4 (0%)  |
     And I should not see "v1"
     And I should not see "Policy1"
-    And I open the action menu in "Policy2" "table_row"
     And I click on "View previous versions" "link" in the "Policy2" "table_row"
     And I should see "Policy2 previous versions"
     And the following should exist in the "tool-policy-managedocs-wrapper" table:
diff --git a/admin/tool/task/classes/run_from_cli.php b/admin/tool/task/classes/run_from_cli.php
new file mode 100644 (file)
index 0000000..06e24ef
--- /dev/null
@@ -0,0 +1,95 @@
+<?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/>.
+
+/**
+ * Form for scheduled tasks admin pages.
+ *
+ * @package    tool_task
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Running tasks from CLI.
+ *
+ * @copyright  2018 Toni Barbera <toni@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class run_from_cli {
+
+    /**
+     * Find the path of PHP CLI binary.
+     *
+     * @return string|false The PHP CLI executable PATH
+     */
+    protected static function find_php_cli_path() {
+        global $CFG;
+
+        if (!empty($CFG->pathtophp) && is_executable(trim($CFG->pathtophp))) {
+            return $CFG->pathtophp;
+        }
+
+        return false;
+    }
+
+    /**
+     * Returns if Moodle have access to PHP CLI binary or not.
+     *
+     * @return bool
+     */
+    public static function is_runnable():bool {
+        return self::find_php_cli_path() !== false;
+    }
+
+    /**
+     * Executes a cron from web invocation using PHP CLI.
+     *
+     * @param \core\task\task_base $task Task that be executed via CLI.
+     * @return bool
+     * @throws \moodle_exception
+     */
+    public static function execute(\core\task\task_base $task):bool {
+        global $CFG;
+
+        if (!self::is_runnable()) {
+            $redirecturl = new \moodle_url('/admin/settings.php', ['section' => 'systempaths']);
+            throw new \moodle_exception('cannotfindthepathtothecli', 'tool_task', $redirecturl->out());
+        } else {
+            // Shell-escaped path to the PHP binary.
+            $phpbinary = escapeshellarg(self::find_php_cli_path());
+
+            // Shell-escaped path CLI script.
+            $pathcomponents = [$CFG->dirroot, $CFG->admin, 'tool', 'task', 'cli', 'schedule_task.php'];
+            $scriptpath     = escapeshellarg(implode(DIRECTORY_SEPARATOR, $pathcomponents));
+
+            // Shell-escaped task name.
+            $classname = get_class($task);
+            $taskarg   = escapeshellarg("--execute={$classname}");
+
+            // Build the CLI command.
+            $command = "{$phpbinary} {$scriptpath} {$taskarg}";
+
+            // Execute it.
+            passthru($command);
+        }
+
+        return true;
+    }
+}
index 43c1d9f..2e2e20a 100644 (file)
@@ -25,6 +25,7 @@
 $string['asap'] = 'ASAP';
 $string['backtoscheduledtasks'] = 'Back to scheduled tasks';
 $string['blocking'] = 'Blocking';
+$string['cannotfindthepathtothecli'] = 'Cannot find the path to the PHP CLI executable so task execution aborted. Set the "Path to PHP CLI" setting in "Site administration / Server / System paths"';
 $string['clearfaildelay_confirm'] = 'Are you sure you want to clear the fail delay for task \'{$a}\'? After clearing the delay, the task will run according to its normal schedule.';
 $string['component'] = 'Component';
 $string['corecomponent'] = 'Core';
@@ -58,3 +59,4 @@ $string['taskscheduleminute_help'] = 'Minute field for task schedule. The field
 $string['taskschedulemonth'] = 'Month';
 $string['taskschedulemonth_help'] = 'Month field for task schedule. The field uses the same format as unix cron. Some examples are:<br/><ul><li><strong>*</strong> Every month</li><li><strong>*/2</strong> Every second month</li><li><strong>1</strong> Every January</li><li><strong>1,5</strong> Every January and May</li></ul>';
 $string['privacy:metadata'] = 'The Scheduled task configuration plugin does not store any personal data.';
+
index d612355..88dbdd4 100644 (file)
@@ -62,6 +62,7 @@ class tool_task_renderer extends plugin_renderer_base {
         $asap = get_string('asap', 'tool_task');
         $disabledstr = get_string('taskdisabled', 'tool_task');
         $plugindisabledstr = get_string('plugindisabled', 'tool_task');
+        $runnabletasks = tool_task\run_from_cli::is_runnable();
         foreach ($tasks as $task) {
             $customised = $task->is_customised() ? $no : $yes;
             if (empty($CFG->preventscheduledtaskchanges)) {
@@ -105,7 +106,7 @@ class tool_task_renderer extends plugin_renderer_base {
             }
 
             $runnow = '';
-            if (!$disabled && get_config('tool_task', 'enablerunnow')) {
+            if ( ! $disabled && get_config('tool_task', 'enablerunnow') && $runnabletasks ) {
                 $runnow = html_writer::div(html_writer::link(
                         new moodle_url('/admin/tool/task/schedule_task.php',
                             array('task' => get_class($task))),
index afb20b6..13cf1ce 100644 (file)
@@ -88,7 +88,8 @@ echo html_writer::start_tag('pre');
 $CFG->mtrace_wrapper = 'tool_task_mtrace_wrapper';
 
 // Run the specified task (this will output an error if it doesn't exist).
-cron_run_single_task($task);
+\tool_task\run_from_cli::execute($task);
+
 echo html_writer::end_tag('pre');
 
 $output = $PAGE->get_renderer('tool_task');
diff --git a/admin/tool/task/tests/behat/run_task_now.feature b/admin/tool/task/tests/behat/run_task_now.feature
deleted file mode 100644 (file)
index ed8ff3e..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-@tool @tool_task
-Feature: Run tasks from web interface
-  In order to run scheduled tasks immediately
-  As an admin
-  I need to be able to run a task from the web interface
-
-  Scenario: Run a task
-    Given I log in as "admin"
-    When I navigate to "Server > Scheduled tasks" in site administration
-    Then I should see "Never" in the "Log table cleanup" "table_row"
-
-    And I click on "Run now" "text" in the "Log table cleanup" "table_row"
-    And I should see "Are you sure you want to run this task"
-    And I press "Run now"
-
-    And I should see "Log table cleanup" in the "h2" "css_element"
-    And I should see "Scheduled task complete: Log table cleanup"
-
-    And I follow "Back to scheduled tasks"
-    And I should not see "Never" in the "Log table cleanup" "table_row"
-
-  Scenario: Cancel running a task
-    Given I log in as "admin"
-    When I navigate to "Server > Scheduled tasks" in site administration
-    And I click on "Run now" "text" in the "Log table cleanup" "table_row"
-    And I press "Cancel"
-    # Confirm we're back on the scheduled tasks page by looking for the table.
-    Then "Log table cleanup" "table_row" should exist
-
-  Scenario: Cannot run a task when the option is disabled
-    Given the following config values are set as admin:
-      | enablerunnow | 0 | tool_task |
-    When I log in as "admin"
-    And I navigate to "Server > Scheduled tasks" in site administration
-    Then I should not see "Run now"
index 4cfd3b5..8979ced 100644 (file)
@@ -58,14 +58,14 @@ Feature: Apply accessibility to a tour
     When I press tab
     Then the focused element is ".usermenu" "css_element"
     When I press tab
-    Then the focused element is "Admin User" "link" in the ".usermenu" "css_element"
+    Then the focused element is "Admin User" "link_or_button" in the ".usermenu" "css_element"
     When I press tab
     And I press tab
     Then the focused element is ".close" "css_element" in the "User menu" "dialogue"
     # Press shift-tab twice should lead us back to "Admin user" link.
     When I press shift tab
     And I press shift tab
-    Then the focused element is "Admin User" "link" in the ".usermenu" "css_element"
+    Then the focused element is "Admin User" "link_or_button" in the ".usermenu" "css_element"
 
   @javascript
   Scenario: Aria tags should not exist
index cdf787e..ddc70e0 100644 (file)
@@ -35,6 +35,11 @@ defined('MOODLE_INTERNAL') || die();
  */
 class manager {
 
+    /**
+     * Default mlbackend
+     */
+    const DEFAULT_MLBACKEND = '\mlbackend_php\processor';
+
     /**
      * @var \core_analytics\predictor[]
      */
@@ -117,9 +122,9 @@ class manager {
     }
 
     /**
-     * Returns the site selected predictions processor.
+     * Returns the provided predictions processor class.
      *
-     * @param string $predictionclass
+     * @param false|string $predictionclass Returns the system default processor if false
      * @param bool $checkisready
      * @return \core_analytics\predictor
      */
@@ -128,13 +133,13 @@ class manager {
         // We want 0 or 1 so we can use it as an array key for caching.
         $checkisready = intval($checkisready);
 
-        if ($predictionclass === false) {
+        if (!$predictionclass) {
             $predictionclass = get_config('analytics', 'predictionsprocessor');
         }
 
         if (empty($predictionclass)) {
             // Use the default one if nothing set.
-            $predictionclass = '\mlbackend_php\processor';
+            $predictionclass = self::default_mlbackend();
         }
 
         if (!class_exists($predictionclass)) {
@@ -179,6 +184,44 @@ class manager {
         return $predictionprocessors;
     }
 
+    /**
+     * Returns the name of the provided predictions processor.
+     *
+     * @param \core_analytics\predictor $predictionsprocessor
+     * @return string
+     */
+    public static function get_predictions_processor_name(\core_analytics\predictor $predictionsprocessor) {
+            $component = substr(get_class($predictionsprocessor), 0, strpos(get_class($predictionsprocessor), '\\', 1));
+        return get_string('pluginname', $component);
+    }
+
+    /**
+     * Whether the provided plugin is used by any model.
+     *
+     * @param string $plugin
+     * @return bool
+     */
+    public static function is_mlbackend_used($plugin) {
+        $models = self::get_all_models();
+        foreach ($models as $model) {
+            $processor = $model->get_predictions_processor();
+            $noprefixnamespace = ltrim(get_class($processor), '\\');
+            $processorplugin = substr($noprefixnamespace, 0, strpos($noprefixnamespace, '\\'));
+            if ($processorplugin == $plugin) {
+                return true;
+            }
+        }
+
+        // Default predictions processor.
+        $defaultprocessorclass = get_config('analytics', 'predictionsprocessor');
+        $pluginclass = '\\' . $plugin . '\\processor';
+        if ($pluginclass === $defaultprocessorclass) {
+            return true;
+        }
+
+        return false;
+    }
+
     /**
      * Get all available time splitting methods.
      *
@@ -546,6 +589,15 @@ class manager {
         }
     }
 
+    /**
+     * Default system backend.
+     *
+     * @return string
+     */
+    public static function default_mlbackend() {
+        return self::DEFAULT_MLBACKEND;
+    }
+
     /**
      * Returns the provided element classes in the site.
      *
index 0bee650..199baf4 100644 (file)
@@ -110,6 +110,11 @@ class model {
      */
     protected $target = null;
 
+    /**
+     * @var \core_analytics\predictor
+     */
+    protected $predictionsprocessor = null;
+
     /**
      * @var \core_analytics\local\indicator\base[]
      */
@@ -336,7 +341,8 @@ class model {
      * @param string $timesplittingid The time splitting method id (its fully qualified class name)
      * @return \core_analytics\model
      */
-    public static function create(\core_analytics\local\target\base $target, array $indicators, $timesplittingid = false) {
+    public static function create(\core_analytics\local\target\base $target, array $indicators,
+                                  $timesplittingid = false, $processor = false) {
         global $USER, $DB;
 
         \core_analytics\manager::check_can_manage_models();
@@ -353,6 +359,14 @@ class model {
         $modelobj->timemodified = $now;
         $modelobj->usermodified = $USER->id;
 
+        if ($processor &&
+                !self::is_valid($processor, '\core_analytics\classifier') &&
+                !self::is_valid($processor, '\core_analytics\regressor')) {
+            throw new \coding_exception('The provided predictions processor \\' . $processor . '\processor is not valid');
+        } else {
+            $modelobj->predictionsprocessor = $processor;
+        }
+
         $id = $DB->insert_record('analytics_models', $modelobj);
 
         // Get db defaults.
@@ -411,9 +425,10 @@ class model {
      * @param int|bool $enabled
      * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
      * @param string|false $timesplittingid False to respect current time splitting method
+     * @param string|false $predictionsprocessor False to respect current predictors processor value
      * @return void
      */
-    public function update($enabled, $indicators = false, $timesplittingid = '') {
+    public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) {
         global $USER, $DB;
 
         \core_analytics\manager::check_can_manage_models();
@@ -433,8 +448,14 @@ class model {
             $timesplittingid = $this->model->timesplitting;
         }
 
+        if ($predictionsprocessor === false) {
+            // Respect current value.
+            $predictionsprocessor = $this->model->predictionsprocessor;
+        }
+
         if ($this->model->timesplitting !== $timesplittingid ||
-                $this->model->indicators !== $indicatorsstr) {
+                $this->model->indicators !== $indicatorsstr ||
+                $this->model->predictionsprocessor !== $predictionsprocessor) {
 
             // Delete generated predictions before changing the model version.
             $this->clear();
@@ -458,6 +479,7 @@ class model {
         $this->model->enabled = intval($enabled);
         $this->model->indicators = $indicatorsstr;
         $this->model->timesplitting = $timesplittingid;
+        $this->model->predictionsprocessor = $predictionsprocessor;
         $this->model->timemodified = $now;
         $this->model->usermodified = $USER->id;
 
@@ -477,8 +499,14 @@ class model {
         $this->clear();
 
         // Method self::clear is already clearing the current model version.
-        $predictor = \core_analytics\manager::get_predictions_processor();
-        $predictor->delete_output_dir($this->get_output_dir(array(), true));
+        $predictor = $this->get_predictions_processor(false);
+        if ($predictor->is_ready() !== true) {
+            $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
+            debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
+                $this->model->id . ' could not be deleted.');
+        } else {
+            $predictor->delete_output_dir($this->get_output_dir(array(), true));
+        }
 
         $DB->delete_records('analytics_models', array('id' => $this->model->id));
         $DB->delete_records('analytics_models_log', array('modelid' => $this->model->id));
@@ -516,7 +544,7 @@ class model {
         $this->heavy_duty_mode();
 
         // Before get_labelled_data call so we get an early exception if it is not ready.
-        $predictor = \core_analytics\manager::get_predictions_processor();
+        $predictor = $this->get_predictions_processor();
 
         $datasets = $this->get_analyser()->get_labelled_data();
 
@@ -608,7 +636,7 @@ class model {
         $outputdir = $this->get_output_dir(array('execution'));
 
         // Before get_labelled_data call so we get an early exception if it is not ready.
-        $predictor = \core_analytics\manager::get_predictions_processor();
+        $predictor = $this->get_predictions_processor();
 
         $datasets = $this->get_analyser()->get_labelled_data();
 
@@ -677,7 +705,7 @@ class model {
 
         // Before get_unlabelled_data call so we get an early exception if it is not ready.
         if (!$this->is_static()) {
-            $predictor = \core_analytics\manager::get_predictions_processor();
+            $predictor = $this->get_predictions_processor();
         }
 
         $samplesdata = $this->get_analyser()->get_unlabelled_data();
@@ -738,6 +766,16 @@ class model {
         return $result;
     }
 
+    /**
+     * Returns the model predictions processor.
+     *
+     * @param bool $checkisready
+     * @return \core_analytics\predictor
+     */
+    public function get_predictions_processor($checkisready = true) {
+        return manager::get_predictions_processor($this->model->predictionsprocessor, $checkisready);
+    }
+
     /**
      * Formats the predictor results.
      *
@@ -1457,8 +1495,14 @@ class model {
         \core_analytics\manager::check_can_manage_models();
 
         // Delete current model version stored stuff.
-        $predictor = \core_analytics\manager::get_predictions_processor();
-        $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
+        $predictor = $this->get_predictions_processor(false);
+        if ($predictor->is_ready() !== true) {
+            $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
+            debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
+                $this->model->id . ' could not be cleared.');
+        } else {
+            $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
+        }
 
         $predictionids = $DB->get_fieldset_select('analytics_predictions', 'id', 'modelid = :modelid',
             array('modelid' => $this->get_id()));
index 39c2b98..34d82f7 100644 (file)
@@ -143,10 +143,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
         }
 
-        set_config('predictionsprocessor', $predictionsprocessorclass, 'analytics');
-
         $model = $this->add_perfect_model();
-        $model->enable($timesplittingid);
+        $model->update(true, false, $timesplittingid, get_class($predictionsprocessor));
 
         // No samples trained yet.
         $this->assertEquals(0, $DB->count_records('analytics_train_samples', array('modelid' => $model->get_id())));
@@ -423,8 +421,7 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
         }
 
-        set_config('predictionsprocessor', $predictionsprocessorclass, 'analytics');
-
+        $model->update(false, false, false, get_class($predictionsprocessor));
         $results = $model->evaluate();
 
         // We check that the returned status includes at least $expectedcode code.
index a72d5ef..10a10a3 100644 (file)
@@ -57,6 +57,16 @@ class core_block_external extends external_api {
                 'dockable'      => new external_value(PARAM_BOOL, 'Whether the block is dockable.'),
                 'weight'        => new external_value(PARAM_INT, 'Used to order blocks within a region.', VALUE_OPTIONAL),
                 'visible'       => new external_value(PARAM_BOOL, 'Whether the block is visible.', VALUE_OPTIONAL),
+                'contents'      => new external_single_structure(
+                    array(
+                        'title'         => new external_value(PARAM_TEXT, 'Block title.'),
+                        'content'       => new external_value(PARAM_RAW, 'Block contents.'),
+                        'contentformat' => new external_format_value('content'),
+                        'footer'        => new external_value(PARAM_RAW, 'Block footer.'),
+                        'files'         => new external_files('Block files.'),
+                    ),
+                    'Block contents (if required).', VALUE_OPTIONAL
+                ),
             ), 'Block information.'
         );
     }
@@ -65,10 +75,11 @@ class core_block_external extends external_api {
      * Convenience function for getting all the blocks of the current $PAGE.
      *
      * @param bool $includeinvisible Whether to include not visible blocks or not
+     * @param bool $returncontents Whether to return the block contents
      * @return array Block information
      * @since  Moodle 3.6
      */
-    private static function get_all_current_page_blocks($includeinvisible = false) {
+    private static function get_all_current_page_blocks($includeinvisible = false, $returncontents = false) {
         global $PAGE, $OUTPUT;
 
         // Load the block instances for all the regions.
@@ -82,20 +93,24 @@ class core_block_external extends external_api {
             // Index block instances to retrieve required info.
             $blockinstances = array();
             foreach ($regioninstances as $ri) {
-                $blockinstances[$ri->instance->id] = $ri->instance;
+                $blockinstances[$ri->instance->id] = $ri;
             }
 
             foreach ($regionblocks as $bc) {
-                $allblocks[] = [
+                $block = [
                     'instanceid' => $bc->blockinstanceid,
-                    'name' => $blockinstances[$bc->blockinstanceid]->blockname,
+                    'name' => $blockinstances[$bc->blockinstanceid]->instance->blockname,
                     'region' => $region,
                     'positionid' => $bc->blockpositionid,
                     'collapsible' => (bool) $bc->collapsible,
                     'dockable' => (bool) $bc->dockable,
-                    'weight' => $blockinstances[$bc->blockinstanceid]->weight,
-                    'visible' => $blockinstances[$bc->blockinstanceid]->visible,
+                    'weight' => $blockinstances[$bc->blockinstanceid]->instance->weight,
+                    'visible' => $blockinstances[$bc->blockinstanceid]->instance->visible,
                 ];
+                if ($returncontents) {
+                    $block['contents'] = (array) $blockinstances[$bc->blockinstanceid]->get_content_for_external($OUTPUT);
+                }
+                $allblocks[] = $block;
             }
         }
         return $allblocks;
@@ -110,7 +125,8 @@ class core_block_external extends external_api {
     public static function get_course_blocks_parameters() {
         return new external_function_parameters(
             array(
-                'courseid'  => new external_value(PARAM_INT, 'course id')
+                'courseid'  => new external_value(PARAM_INT, 'course id'),
+                'returncontents' => new external_value(PARAM_BOOL, 'Whether to return the block contents.', VALUE_DEFAULT, false),
             )
         );
     }
@@ -119,15 +135,17 @@ class core_block_external extends external_api {
      * Returns blocks information for a course.
      *
      * @param int $courseid The course id
+     * @param bool $returncontents Whether to return the block contents
      * @return array Blocks list and possible warnings
      * @throws moodle_exception
      * @since Moodle 3.3
      */
-    public static function get_course_blocks($courseid) {
+    public static function get_course_blocks($courseid, $returncontents = false) {
         global $PAGE;
 
         $warnings = array();
-        $params = self::validate_parameters(self::get_course_blocks_parameters(), ['courseid' => $courseid]);
+        $params = self::validate_parameters(self::get_course_blocks_parameters(),
+            ['courseid' => $courseid, 'returncontents' => $returncontents]);
 
         $course = get_course($params['courseid']);
         $context = context_course::instance($course->id);
@@ -144,7 +162,7 @@ class core_block_external extends external_api {
             $PAGE->set_pagetype('course-view-' . $course->format);
         }
 
-        $allblocks = self::get_all_current_page_blocks();
+        $allblocks = self::get_all_current_page_blocks(false, $params['returncontents']);
 
         return array(
             'blocks' => $allblocks,
@@ -177,7 +195,8 @@ class core_block_external extends external_api {
     public static function get_dashboard_blocks_parameters() {
         return new external_function_parameters(
             array(
-                'userid'  => new external_value(PARAM_INT, 'User id (optional), default is current user.', VALUE_DEFAULT, 0)
+                'userid'  => new external_value(PARAM_INT, 'User id (optional), default is current user.', VALUE_DEFAULT, 0),
+                'returncontents' => new external_value(PARAM_BOOL, 'Whether to return the block contents.', VALUE_DEFAULT, false),
             )
         );
     }
@@ -186,17 +205,19 @@ class core_block_external extends external_api {
      * Returns blocks information for the given user dashboard.
      *
      * @param int $userid The user id to retrive the blocks from, optional, default is to current user.
+     * @param bool $returncontents Whether to return the block contents
      * @return array Blocks list and possible warnings
      * @throws moodle_exception
      * @since Moodle 3.6
      */
-    public static function get_dashboard_blocks($userid = 0) {
+    public static function get_dashboard_blocks($userid = 0, $returncontents = false) {
         global $CFG, $USER, $PAGE;
 
         require_once($CFG->dirroot . '/my/lib.php');
 
         $warnings = array();
-        $params = self::validate_parameters(self::get_dashboard_blocks_parameters(), ['userid' => $userid]);
+        $params = self::validate_parameters(self::get_dashboard_blocks_parameters(),
+            ['userid' => $userid, 'returncontents' => $returncontents]);
 
         $userid = $params['userid'];
         if (empty($userid)) {
@@ -226,7 +247,7 @@ class core_block_external extends external_api {
 
         // Load the block instances in the current $PAGE for all the regions.
         $returninvisible = has_capability('moodle/my:manageblocks', $context) ? true : false;
-        $allblocks = self::get_all_current_page_blocks($returninvisible);
+        $allblocks = self::get_all_current_page_blocks($returninvisible, $params['returncontents']);
 
         return array(
             'blocks' => $allblocks,
index 505ff27..954a809 100644 (file)
@@ -86,6 +86,41 @@ class block_html extends block_base {
         return $this->content;
     }
 
+    public function get_content_for_external($output) {
+        global $CFG;
+        require_once($CFG->libdir . '/externallib.php');
+
+        $bc = new stdClass;
+        $bc->title = null;
+        $bc->content = '';
+        $bc->contenformat = FORMAT_MOODLE;
+        $bc->footer = '';
+        $bc->files = [];
+
+        if (!$this->hide_header()) {
+            $bc->title = $this->title;
+        }
+
+        if (isset($this->config->text)) {
+            $filteropt = new stdClass;
+            if ($this->content_is_trusted()) {
+                // Fancy html allowed only on course, category and system blocks.
+                $filteropt->noclean = true;
+            }
+
+            $format = FORMAT_HTML;
+            // Check to see if the format has been properly set on the config.
+            if (isset($this->config->format)) {
+                $format = $this->config->format;
+            }
+            list($bc->content, $bc->contentformat) =
+                external_format_text($this->config->text, $format, $this->context, 'block_html', 'content', null, $filteropt);
+            $bc->files = external_util::get_area_files($this->context->id, 'block_html', 'content', false, false);
+
+        }
+        return $bc;
+    }
+
 
     /**
      * Serialize and store config data
index a81f7ac..9a52ef7 100644 (file)
@@ -272,6 +272,39 @@ class block_base {
         return $bc;
     }
 
+
+    /**
+     * Return an object containing all the block content to be returned by external functions.
+     *
+     * If your block is returning formatted content or provide files for download, you should override this method to use the
+     * external_format_text, external_format_string functions for formatting or external_util::get_area_files for files.
+     *
+     * @param  core_renderer $output the rendered used for output
+     * @return stdClass      object containing the block title, central content, footer and linked files (if any).
+     * @since  Moodle 3.6
+     */
+    public function get_content_for_external($output) {
+        $bc = new stdClass;
+        $bc->title = null;
+        $bc->content = null;
+        $bc->contentformat = FORMAT_HTML;
+        $bc->footer = null;
+        $bc->files = [];
+
+        if ($this->instance->visible) {
+            $bc->content = $this->formatted_contents($output);
+            if (!empty($this->content->footer)) {
+                $bc->footer = $this->content->footer;
+            }
+        }
+
+        if (!$this->hide_header()) {
+            $bc->title = $this->title;
+        }
+
+        return $bc;
+    }
+
     /**
      * Convert the contents of the block to HTML.
      *
index ab27262..92f1945 100644 (file)
Binary files a/blocks/myoverview/amd/build/repository.min.js and b/blocks/myoverview/amd/build/repository.min.js differ
index 1d9b214..5f3fb07 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js and b/blocks/myoverview/amd/build/view.min.js differ
index 978c770..7af70de 100644 (file)
Binary files a/blocks/myoverview/amd/build/view_nav.min.js and b/blocks/myoverview/amd/build/view_nav.min.js differ
index b4f7b5d..fde78c7 100644 (file)
@@ -20,7 +20,7 @@
  * @copyright  2018 Bas Brands <base@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['core/ajax'], function(Ajax) {
+define(['core/ajax', 'core/notification'], function(Ajax, Notification) {
 
     /**
      * Retrieve a list of enrolled courses.
@@ -47,7 +47,55 @@ define(['core/ajax'], function(Ajax) {
         return promise;
     };
 
+    /**
+     * Set the favourite state on a list of courses.
+     *
+     * Valid args are:
+     * Array courses  list of course id numbers.
+     *
+     * @param {Object} args Arguments send to the webservice.
+     * @return {Promise} Resolve with warnings.
+     */
+    var setFavouriteCourses = function(args) {
+
+        var request = {
+            methodname: 'core_course_set_favourite_courses',
+            args: args
+        };
+
+        var promise = Ajax.call([request])[0];
+
+        return promise;
+    };
+
+    /**
+     * Update the user preferences.
+     *
+     * @param {Object} args Arguments send to the webservice.
+     *
+     * Sample args:
+     * {
+     *     preferences: [
+     *         {
+     *             type: 'block_example_user_sort_preference'
+     *             value: 'title'
+     *         }
+     *     ]
+     * }
+     */
+    var updateUserPreferences = function(args) {
+        var request = {
+            methodname: 'core_user_update_user_preferences',
+            args: args
+        };
+
+        Ajax.call([request])[0]
+            .fail(Notification.exception);
+    };
+
     return {
-        getEnrolledCoursesByTimeline: getEnrolledCoursesByTimeline
+        getEnrolledCoursesByTimeline: getEnrolledCoursesByTimeline,
+        setFavouriteCourses: setFavouriteCourses,
+        updateUserPreferences: updateUserPreferences
     };
 });
index 5f3f15a..f4304fa 100644 (file)
 define(
 [
     'jquery',
-    'core/notification',
     'block_myoverview/repository',
     'core/paged_content_factory',
+    'core/custom_interaction_events',
+    'core/notification',
     'core/templates',
 ],
 function(
     $,
-    Notification,
     Repository,
     PagedContentFactory,
+    CustomEvents,
+    Notification,
     Templates
 ) {
 
+    var SELECTORS = {
+        ACTION_ADD_FAVOURITE: '[data-action="add-favourite"]',
+        ACTION_REMOVE_FAVOURITE: '[data-action="remove-favourite"]',
+        FAVOURITE_ICON: '[data-region="favourite-icon"]',
+        ICON_IS_FAVOURITE: '[data-region="is-favourite"]',
+        ICON_NOT_FAVOURITE: '[data-region="not-favourite"]',
+        PAGED_CONTENT_CONTAINER: '[data-region="page-container"]'
+
+    };
+
     var TEMPLATES = {
         COURSES_CARDS: 'block_myoverview/view-cards',
         COURSES_LIST: 'block_myoverview/view-list',
@@ -44,9 +56,9 @@ function(
         NOCOURSES: 'block_myoverview/no-courses'
     };
 
-    var NUMCOURSES_PERPAGE = [12, 24];
+    var NUMCOURSES_PERPAGE = [12, 24, 48];
 
-    var currentCourseList = [];
+    var loadedPages = [];
 
     /**
      * Get filter values from DOM.
@@ -62,7 +74,7 @@ function(
         return filters;
     };
 
-    // We want the paged content controls below the paged content area
+    // We want the paged content controls below the paged content area.
     // and the controls should be ignored while data is loading.
     var DEFAULT_PAGED_CONTENT_CONFIG = {
         ignoreControlWhileLoading: true,
@@ -78,6 +90,7 @@ function(
      * @return {promise} Resolved with an array of courses.
      */
     var getMyCourses = function(filters, limit, pageNumber) {
+
         return Repository.getEnrolledCoursesByTimeline({
             offset:  pageNumber * limit,
             limit: limit,
@@ -86,15 +99,178 @@ function(
         });
     };
 
+    /**
+     * Get the container element for the favourite icon.
+     *
+     * @param  {Object} root The course overview container
+     * @param  {Number} courseId Course id number
+     * @return {Object} The favourite icon container
+     */
+    var getFavouriteIconContainer = function(root, courseId) {
+        return root.find(SELECTORS.FAVOURITE_ICON + '[data-course-id="' + courseId + '"]');
+    };
+
+    /**
+     * Get the paged content container element.
+     *
+     * @param  {Object} root The course overview container
+     * @param  {Number} index Rendered page index.
+     * @return {Object} The rendered paged container.
+     */
+    var getPagedContentContainer = function(root, index) {
+        return root.find('[data-region="paged-content-page"][data-page="' + index + '"]');
+    };
+
+    /**
+     * Get the course id from a favourite element.
+     *
+     * @param {Object} root The favourite icon container element.
+     * @return {Number} Course id.
+     */
+    var getFavouriteCourseId = function(root) {
+        return root.attr('data-course-id');
+    };
+
+    /**
+     * Hide the favourite icon.
+     *
+     * @param {Object} root The favourite icon container element.
+     * @param  {Number} courseId Course id number.
+     */
+    var hideFavouriteIcon = function(root, courseId) {
+        var iconContainer = getFavouriteIconContainer(root, courseId);
+        var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
+        isFavouriteIcon.addClass('hidden');
+        isFavouriteIcon.attr('aria-hidden', true);
+        var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
+        notFavourteIcon.removeClass('hidden');
+        notFavourteIcon.attr('aria-hidden', false);
+    };
+
+    /**
+     * Show the favourite icon.
+     *
+     * @param  {Object} root The course overview container.
+     * @param  {Number} courseId Course id number.
+     */
+    var showFavouriteIcon = function(root, courseId) {
+        var iconContainer = getFavouriteIconContainer(root, courseId);
+        var isFavouriteIcon = iconContainer.find(SELECTORS.ICON_IS_FAVOURITE);
+        isFavouriteIcon.removeClass('hidden');
+        isFavouriteIcon.attr('aria-hidden', false);
+        var notFavourteIcon = iconContainer.find(SELECTORS.ICON_NOT_FAVOURITE);
+        notFavourteIcon.addClass('hidden');
+        notFavourteIcon.attr('aria-hidden', true);
+    };
+
+    /**
+     * Get the action menu item
+     *
+     * @param {Object} root  root The course overview container
+     * @param {Number} courseId Course id.
+     * @return {Object} The add to favourite menu item.
+     */
+    var getAddFavouriteMenuItem = function(root, courseId) {
+        return root.find('[data-action="add-favourite"][data-course-id="' + courseId + '"]');
+    };
+
+    /**
+     * Get the action menu item
+     *
+     * @param {Object} root  root The course overview container
+     * @param {Number} courseId Course id.
+     * @return {Object} The remove from favourites menu item.
+     */
+    var getRemoveFavouriteMenuItem = function(root, courseId) {
+        return root.find('[data-action="remove-favourite"][data-course-id="' + courseId + '"]');
+    };
+
+    /**
+     * Add course to favourites
+     *
+     * @param  {Object} root The course overview container
+     * @param  {Number} courseId Course id number
+     */
+    var addToFavourites = function(root, courseId) {
+        var removeAction = getRemoveFavouriteMenuItem(root, courseId);
+        var addAction = getAddFavouriteMenuItem(root, courseId);
+
+        setCourseFavouriteState(courseId, true).then(function(success) {
+            if (success) {
+                removeAction.removeClass('hidden');
+                addAction.addClass('hidden');
+                showFavouriteIcon(root, courseId);
+            } else {
+                Notification.alert('Starring course failed', 'Could not change favourite state');
+            }
+            return;
+        }).catch(Notification.exception);
+    };
+
+    /**
+     * Remove course from favourites
+     *
+     * @param  {Object} root The course overview container
+     * @param  {Number} courseId Course id number
+     */
+    var removeFromFavourites = function(root, courseId) {
+        var removeAction = getRemoveFavouriteMenuItem(root, courseId);
+        var addAction = getAddFavouriteMenuItem(root, courseId);
+
+        setCourseFavouriteState(courseId, false).then(function(success) {
+            if (success) {
+                removeAction.addClass('hidden');
+                addAction.removeClass('hidden');
+                hideFavouriteIcon(root, courseId);
+            } else {
+                Notification.alert('Starring course failed', 'Could not change favourite state');
+            }
+            return;
+        }).catch(Notification.exception);
+    };
+
+    /**
+     * Set the courses favourite status and push to repository
+     *
+     * @param  {Number} courseId Course id to favourite.
+     * @param  {Bool} status new favourite status.
+     * @return {Promise} Repository promise.
+     */
+    var setCourseFavouriteState = function(courseId, status) {
+
+        return Repository.setFavouriteCourses({
+            courses: [
+                    {
+                        'id': courseId,
+                        'favourite': status
+                    }
+                ]
+        }).then(function(result) {
+            if (result.warnings.length == 0) {
+                loadedPages.forEach(function(courseList) {
+                    courseList.courses.forEach(function(course, index) {
+                        if (course.id == courseId) {
+                            courseList.courses[index].isfavourite = status;
+                        }
+                    });
+                });
+                return true;
+            } else {
+                return false;
+            }
+        }).catch(Notification.exception);
+    };
+
     /**
      * Render the dashboard courses.
      *
      * @param {object} root The root element for the courses view.
      * @param {array} coursesData containing array of returned courses.
-     * @param {object} filters The filters for this view.
      * @return {promise} jQuery promise resolved after rendering is complete.
      */
-    var renderCourses = function(root, coursesData, filters) {
+    var renderCourses = function(root, coursesData) {
+
+        var filters = getFilterValues(root);
 
         var currentTemplate = '';
         if (filters.display == 'cards') {
@@ -127,6 +303,11 @@ function(
 
         root = $(root);
 
+        if (!root.attr('data-init')) {
+            registerEventListeners(root);
+            root.attr('data-init', true);
+        }
+
         var filters = getFilterValues(root);
 
         var pagedContentPromise = PagedContentFactory.createWithLimit(
@@ -135,6 +316,7 @@ function(
                 var promises = [];
 
                 pagesData.forEach(function(pageData) {
+                    var currentPage = pageData.pageNumber;
                     var pageNumber = pageData.pageNumber - 1;
 
                     var pagePromise = getMyCourses(
@@ -145,8 +327,8 @@ function(
                         if (coursesData.courses.length < pageData.limit) {
                             actions.allItemsLoaded(pageData.pageNumber);
                         }
-                        currentCourseList = coursesData;
-                        return renderCourses(root, coursesData, filters);
+                        loadedPages[currentPage] = coursesData;
+                        return renderCourses(root, coursesData);
                     })
                     .catch(Notification.exception);
 
@@ -163,6 +345,35 @@ function(
         }).catch(Notification.exception);
     };
 
+    /**
+     * Listen to, and handle events for  the myoverview block.
+     *
+     * @param {Object} root The myoverview block container element.
+     */
+    var registerEventListeners = function(root) {
+        CustomEvents.define(root, [
+            CustomEvents.events.activate
+        ]);
+
+        root.on(CustomEvents.events.activate, SELECTORS.ACTION_ADD_FAVOURITE, function(e, data) {
+            var favourite = $(e.target).closest(SELECTORS.ACTION_ADD_FAVOURITE);
+            var courseId = getFavouriteCourseId(favourite);
+            addToFavourites(root, courseId);
+            data.originalEvent.preventDefault();
+        });
+
+        root.on(CustomEvents.events.activate, SELECTORS.ACTION_REMOVE_FAVOURITE, function(e, data) {
+            var favourite = $(e.target).closest(SELECTORS.ACTION_REMOVE_FAVOURITE);
+            var courseId = getFavouriteCourseId(favourite);
+            removeFromFavourites(root, courseId);
+            data.originalEvent.preventDefault();
+        });
+
+        root.on(CustomEvents.events.activate, SELECTORS.FAVOURITE_ICON, function(e, data) {
+            data.originalEvent.preventDefault();
+        });
+    };
+
     /**
      * Reset the courses views to their original
      * state on first page load.
@@ -170,15 +381,21 @@ function(
      * This is called when configuration has changed for the event lists
      * to cause them to reload their data.
      *
-     * @param {object} root The root element for the timeline view.
-     * @param {object} content The content element for the timeline view.
+     * @param {Object} root The root element for the timeline view.
+     * @param {Object} content The content element for the timeline view.
      */
     var reset = function(root, content) {
-        var filters = getFilterValues(root);
-        renderCourses(root, currentCourseList, filters)
-            .then(function(html, js) {
-                return Templates.replaceNodeContents(content, html, js);
-            }).catch(Notification.exception);
+
+        if (loadedPages.length > 0) {
+            loadedPages.forEach(function(courseList, index) {
+                var pagedContentPage = getPagedContentContainer(root, index);
+                renderCourses(root, courseList).then(function(html, js) {
+                    return Templates.replaceNodeContents(pagedContentPage, html, js);
+                }).catch(Notification.exception);
+            });
+        } else {
+            init(root, content);
+        }
     };
 
     return {
index 42f5e33..49f211d 100644 (file)
@@ -25,11 +25,13 @@ define(
 [
     'jquery',
     'core/custom_interaction_events',
+    'block_myoverview/repository',
     'block_myoverview/view'
 ],
 function(
     $,
     CustomEvents,
+    Repository,
     View
 ) {
 
@@ -39,6 +41,32 @@ function(
         DISPLAY_OPTION: '[data-display-option]'
     };
 
+    /**
+     * Update the user preference for the block.
+     *
+     * @param {String} filter The type of filter: display/sort/grouping.
+     * @param {String} value The current preferred value.
+     */
+    var updatePreferences = function(filter, value) {
+        var type = null;
+        if (filter == 'display') {
+            type = 'block_myoverview_user_view_preference';
+        } else if (filter == 'sort') {
+            type = 'block_myoverview_user_sort_preference';
+        } else {
+            type = 'block_myoverview_user_grouping_preference';
+        }
+
+        Repository.updateUserPreferences({
+            preferences: [
+                {
+                    type: type,
+                    value: value
+                }
+            ]
+        });
+    };
+
     /**
      * Event listener for the Display filter (cards, list).
      *
@@ -62,8 +90,14 @@ function(
                     return;
                 }
 
-                var attributename = 'data-' + option.attr('data-filter');
-                viewRoot.attr(attributename, option.attr('data-value'));
+                var filter = option.attr('data-filter');
+                var attributename = 'data-' + filter;
+                var value = option.attr('data-value');
+                var pref = option.attr('data-pref');
+
+                viewRoot.attr(attributename, value);
+
+                updatePreferences(filter, pref);
 
                 // Reset the views.
                 View.init(viewRoot, viewContent);
@@ -83,7 +117,12 @@ function(
                     return;
                 }
 
-                viewRoot.attr('data-display', option.attr('data-value'));
+                var filter = option.attr('data-display-option');
+                var value = option.attr('data-value');
+                var pref = option.attr('data-pref');
+
+                updatePreferences(filter, pref);
+                viewRoot.attr('data-display', value);
                 View.reset(viewRoot, viewContent);
                 data.originalEvent.preventDefault();
             }
index f22ce15..cbd2f9d 100644 (file)
@@ -49,8 +49,11 @@ class block_myoverview extends block_base {
         if (isset($this->content)) {
             return $this->content;
         }
+        $group = get_user_preferences('block_myoverview_user_grouping_preference');
+        $sort = get_user_preferences('block_myoverview_user_sort_preference');
+        $view = get_user_preferences('block_myoverview_user_view_preference');
 
-        $renderable = new \block_myoverview\output\main();
+        $renderable = new \block_myoverview\output\main($group, $sort, $view);
         $renderer = $this->page->get_renderer('block_myoverview');
 
         $this->content = new stdClass();
index c734468..7cd6d1e 100644 (file)
@@ -28,7 +28,7 @@ use renderable;
 use renderer_base;
 use templatable;
 
-require_once($CFG->libdir . '/completionlib.php');
+require_once($CFG->dirroot . '/blocks/myoverview/lib.php');
 
 /**
  * Class containing data for my overview block.
@@ -37,18 +37,75 @@ require_once($CFG->libdir . '/completionlib.php');
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class main implements renderable, templatable {
+
+    /**
+     * Store the grouping preference
+     *
+     * @var string String matching the grouping constants defined in myoverview/lib.php
+     */
+    private $grouping;
+
+    /**
+     * Store the sort preference
+     *
+     * @var string String matching the sort constants defined in myoverview/lib.php
+     */
+    private $sort;
+
+    /**
+     * Store the view preference
+     *
+     * @var string String matching the view/display constants defined in myoverview/lib.php
+     */
+    private $view;
+
+    /**
+     * main constructor.
+     * Initialize the user preferences
+     *
+     * @param string $grouping Grouping user preference
+     * @param string $sort Sort user preference
+     * @param string $view Display user preference
+     */
+    public function __construct($grouping, $sort, $view) {
+        $this->grouping = $grouping ? $grouping : BLOCK_MYOVERVIEW_GROUPING_ALL;
+        $this->sort = $sort ? $sort : BLOCK_MYOVERVIEW_SORTING_TITLE;
+        $this->view = $view ? $view : BLOCK_MYOVERVIEW_VIEW_CARD;
+    }
+
+    /**
+     * Get the user preferences as an array to figure out what has been selected
+     *
+     * @return array $preferences Array with the pref as key and value set to true
+     */
+    public function get_preferences_as_booleans() {
+        $preferences = [];
+        $preferences[$this->view] = true;
+        $preferences[$this->sort] = true;
+        $preferences[$this->grouping] = true;
+
+        return $preferences;
+    }
+
     /**
      * Export this data so it can be used as the context for a mustache template.
      *
      * @param \renderer_base $output
-     * @return stdClass
+     * @return array Context variables for the template
      */
     public function export_for_template(renderer_base $output) {
 
         $nocoursesurl = $output->image_url('courses', 'block_myoverview')->out();
 
-        return (object) [
-            'nocoursesimg' => $nocoursesurl
+        $defaultvariables = [
+            'nocoursesimg' => $nocoursesurl,
+            'grouping' => $this->grouping,
+            'sort' => $this->sort == BLOCK_MYOVERVIEW_SORTING_TITLE ? 'fullname' : 'ul.timeaccess desc',
+            'view' => $this->view
         ];
+
+        $preferences = $this->get_preferences_as_booleans();
+        return array_merge($defaultvariables, $preferences);
+
     }
-}
+}
\ No newline at end of file
index b3cf042..34463a1 100644 (file)
@@ -24,6 +24,9 @@
 
 namespace block_myoverview\privacy;
 
+use core_privacy\local\request\user_preference_provider;
+use core_privacy\local\metadata\collection;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -32,15 +35,48 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2018 Zig Tan <zig@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\null_provider {
+class provider implements \core_privacy\local\metadata\provider, user_preference_provider {
 
     /**
-     * Get the language string identifier with the component's language
-     * file to explain why this plugin stores no data.
+     * Returns meta-data information about the myoverview block.
      *
-     * @return  string
+     * @param  \core_privacy\local\metadata\collection $collection A collection of meta-data.
+     * @return \core_privacy\local\metadata\collection Return the collection of meta-data.
      */
-    public static function get_reason() : string {
-        return 'privacy:metadata';
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_user_preference('block_myoverview_user_sort_preference', 'privacy:metadata:overviewsortpreference');
+        $collection->add_user_preference('block_myoverview_user_view_preference', 'privacy:metadata:overviewviewpreference');
+        $collection->add_user_preference('block_myoverview_user_grouping_preference',
+            'privacy:metadata:overviewgroupingpreference');
+        return $collection;
+    }
+    /**
+     * Export all user preferences for the myoverview block
+     *
+     * @param int $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $preference = get_user_preferences('block_myoverview_user_sort_preference', null, $userid);
+        if (isset($preference)) {
+            \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+                'block_myoverview_user_sort_preference', get_string($preference, 'block_myoverview'),
+                get_string('privacy:metadata:overviewsortpreference', 'block_myoverview'));
+        }
+
+        $preference = get_user_preferences('block_myoverview_user_view_preference', null, $userid);
+        if (isset($preference)) {
+            \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+                'block_myoverview_user_view_preference',
+                get_string($preference, 'block_myoverview'),
+                get_string('privacy:metadata:overviewviewpreference', 'block_myoverview'));
+        }
+
+        $preference = get_user_preferences('block_myoverview_user_grouping_preference', null, $userid);
+        if (isset($preference)) {
+            \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+                'block_myoverview_user_grouping_preference',
+                get_string($preference, 'block_myoverview'),
+                get_string('privacy:metadata:overviewgroupingpreference', 'block_myoverview'));
+        }
     }
-}
+}
\ No newline at end of file
index 49fe21c..6de059d 100644 (file)
  */
 
 $string['all'] = 'All';
+$string['addtofavourites'] = 'Star this course';
+$string['aria:addtofavourites'] = 'Star for';
 $string['aria:allcourses'] = 'All courses';
 $string['aria:card'] = 'Switch to card view';
 $string['aria:controls'] = 'Course overview controls';
+$string['aria:courseactions'] = 'Actions for current course';
 $string['aria:courseimage'] = 'Course image:';
 $string['aria:coursename'] = 'Course name:';
 $string['aria:coursesummary'] = 'Course summary text:';
 $string['aria:courseprogress'] = 'Course progress:';
 $string['aria:displaydropdown'] = 'Display dropdown';
+$string['aria:favourite'] = 'Course is starred';
+$string['aria:favourites'] = 'Show starred courses';
 $string['aria:future'] = 'Show future courses';
 $string['aria:groupingdropdown'] = 'Grouping dropdown';
 $string['aria:inprogress'] = 'Show in courses in progress';
@@ -38,32 +43,45 @@ $string['aria:lastaccessed'] = 'Sort courses by last accessed date';
 $string['aria:list'] = 'Switch to list view';
 $string['aria:title'] = 'Sort courses by title';
 $string['aria:past'] = 'Show past courses';
+$string['aria:removefromfavourites'] = 'Remove star for';
 $string['aria:summary'] = 'Switch to summary view';
 $string['aria:sortingdropdown'] = 'Sorting dropdown';
 $string['card'] = 'Card';
-$string['courseprogress'] = 'Course progress:';
+$string['cards'] = 'Cards';
 $string['complete'] = 'Complete';
-$string['favorite'] = 'Favorite';
+$string['favourite'] = 'Starred course';
+$string['favourites'] = 'Starred';
 $string['future'] = 'Future';
-$string['future:aria'] = 'View future courses';
-$string['hidden'] = 'Hidden';
 $string['inprogress'] = 'In progress';
-$string['inprogress:aria'] = 'View in progress courses';
+$string['notfavourite'] = 'Not starred';
 $string['lastaccessed'] = 'Last accessed';
-$string['lastaccessed:aria'] = 'Sort course by lastaccessed';
 $string['list'] = 'List';
-$string['morecourses'] = 'More courses';
 $string['myoverview:addinstance'] = 'Add a new course overview block';
 $string['myoverview:myaddinstance'] = 'Add a new course overview block to Dashboard';
-$string['nocoursesfuture'] = 'No future courses';
-$string['nocoursesinprogress'] = 'No in progress courses';
 $string['nocourses'] = 'No courses';
-$string['nocoursespast'] = 'No past courses';
 $string['past'] = 'Past';
 $string['pluginname'] = 'Course overview';
-$string['privacy:metadata'] = 'The myoverview block does not store any personal data.';
+$string['privacy:metadata:overviewsortpreference'] = 'The myoverview block sort preference.';
+$string['privacy:metadata:overviewviewpreference'] = 'The myoverview block view preference.';
+$string['privacy:metadata:overviewgroupingpreference'] = 'The myoverview block grouping preference.';
+$string['removefromfavourites'] = 'Unstar this course';
 $string['summary'] = 'Summary';
 $string['title'] = 'Title';
+
+// Deprecated since Moodle 3.6.
+$string['defaulttab'] = 'Default tab';
+$string['defaulttab_desc'] = 'The tab that will be displayed when a user first views their course overview. When returning to their course overview, the user\'s active tab is remembered.';
+$string['morecourses'] = 'More courses';
+$string['nocoursesinprogress'] = 'No in progress courses';
+$string['nocoursesfuture'] = 'No future courses';
+$string['nocoursespast'] = 'No past courses';
+$string['noevents'] = 'No upcoming activities due';
+$string['next30days'] = 'Next 30 days';
+$string['next7days'] = 'Next 7 days';
+$string['recentlyoverdue'] = 'Recently overdue';
+$string['sortbycourses'] = 'Sort by courses';
+$string['sortbydates'] = 'Sort by dates';
+$string['timeline'] = 'Timeline';
 $string['viewcoursename'] = 'View course {$a}';
-$string['viewcourse'] = 'View course';
+$string['privacy:metadata:overviewlasttab'] = 'This stores the last tab selected by the user on the overview block.';
 
diff --git a/blocks/myoverview/lang/en/deprecated.txt b/blocks/myoverview/lang/en/deprecated.txt
new file mode 100644 (file)
index 0000000..7927bba
--- /dev/null
@@ -0,0 +1,15 @@
+defaulttab,block_myoverview
+defaulttab_desc,block_myoverview
+morecourses,block_myoverview
+nocoursesinprogress,block_myoverview
+nocoursesfuture,block_myoverview
+nocoursespast,block_myoverview
+noevents,block_myoverview
+next30days,block_myoverview
+next7days,block_myoverview
+recentlyoverdue,block_myoverview
+sortbycourses,block_myoverview
+sortbydates,block_myoverview
+timeline,block_myoverview
+viewcoursename,block_myoverview
+privacy:metadata:overviewlasttab,block_myoverview
\ No newline at end of file
diff --git a/blocks/myoverview/lib.php b/blocks/myoverview/lib.php
new file mode 100644 (file)
index 0000000..bc0530e
--- /dev/null
@@ -0,0 +1,88 @@
+<?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/>.
+
+/**
+ * Library functions for overview.
+ *
+ * @package   block_myoverview
+ * @copyright 2018 Peter Dias
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Constants for the user preferences grouping options
+ */
+define('BLOCK_MYOVERVIEW_GROUPING_ALL', 'all');
+define('BLOCK_MYOVERVIEW_GROUPING_INPROGRESS', 'inprogress');
+define('BLOCK_MYOVERVIEW_GROUPING_FUTURE', 'future');
+define('BLOCK_MYOVERVIEW_GROUPING_PAST', 'past');
+define('BLOCK_MYOVERVIEW_GROUPING_FAVOURITES', 'favourites');
+
+/**
+ * Constants for the user preferences sorting options
+ * timeline
+ */
+define('BLOCK_MYOVERVIEW_SORTING_TITLE', 'title');
+define('BLOCK_MYOVERVIEW_SORTING_LASTACCESSED', 'lastaccessed');
+
+/**
+ * Constants for the user preferences view options
+ */
+define('BLOCK_MYOVERVIEW_VIEW_CARD', 'cards');
+define('BLOCK_MYOVERVIEW_VIEW_LIST', 'list');
+define('BLOCK_MYOVERVIEW_VIEW_SUMMARY', 'summary');
+
+/**
+ * Get the current user preferences that are available
+ *
+ * @return mixed Array representing current options along with defaults
+ */
+function block_myoverview_user_preferences() {
+    $preferences['block_myoverview_user_grouping_preference'] = array(
+        'null' => NULL_NOT_ALLOWED,
+        'default' => BLOCK_MYOVERVIEW_GROUPING_ALL,
+        'type' => PARAM_ALPHA,
+        'choices' => array(
+            BLOCK_MYOVERVIEW_GROUPING_ALL,
+            BLOCK_MYOVERVIEW_GROUPING_INPROGRESS,
+            BLOCK_MYOVERVIEW_GROUPING_FUTURE,
+            BLOCK_MYOVERVIEW_GROUPING_PAST,
+            BLOCK_MYOVERVIEW_GROUPING_FAVOURITES
+        )
+    );
+    $preferences['block_myoverview_user_sort_preference'] = array(
+        'null' => NULL_NOT_ALLOWED,
+        'default' => BLOCK_MYOVERVIEW_SORTING_TITLE,
+        'type' => PARAM_ALPHA,
+        'choices' => array(
+            BLOCK_MYOVERVIEW_SORTING_TITLE,
+            BLOCK_MYOVERVIEW_SORTING_LASTACCESSED
+        )
+    );
+    $preferences['block_myoverview_user_view_preference'] = array(
+        'null' => NULL_NOT_ALLOWED,
+        'default' => BLOCK_MYOVERVIEW_VIEW_CARD,
+        'type' => PARAM_ALPHA,
+        'choices' => array(
+            BLOCK_MYOVERVIEW_VIEW_CARD,
+            BLOCK_MYOVERVIEW_VIEW_LIST,
+            BLOCK_MYOVERVIEW_VIEW_SUMMARY
+        )
+    );
+    return $preferences;
+}
\ No newline at end of file
diff --git a/blocks/myoverview/templates/course-action-menu.mustache b/blocks/myoverview/templates/course-action-menu.mustache
new file mode 100644 (file)
index 0000000..027bdab
--- /dev/null
@@ -0,0 +1,61 @@
+{{!
+    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 block_myoverview/course-action-menu
+
+    This template renders action menu for each course.
+
+    Example context (json):
+    {
+        "isfavourite": true
+    }
+}}
+<div class="ml-auto dropdown">
+    <button class="btn btn-link btn-icon icon-size-3 coursemenubtn"
+        type="button"
+        data-toggle="dropdown"
+        aria-haspopup="true"
+        aria-expanded="false">
+        {{#pix}} i/moremenu, core {{/pix}}
+            <span class="sr-only">
+                {{#str}} aria:courseactions, block_myoverview {{/str}} {{{fullname}}}
+            </span>
+    </button>
+    <div class="dropdown-menu dropdown-menu-right">
+        <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}}" href="#"
+            data-action="add-favourite"
+            data-course-id="{{id}}"
+            aria-controls="favorite-icon-{{ id }}"
+            >
+            {{#pix}} i/star, core, {{#str}} favourite, block_myoverview {{/str}} {{/pix}}
+            {{#str}} addtofavourites, block_myoverview {{/str}}
+            <div class="sr-only">
+                {{#str}} aria:addtofavourites, block_myoverview {{/str}} {{{fullname}}}
+            </div>
+        </a>
+        <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}}" href="#"
+            data-action="remove-favourite"
+            data-course-id="{{id}}"
+            aria-controls="favorite-icon-{{ id }}"
+            >
+            {{#str}} removefromfavourites, block_myoverview {{/str}}
+            <div class="sr-only">
+                {{#str}} aria:removefromfavourites, block_myoverview {{/str}} {{{fullname}}}
+            </div>
+        </a>
+    </div>
+</div>
\ No newline at end of file
index ab97aa4..d8f2aa4 100644 (file)
 
     Example context (json):
     {
-        "nocoursesimg": "https://moodlesite/theme/image.php/boost/block_myoverview/1535727318/courses"
+        "nocoursesimg": "https://moodlesite/theme/image.php/boost/block_myoverview/1535727318/courses",
+        "grouping": "all",
+        "sort": "fullname",
+        "view": "card"
     }
 }}
 <div id="courses-view-{{uniqid}}"
     data-region="courses-view"
-    data-display="cards"
-    data-grouping="all"
-    data-sort="fullname"
+    data-display="{{view}}"
+    data-grouping="{{grouping}}"
+    data-sort="{{sort}}"
     data-nocoursesimg="{{nocoursesimg}}">
     <div data-region="course-view-content">
         <div data-region="courses-loading-placeholder">
-            <div class="row card-deck">
+            <div class="card-deck dashboard-card-deck">
                 {{> block_myoverview/placeholder-course }}
                 {{> block_myoverview/placeholder-course }}
                 {{> block_myoverview/placeholder-course }}
@@ -40,4 +43,4 @@
             </div>
         </div>
     </div>
-</div>
+</div>
\ No newline at end of file
diff --git a/blocks/myoverview/templates/favourite-icon.mustache b/blocks/myoverview/templates/favourite-icon.mustache
new file mode 100644 (file)
index 0000000..4cb204b
--- /dev/null
@@ -0,0 +1,49 @@
+{{!
+    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 block_myoverview/favourite-icon
+
+    This template renders the favourite icon for a course.
+
+    Example context (json):
+    {
+        "id": 3,
+        "isfavourite": true
+    }
+}}
+<div id="favorite-icon-{{ id }}"
+    data-region="favourite-icon"
+    data-course-id="{{id}}"
+    >
+    <div class="btn btn-link favouritebtn p-2">
+        <span
+            {{^isfavourite}}class="hidden"{{/isfavourite}}
+            data-region="is-favourite"
+            aria-hidden="{{^isfavourite}}true{{/isfavourite}}{{#isfavourite}}false{{/isfavourite}}"
+            >
+            {{#pix}} i/star, core, {{#str}} favourite, block_myoverview {{/str}} {{/pix}}
+            <span class="sr-only">{{#str}} aria:favourite, block_myoverview {{/str}}</span>
+        </span>
+        <span
+            {{#isfavourite}}class="hidden"{{/isfavourite}}
+            data-region="not-favourite"
+            aria-hidden="{{^isfavourite}}false{{/isfavourite}}{{#isfavourite}}true{{/isfavourite}}"
+            >
+            {{#pix}} i/empty, core, {{#str}} notfavourite, block_myoverview {{/str}} {{/pix}}
+        </span>
+    </div>
+</div>
index 1d593e8..00a51d0 100644 (file)
@@ -25,7 +25,7 @@
 
 <div id="block-myoverview-{{uniqid}}" class="block-myoverview" data-region="myoverview" role="navigation">
 
-            <div data-region="filter" class="d-flex m-b-1" aria-label="{{#str}} aria:controls, block_myoverview {{/str}}">
+            <div data-region="filter" class="d-flex align-items-center flex-wrap" aria-label="{{#str}} aria:controls, block_myoverview {{/str}}">
                 {{> block_myoverview/nav-grouping-selector }}
 
                 {{> block_myoverview/nav-sort-selector }}
index e5661dc..8d28caf 100644 (file)
     This template renders display dropdown.
 
     Example context (json):
-    {}
+    {
+        "cards": true,
+        "list": false,
+        "summary": false
+    }
 }}
-<div class="dropdown">
+<div class="dropdown m-b-1">
     <button id="displaydropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
     aria-label="{{#str}} aria:displaydropdown, block_myoverview {{/str}}">
-        <span class="d-sm-inline-block">{{#pix}} a/view_icon_active {{/pix}}</span>
+        {{#pix}} a/view_icon_active {{/pix}}
+        <span class="d-sm-inline-block" data-active-item-text>
+            {{#cards}}{{#str}} card, block_myoverview {{/str}}{{/cards}}
+            {{#list}}{{#str}} list, block_myoverview {{/str}}{{/list}}
+            {{#summary}}{{#str}} summary, block_myoverview {{/str}}{{/summary}}
+        </span>
     </button>
     <ul class="dropdown-menu" data-show-active-item aria-labelledby="displaydropdown">
         <li>
-            <a class="dropdown-item active" href="#" data-display-option="display" data-value="cards" aria-label="{{#str}} aria:card, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item {{#cards}}active{{/cards}}" href="#" data-display-option="display" data-value="cards" data-pref="cards" aria-label="{{#str}} aria:card, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
             {{#str}} card, block_myoverview {{/str}}
             </a>
         </li>
         <li>
-            <a class="dropdown-item" href="#" data-display-option="display" data-value="list" aria-label="{{#str}} aria:list, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item {{#list}}active{{/list}}" href="#" data-display-option="display" data-value="list" data-pref="list" aria-label="{{#str}} aria:list, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
             {{#str}} list, block_myoverview {{/str}}
             </a>
         </li>
         <li>
-            <a class="dropdown-item" href="#" data-display-option="display" data-value="summary" aria-label="{{#str}} aria:summary, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item {{#summary}}active{{/summary}}" href="#" data-display-option="display" data-value="summary" data-pref="summary" aria-label="{{#str}} aria:summary, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
             {{#str}} summary, block_myoverview {{/str}}
             </a>
         </li>
     </ul>
-</div>
\ No newline at end of file
+</div>
index ac57aa7..9e341a6 100644 (file)
     This template renders grouping dropdown.
 
     Example context (json):
-    {}
+    {
+        "all": true,
+        "inprogress": false,
+        "future": false,
+        "past": false
+    }
 }}
-<div class="dropdown">
+<div class="dropdown m-b-1 mr-auto">
     <button id="groupingdropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="{{#str}} aria:groupingdropdown, block_myoverview {{/str}}">
-        <span class="d-sm-inline-block">{{#str}} all, block_myoverview {{/str}}</span>
+        {{#pix}} i/filter {{/pix}}
+        <span class="d-sm-inline-block" data-active-item-text>
+            {{#all}}{{#str}} all, block_myoverview {{/str}}{{/all}}
+            {{#inprogress}}{{#str}} inprogress, block_myoverview {{/str}}{{/inprogress}}
+            {{#future}}{{#str}} future, block_myoverview {{/str}}{{/future}}
+            {{#past}}{{#str}} past, block_myoverview {{/str}}{{/past}}
+            {{#favourites}}{{#str}} favourites, block_myoverview {{/str}}{{/favourites}}
+        </span>
     </button>
-    <ul class="dropdown-menu" data-show-active-item aria-labelledby="groupingdropdown">
+    <ul class="dropdown-menu" data-show-active-item data-active-item-text aria-labelledby="groupingdropdown">
         <li>
-            <a class="dropdown-item active" href="#" data-filter="grouping" data-value="all" aria-label="{{#str}} aria:allcourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item {{#all}}active{{/all}}" href="#" data-filter="grouping" data-value="all" data-pref="all" aria-label="{{#str}} aria:allcourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
                 {{#str}} all, block_myoverview {{/str}}
             </a>
         </li>
         <li>
-            <a class="dropdown-item" href="#" data-filter="grouping" data-value="inprogress" aria-label="{{#str}} aria:inprogress, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item {{#inprogress}}active{{/inprogress}}" href="#" data-filter="grouping" data-value="inprogress" data-pref="inprogress" aria-label="{{#str}} aria:inprogress, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
                 {{#str}} inprogress, block_myoverview {{/str}}
             </a>
         </li>
         <li>
-            <a class="dropdown-item" href="#" data-filter="grouping" data-value="future" aria-label="{{#str}} aria:future, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item {{#future}}active{{/future}}" href="#" data-filter="grouping" data-value="future" data-pref="future" aria-label="{{#str}} aria:future, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
                 {{#str}} future, block_myoverview {{/str}}
             </a>
         </li>
         <li>
-            <a class="dropdown-item" href="#" data-filter="grouping" data-value="past" aria-label="{{#str}} aria:past, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item {{#past}}active{{/past}}" href="#" data-filter="grouping" data-value="past" data-pref="past" aria-label="{{#str}} aria:past, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
                 {{#str}} past, block_myoverview {{/str}}
             </a>
         </li>
+        <li>
+            <a class="dropdown-item {{#favourites}}active{{/favourites}}" href="#" data-filter="grouping" data-value="favourites"  data-pref="favourites" aria-label="{{#str}} aria:favourites, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+                {{#str}} favourites, block_myoverview {{/str}}
+            </a>
+        </li>
     </ul>
 </div>
index e647be9..2567555 100644 (file)
     This template renders sorting dropdown.
 
     Example context (json):
-    {}
+    {
+        "title": false,
+        "lastaccessed": true
+    }
 }}
 
-<div class="dropdown mr-1 ml-auto">
-    {{#str}} sortby, core {{/str}}
-    <button id="sortingdropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown"  aria-haspopup="true" aria-expanded="false" aria-label="{{#str}} aria:sortingdropdown, block_myoverview {{/str}}">
-        <span class="d-sm-inline-block">{{#str}} title, block_myoverview {{/str}}</span>
-    </button>
-    <ul class="dropdown-menu" data-show-active-item aria-labelledby="sortingdropdown">
-        <li>
-            <a class="dropdown-item active" href="#" data-filter="sort" data-value="fullname" aria-label="{{#str}} aria:title, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
-                {{#str}} title, block_myoverview {{/str}}
-            </a>
-        </li>
-        <li>
-            <a class="dropdown-item" href="#" data-filter="sort" data-value="ul.timeaccess desc" aria-label="{{#str}} aria:lastaccessed, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
-                {{#str}} lastaccessed, block_myoverview {{/str}}
-            </a>
-        </li>
-    </ul>
+<div class="m-b-1 mr-1 d-flex align-items-center">
+    <div class="d-none d-md-inline-block mr-1">{{#str}} sortby, core {{/str}}</div>
+    <div class="dropdown">
+        <button id="sortingdropdown" type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown"  aria-haspopup="true" aria-expanded="false" aria-label="{{#str}} aria:sortingdropdown, block_myoverview {{/str}}">
+            <span data-active-item-text>
+                {{#title}}{{#str}} title, block_myoverview {{/str}}{{/title}}
+                {{#lastaccessed}}{{#str}} lastaccessed, block_myoverview {{/str}}{{/lastaccessed}}
+            </span>
+        </button>
+        <ul class="dropdown-menu" data-show-active-item aria-labelledby="sortingdropdown">
+            <li>
+                <a class="dropdown-item {{#title}}active{{/title}}" href="#" data-filter="sort" data-pref="title" data-value="fullname" aria-label="{{#str}} aria:title, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+                    {{#str}} title, block_myoverview {{/str}}
+                </a>
+            </li>
+            <li>
+                <a class="dropdown-item {{#lastaccessed}}active{{/lastaccessed}}" href="#" data-filter="sort" data-pref="lastaccessed" data-value="ul.timeaccess desc" aria-label="{{#str}} aria:lastaccessed, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+                    {{#str}} lastaccessed, block_myoverview {{/str}}
+                </a>
+            </li>
+        </ul>
+    </div>
 </div>
\ No newline at end of file
index 86b681e..e377042 100644 (file)
@@ -22,7 +22,7 @@
     Example context (json):
     {}
 }}
-<div class="card course-card border-0">
+<div class="card dashboard-card border-0">
     <div class="card-img-top bg-pulse-grey w-100" style="height: 7rem">
     </div>
     <div class="card-body course-info-container">
index 8b0eaa3..ce97c1f 100644 (file)
     }
 }}
 
-<div class="row card-deck" role="list">
+<div class="card-deck dashboard-card-deck" role="list">
 {{#courses}}
-    <div class="card course-card" role="listitem">
-        <a href="{{viewurl}}" tabindex="-1" role="presentation">
-            <div class="card-img-top myoverviewimg" style='background-image: url("{{{courseimage}}}");'>
+    <div class="card dashboard-card" role="listitem">
+        <a href="{{viewurl}}" tabindex="-1">
+            <div class="card-img dashboard-card-img" style='background-image: url("{{{courseimage}}}");'>
                 <span class="sr-only">{{#str}}aria:courseimage, block_myoverview{{/str}}</span>
+                {{> block_myoverview/favourite-icon }}
             </div>
         </a>
-        <div class="card-body course-info-container" id="course-info-container-{{id}}">
-            <div class="d-flex">
-                <div class="card-title">
+        <div class="card-body pr-1 course-info-container" id="course-info-container-{{id}}">
+            <div class="d-flex align-items-start">
+                <div class="card-title mr-2">
                     <a href="{{viewurl}}">
                         <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
-                        {{#shortentext}}140, {{{fullname}}} {{/shortentext}}
+                        {{#shortentext}}40, {{{fullname}}} {{/shortentext}}
                     </a>
                 </div>
+                {{> block_myoverview/course-action-menu }}
             </div>
         </div>
         {{#hasprogress}}
-        <div class="card-footer course-card-footer">
+        <div class="card-footer dashboard-card-footer">
             {{> block_myoverview/progress-bar}}
         </div>
         {{/hasprogress}}
index 18e45ad..389036b 100644 (file)
     }
 }}
 
-<ul class="list-group" role="list">
+<ul class="list-group">
 {{#courses}}
-    <li class="list-group-item course-listitem" role="listitem">
+    <li class="list-group-item course-listitem">
         <div class="row-fluid">
-            <div class="{{#hasprogress}}col-6 span6{{/hasprogress}}{{^hasprogress}}col-12 span12{{/hasprogress}}">
-                <a href="{{viewurl}}">
-                    <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
-                    {{{fullname}}}
-                </a>
+            <div class="{{#hasprogress}}col-6 span6{{/hasprogress}}{{^hasprogress}}col-11 span11{{/hasprogress}} p-l-0">
+                <div class="d-flex align-items-center">
+                    {{> block_myoverview/favourite-icon }}
+                    <a href="{{viewurl}}">
+                        <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+                        {{{fullname}}}
+                    </a>
+                </div>
             </div>
             {{#hasprogress}}
-            <div class="col-6 span6">
+            <div class="col-5 span5">
                 {{> block_myoverview/progress-bar}}
             </div>
             {{/hasprogress}}
+            <div class="col-1 span1 p-0 d-flex">
+                {{> block_myoverview/course-action-menu }}
+            </div>
         </div>
     </li>
 {{/courses}}
index 8f9531c..73779ee 100644 (file)
 {{#courses}}
     <div class="course-summaryitem m-b-1 p-2" role="listitem">
         <div class="row-fluid d-flex">
-            <a href="{{viewurl}}" class="col-sm-4 col-xl-3 span4" tabindex="-1" role="presentation">
+            <a href="{{viewurl}}" class="col-sm-4 col-xl-3 span4 position-relative" tabindex="-1">
+                <div class="position-absolute">
+                    {{> block_myoverview/favourite-icon }}
+                </div>
                 <img src="{{{courseimage}}}" class="summaryimage img-fluid" alt="{{#str}}aria:courseimage, block_myoverview{{/str}}">
+
             </a>
             <div class="col-sm-8 col-xl-9 span8 align-self-stretch d-flex flex-column">
-                <a href="{{viewurl}}">
-                    <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
-                    <h4>{{{fullname}}}</h4>
-                </a>
+                <div class="d-flex">
+                    <a href="{{viewurl}}">
+                        <span class="sr-only">{{#str}}aria:coursename, block_myoverview{{/str}}</span>
+                        <h4>{{{fullname}}}</h4>
+                    </a>
+                    {{> block_myoverview/course-action-menu }}
+                </div>
                 <div class="summary">
                     <span class="sr-only">{{#str}}aria:coursesummary, block_myoverview{{/str}}</span>
                     {{{summary}}}
index 7607052..f63c118 100644 (file)
@@ -65,4 +65,96 @@ Feature: The my overview block allows users to easily access their courses
     Then I should see "Course 3" in the "Course overview" "block"
     Then I should see "Course 4" in the "Course overview" "block"
     Then I should see "Course 5" in the "Course overview" "block"
-    And I log out
\ No newline at end of file
+    And I log out
+
+  Scenario: View inprogress courses - test persistence
+    Given I log in as "student1"
+    And I click on "All" "button" in the "Course overview" "block"
+    And I click on "In progress" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "In progress" in the "Course overview" "block"
+    Then I should see "Course 2" in the "Course overview" "block"
+    Then I should see "Course 3" in the "Course overview" "block"
+    Then I should see "Course 4" in the "Course overview" "block"
+    And I should not see "Course 1" in the "Course overview" "block"
+    And I should not see "Course 5" in the "Course overview" "block"
+    And I log out
+
+  Scenario: View all courses - w/ persistence
+    Given I log in as "student1"
+    And I click on "All" "button" in the "Course overview" "block"
+    When I click on "All" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "All" in the "Course overview" "block"
+    Then I should see "Course 1" in the "Course overview" "block"
+    Then I should see "Course 2" in the "Course overview" "block"
+    Then I should see "Course 3" in the "Course overview" "block"
+    Then I should see "Course 4" in the "Course overview" "block"
+    Then I should see "Course 5" in the "Course overview" "block"
+    And I log out
+
+  Scenario: View past courses - w/ persistence
+    Given I log in as "student1"
+    And I click on "All" "button" in the "Course overview" "block"
+    When I click on "Past" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "Past" in the "Course overview" "block"
+    Then I should see "Course 1" in the "Course overview" "block"
+    And I should not see "Course 2" in the "Course overview" "block"
+    And I should not see "Course 3" in the "Course overview" "block"
+    And I should not see "Course 4" in the "Course overview" "block"
+    And I should not see "Course 5" in the "Course overview" "block"
+    And I log out
+
+  Scenario: View future courses - w/ persistence
+    Given I log in as "student1"
+    And I click on "All" "button" in the "Course overview" "block"
+    When I click on "Future" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "Future" in the "Course overview" "block"
+    Then I should see "Course 5" in the "Course overview" "block"
+    And I should not see "Course 1" in the "Course overview" "block"
+    And I should not see "Course 2" in the "Course overview" "block"
+    And I should not see "Course 3" in the "Course overview" "block"
+    And I should not see "Course 4" in the "Course overview" "block"
+    And I log out
+
+  Scenario: List display  persistence
+    Given I log in as "student1"
+    And I click on "Display dropdown" "button" in the "Course overview" "block"
+    And I click on "List" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "List" in the "Course overview" "block"
+    And "[data-display='list']" "css_element" in the "Course overview" "block" should be visible
+
+  Scenario: Cards display  persistence
+    Given I log in as "student1"
+    And I click on "Display dropdown" "button" in the "Course overview" "block"
+    And I click on "Card" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "Card" in the "Course overview" "block"
+    And "[data-display='cards']" "css_element" in the "Course overview" "block" should be visible
+
+  Scenario: Summary display  persistence
+    Given I log in as "student1"
+    And I click on "Display dropdown" "button" in the "Course overview" "block"
+    And I click on "Summary" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "Summary" in the "Course overview" "block"
+    And "[data-display='summary']" "css_element" in the "Course overview" "block" should be visible
+
+  Scenario: Title sort persistence
+    Given I log in as "student1"
+    And I click on "sortingdropdown" "button" in the "Course overview" "block"
+    And I click on "Title" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "Title" in the "Course overview" "block"
+    And "[data-sort='fullname']" "css_element" in the "Course overview" "block" should be visible
+
+  Scenario: Last accessed sort persistence
+    Given I log in as "student1"
+    And I click on "sortingdropdown" "button" in the "Course overview" "block"
+    And I click on "Last accessed" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "Last accessed" in the "Course overview" "block"
+    And "[data-sort='ul.timeaccess desc']" "css_element" in the "Course overview" "block" should be visible
\ No newline at end of file
diff --git a/blocks/myoverview/tests/behat/block_myoverview_favourite.feature b/blocks/myoverview/tests/behat/block_myoverview_favourite.feature
new file mode 100644 (file)
index 0000000..973b634
--- /dev/null
@@ -0,0 +1,66 @@
+@block @block_myoverview @javascript
+Feature: The my overview block allows users to favourite their courses
+  In order to enable the my overview block in a course
+  As a student
+  I can add the my overview block to my dashboard
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                | idnumber |
+      | student1 | Student   | X        | student1@example.com | S1       |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
+      | Course 3 | C3        | 0        |
+      | Course 4 | C4        | 0        |
+      | Course 5 | C5        | 0        |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+
+  Scenario: Favourite a course on a course card
+    Given I log in as "student1"
+    When I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 2')]" "xpath_element"
+    And I reload the page
+    Then "//div[@role='listitem' and contains(.,'Course 2')]//span[@data-region='is-favourite' and @aria-hidden='false']" "xpath_element" should exist
+    And "//div[@role='listitem' and contains(.,'Course 2')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should not exist
+    And "//div[@role='listitem' and contains(.,'Course 2')]//span[@data-region='not-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And "//div[@role='listitem' and contains(.,'Course 1')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And "//div[@role='listitem' and contains(.,'Course 3')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And I log out
+
+  Scenario: Star a course and switch display to list
+    Given I log in as "student1"
+    When I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+    And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+    And I reload the page
+    And I click on "Display dropdown" "button" in the "Course overview" "block"
+    And I click on "List" "link" in the "Course overview" "block"
+    And I reload the page
+    Then "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 5')]//span[@data-region='is-favourite' and @aria-hidden='false']" "xpath_element" should exist
+    And "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 5')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should not exist
+    And "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 5')]//span[@data-region='not-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 1')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And "//li[contains(concat(' ', normalize-space(@class), ' '), 'list-group-item') and contains(.,'Course 3')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And I log out
+
+  Scenario: Star a course and switch display to summary
+    Given I log in as "student1"
+    When I click on ".coursemenubtn" "css_element" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+    And I click on "Star this course" "link" in the "//div[@class='card dashboard-card' and contains(.,'Course 5')]" "xpath_element"
+    And I reload the page
+    And I click on "Display dropdown" "button" in the "Course overview" "block"
+    And I click on "Summary" "link" in the "Course overview" "block"
+    And I reload the page
+    Then "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 5')]//span[@data-region='is-favourite' and @aria-hidden='false']" "xpath_element" should exist
+    And "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 5')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should not exist
+    And "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 5')]//span[@data-region='not-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 1')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And "//div[contains(concat(' ', normalize-space(@class), ' '), 'course-summaryitem') and contains(.,'Course 3')]//span[@data-region='is-favourite' and @aria-hidden='true']" "xpath_element" should exist
+    And I log out
diff --git a/blocks/myoverview/tests/privacy_test.php b/blocks/myoverview/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..5e2d031
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+/**
+ * Unit tests for the block_myoverview implementation of the privacy API.
+ *
+ * @package    block_myoverview
+ * @category   test
+ * @copyright  2018 Peter Dias <peter@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+use \core_privacy\local\request\writer;
+use \block_myoverview\privacy\provider;
+/**
+ * Unit tests for the block_myoverview implementation of the privacy API.
+ *
+ * @copyright  2018 Peter Dias <peter@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_myoverview_privacy_testcase extends \core_privacy\tests\provider_testcase {
+    /**
+     * Ensure that export_user_preferences returns no data if the user has not visited the myoverview block.
+     */
+    public function test_export_user_preferences_no_pref() {
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(\context_system::instance());
+        $this->assertFalse($writer->has_any_data());
+    }
+
+    /**
+     * Test the export_user_preferences given different inputs
+     *
+     * @param string $type The name of the user preference to get/set
+     * @param string $value The value you are storing
+     *
+     * @dataProvider user_preference_provider
+     */
+    public function test_export_user_preferences($type, $value) {
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+        set_user_preference($type, $value, $user);
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(\context_system::instance());
+        $blockpreferences = $writer->get_user_preferences('block_myoverview');
+        $this->assertEquals(get_string($value, 'block_myoverview'), $blockpreferences->{$type}->value);
+    }
+
+    /**
+     * Create an array of valid user preferences for the myoverview block.
+     *
+     * @return array Array of valid user preferences.
+     */
+    public function user_preference_provider() {
+        return array(
+            array('block_myoverview_user_sort_preference', 'lastaccessed'),
+            array('block_myoverview_user_sort_preference', 'title'),
+            array('block_myoverview_user_grouping_preference', 'all'),
+            array('block_myoverview_user_grouping_preference', 'inprogress'),
+            array('block_myoverview_user_grouping_preference', 'future'),
+            array('block_myoverview_user_grouping_preference', 'past'),
+            array('block_myoverview_user_view_preference', 'card'),
+            array('block_myoverview_user_view_preference', 'list'),
+            array('block_myoverview_user_view_preference', 'summary')
+        );
+    }
+}
\ No newline at end of file
index 566ce53..647e992 100644 (file)
@@ -139,6 +139,82 @@ class core_block_externallib_testcase extends externallib_advanced_testcase {
 
     }
 
+    /**
+     * Test get_course_blocks contents
+     */
+    public function test_get_course_blocks_contents() {
+        global $DB, $FULLME;
+
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id);
+        $coursecontext = context_course::instance($course->id);
+
+        // Create a HTML block.
+        $title = 'Some course info';
+        $body = 'Some course info<br /><p>Some contents</p>';
+        $bodyformat = FORMAT_MOODLE;
+        $page = new moodle_page();
+        $page->set_context($coursecontext);
+        $page->set_pagelayout('course');
+        $course->format = course_get_format($course)->get_format();
+        $page->set_pagetype('course-view-' . $course->format);
+        $page->blocks->load_blocks();
+        $newblock = 'html';
+        $page->blocks->add_block_at_end_of_default_region($newblock);
+
+        $this->setUser($user);
+        // Re-create the page.
+        $page = new moodle_page();
+        $page->set_context($coursecontext);
+        $page->set_pagelayout('course');
+        $course->format = course_get_format($course)->get_format();
+        $page->set_pagetype('course-view-' . $course->format);
+        $page->blocks->load_blocks();
+        $blocks = $page->blocks->get_blocks_for_region($page->blocks->get_default_region());
+        $block = end($blocks);
+        $block = block_instance('html', $block->instance);
+        $configdata = (object) [
+            'title' => $title,
+            'text' => [
+                'itemid' => 0,
+                'text' => $body,
+                'format' => $bodyformat,
+            ],
+        ];
+        $block->instance_config_save((object) $configdata);
+        $filename = 'img.png';
+        $filerecord = array(
+            'contextid' => context_block::instance($block->instance->id)->id,
+            'component' => 'block_html',
+            'filearea' => 'content',
+            'itemid' => 0,
+            'filepath' => '/',
+            'filename' => $filename,
+        );
+        // Create an area to upload the file.
+        $fs = get_file_storage();
+        // Create a file from the string that we made earlier.
+        $file = $fs->create_file_from_string($filerecord, 'some fake content (should be an image).');
+
+        // Check for the new block.
+        $result = core_block_external::get_course_blocks($course->id, true);
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $result = external_api::clean_returnvalue(core_block_external::get_course_blocks_returns(), $result);
+
+        // Expect the new block.
+        $this->assertCount(1, $result['blocks']);
+        $this->assertEquals($title, $result['blocks'][0]['contents']['title']);
+        $this->assertEquals($body, $result['blocks'][0]['contents']['content']);
+        $this->assertEquals(FORMAT_HTML, $result['blocks'][0]['contents']['contentformat']);    // Format change for external.
+        $this->assertEquals('', $result['blocks'][0]['contents']['footer']);
+        $this->assertCount(1, $result['blocks'][0]['contents']['files']);
+        $this->assertEquals($newblock, $result['blocks'][0]['name']);
+    }
+
     /**
      * Test user get default dashboard blocks.
      */
index c42d1ec..6ed2bea 100644 (file)
@@ -5,6 +5,12 @@ information provided here is intended especially for developers.
 
 * The timeline view from block_myoverview has been split out into block_timeline.
 * External function core_blocks::get_course_blocks now returns the block visible status and weight for ordering.
+* New method added block_base::get_content_for_external(). It will return all the block contents rendered for external functions.
+  If your block is returning formatted content or provide files for download, you should override this method to use the
+  external_format_text, external_format_string functions for formatting or external_util::get_area_files for files.
+  See block_html as example.
+* External functions core_block::get_course_blocks and core_block::get_dashboard_blocks have a new parameter to indicate if
+  you want to receive the block contents.
 
 === 3.4 ===
 
index 9b9e371..d48ecab 100644 (file)
@@ -872,6 +872,11 @@ $CFG->admin = 'admin';
 // and 'gsdll32.dll' to a new folder without a space in the path)
 //      $CFG->pathtogs = '/usr/bin/gs';
 //
+// Path to PHP CLI.
+// Probably something like /usr/bin/php. If you enter this, cron scripts can be
+// executed from admin web interface.
+// $CFG->pathtophp = '';
+//
 // Path to du.
 // Probably something like /usr/bin/du. If you enter this, pages that display
 // directory contents will run much faster for directories with a lot of files.
index 032c018..9714b2d 100644 (file)
@@ -35,12 +35,26 @@ use moodle_url;
  */
 class course_summary_exporter extends \core\external\exporter {
 
+    /**
+     * Constructor - saves the persistent object, and the related objects.
+     *
+     * @param mixed $data - Either an stdClass or an array of values.
+     * @param array $related - An optional list of pre-loaded objects related to this object.
+     */
+    public function __construct($data, $related = array()) {
+        if (!array_key_exists('isfavourite', $related)) {
+            $related['isfavourite'] = false;
+        }
+        parent::__construct($data, $related);
+    }
+
     protected static function define_related() {
         // We cache the context so it does not need to be retrieved from the course.
-        return array('context' => '\\context');
+        return array('context' => '\\context', 'isfavourite' => 'bool?');
     }
 
     protected function get_other_values(renderer_base $output) {
+
         $courseimage = self::get_course_image($this->data);
         if (!$courseimage) {
             $courseimage = self::get_course_pattern($this->data);
@@ -56,7 +70,8 @@ class course_summary_exporter extends \core\external\exporter {
             'viewurl' => (new moodle_url('/course/view.php', array('id' => $this->data->id)))->out(false),
             'courseimage' => $courseimage,
             'progress' => $progress,
-            'hasprogress' => $hasprogress
+            'hasprogress' => $hasprogress,
+            'isfavourite' => $this->related['isfavourite']
         );
     }
 
@@ -119,6 +134,9 @@ class course_summary_exporter extends \core\external\exporter {
             ),
             'hasprogress' => array(
                 'type' => PARAM_BOOL
+            ),
+            'isfavourite' => array(
+                'type' => PARAM_BOOL
             )
         );
     }
index ea887ce..0873f46 100644 (file)
@@ -3663,6 +3663,8 @@ class core_course_external extends external_api {
                 break;
             case COURSE_TIMELINE_FUTURE:
                 break;
+            case COURSE_FAVOURITES:
+                break;
             default:
                 throw new invalid_parameter_exception('Invalid classification');
         }
@@ -3672,17 +3674,42 @@ class core_course_external extends external_api {
         $requiredproperties = course_summary_exporter::define_properties();
         $fields = join(',', array_keys($requiredproperties));
         $courses = course_get_enrolled_courses_for_logged_in_user(0, $offset, $sort, $fields);
-        list($filteredcourses, $processedcount) = course_filter_courses_by_timeline_classification(
-            $courses,
-            $classification,
-            $limit
-        );
+
+        $favouritecourseids = [];
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context(\context_user::instance($USER->id));
+        $favourites = $ufservice->find_favourites_by_type('core_course', 'courses');
+
+        if ($favourites) {
+            $favouritecourseids = array_map(
+                function($favourite) {
+                    return $favourite->itemid;
+                }, $favourites);
+        }
+
+        if ($classification == COURSE_FAVOURITES) {
+            list($filteredcourses, $processedcount) = course_filter_courses_by_favourites(
+                $courses,
+                $favouritecourseids,
+                $limit
+            );
+
+        } else {
+            list($filteredcourses, $processedcount) = course_filter_courses_by_timeline_classification(
+                $courses,
+                $classification,
+                $limit
+            );
+        }
 
         $renderer = $PAGE->get_renderer('core');
-        $formattedcourses = array_map(function($course) use ($renderer) {
+        $formattedcourses = array_map(function($course) use ($renderer, $favouritecourseids) {
             context_helper::preload_from_record($course);
             $context = context_course::instance($course->id);
-            $exporter = new course_summary_exporter($course, ['context' => $context]);
+            $isfavourite = false;
+            if (in_array($course->id, $favouritecourseids)) {
+                $isfavourite = true;
+            }
+            $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => $isfavourite]);
             return $exporter->export($renderer);
         }, $filteredcourses);
 
@@ -3705,4 +3732,114 @@ class core_course_external extends external_api {
             )
         );
     }
+
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     */
+    public static function set_favourite_courses_parameters() {
+        return new external_function_parameters(
+            array(
+                'courses' => new external_multiple_structure(
+                    new external_single_structure(
+                        array(
+                            'id' => new external_value(PARAM_INT, 'course ID'),
+                            'favourite' => new external_value(PARAM_BOOL, 'favourite status')
+                        )
+                    )
+                )
+            )
+        );
+    }
+
+    /**
+     * Set the course favourite status for an array of courses.
+     *
+     * @param  array $courses List with course id's and favourite status.
+     * @return array Array with an array of favourite courses.
+     */
+    public static function set_favourite_courses(
+        array $courses
+    ) {
+        global $USER;
+
+        $params = self::validate_parameters(self::set_favourite_courses_parameters(),
+            array(
+                'courses' => $courses
+            )
+        );
+
+        $warnings = [];
+
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context(\context_user::instance($USER->id));
+
+        foreach ($params['courses'] as $course) {
+
+            $warning = [];
+
+            $favouriteexists = $ufservice->favourite_exists('core_course', 'courses', $course['id'], \context_system::instance());
+
+            if ($course['favourite']) {
+                if (!$favouriteexists) {
+                    try {
+                        $ufservice->create_favourite('core_course', 'courses', $course['id'], \context_system::instance());
+                    } catch (Exception $e) {
+                        $warning['courseid'] = $course['id'];
+                        if ($e instanceof moodle_exception) {
+                            $warning['warningcode'] = $e->errorcode;
+                        } else {
+                            $warning['warningcode'] = $e->getCode();
+                        }
+                        $warning['message'] = $e->getMessage();
+                        $warnings[] = $warning;
+                        $warnings[] = $warning;
+                    }
+                } else {
+                    $warning['courseid'] = $course['id'];
+                    $warning['warningcode'] = 'coursealreadyfavourited';
+                    $warning['message'] = 'Course already favourited';
+                    $warnings[] = $warning;
+                }
+            } else {
+                if ($favouriteexists) {
+                    try {
+                        $ufservice->delete_favourite('core_course', 'courses', $course['id'], \context_system::instance());
+                    } catch (Exception $e) {
+                        $warning['courseid'] = $course['id'];
+                        if ($e instanceof moodle_exception) {
+                            $warning['warningcode'] = $e->errorcode;
+                        } else {
+                            $warning['warningcode'] = $e->getCode();
+                        }
+                        $warning['message'] = $e->getMessage();
+                        $warnings[] = $warning;
+                        $warnings[] = $warning;
+                    }
+                } else {
+                    $warning['courseid'] = $course['id'];
+                    $warning['warningcode'] = 'cannotdeletefavourite';
+                    $warning['message'] = 'Could not delete favourite status for course';
+                    $warnings[] = $warning;
+                }
+            }
+        }
+
+        return [
+            'warnings' => $warnings
+        ];
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_description
+     */
+    public static function set_favourite_courses_returns() {
+        return new external_single_structure(
+            array(
+                'warnings' => new external_warnings()
+            )
+        );
+    }
 }
index cba9402..f063649 100644 (file)
@@ -59,6 +59,7 @@ define('COURSE_TIMELINE_ALL', 'all');
 define('COURSE_TIMELINE_PAST', 'past');
 define('COURSE_TIMELINE_INPROGRESS', 'inprogress');
 define('COURSE_TIMELINE_FUTURE', 'future');
+define('COURSE_FAVOURITES', 'favourites');
 define('COURSE_DB_QUERY_LIMIT', 1000);
 
 function make_log_url($module, $url) {
@@ -4271,6 +4272,51 @@ function course_filter_courses_by_timeline_classification(
     return [$filteredcourses, $numberofcoursesprocessed];
 }
 
+/**
+ * Search the given $courses for any that match the given $classification up to the specified
+ * $limit.
+ *
+ * This function will return the subset of courses that are favourites as well as the
+ * number of courses it had to process to build that subset.
+ *
+ * It is recommended that for larger sets of courses this function is given a Generator that loads
+ * the courses from the database in chunks.
+ *
+ * @param array|Traversable $courses List of courses to process
+ * @param array $favouritecourseids Array of favourite courses.
+ * @param int $limit Limit the number of results to this amount
+ * @return array First value is the filtered courses, second value is the number of courses processed
+ */
+function course_filter_courses_by_favourites(
+    $courses,
+    $favouritecourseids,
+    int $limit = 0
+) : array {
+
+    $filteredcourses = [];
+    $numberofcoursesprocessed = 0;
+    $filtermatches = 0;
+
+    foreach ($courses as $course) {
+        $numberofcoursesprocessed++;
+
+        if (in_array($course->id, $favouritecourseids)) {
+            $filteredcourses[] = $course;
+            $filtermatches++;
+        }
+
+        if ($limit && $filtermatches >= $limit) {
+            // We've found the number of requested courses. No need to continue searching.
+            break;
+        }
+    }
+
+    // Return the number of filtered courses as well as the number of courses that were searched
+    // in order to find the matching courses. This allows the calling code to do some kind of
+    // pagination.
+    return [$filteredcourses, $numberofcoursesprocessed];
+}
+
 /**
  * Check module updates since a given time.
  * This function checks for updates in the module config, file areas, completion, grades, comments and ratings.
index 5bef53f..eb3995d 100644 (file)
@@ -34,7 +34,7 @@ Feature: Course category management interface performs as expected
     And I should not see "Course categories and courses" in the ".view-mode-selector .menu" "css_element"
     And I should not see "Course categories" in the ".view-mode-selector .menu" "css_element"
     And I should not see "Courses" in the ".view-mode-selector .menu" "css_element"
-    When I click on "Course categories" "link" in the ".view-mode-selector" "css_element"
+    And I open the action menu in ".view-mode-selector" "css_element"
     And I start watching to see if a new page loads
     Then I should see "Course categories and courses" in the ".view-mode-selector .menu" "css_element"
     And I should see "Course categories" in the ".view-mode-selector .menu" "css_element"
@@ -55,7 +55,7 @@ Feature: Course category management interface performs as expected
     And I should see "Cat 1" in the "#course-listing h3" "css_element"
     And I should see "Cat 1" in the "#category-listing" "css_element"
     And I should see "Course 1" in the "#course-listing" "css_element"
-    When I click on "Course categories" "link" in the ".view-mode-selector" "css_element"
+    And I open the action menu in ".view-mode-selector" "css_element"
     Then I should see "Courses" in the ".view-mode-selector .menu" "css_element"
     And I click on "Courses" "link" in the ".view-mode-selector .menu" "css_element"
     And a new page should have loaded since I started watching
@@ -315,7 +315,7 @@ Feature: Course category management interface performs as expected
     And a new page should have loaded since I started watching
     And I start watching to see if a new page loads
     And I should see the "Course categories and courses" management page
-    And I click on "Sort courses" "link"
+    And I open the action menu in ".course-listing-actions" "css_element"
     And I should see "Sort by Course full name ascending" in the ".course-listing-actions" "css_element"
     And I should see "Sort by Course full name descending" in the ".course-listing-actions" "css_element"
     And I should see "Sort by Course short name ascending" in the ".course-listing-actions" "css_element"
@@ -369,7 +369,7 @@ Feature: Course category management interface performs as expected
     And a new page should have loaded since I started watching
     And I start watching to see if a new page loads
     And I should see the "Course categories and courses" management page
-    And I click on "Sort courses" "link"
+    And I open the action menu in ".course-listing-actions" "css_element"
     And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
     And a new page should have loaded since I started watching
     And I start watching to see if a new page loads
@@ -386,7 +386,7 @@ Feature: Course category management interface performs as expected
     And I should see course listing "Course 10" before "Course 11"
     And I should see course listing "Course 11" before "Course 12"
     And "#course-listing .pagination" "css_element" should not exist
-    And I click on "Per page: 20" "link" in the ".course-listing-actions" "css_element"
+    And I open the action menu in ".courses-per-page" "css_element"
     And I should see "5" in the ".courses-per-page" "css_element"
     And I should see "10" in the ".courses-per-page" "css_element"
     And I should see "20" in the ".courses-per-page" "css_element"
@@ -501,7 +501,7 @@ Feature: Course category management interface performs as expected
     And I click on "Cat 1" "link"
     # Redirect.
     And I should see the "Course categories and courses" management page
-    And I click on "Sort courses" "link"
+    And I open the action menu in ".course-listing-actions" "css_element"
     And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
     # Redirect.
     And I should see "Per page: 20" in the ".course-listing-actions" "css_element"
@@ -568,7 +568,7 @@ Feature: Course category management interface performs as expected
     And a new page should have loaded since I started watching
     And I start watching to see if a new page loads
     And I should see the "Course categories and courses" management page
-    And I click on "Sort courses" "link"
+    And I open the action menu in ".course-listing-actions" "css_element"
     And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
     And a new page should have loaded since I started watching
     And I start watching to see if a new page loads
@@ -578,7 +578,7 @@ Feature: Course category management interface performs as expected
     And I should see course listing "Course 19" before "Course 20"
     And I should not see "Course 21"
     And I should see "Showing courses 1 to 20 of 32 courses"
-    And I click on "Per page: 20" "link" in the ".course-listing-actions" "css_element"
+    And I open the action menu in ".courses-per-page" "css_element"
     And I click on "100" "link" in the ".courses-per-page" "css_element"
     And a new page should have loaded since I started watching
     And I start watching to see if a new page loads
@@ -589,7 +589,7 @@ Feature: Course category management interface performs as expected
     And I should see course listing "Course 21" before "Course 22"
     And I should see course listing "Course 31" before "Course 32"
     And "#course-listing .pagination" "css_element" should not exist
-    And I click on "Per page: 100" "link" in the ".course-listing-actions" "css_element"
+    And I open the action menu in ".courses-per-page" "css_element"
     And I click on "5" "link" in the ".courses-per-page" "css_element"
     And a new page should have loaded since I started watching
     And I start watching to see if a new page loads
index 25c5014..70f2add 100644 (file)
@@ -94,7 +94,7 @@ Feature: We can change the visibility of courses in the management interface.
     And course in management listing should be dimmed "C1"
     And I toggle visibility of category "CAT1" in management listing
     And I toggle visibility of course "C1" in management listing
-    And I click on "Course categories and courses" "link" in the ".view-mode-selector" "css_element"
+    And I open the action menu in ".view-mode-selector" "css_element"
     And I click on "Courses" "link" in the ".view-mode-selector" "css_element"
     And a new page should have loaded since I started watching
     And I start watching to see if a new page loads
index 140fd3f..cdf5ea8 100644 (file)
@@ -54,7 +54,6 @@ Feature: Managers can create courses
       | id_enddate_year | 2016 |
     And I press "Save and return"
     Then I should see the "Course categories and courses" management page
-    And I click on "Sort courses" "link"
     And I click on "Sort by Course time created ascending" "link" in the ".course-listing-actions" "css_element"
     And I should see course listing "Course 1" before "Course 2"
     And I click on "Course 2" "link" in the "region-main" "region"
index e8f9eb1..54a52ec 100644 (file)
@@ -75,7 +75,7 @@ Feature: Test we can resort course in the management interface.
     And I should not see "Sort by Course ID number descending" in the ".course-listing-actions" "css_element"
     And I should not see "Sort by Course time created ascending" in the ".course-listing-actions" "css_element"
     And I should not see "Sort by Course time created descending" in the ".course-listing-actions" "css_element"
-    And I click on "Sort courses" "link"
+    And I open the action menu in ".course-listing-actions" "css_element"
     And I should see "Sort by Course full name ascending" in the ".course-listing-actions" "css_element"
     And I should see "Sort by Course full name descending" in the ".course-listing-actions" "css_element"
     And I should see "Sort by Course short name ascending" in the ".course-listing-actions" "css_element"
@@ -119,7 +119,7 @@ Feature: Test we can resort course in the management interface.
     And I should see the "Course categories and courses" management page
     And I should see "Course categories" in the "#category-listing h3" "css_element"
     And I should see "Cat 1" in the "#category-listing" "css_element"
-    And I click on "Sort courses" "link"
+    And I open the action menu in ".course-listing-actions" "css_element"
     And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
     # Redirect.
     And I should see the "Course categories and courses" management page
@@ -158,7 +158,7 @@ Feature: Test we can resort course in the management interface.
     And I should see the "Course categories and courses" management page
     And I should see "Course categories" in the "#category-listing h3" "css_element"
     And I should see "Cat 1" in the "#category-listing" "css_element"
-    And I click on "Sort courses" "link"
+    And I open the action menu in ".course-listing-actions" "css_element"
     And I click on "Sort by Course ID number ascending" "link" in the ".course-listing-actions" "css_element"
     And a new page should have loaded since I started watching
     And I start watching to see if a new page loads
index d3b6f6d..16718b8 100644 (file)
@@ -135,4 +135,25 @@ class user_favourite_service {
 
         $this->repo->delete($favourite->id);
     }
+
+    /**
+     * Check whether an item has been marked as a favourite in the respective area.
+     *
+     * @param string $component the frankenstyle component name.
+     * @param string $itemtype the type of the favourited item.
+     * @param int $itemid the id of the item which was favourited (not the favourite's id).
+     * @param \context $context the context of the item which was favourited.
+     * @return bool true if the item is favourited, false otherwise.
+     */
+    public function favourite_exists(string $component, string $itemtype, int $itemid, \context $context) : bool {
+        return $this->repo->exists_by(
+            [
+                'userid' => $this->userid,
+                'component' => $component,
+                'itemtype' => $itemtype,
+                'itemid' => $itemid,
+                'contextid' => $context->id
+            ]
+        );
+    }
 }
index 6600c2d..939e544 100644 (file)
@@ -126,6 +126,19 @@ class user_favourite_service_testcase extends advanced_testcase {
                 return array_key_exists($id, $mockstore);
             })
         );
+        $mockrepo->expects($this->any())
+            ->method('exists_by')
+            ->will($this->returnCallback(function(array $criteria) use (&$mockstore) {
+                // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
+                foreach ($mockstore as $index => $mockrow) {
+                    $mockrowarr = (array)$mockrow;
+                    if (array_diff($criteria, $mockrowarr) == []) {
+                        return true;
+                    }
+                }
+                return false;
+            })
+        );
         $mockrepo->expects($this->any())
             ->method('delete')
             ->will($this->returnCallback(function(int $id) use (&$mockstore) {
@@ -305,4 +318,38 @@ class user_favourite_service_testcase extends advanced_testcase {
         $this->expectException(\moodle_exception::class);
         $service->delete_favourite('core_course', 'course', $course1context->instanceid, $course1context);
     }
+
+    /**
+     * Test confirming the behaviour of the favourite_exists() method.
+     */
+    public function test_favourite_exists() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for the user.
+        $repo = $this->get_mock_repository([]);
+        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+        // Favourite a course.
+        $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+
+        // Verify we can check existence of the favourite.
+        $this->assertTrue(
+            $service->favourite_exists(
+                'core_course',
+                'course',
+                $course1context->instanceid,
+                $course1context
+            )
+        );
+
+        // And one that we know doesn't exist.
+        $this->assertFalse(
+            $service->favourite_exists(
+                'core_course',
+                'someothertype',
+                $course1context->instanceid,
+                $course1context
+            )
+        );
+    }
 }
index e597adf..ce93f05 100644 (file)
@@ -29,6 +29,8 @@ defined('MOODLE_INTERNAL') || die();
 use \core_privacy\local\metadata\collection;
 use \core_privacy\local\request\contextlist;
 use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\userlist;
+use \core_privacy\local\request\approved_userlist;
 
 /**
  * Privacy class for requesting user data.
@@ -37,7 +39,10 @@ use \core_privacy\local\request\approved_contextlist;
  * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\plugin\provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\core_userlist_provider,
+        \core_privacy\local\request\plugin\provider {
 
     /**
      * Returns meta data about this system.
@@ -65,6 +70,14 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
         return $contextlist;
     }
 
+    /**
+     * Get the list of users who have data within a context.
+     *
+     * @param   userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -81,6 +94,14 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
     public static function delete_data_for_all_users_in_context(\context $context) {
     }
 
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param   approved_userlist       $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+    }
+
     /**
      * Delete all user data for the specified user, in the specified contexts.
      *
index 3303bdc..b7da964 100644 (file)
@@ -53,7 +53,6 @@ Feature: Editing a grade item
     And I press "Save changes"
 
   Scenario: Being able to change the grade type, scale and maximum grade for a grade category when there are no overridden grades
-    And I click on "Edit" "link" in the "EN Cat 1" "table_row"
     When I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
     Then I should not see "This category has associated grade items which have been overridden. Therefore some grades have already been awarded"
     And I set the field "Grade type" to "Scale"
@@ -62,7 +61,6 @@ Feature: Editing a grade item
     And I set the field "Scale" to "EN ABCDEF"
     And I press "Save changes"
     And I should not see "You cannot change the type, as grades already exist for this item"
-    And I click on "Edit" "link" in the "EN Cat 1" "table_row"
     And I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
     And I should not see "This category has associated grade items which have been overridden. Therefore some grades have already been awarded"
     And I set the field "Scale" to "EN Letter scale"
@@ -75,14 +73,12 @@ Feature: Editing a grade item
     And I give the grade "20.00" to the user "Student 1" for the grade item "EN Cat 1 total"
     And I press "Save changes"
     And I navigate to "Setup > Gradebook setup" in the course gradebook
-    And I click on "Edit" "link" in the "EN Cat 1" "table_row"
     When I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
     Then I should see "This category has associated grade items which have been overridden. Therefore some grades have already been awarded, so the grade type cannot be changed. If you wish to change the maximum grade, you must first choose whether or not to rescale existing grades."
     And "//div[contains(concat(' ', normalize-space(@class), ' '), 'felement') and contains(text(), 'Value')]" "xpath_element" should exist
 
   Scenario: Attempting to change a category item's scale when overridden grades already exist
-    Given I click on "Edit" "link" in the "EN Cat 1" "table_row"
-    And I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
+    Given I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
     And I set the field "Grade type" to "Scale"
     And I set the field "Scale" to "ABCDEF"
     And I press "Save changes"
@@ -91,7 +87,6 @@ Feature: Editing a grade item
     And I give the grade "C" to the user "Student 1" for the grade item "EN Cat 1 total"
     And I press "Save changes"
     And I navigate to "Setup > Gradebook setup" in the course gradebook
-    And I click on "Edit" "link" in the "EN Cat 1" "table_row"
     When I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
     Then I should see "This category has associated grade items which have been overridden. Therefore some grades have already been awarded, so the grade type and scale cannot be changed."
     And "//div[contains(concat(' ', normalize-space(@class), ' '), 'felement') and contains(text(), 'ABCDEF')]" "xpath_element" should exist
@@ -102,7 +97,6 @@ Feature: Editing a grade item
     And I give the grade "20.00" to the user "Student 1" for the grade item "EN Cat 1 total"
     And I press "Save changes"
     And I navigate to "Setup > Gradebook setup" in the course gradebook
-    And I click on "Edit" "link" in the "EN Cat 1" "table_row"
     And I click on "Edit settings" "link" in the "EN Cat 1" "table_row"
     And I set the field "Maximum grade" to "50"
     When I press "Save changes"
index 1ed3712..874cd9b 100644 (file)
@@ -41,7 +41,6 @@ Feature: We can change the maximum and minimum number of points for manual items
     And I give the grade "8.00" to the user "Student 2" for the grade item "Manual item 1"
     And I press "Save changes"
     When I navigate to "Setup > Gradebook setup" in the course gradebook
-    And I click on "Edit" "link" in the "Manual item 1" "table_row"
     And I click on "Edit settings" "link" in the "Manual item 1" "table_row"
     And I set the following fields to these values:
       | Maximum grade | 10 |
@@ -57,7 +56,6 @@ Feature: We can change the maximum and minimum number of points for manual items
       | Grade item    | Calculated weight | Grade  | Contribution to course total |
       | Manual item 1 | 100.00 %          | 8.00   | 80.00 %                      |
     And I navigate to "Setup > Gradebook setup" in the course gradebook
-    And I click on "Edit" "link" in the "Manual item 1" "table_row"
     And I click on "Edit settings" "link" in the "Manual item 1" "table_row"
     And I set the following fields to these values:
       | Maximum grade | 20 |
index a78a213..cd9392e 100644 (file)
@@ -38,16 +38,14 @@ Feature: Grade item validation
     And I press "Save changes"
 
   Scenario: Being able to change the grade type, scale and maximum grade for a manual grade item when there are no grades
-    Given I click on "Edit" "link" in the "EN MI 1" "table_row"
-    When I click on "Edit settings" "link" in the "EN MI 1" "table_row"
-    Then I should not see "Some grades have already been awarded, so the grade type"
-    And I set the field "Grade type" to "Scale"
+    Given I click on "Edit settings" "link" in the "EN MI 1" "table_row"
+    When I should not see "Some grades have already been awarded, so the grade type"
+    Then I set the field "Grade type" to "Scale"
     And I press "Save changes"
     And I should see "Scale must be selected"
     And I set the field "Scale" to "EN ABCDEF"
     And I press "Save changes"
     And I should not see "You cannot change the type, as grades already exist for this item"
-    And I click on "Edit" "link" in the "MI 1" "table_row"
     And I click on "Edit settings" "link" in the "EN MI 1" "table_row"
     And I should not see "Some grades have already been awarded, so the grade type"
     And I set the field "Scale" to "EN Letter scale"
@@ -60,14 +58,12 @@ Feature: Grade item validation
     And I give the grade "20.00" to the user "Student 1" for the grade item "EN MI 1"
     And I press "Save changes"
     And I navigate to "Setup > Gradebook setup" in the course gradebook
-    And I click on "Edit" "link" in the "EN MI 1" "table_row"
     When I click on "Edit settings" "link" in the "EN MI 1" "table_row"
     Then I should see "Some grades have already been awarded, so the grade type cannot be changed. If you wish to change the maximum grade, you must first choose whether or not to rescale existing grades."
     And "//div[contains(concat(' ', normalize-space(@class), ' '), 'felement') and contains(text(), 'Value')]" "xpath_element" should exist
 
   Scenario: Attempting to change a manual item's scale when grades already exist
-    Given I click on "Edit" "link" in the "EN MI 1" "table_row"
-    And I click on "Edit settings" "link" in the "EN MI 1" "table_row"
+    Given I click on "Edit settings" "link" in the "EN MI 1" "table_row"
     And I set the field "Grade type" to "Scale"
     And I set the field "Scale" to "EN ABCDEF"
     And I press "Save changes"
@@ -76,7 +72,6 @@ Feature: Grade item validation
     And I give the grade "C" to the user "Student 1" for the grade item "EN MI 1"
     And I press "Save changes"
     And I navigate to "Setup > Gradebook setup" in the course gradebook
-    And I click on "Edit" "link" in the "EN MI 1" "table_row"
     When I click on "Edit settings" "link" in the "EN MI 1" "table_row"
     Then I should see "Some grades have already been awarded, so the grade type and scale cannot be changed."
     And "//div[contains(concat(' ', normalize-space(@class), ' '), 'felement') and contains(text(), 'ABCDEF')]" "xpath_element" should exist
@@ -87,7 +82,6 @@ Feature: Grade item validation
     And I give the grade "20.00" to the user "Student 1" for the grade item "EN MI 1"
     And I press "Save changes"
     And I navigate to "Setup > Gradebook setup" in the course gradebook
-    And I click on "Edit" "link" in the "MI 1" "table_row"
     And I click on "Edit settings" "link" in the "EN MI 1" "table_row"
     And I set the field "Maximum grade" to "50"
     When I press "Save changes"
index 46c8cb3..a453d2a 100644 (file)
@@ -91,7 +91,7 @@ Feature: Control the aggregation of the scales
     And I navigate to "Setup > Gradebook setup" in the course gradebook
     And I set the field "Override weight of Grade me" to "1"
     Then the field "Override weight of Grade me" matches value "100.00"
-    And I click on "Edit" "link" in the "Scale me" "table_row"
+    And I open the action menu in "Scale me" "table_row"
     And I click on "Edit settings" "link" in the "Scale me" "table_row"
     And I follow "Show more..."
     And I should not see "Weight adjusted"
@@ -104,7 +104,7 @@ Feature: Control the aggregation of the scales
     And the field "Override weight of Grade me" matches value "95.238"
     And I set the field "Override weight of Scale me" to "1"
     And the field "Override weight of Scale me" matches value "4.8"
-    And I click on "Edit" "link" in the "Scale me" "table_row"
+    And I open the action menu in "Scale me" "table_row"
     And I click on "Edit settings" "link" in the "Scale me" "table_row"
     And I follow "Show more..."
     And I should see "Weight adjusted"
index 1ebf853..b92dcec 100644 (file)
@@ -66,6 +66,12 @@ class group_form extends moodleform {
         $mform->addHelpButton('enrolmentkey', 'enrolmentkey', 'group');
         $mform->setType('enrolmentkey', PARAM_RAW);
 
+        // Group conversation messaging.
+        if (\core_message\api::can_create_group_conversation($USER->id, $coursecontext)) {
+            $mform->addElement('selectyesno', 'enablemessaging', get_string('enablemessaging', 'group'));
+            $mform->addHelpButton('enablemessaging', 'enablemessaging', 'group');
+        }
+
         $mform->addElement('static', 'currentpicture', get_string('currentpicture'));
 
         $mform->addElement('checkbox', 'deletepicture', get_string('delete'));
@@ -90,13 +96,19 @@ class group_form extends moodleform {
      * Extend the form definition after the data has been parsed.
      */
     public function definition_after_data() {
-        global $COURSE, $DB;
+        global $COURSE, $DB, $USER;
 
         $mform = $this->_form;
         $groupid = $mform->getElementValue('id');
+        $coursecontext = context_course::instance($COURSE->id);
 
         if ($group = $DB->get_record('groups', array('id' => $groupid))) {
-
+            // If can create group conversation then get if a conversation area exists and it is enabled.
+            if (\core_message\api::can_create_group_conversation($USER->id, $coursecontext)) {
+                if (\core_message\api::is_conversation_area_enabled('core_group', 'groups', $groupid, $coursecontext->id)) {
+                    $mform->getElement('enablemessaging')->setSelected(1);
+                }
+            }
             // Print picture.
             if (!($pic = print_group_picture($group, $COURSE->id, true, true, false))) {
                 $pic = get_string('none');
index f9339b9..c2edce9 100644 (file)
@@ -107,6 +107,11 @@ function groups_add_member($grouporid, $userorid, $component=null, $itemid=0) {
     // Invalidate the group and grouping cache for users.
     cache_helper::invalidate_by_definition('core', 'user_group_groupings', array(), array($userid));
 
+    // Group conversation messaging.
+    if ($conversation = \core_message\api::get_conversation_by_area('core_group', 'groups', $groupid, $context->id)) {
+        \core_message\api::add_members_to_conversation([$userid], $conversation->id);
+    }
+
     // Trigger group event.
     $params = array(
         'context' => $context,
@@ -211,6 +216,12 @@ function groups_remove_member($grouporid, $userorid) {
     // Invalidate the group and grouping cache for users.
     cache_helper::invalidate_by_definition('core', 'user_group_groupings', array(), array($userid));
 
+    // Group conversation messaging.
+    $context = context_course::instance($group->courseid);
+    if ($conversation = \core_message\api::get_conversation_by_area('core_group', 'groups', $groupid, $context->id)) {
+        \core_message\api::remove_members_from_conversation([$userid], $conversation->id);
+    }
+
     // Trigger group event.
     $params = array(
         'context' => context_course::instance($group->courseid),
@@ -233,7 +244,7 @@ function groups_remove_member($grouporid, $userorid) {
  * @return id of group or false if error
  */
 function groups_create_group($data, $editform = false, $editoroptions = false) {
-    global $CFG, $DB;
+    global $CFG, $DB, $USER;
 
     //check that courseid exists
     $course = $DB->get_record('course', array('id' => $data->courseid), '*', MUST_EXIST);
@@ -275,6 +286,21 @@ function groups_create_group($data, $editform = false, $editoroptions = false) {
     // Invalidate the grouping cache for the course
     cache_helper::invalidate_by_definition('core', 'groupdata', array(), array($course->id));
 
+    // Group conversation messaging.
+    if (\core_message\api::can_create_group_conversation($USER->id, $context)) {
+        if (!empty($data->enablemessaging)) {
+            \core_message\api::create_conversation(
+                \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+                [],
+                $group->name,
+                \core_message\api::MESSAGE_CONVERSATION_ENABLED,
+                'core_group',
+                'groups',
+                $group->id,
+                $context->id);
+        }
+    }
+
     // Trigger group event.
     $params = array(
         'context' => $context,
@@ -383,7 +409,7 @@ function groups_update_group_icon($group, $data, $editform) {
  * @return bool true or exception
  */
 function groups_update_group($data, $editform = false, $editoroptions = false) {
-    global $CFG, $DB;
+    global $CFG, $DB, $USER;
 
     $context = context_course::instance($data->courseid);
 
@@ -413,6 +439,43 @@ function groups_update_group($data, $editform = false, $editoroptions = false) {
         groups_update_group_icon($group, $data, $editform);
     }
 
+    // Group conversation messaging.
+    if (\core_message\api::can_create_group_conversation($USER->id, $context)) {
+        if ($conversation = \core_message\api::get_conversation_by_area('core_group', 'groups', $group->id, $context->id)) {
+            if ($data->enablemessaging && $data->enablemessaging != $conversation->enabled) {
+                \core_message\api::enable_conversation($conversation->id);
+            }
+            if (!$data->enablemessaging && $data->enablemessaging != $conversation->enabled) {
+                \core_message\api::disable_conversation($conversation->id);
+            }
+            \core_message\api::update_conversation_name($conversation->id, $group->name);
+        } else {
+            if (!empty($data->enablemessaging)) {
+                $conversation = \core_message\api::create_conversation(
+                    \core_message\api::MESSAGE_CONVERSATION_TYPE_GROUP,
+                    [],
+                    $group->name,
+                    \core_message\api::MESSAGE_CONVERSATION_ENABLED,
+                    'core_group',
+                    'groups',
+                    $group->id,
+                    $context->id
+                );
+
+                // Add members to conversation if they exists in the group.
+                if ($groupmemberroles = groups_get_members_by_role($group->id, $group->courseid, 'u.id')) {
+                    $users = [];
+                    foreach ($groupmemberroles as $roleid => $roledata) {
+                        foreach ($roledata->users as $member) {
+                            $users[] = $member->id;
+                        }
+                    }
+                    \core_message\api::add_members_to_conversation($users, $conversation->id);
+                }
+            }
+        }
+    }
+
     // Trigger group event.
     $params = array(
         'context' => $context,
index 8791f19..c572272 100644 (file)
@@ -528,4 +528,240 @@ class core_group_lib_testcase extends advanced_testcase {
         }
         $this->assertEquals(2, $DB->count_records('groups_members', array('groupid' => $group6->id)));
     }
+
+    /**
+     * Test groups_create_group enabling a group conversation.
+     */
+    public function test_groups_create_group_with_conversation() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $course1 = $this->getDataGenerator()->create_course();
+        $coursecontext1 = context_course::instance($course1->id);
+
+        // Create two groups and only one group with enablemessaging = 1.
+        $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 1));
+        $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 0));
+
+        $conversations = $DB->get_records('message_conversations',
+            [
+                'contextid' => $coursecontext1->id,
+                'component' => 'core_group',
+                'itemtype' => 'groups',
+                'enabled' => \core_message\api::MESSAGE_CONVERSATION_ENABLED
+            ]
+        );
+        $this->assertCount(1, $conversations);
+
+        $conversation = reset($conversations);
+        // Check groupid was stored in itemid on conversation area.
+        $this->assertEquals($group1a->id, $conversation->itemid);
+
+        $conversations = $DB->get_records('message_conversations', ['id' => $conversation->id]);
+        $this->assertCount(1, $conversations);
+
+        $conversation = reset($conversations);
+
+        // Check group name was stored in conversation.
+        $this->assertEquals($group1a->name, $conversation->name);
+    }
+
+    /**
+     * Test groups_update_group enabling and disabling a group conversation.
+     */
+    public function test_groups_update_group_conversation() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $course1 = $this->getDataGenerator()->create_course();
+        $coursecontext1 = context_course::instance($course1->id);
+
+        // Create two groups and only one group with enablemessaging = 1.
+        $group1a = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 1));
+        $group1b = $this->getDataGenerator()->create_group(array('courseid' => $course1->id, 'enablemessaging' => 0));
+
+        $conversations = $DB->get_records('message_conversations',
+            [
+                'contextid' => $coursecontext1->id,
+                'component' => 'core_group',
+                'itemtype' => 'groups',
+                'enabled' => \core_message\api::MESSAGE_CONVERSATION_ENABLED
+            ]
+        );
+        $this->assertCount(1, $conversations);
+
+        // Check that the conversation area is created when group messaging is enabled in the course group.
+        $group1b->enablemessaging = 1;
+        groups_update_group($group1b);
+
+        $conversations = $DB->get_records('message_conversations',
+            [
+                'contextid' => $coursecontext1->id,
+                'component' => 'core_group',
+                'itemtype' => 'groups',
+                'enabled' => \core_message\api::MESSAGE_CONVERSATION_ENABLED
+            ],
+        'id ASC');
+        $this->assertCount(2, $conversations);
+
+        $conversation1a = array_shift($conversations);
+        $conversation1b = array_shift($conversations);
+
+        $conversation1b = $DB->get_record('message_conversations', ['id' => $conversation1b->id]);
+
+        // Check for group1b that group name was stored in conversation.
+        $this->assertEquals($group1b->name, $conversation1b->name);
+
+        $group1b->enablemessaging = 0;
+        groups_update_group($group1b);
+        $this->assertEquals(0, $DB->get_field("message_conversations", "enabled", ['id' => $conversation1b->id]));
+
+        // Check that the name of the conversation is changed when the name of the course group is updated.
+        $group1b->name = 'New group name';
+        groups_update_group($group1b);
+        $conversation1b = $DB->get_record('message_conversations', ['id' => $conversation1b->id]);
+        $this->assertEquals($group1b->name, $conversation1b->name);
+    }
+
+    /**
+     * Test groups_add_member to conversation.
+     */
+    public function test_groups_add_member_conversation() {
+        global $DB;
+        $this->resetAfterTest();
+
+        $this->setAdminUser();