Merge branch 'MDL-63656-master' of git://github.com/mickhawkins/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 30 Oct 2018 00:49:56 +0000 (08:49 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 30 Oct 2018 00:49:56 +0000 (08:49 +0800)
252 files changed:
admin/settings/analytics.php
admin/settings/courses.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/behat/lang/en/tool_behat.php
admin/tool/behat/tests/behat/list_steps.feature
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/lp/lang/en/tool_lp.php
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
auth/classes/external.php
auth/classes/output/login.php
auth/db/lang/en/auth_db.php
auth/mnet/lang/en/auth_mnet.php
auth/tests/behat/login.feature
auth/tests/behat/verifyageofconsent.feature
auth/tests/external_test.php
availability/condition/profile/lang/en/availability_profile.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/tag_youtube/lang/en/block_tag_youtube.php
blocks/tests/externallib_test.php
blocks/timeline/lang/en/block_timeline.php
blocks/upgrade.txt
cache/stores/mongodb/lang/en/cachestore_mongodb.php
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
course/tests/externallib_test.php
course/upgrade.txt
enrol/imsenterprise/lang/en/enrol_imsenterprise.php
favourites/classes/local/service/user_favourite_service.php
favourites/tests/service_test.php
files/converter/googledrive/classes/privacy/provider.php
grade/grading/classes/privacy/provider.php
grade/grading/form/guide/tests/privacy_test.php
grade/grading/tests/fixtures/marking_guide.php
grade/grading/tests/privacy_test.php
grade/report/upgrade.txt
grade/report/user/externallib.php
grade/report/user/lib.php
grade/report/user/tests/externallib_test.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/debug.php
lang/en/group.php
lang/en/install.php
lang/en/message.php
lang/en/moodle.php
lang/en/question.php
lang/en/repository.php
lang/en/user.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/outputrenderers.php
lib/phpunit/bootstrap.php
lib/templates/course_header_image.mustache [new file with mode: 0644]
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/feedback/offline/lang/en/assignfeedback_offline.php
mod/assign/lang/en/assign.php
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/choice/classes/privacy/provider.php
mod/choice/tests/privacy_provider_test.php
mod/forum/lang/en/forum.php
mod/lti/lang/en/lti.php
mod/lti/service/gradebookservices/classes/privacy/provider.php
mod/lti/service/memberships/classes/privacy/provider.php
mod/quiz/lang/en/quiz.php
mod/quiz/report/grading/renderer.php [new file with mode: 0755]
mod/quiz/report/grading/report.php
mod/quiz/report/statistics/tests/behat/basic.feature
mod/quiz/report/statistics/tests/statistics_table_test.php
mod/quiz/tests/behat/editing_repaginate.feature
mod/quiz/tests/behat/editing_section_headings.feature
mod/quiz/tests/reportlib_test.php
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/calculated/lang/en/qtype_calculated.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
question/type/multichoice/lang/en/qtype_multichoice.php
question/type/shortanswer/lang/en/qtype_shortanswer.php
question/type/truefalse/lang/en/qtype_truefalse.php
report/performance/lang/en/report_performance.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/config.php
theme/boost/scss/moodle.scss
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/dashboard.scss [new file with mode: 0644]
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/header.mustache
theme/boost/templates/navbar.mustache
theme/boost/tests/behat/contextmenu.feature
theme/boost/tests/behat/regionmainsettingsmenu.feature
theme/bootstrapbase/config.php
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/less/moodle/bs4-compat.less
theme/bootstrapbase/less/moodle/course.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.
index cf9b286..40063a6 100644 (file)
@@ -121,6 +121,9 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configselect('moodlecourse/showreports', new lang_string('showreports'), '', 0,
         array(0 => new lang_string('no'), 1 => new lang_string('yes'))));
 
+    $temp->add(new admin_setting_configcheckbox('moodlecourse/showcourseimages', get_string('showcourseimages'),
+        get_string('showcourseimages_desc'), 1));
+
     // Files and uploads.
     $temp->add(new admin_setting_heading('filesanduploadshdr', new lang_string('filesanduploads'), ''));
 
@@ -155,7 +158,6 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
 
     $ADMIN->add('courses', $temp);
 
-
     // "courserequests" settingpage.
     $temp = new admin_settingpage('courserequest', new lang_string('courserequest'));
     $temp->add(new admin_setting_configcheckbox('enablecourserequests', new lang_string('enablecourserequests', 'admin'), new lang_string('configenablecourserequests', 'admin'), 0));
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 7aea934..f2cd279 100644 (file)
  */
 
 $string['aim'] = 'This administration tool helps developers and test writers to create .feature files describing Moodle\'s functionalities and run them automatically. Step definitions available for use in .feature files are listed below.';
-$string['allavailablesteps'] = 'All the available steps definitions';
+$string['allavailablesteps'] = 'All available step definitions';
 $string['errorbehatcommand'] = 'Error running behat CLI command. Try running "{$a} --help" manually from CLI to find out more about the problem.';
 $string['errorcomposer'] = 'Composer dependencies are not installed.';
 $string['errordataroot'] = '$CFG->behat_dataroot is not set or is invalid.';
 $string['errorsetconfig'] = '$CFG->behat_dataroot, $CFG->behat_prefix and $CFG->behat_wwwroot need to be set in config.php.';
 $string['erroruniqueconfig'] = '$CFG->behat_dataroot, $CFG->behat_prefix and $CFG->behat_wwwroot values need to be different than $CFG->dataroot, $CFG->prefix, $CFG->wwwroot, $CFG->phpunit_dataroot and $CFG->phpunit_prefix values.';
 $string['fieldvalueargument'] = 'Field value arguments';
-$string['fieldvalueargument_help'] = 'This argument should be completed by a field value, there are many field types, simple ones like checkboxes, selects or textareas or complex ones like date selectors. You can check <a href="http://docs.moodle.org/dev/Acceptance_testing#Providing_values_to_steps" target="_blank">Field values</a> to see the expected field value depending on the field type you provide.';
+$string['fieldvalueargument_help'] = 'This argument should be completed by a field value. There are many field types, including  simple ones like checkboxes, selects or textareas, or complex ones like date selectors. See the dev documentation <a href="http://docs.moodle.org/dev/Acceptance_testing" target="_blank">Acceptance_testing</a> for details of expected field values.';
 $string['giveninfo'] = 'Given. Processes to set up the environment';
 $string['infoheading'] = 'Info';
 $string['installinfo'] = 'Read {$a} for installation and tests execution info';
-$string['newstepsinfo'] = 'Read {$a} for info about how to add new steps definitions';
+$string['newstepsinfo'] = 'Read {$a} for info about how to add new step definitions';
 $string['newtestsinfo'] = 'Read {$a} for info about how to write new tests';
-$string['nostepsdefinitions'] = 'There aren\'t steps definitions matching this filters';
+$string['nostepsdefinitions'] = 'There aren\'t any step definitions matching this filter';
 $string['pluginname'] = 'Acceptance testing';
 $string['stepsdefinitionscomponent'] = 'Area';
 $string['stepsdefinitionscontains'] = 'Contains';
-$string['stepsdefinitionsfilters'] = 'Steps definitions';
+$string['stepsdefinitionsfilters'] = 'Step definitions';
 $string['stepsdefinitionstype'] = 'Type';
 $string['theninfo'] = 'Then. Checkings to ensure the outcomes are the expected ones';
 $string['unknownexceptioninfo'] = 'There was a problem with Selenium or your browser. Please ensure you are using the latest version of Selenium. Error:';
 $string['viewsteps'] = 'Filter';
-$string['wheninfo'] = 'When. Actions that provokes an event';
+$string['wheninfo'] = 'When. Action that provokes an event';
 $string['wrongbehatsetup'] = 'Something is wrong with the behat setup and so step definitions cannot be listed: <b>{$a->errormsg}</b><br/><br/>Please check:<ul>
 <li>$CFG->behat_dataroot, $CFG->behat_prefix and $CFG->behat_wwwroot are set in config.php with different values from $CFG->dataroot, $CFG->prefix and $CFG->wwwroot.</li>
 <li>You ran "{$a->behatinit}" from your Moodle root directory.</li>
index 2df5363..9ad81f9 100644 (file)
@@ -11,7 +11,7 @@ Feature: List the system steps definitions
 
   @javascript
   Scenario: Accessing the list
-    Then I should see "Steps definitions"
+    Then I should see "Step definitions"
     And I should not see "There aren't steps definitions matching this filter"
 
   @javascript
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..0c5ac5f 100644 (file)
@@ -35,7 +35,7 @@ $string['approverequest'] = 'Approve request';
 $string['bulkapproverequests'] = 'Approve requests';
 $string['bulkdenyrequests'] = 'Deny requests';
 $string['cachedef_purpose'] = 'Data purposes';
-$string['cachedef_purpose_overrides'] = 'Purpose overrides in the Data Privacy tool';
+$string['cachedef_purpose_overrides'] = 'Purpose overrides in the Data privacy tool';
 $string['cachedef_contextlevel'] = 'Context levels purpose and category';
 $string['cancelrequest'] = 'Cancel request';
 $string['cancelrequestconfirmation'] = 'Do you really want cancel this data request?';
@@ -77,7 +77,7 @@ $string['dataprivacy:downloadownrequest'] = 'Download your own exported data';
 $string['dataprivacy:downloadallrequests'] = 'Download exported data for everyone';
 $string['dataregistry'] = 'Data registry';
 $string['dataregistryinfo'] = 'The data registry enables categories (types of data) and purposes (the reasons for processing data) to be set for all content on the site - from users and courses down to activities and blocks. For each purpose, a retention period may be set. When a retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.';
-$string['dataretentionexplanation'] = 'This summary shows the default categories and purposes for retaining user\'s information on this system. Certain areas of the system may have more specific categories and purposes than those listed here.';
+$string['dataretentionexplanation'] = 'This summary shows the default categories and purposes for retaining user data. Certain areas may have more specific categories and purposes than those listed here.';
 $string['dataretentionsummary'] = 'Data retention summary';
 $string['datarequestcreatedforuser'] = 'Data request created for {$a}';
 $string['datarequestemailsubject'] = 'Data request: {$a}';
@@ -92,7 +92,8 @@ $string['deletecategorytext'] = 'Are you sure you want to delete the category \'
 $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['deleteexpireddatarequeststask'] = 'Delete expired data request export files';
+$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';
@@ -234,9 +235,9 @@ $string['requestcomments'] = 'Comments';
 $string['requestcomments_help'] = 'This box enables you to enter any further details about your data request.';
 $string['requestdenied'] = 'The request has been denied';
 $string['requestemailintro'] = 'You have received a data request:';
-$string['requestfor'] = 'Requesting for';
+$string['requestfor'] = 'User';
 $string['requestmarkedcomplete'] = 'The request has been marked as complete';
-$string['requestorigin'] = 'Request origin';
+$string['requestorigin'] = 'Site';
 $string['requestsapproved'] = 'The requests have been approved';
 $string['requestsdenied'] = 'The requests have been denied';
 $string['requeststatus'] = 'Status';
@@ -256,7 +257,7 @@ $string['requireallenddatesforuserdeletion_desc'] = 'When calculating user expir
 * the user\'s last login time is compared against the retention period for users; and
 * whether the user is actively enrolled in any courses.
 
-When checking the active enrolment of a corse, if the course has no end date then this setting is used to determine whether that course is considered active or not.
+When checking the active enrolment in a course, if the course has no end date then this setting is used to determine whether that course is considered active or not.
 
 If the course has no end date, and this setting is enabled, then the user cannot be deleted.';
 $string['requiresattention'] = 'Requires attention.';
@@ -309,7 +310,7 @@ $string['tobedeleted'] = 'Data to be deleted';
 $string['addroleoverride'] = 'Add role override';
 $string['roleoverride'] = 'Role override';
 $string['role'] = 'Role';
-$string['role_help'] = 'Which role do you wish to apply this override to';
+$string['role_help'] = 'The role which the override should apply to.';
 $string['duplicaterole'] = 'Role already specified';
 $string['purposeoverview'] = 'A purpose describes the intended use and retention policy for stored data. The basis for storing and retaining that data is also described in the purpose.';
 $string['roleoverrideoverview'] = 'The default retention policy can be overridden for specific user roles, allowing you to specify a longer, or a shorter, retention policy. A user is only expired when all of their roles have expired.';
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..99daa14 100644 (file)
@@ -30,7 +30,7 @@ Feature: Data delete from the privacy API
     And I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
     And I follow "New request"
-    And I set the field "Requesting for" to "Victim User 1"
+    And I set the field "User" to "Victim User 1"
     And I set the field "Type" to "Delete all of my personal data"
     And I press "Save changes"
     Then I should see "Victim User 1"
@@ -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"
 
@@ -93,7 +93,7 @@ Feature: Data delete from the privacy API
     And I follow "Profile" in the user menu
     And I follow "Data requests"
     And I follow "New request"
-    And I set the field "Requesting for" to "Victim User 1"
+    And I set the field "User" to "Victim User 1"
     And I set the field "Type" to "Delete all of my personal data"
     And I press "Save changes"
     Then I should see "Victim User 1"
@@ -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..b2a8235 100644 (file)
@@ -27,28 +27,28 @@ Feature: Data export from the privacy API
     Given I log in as "admin"
     And I navigate to "Users > Privacy and policies > Data requests" in site administration
     And I follow "New request"
-    And I set the field "Requesting for" to "Victim User 1"
+    And I set the field "User" to "Victim User 1"
     And I press "Save changes"
     Then I should see "Victim User 1"
     And I should see "Pending" in the "Victim User 1" "table_row"
     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:
@@ -96,7 +96,7 @@ Feature: Data export from the privacy API
     And I follow "Profile" in the user menu
     And I follow "Data requests"
     And I follow "New request"
-    And I set the field "Requesting for" to "Victim User 1"
+    And I set the field "User" to "Victim User 1"
     And I press "Save changes"
     Then I should see "Victim User 1"
     And I should see "Pending" in the "Victim User 1" "table_row"
@@ -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 f09718c..8de80cd 100644 (file)
@@ -42,7 +42,7 @@ $string['aisrequired'] = '\'{$a}\' is required';
 $string['aplanswerecreated'] = '{$a} learning plans were created.';
 $string['aplanswerecreatedmoremayrequiresync'] = '{$a} learning plans were created; more will be created during the next synchronisation.';
 $string['assigncohorts'] = 'Assign cohorts';
-$string['averageproficiencyrate'] = 'The average proficiency rate for completed learning plans based on this template is {$a} %';
+$string['averageproficiencyrate'] = 'The average proficiency rate for completed learning plans based on this template is {$a}%.';
 $string['cancelreviewrequest'] = 'Cancel review request';
 $string['cannotaddrules'] = 'This competency cannot be configured.';
 $string['cannotcreateuserplanswhentemplateduedateispassed'] = 'New learning plans cannot be created. The template due date has expired, or is about to expire.';
@@ -79,7 +79,7 @@ $string['coursecompetencyratingsarenotpushedtouserplans'] = 'Competency ratings
 $string['coursecompetencyratingsarepushedtouserplans'] = 'Competency ratings in this course are updated immediately in learning plans.';
 $string['coursecompetencyratingsquestion'] = 'When a course competency is rated, does the rating update the competency in the learning plans, or is it only applied to the course?';
 $string['coursesusingthiscompetency'] = 'Courses linked to this competency';
-$string['coveragesummary'] = '{$a->competenciescoveredcount} of {$a->competenciescount} competencies are covered ( {$a->coveragepercentage} % )';
+$string['coveragesummary'] = '{$a->competenciescoveredcount} of {$a->competenciescount} competencies are covered ( {$a->coveragepercentage}% )';
 $string['createplans'] = 'Create learning plans';
 $string['createlearningplans'] = 'Create learning plans';
 $string['crossreferencedcompetencies'] = 'Cross-referenced competencies';
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 7c08349..2cb651d 100644 (file)
@@ -336,4 +336,95 @@ class core_auth_external extends external_api {
             )
         );
     }
+
+    /**
+     * Describes the parameters for resend_confirmation_email.
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.6
+     */
+    public static function resend_confirmation_email_parameters() {
+        return new external_function_parameters(
+            array(
+                'username' => new external_value(core_user::get_property_type('username'), 'Username.'),
+                'password' => new external_value(core_user::get_property_type('password'), 'Plain text password.'),
+                'redirect' => new external_value(PARAM_LOCALURL, 'Redirect the user to this site url after confirmation.',
+                    VALUE_DEFAULT, ''),
+            )
+        );
+    }
+
+    /**
+     * Requests resend the confirmation email.
+     *
+     * @param  string $username user name
+     * @param  string $password plain text password
+     * @param  string $redirect redirect the user to this site url after confirmation
+     * @return array warnings and success status
+     * @since Moodle 3.6
+     * @throws moodle_exception
+     */
+    public static function resend_confirmation_email($username, $password, $redirect = '') {
+        global $PAGE;
+
+        $warnings = array();
+        $params = self::validate_parameters(
+            self::resend_confirmation_email_parameters(),
+            array(
+                'username' => $username,
+                'password' => $password,
+                'redirect' => $redirect,
+            )
+        );
+
+        $context = context_system::instance();
+        $PAGE->set_context($context);   // Need by internal APIs.
+        $username = trim(core_text::strtolower($params['username']));
+        $password = $params['password'];
+
+        if (is_restored_user($username)) {
+            throw new moodle_exception('restoredaccountresetpassword', 'webservice');
+        }
+
+        $user = authenticate_user_login($username, $password);
+
+        if (empty($user)) {
+            throw new moodle_exception('invalidlogin');
+        }
+
+        if ($user->confirmed) {
+            throw new moodle_exception('alreadyconfirmed');
+        }
+
+        // Check if we should redirect the user once the user is confirmed.
+        $confirmationurl = null;
+        if (!empty($params['redirect'])) {
+            // Pass via moodle_url to fix thinks like admin links.
+            $redirect = new moodle_url($params['redirect']);
+
+            $confirmationurl = new moodle_url('/login/confirm.php', array('redirect' => $redirect->out()));
+        }
+        $status = send_confirmation_email($user, $confirmationurl);
+
+        return array(
+            'status' => $status,
+            'warnings' => $warnings,
+        );
+    }
+
+    /**
+     * Describes the resend_confirmation_email return value.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.6
+     */
+    public static function resend_confirmation_email_returns() {
+
+        return new external_single_structure(
+            array(
+                'status' => new external_value(PARAM_BOOL, 'True if the confirmation email was sent, false otherwise.'),
+                'warnings'  => new external_warnings(),
+            )
+        );
+    }
 }
index 973c7d7..584abb8 100644 (file)
@@ -78,7 +78,7 @@ class login implements renderable, templatable {
      * @param string $username The username to display.
      */
     public function __construct(array $authsequence, $username = '') {
-        global $CFG, $SESSION;
+        global $CFG;
 
         $this->username = $username;
 
@@ -87,12 +87,13 @@ class login implements renderable, templatable {
         $this->cansignup = $CFG->registerauth == 'email' || !empty($CFG->registerauth);
         if ($CFG->rememberusername == 0) {
             $this->cookieshelpicon = new help_icon('cookiesenabledonlysession', 'core');
+            $this->rememberusername = false;
         } else {
             $this->cookieshelpicon = new help_icon('cookiesenabled', 'core');
+            $this->rememberusername = true;
         }
 
         $this->autofocusform = !empty($CFG->loginpageautofocus);
-        $this->rememberusername = isset($CFG->rememberusername) and $CFG->rememberusername == 2;
 
         $this->forgotpasswordurl = new moodle_url('/login/forgot_password.php');
         $this->loginurl = new moodle_url('/login/index.php');
index 8f77a3c..c6c0145 100644 (file)
@@ -59,7 +59,7 @@ $string['auth_dbsybasequotinghelp'] = 'Sybase style single quote escaping - need
 $string['auth_dbsyncuserstask'] = 'Synchronise users task';
 $string['auth_dbtable'] = 'Name of the table in the database';
 $string['auth_dbtable_key'] = 'Table';
-$string['auth_dbtype'] = 'The database type (See the <a href="http://phplens.com/adodb/supported.databases.html" target="_blank">ADOdb documentation</a> for details)';
+$string['auth_dbtype'] = 'The database type (see the documentation <a href="http://adodb.org/dokuwiki/doku.php" target="_blank">ADOdb - Database Abstraction Layer for PHP</a> for details).';
 $string['auth_dbtype_key'] = 'Database';
 $string['auth_dbupdateusers'] = 'Update users';
 $string['auth_dbupdateusers_description'] = 'As well as inserting new users, update existing users.';
index 6074f71..06f36e7 100644 (file)
@@ -86,7 +86,7 @@ $string['privacy:metadata:mnet_log:coursename'] = 'Remote system course full nam
 $string['privacy:metadata:mnet_log:hostid'] = 'Remote system MNet ID.';
 $string['privacy:metadata:mnet_log:info'] = 'Additional information about the action.';
 $string['privacy:metadata:mnet_log:ip'] = 'The IP address used at the time of the action occurred.';
-$string['privacy:metadata:mnet_log:module'] = 'Remote system module where the event the action occurred.';
+$string['privacy:metadata:mnet_log:module'] = 'Remote system module where the action occurred.';
 $string['privacy:metadata:mnet_log:remoteid'] = 'Remote ID of the user who carried out the action in the remote system.';
 $string['privacy:metadata:mnet_log:time'] = 'Time when the action occurred.';
 $string['privacy:metadata:mnet_log:url'] = 'Remote system URL where the action occurred.';
index 6970800..f80390b 100644 (file)
@@ -39,3 +39,18 @@ Feature: Authentication
     Given I log in as "admin"
     When I log out
     Then I should see "You are not logged in" in the "page-footer" "region"
+
+  Scenario Outline: Checking the display of the Remember username checkbox
+    Given I log in as "admin"
+    And I set the following administration settings values:
+      | rememberusername | <settingvalue> |
+    And I log out
+    And I am on homepage
+    When I click on "Log in" "link" in the ".logininfo" "css_element"
+    Then I should <expect> "Remember username"
+
+    Examples:
+      | settingvalue | expect  |
+      | 0            | not see |
+      | 1            | see     |
+      | 2            | see     |
index 9f04967..8782f89 100644 (file)
@@ -35,11 +35,11 @@ Feature: Test the 'Digital age of consent verification' feature works.
     When I set the field "What is your age?" to "12"
     And I set the field "In which country do you live?" to "AT"
     And I press "Proceed"
-    Then I should see "You are considered to be a digital minor."
-    And I should see "To create an account on this site please have your parent/guardian contact the following person."
+    Then I should see "You are too young to create an account on this site."
+    And I should see "Please ask your parent/guardian to contact:"
     # Try to access the sign up page again.
     When I click on "Back to the site home" "link"
     And I click on "Log in" "link" in the ".logininfo" "css_element"
     And I press "Create new account"
-    Then I should see "You are considered to be a digital minor."
-    And I should see "To create an account on this site please have your parent/guardian contact the following person."
+    Then I should see "You are too young to create an account on this site."
+    And I should see "Please ask your parent/guardian to contact:"
index 84ffa1f..237852c 100644 (file)
@@ -40,6 +40,9 @@ require_once($CFG->dirroot . '/webservice/tests/helpers.php');
  */
 class core_auth_external_testcase extends externallib_advanced_testcase {
 
+    /** @var string Original error log */
+    protected $oldlog;
+
     /**
      * Set up for every test
      */
@@ -48,6 +51,18 @@ class core_auth_external_testcase extends externallib_advanced_testcase {
 
         $this->resetAfterTest(true);
         $CFG->registerauth = 'email';
+
+        // Discard error logs.
+        $this->oldlog = ini_get('error_log');
+        ini_set('error_log', "$CFG->dataroot/testlog.log");
+    }
+
+    /**
+     * Tear down to restore old logging..
+     */
+    protected function tearDown() {
+        ini_set('error_log', $this->oldlog);
+        parent::tearDown();
     }
 
     /**
@@ -115,4 +130,105 @@ class core_auth_external_testcase extends externallib_advanced_testcase {
             core_auth_external::is_age_digital_consent_verification_enabled_returns(), $result);
         $this->assertTrue($result['status']);
     }
+
+    /**
+     * Test resend_confirmation_email.
+     */
+    public function test_resend_confirmation_email() {
+        global $DB;
+
+        $username = 'pepe';
+        $password = 'abcdefAª.ªª!!3';
+        $firstname = 'Pepe';
+        $lastname = 'Pérez';
+        $email = 'myemail@no.zbc';
+
+        // Create new user.
+        $result = auth_email_external::signup_user($username, $password, $firstname, $lastname, $email);
+        $result = external_api::clean_returnvalue(auth_email_external::signup_user_returns(), $result);
+        $this->assertTrue($result['success']);
+        $this->assertEmpty($result['warnings']);
+
+        $result = core_auth_external::resend_confirmation_email($username, $password);
+        $result = external_api::clean_returnvalue(core_auth_external::resend_confirmation_email_returns(), $result);
+        $this->assertTrue($result['status']);
+        $this->assertEmpty($result['warnings']);
+        $confirmed = $DB->get_field('user', 'confirmed', array('username' => $username));
+        $this->assertEquals(0, $confirmed);
+    }
+
+    /**
+     * Test resend_confirmation_email invalid username.
+     */
+    public function test_resend_confirmation_email_invalid_username() {
+
+        $username = 'pepe';
+        $password = 'abcdefAª.ªª!!3';
+        $firstname = 'Pepe';
+        $lastname = 'Pérez';
+        $email = 'myemail@no.zbc';
+
+        // Create new user.
+        $result = auth_email_external::signup_user($username, $password, $firstname, $lastname, $email);
+        $result = external_api::clean_returnvalue(auth_email_external::signup_user_returns(), $result);
+        $this->assertTrue($result['success']);
+        $this->assertEmpty($result['warnings']);
+
+        $_SERVER['HTTP_USER_AGENT'] = 'no browser'; // Hack around missing user agent in CLI scripts.
+        $this->expectException('moodle_exception');
+        $this->expectExceptionMessage('error/invalidlogin');
+        $result = core_auth_external::resend_confirmation_email('abc', $password);
+    }
+
+    /**
+     * Test resend_confirmation_email invalid password.
+     */
+    public function test_resend_confirmation_email_invalid_password() {
+
+        $username = 'pepe';
+        $password = 'abcdefAª.ªª!!3';
+        $firstname = 'Pepe';
+        $lastname = 'Pérez';
+        $email = 'myemail@no.zbc';
+
+        // Create new user.
+        $result = auth_email_external::signup_user($username, $password, $firstname, $lastname, $email);
+        $result = external_api::clean_returnvalue(auth_email_external::signup_user_returns(), $result);
+        $this->assertTrue($result['success']);
+        $this->assertEmpty($result['warnings']);
+
+        $_SERVER['HTTP_USER_AGENT'] = 'no browser'; // Hack around missing user agent in CLI scripts.
+        $this->expectException('moodle_exception');
+        $this->expectExceptionMessage('error/invalidlogin');
+        $result = core_auth_external::resend_confirmation_email($username, 'abc');
+    }
+
+    /**
+     * Test resend_confirmation_email already confirmed user.
+     */
+    public function test_resend_confirmation_email_already_confirmed_user() {
+        global $DB;
+
+        $username = 'pepe';
+        $password = 'abcdefAª.ªª!!3';
+        $firstname = 'Pepe';
+        $lastname = 'Pérez';
+        $email = 'myemail@no.zbc';
+
+        // Create new user.
+        $result = auth_email_external::signup_user($username, $password, $firstname, $lastname, $email);
+        $result = external_api::clean_returnvalue(auth_email_external::signup_user_returns(), $result);
+        $this->assertTrue($result['success']);
+        $this->assertEmpty($result['warnings']);
+        $secret = $DB->get_field('user', 'secret', array('username' => $username));
+
+        // Confirm the user.
+        $result = core_auth_external::confirm_user($username, $secret);
+        $result = external_api::clean_returnvalue(core_auth_external::confirm_user_returns(), $result);
+        $this->assertTrue($result['success']);
+
+        $this->expectException('moodle_exception');
+        $this->expectExceptionMessage('error/alreadyconfirmed');
+        core_auth_external::resend_confirmation_email($username, $password);
+    }
 }
index aa73738..c78dace 100644 (file)
@@ -23,7 +23,7 @@
  */
 
 $string['conditiontitle'] = 'User profile field';
-$string['description'] = 'Control access based on fields within the student&rsquo;s profile.';
+$string['description'] = 'Control access based on fields within the student\'s profile.';
 $string['error_selectfield'] = 'You must select a profile field.';
 $string['error_setvalue'] = 'You must type a value.';
 $string['label_operator'] = 'Method of comparison';
@@ -42,7 +42,7 @@ $string['requires_startswith'] = 'Your <strong>{$a->field}</strong> starts with
 $string['missing'] = '(Missing custom field: {$a})';
 $string['title'] = 'User profile';
 $string['op_contains'] = 'contains';
-$string['op_doesnotcontain'] = 'doesn&rsquo;t contain';
+$string['op_doesnotcontain'] = 'doesn\'t contain';
 $string['op_endswith'] = 'ends with';
 $string['op_isempty'] = 'is empty';
 $string['op_isequalto'] = 'is equal to';
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..9500656 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 Course overview block sort preference.';
+$string['privacy:metadata:overviewviewpreference'] = 'The Course overview block view preference.';
+$string['privacy:metadata:overviewgroupingpreference'] = 'The Course overview 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..0470ca4 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">
-                <img src="{{{courseimage}}}" class="summaryimage img-fluid" alt="{{#str}}aria:courseimage, block_myoverview{{/str}}">
+            <a href="{{viewurl}}" class="col-sm-4 col-xl-3 span4 position-relative" tabindex="-1">
+                <div class="position-absolute">
+                    {{> block_myoverview/favourite-icon }}
+                </div>
+                <div class="card-img-top summaryimage" style='background-image: url("{{{courseimage}}}");'>
+                    <span class="sr-only">{{#str}}aria:courseimage, block_myoverview{{/str}}</span>
+                </div>
             </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 05ecc00..7b3fd69 100644 (file)
@@ -26,25 +26,25 @@ $string['anycategory'] = 'Any category';
 $string['apierror'] = 'The YouTube API key is not set. Contact your administrator.';
 $string['apikey'] = 'API key';
 $string['apikeyinfo'] = 'Get a <a href="https://developers.google.com/youtube/v3/getting-started">Google API key</a> for your Moodle site.';
-$string['autosvehicles'] = 'Autos &amp; Vehicles';
+$string['autosvehicles'] = 'Autos & Vehicles';
 $string['category'] = 'Category';
 $string['comedy'] = 'Comedy';
 $string['configtitle'] = 'YouTube block title';
 $string['education'] = 'Education';
 $string['entertainment'] = 'Entertainment';
-$string['filmsanimation'] = 'Films &amp; Animation';
-$string['gadgetsgames'] = 'Gadgets &amp; Games';
-$string['howtodiy'] = 'How-to &amp; DIY';
+$string['filmsanimation'] = 'Films & Animation';
+$string['gadgetsgames'] = 'Gadgets & Games';
+$string['howtodiy'] = 'How-to & DIY';
 $string['includeonlyvideosfromplaylist'] = 'Include only videos from the playlist with id';
 $string['music'] = 'Music';
-$string['newspolitics'] = 'News &amp; Politics';
+$string['newspolitics'] = 'News & Politics';
 $string['numberofvideos'] = 'Number of videos';
-$string['peopleblogs'] = 'People &amp; Blogs';
-$string['petsanimals'] = 'Pets &amp; Animals';
+$string['peopleblogs'] = 'People & Blogs';
+$string['petsanimals'] = 'Pets & Animals';
 $string['pluginname'] = 'YouTube';
 $string['requesterror'] = 'Data could not be obtained from the server. Contact your administrator if the problem persists.';
-$string['scienceandtech'] = 'Science &amp; Tech';
+$string['scienceandtech'] = 'Science & Tech';
 $string['sports'] = 'Sports';
 $string['tag_youtube:addinstance'] = 'Add a new YouTube block';
-$string['travel'] = 'Travel &amp; Places';
+$string['travel'] = 'Travel & Places';
 $string['privacy:metadata'] = 'The YouTube block only shows data stored in other locations.';
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 d474cc9..12dae0d 100644 (file)
@@ -34,7 +34,7 @@ $string['duedate'] = 'Due date';
 $string['morecourses'] = 'More courses';
 $string['timeline:addinstance'] = 'Add a new timeline block';
 $string['timeline:myaddinstance'] = 'Add a new timeline block to Dashboard';
-$string['nocoursesinprogress'] = 'No in progress courses';
+$string['nocoursesinprogress'] = 'No in-progress courses';
 $string['noevents'] = 'No upcoming activities due';
 $string['next30days'] = 'Next 30 days';
 $string['next7days'] = 'Next 7 days';
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 d0ca148..84343b9 100644 (file)
@@ -25,7 +25,7 @@
 $string['database'] = 'Database';
 $string['database_help'] = 'The name of the database to make use of.';
 $string['extendedmode'] = 'Use extended keys';
-$string['extendedmode_help'] = 'If enabled full key sets will be used when working with the plugin. This isn\'t used internally yet but would allow you to easily search and investigate the MongoDB plugin manually if you so choose. Turning this on will add an small overhead so should only be done if you require it.';
+$string['extendedmode_help'] = 'If enabled full key sets will be used when working with the plugin. This isn\'t used internally yet but would allow you to easily search and investigate the MongoDB plugin manually if you so choose. Turning this on will add a small overhead so should only be done if you require it.';
 $string['password'] = 'Password';
 $string['password_help'] = 'The password of the user being used for the connection.';
 $string['pleaseupgrademongo'] = 'You are using an old version of the PHP Mongo extension (< 1.3). Support for old versions of the Mongo extension will be dropped in the future. Please consider upgrading.';
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..acc1198 100644 (file)
@@ -85,6 +85,7 @@ class core_course_external extends external_api {
     public static function get_course_contents($courseid, $options = array()) {
         global $CFG, $DB;
         require_once($CFG->dirroot . "/course/lib.php");
+        require_once($CFG->libdir . '/completionlib.php');
 
         //validate parameter
         $params = self::validate_parameters(self::get_course_contents_parameters(),
@@ -168,6 +169,8 @@ class core_course_external extends external_api {
             $coursenumsections = course_get_format($course)->get_last_section_number();
             $stealthmodules = array();   // Array to keep all the modules available but not visible in a course section/topic.
 
+            $completioninfo = new completion_info($course);
+
             //for each sections (first displayed to last displayed)
             $modinfosections = $modinfo->get_sections();
             foreach ($sections as $key => $section) {
@@ -261,6 +264,21 @@ class core_course_external extends external_api {
                         $module['modplural'] = $cm->modplural;
                         $module['modicon'] = $cm->get_icon_url()->out(false);
                         $module['indent'] = $cm->indent;
+                        $module['onclick'] = $cm->onclick;
+                        $module['afterlink'] = $cm->afterlink;
+                        $module['customdata'] = json_encode($cm->customdata);
+                        $module['completion'] = $cm->completion;
+
+                        // Check module completion.
+                        $completion = $completioninfo->is_enabled($cm);
+                        if ($completion != COMPLETION_DISABLED) {
+                            $completiondata = $completioninfo->get_data($cm, true);
+                            $module['completiondata'] = array(
+                                'state'         => $completiondata->completionstate,
+                                'timecompleted' => $completiondata->timemodified,
+                                'overrideby'    => $completiondata->overrideby
+                            );
+                        }
 
                         if (!empty($cm->showdescription) or $cm->modname == 'label') {
                             // We want to use the external format. However from reading get_formatted_content(), $cm->content format is always FORMAT_HTML.
@@ -408,6 +426,21 @@ class core_course_external extends external_api {
                                     'modplural' => new external_value(PARAM_TEXT, 'activity module plural name'),
                                     'availability' => new external_value(PARAM_RAW, 'module availability settings', VALUE_OPTIONAL),