Merge branch 'MDL-62172-master' of git://github.com/bmbrands/moodle
authorJun Pataleta <jun@moodle.com>
Fri, 27 Apr 2018 01:55:08 +0000 (09:55 +0800)
committerJun Pataleta <jun@moodle.com>
Fri, 27 Apr 2018 01:55:08 +0000 (09:55 +0800)
95 files changed:
admin/tool/cohortroles/classes/privacy/provider.php [new file with mode: 0644]
admin/tool/cohortroles/lang/en/tool_cohortroles.php
admin/tool/cohortroles/tests/privacy_test.php [new file with mode: 0644]
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/messageinbound/lang/en/tool_messageinbound.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/classes/privacy/provider.php [new file with mode: 0644]
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/settings.php
admin/tool/mobile/tests/externallib_test.php
admin/tool/mobile/tests/privacy_provider_test.php [new file with mode: 0644]
admin/tool/monitor/lang/en/tool_monitor.php
admin/tool/policy/lang/en/tool_policy.php
auth/email/classes/external.php
blocks/activity_modules/lang/en/block_activity_modules.php
blocks/activity_results/lang/en/block_activity_results.php
blocks/badges/lang/en/block_badges.php
blocks/calendar_upcoming/lang/en/block_calendar_upcoming.php
blocks/course_summary/lang/en/block_course_summary.php
blocks/login/lang/en/block_login.php
blocks/myprofile/lang/en/block_myprofile.php
blocks/private_files/lang/en/block_private_files.php
blocks/tag_youtube/lang/en/block_tag_youtube.php
calendar/type/gregorian/lang/en/calendartype_gregorian.php
cohort/classes/privacy/provider.php [new file with mode: 0644]
cohort/tests/privacy_test.php [new file with mode: 0644]
course/lib.php
dataformat/csv/lang/en/dataformat_csv.php
dataformat/excel/lang/en/dataformat_excel.php
dataformat/html/lang/en/dataformat_html.php
dataformat/json/lang/en/dataformat_json.php
dataformat/ods/lang/en/dataformat_ods.php
enrol/manual/classes/enrol_users_form.php
enrol/manual/lang/en/enrol_manual.php
enrol/paypal/lang/en/enrol_paypal.php
enrol/self/lang/en/enrol_self.php
lang/en/admin.php
lang/en/analytics.php
lang/en/badges.php
lang/en/blog.php
lang/en/cohort.php
lang/en/editor.php
lang/en/moodle.php
lang/en/my.php
lang/en/notes.php
lang/en/rss.php
lang/en/tag.php
lib/antivirus/clamav/lang/en/antivirus_clamav.php
lib/editor/atto/lang/en/editor_atto.php
lib/editor/textarea/lang/en/editor_textarea.php
lib/editor/tinymce/lang/en/editor_tinymce.php
lib/editor/tinymce/plugins/pdw/lang/en/tinymce_pdw.php
lib/form/templates/filetypes-descriptions.mustache
lib/tests/component_test.php
lib/yui/build/moodle-core-notification-confirm/moodle-core-notification-confirm-debug.js
lib/yui/build/moodle-core-notification-confirm/moodle-core-notification-confirm-min.js
lib/yui/build/moodle-core-notification-confirm/moodle-core-notification-confirm.js
lib/yui/src/notification/js/confirm.js
message/output/airnotifier/lang/en/message_airnotifier.php
message/output/jabber/lang/en/message_jabber.php
mod/choice/classes/external.php
mod/choice/lang/en/choice.php
mod/choice/tests/externallib_test.php
mod/feedback/lang/en/feedback.php
mod/folder/lang/en/folder.php
mod/forum/tests/behat/edit_post_student.feature
mod/label/lang/en/label.php
mod/lesson/classes/privacy/provider.php [new file with mode: 0644]
mod/lesson/lang/en/lesson.php
mod/lesson/tests/privacy_test.php [new file with mode: 0644]
mod/lti/service/gradebookservices/lang/en/ltiservice_gradebookservices.php
mod/page/lang/en/page.php
mod/resource/lang/en/resource.php
mod/survey/lang/en/survey.php
mod/url/lang/en/url.php
my/classes/privacy/provider.php [new file with mode: 0644]
my/tests/privacy_test.php [new file with mode: 0644]
notes/classes/privacy/provider.php [new file with mode: 0644]
notes/tests/privacy_test.php [new file with mode: 0644]
question/classes/output/qbank_chooser.php
repository/flickr_public/lang/en/repository_flickr_public.php
repository/youtube/lang/en/repository_youtube.php
rss/classes/privacy/provider.php
theme/boost/scss/moodle/bootstrap-rtl.scss
theme/boost/scss/moodle/bs4alphacompat.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/forms.scss
theme/boost/scss/moodle/icons.scss
theme/boost/scss/moodle/question.scss
theme/boost/scss/preset/default.scss
theme/boost/templates/core/action_menu.mustache
user/profile/field/checkbox/classes/privacy/provider.php [new file with mode: 0644]
user/profile/field/checkbox/lang/en/profilefield_checkbox.php
user/profile/field/checkbox/tests/privacy_test.php [new file with mode: 0644]
version.php

diff --git a/admin/tool/cohortroles/classes/privacy/provider.php b/admin/tool/cohortroles/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..bc38e7c
--- /dev/null
@@ -0,0 +1,200 @@
+<?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/>.
+
+/**
+ * Privacy Subsystem implementation for tool_cohortroles.
+ *
+ * @package    tool_cohortroles
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_cohortroles\privacy;
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\context;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for tool_cohortroles implementing metadata and plugin providers.
+ *
+ * @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\provider, \core_privacy\local\request\plugin\provider {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection     A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        // The tool_cohortroles plugin utilises the mdl_tool_cohortroles table.
+        $collection->add_database_table(
+            'tool_cohortroles',
+            [
+                'id' => 'privacy:metadata:tool_cohortroles:id',
+                'cohortid' => 'privacy:metadata:tool_cohortroles:cohortid',
+                'roleid' => 'privacy:metadata:tool_cohortroles:roleid',
+                'userid' => 'privacy:metadata:tool_cohortroles:userid',
+                'timecreated' => 'privacy:metadata:tool_cohortroles:timecreated',
+                'timemodified' => 'privacy:metadata:tool_cohortroles:timemodified',
+                'usermodified' => 'privacy:metadata:tool_cohortroles:usermodified'
+            ],
+            'privacy:metadata:tool_cohortroles'
+        );
+
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param   int $userid The user to search.
+     * @return  contextlist   $contextlist  The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        $contextlist = new contextlist();
+
+        // Retrieve the User context associated with tool_cohortroles records.
+        $sql = "SELECT DISTINCT c.id
+                  FROM {context} c
+                  JOIN {tool_cohortroles} cr ON cr.userid = c.instanceid AND c.contextlevel = :contextuser
+                 WHERE cr.userid = :userid";
+
+        $params = [
+            'contextuser' => CONTEXT_USER,
+            'userid'       => $userid
+        ];
+
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        // If the user has tool_cohortroles data, then only the User context should be present so get the first context.
+        $contexts = $contextlist->get_contexts();
+        if (count($contexts) == 0) {
+            return;
+        }
+        $context = reset($contexts);
+
+        // Sanity check that context is at the User context level, then get the userid.
+        if ($context->contextlevel !== CONTEXT_USER) {
+            return;
+        }
+        $userid = $context->instanceid;
+
+        // Retrieve the tool_cohortroles records created for the user.
+        $sql = 'SELECT cr.id as cohortroleid,
+                       c.name as cohortname,
+                       c.idnumber as cohortidnumber,
+                       c.description as cohortdescription,
+                       r.shortname as roleshortname,
+                       cr.userid as userid,
+                       cr.timecreated as timecreated,
+                       cr.timemodified as timemodified
+                  FROM {tool_cohortroles} cr
+                  JOIN {cohort} c ON c.id = cr.cohortid
+                  JOIN {role} r ON r.id = cr.roleid
+                 WHERE cr.userid = :userid';
+
+        $params = [
+            'userid' => $userid
+        ];
+
+        $cohortroles = $DB->get_records_sql($sql, $params);
+        foreach ($cohortroles as $cohortrole) {
+            // The tool_cohortroles data export is organised in:
+            // {User Context}/Cohort roles management/{cohort name}/{role shortname}/data.json.
+            $subcontext = [
+                get_string('pluginname', 'tool_cohortroles'),
+                $cohortrole->cohortname,
+                $cohortrole->roleshortname
+            ];
+
+            $data = (object) [
+                'cohortname' => $cohortrole->cohortname,
+                'cohortidnumber' => $cohortrole->cohortidnumber,
+                'cohortdescription' => $cohortrole->cohortdescription,
+                'roleshortname' => $cohortrole->roleshortname,
+                'userid' => transform::user($cohortrole->userid),
+                'timecreated' => transform::datetime($cohortrole->timecreated),
+                'timemodified' => transform::datetime($cohortrole->timemodified)
+            ];
+
+            writer::with_context($context)->export_data($subcontext, $data);
+        }
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param   context $context The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        global $DB;
+
+        // Sanity check that context is at the User context level, then get the userid.
+        if ($context->contextlevel !== CONTEXT_USER) {
+            return;
+        }
+        $userid = $context->instanceid;
+
+        // Delete the tool_cohortroles records created for the userid.
+        $DB->delete_records('tool_cohortroles', ['userid' => $userid]);
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+
+        // If the user has tool_cohortroles data, then only the User context should be present so get the first context.
+        $contexts = $contextlist->get_contexts();
+        if (count($contexts) == 0) {
+            return;
+        }
+        $context = reset($contexts);
+
+        // Sanity check that context is at the User context level, then get the userid.
+        if ($context->contextlevel !== CONTEXT_USER) {
+            return;
+        }
+        $userid = $context->instanceid;
+
+        // Delete the tool_cohortroles records created for the userid.
+        $DB->delete_records('tool_cohortroles', ['userid' => $userid]);
+    }
+
+}
index 08f00c9..a8afe83 100644 (file)
@@ -42,3 +42,11 @@ $string['selectrole'] = 'Select role';
 $string['selectusers'] = 'Select users to assign role';
 $string['taskname'] = 'Sync cohort role assignments';
 $string['thisuserroles'] = 'Roles assigned relative to this user';
+$string['privacy:metadata:tool_cohortroles'] = 'The Cohort roles management plugin stores user cohort role mappings.';
+$string['privacy:metadata:tool_cohortroles:id'] = 'The ID of the cohort role mapping record.';
+$string['privacy:metadata:tool_cohortroles:cohortid'] = 'The ID of the cohort.';
+$string['privacy:metadata:tool_cohortroles:roleid'] = 'The ID of the role.';
+$string['privacy:metadata:tool_cohortroles:userid'] = 'The ID of the user.';
+$string['privacy:metadata:tool_cohortroles:timecreated'] = 'The date/time of when the cohort  role mapping was created.';
+$string['privacy:metadata:tool_cohortroles:timemodified'] = 'The date/time of when the cohort role mapping was modified.';
+$string['privacy:metadata:tool_cohortroles:usermodified'] = 'The ID of the user who last modified the cohort role mapping.';
diff --git a/admin/tool/cohortroles/tests/privacy_test.php b/admin/tool/cohortroles/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..f118b74
--- /dev/null
@@ -0,0 +1,206 @@
+<?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 tool_cohortroles implementation of the privacy API.
+ *
+ * @package    tool_cohortroles
+ * @category   test
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use \core_privacy\local\request\writer;
+use \core_privacy\local\request\approved_contextlist;
+use \tool_cohortroles\api;
+use \tool_cohortroles\privacy\provider;
+
+/**
+ * Unit tests for the tool_cohortroles implementation of the privacy API.
+ *
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_cohortroles_privacy_testcase extends \core_privacy\tests\provider_testcase {
+
+    /**
+     * Overriding setUp() function to always reset after tests.
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+    }
+
+    /**
+     * Test for provider::get_contexts_for_userid().
+     */
+    public function test_get_contexts_for_userid() {
+        global $DB;
+
+        // Test setup.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+        $this->setAdminUser();
+
+        $nocohortroles = 3;
+        $this->setup_test_scenario_data($user->id, $nocohortroles);
+
+        // Test the User's assigned cohortroles matches 3.
+        $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
+        $this->assertCount($nocohortroles, $cohortroles);
+
+        // Test the User's retrieved contextlist contains only one context.
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $contexts = $contextlist->get_contexts();
+        $this->assertCount(1, $contexts);
+
+        // Test the User's contexts equal the User's own context.
+        $context = reset($contexts);
+        $this->assertEquals(CONTEXT_USER, $context->contextlevel);
+        $this->assertEquals($user->id, $context->instanceid);
+    }
+
+    /**
+     * Test for provider::export_user_data().
+     */
+    public function test_export_user_data() {
+        // Test setup.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+        $this->setAdminUser();
+
+        $nocohortroles = 3;
+        $this->setup_test_scenario_data($user->id, $nocohortroles);
+
+        // Test the User's retrieved contextlist contains only one context.
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $contexts = $contextlist->get_contexts();
+        $this->assertCount(1, $contexts);
+
+        // Test the User's contexts equal the User's own context.
+        $context = reset($contexts);
+        $this->assertEquals(CONTEXT_USER, $context->contextlevel);
+        $this->assertEquals($user->id, $context->instanceid);
+
+        // Retrieve the User's tool_cohortroles data.
+        $approvedcontextlist = new approved_contextlist($user, 'tool_cohortroles', $contextlist->get_contextids());
+        provider::export_user_data($approvedcontextlist);
+
+        // Test the tool_cohortroles data is exported at the User context level.
+        $writer = writer::with_context($context);
+        $this->assertTrue($writer->has_any_data());
+    }
+
+    /**
+     * Test for provider::delete_data_for_all_users_in_context().
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        // Test setup.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+        $this->setAdminUser();
+
+        $nocohortroles = 4;
+        $this->setup_test_scenario_data($user->id, $nocohortroles);
+
+        // Test the User's assigned cohortroles matches 4.
+        $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
+        $this->assertCount($nocohortroles, $cohortroles);
+
+        // Test the User's retrieved contextlist contains only one context.
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $contexts = $contextlist->get_contexts();
+        $this->assertCount(1, $contexts);
+
+        // Test the User's contexts equal the User's own context.
+        $context = reset($contexts);
+        $this->assertEquals(CONTEXT_USER, $context->contextlevel);
+        $this->assertEquals($user->id, $context->instanceid);
+
+        // Delete all the User's records in mdl_tool_cohortroles table by the specified User context.
+        provider::delete_data_for_all_users_in_context($context);
+
+        // Test the cohort roles records in mdl_tool_cohortroles table is equals zero.
+        $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
+        $this->assertCount(0, $cohortroles);
+    }
+
+    /**
+     * Test for provider::delete_data_for_user().
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        // Test setup.
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+        $this->setAdminUser();
+
+        $nocohortroles = 4;
+        $this->setup_test_scenario_data($user->id, $nocohortroles);
+
+        // Test the User's assigned cohortroles matches 4.
+        $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
+        $this->assertCount($nocohortroles, $cohortroles);
+
+        // Test the User's retrieved contextlist contains only one context.
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $contexts = $contextlist->get_contexts();
+        $this->assertCount(1, $contexts);
+
+        // Test the User's contexts equal the User's own context.
+        $context = reset($contexts);
+        $this->assertEquals(CONTEXT_USER, $context->contextlevel);
+        $this->assertEquals($user->id, $context->instanceid);
+
+        // Delete all the User's records in mdl_tool_cohortroles table by the specified User approved context list.
+        $approvedcontextlist = new approved_contextlist($user, 'tool_cohortroles', $contextlist->get_contextids());
+        provider::delete_data_for_user($approvedcontextlist);
+
+        // Test the records in mdl_tool_cohortroles table is equals zero.
+        $cohortroles = $DB->get_records('tool_cohortroles', ['userid' => $user->id]);
+        $this->assertCount(0, $cohortroles);
+    }
+
+    /**
+     * Helper function to setup tool_cohortroles records for testing a specific user.
+     *
+     * @param int $userid           The ID of the user used for testing.
+     * @param int $nocohortroles    The number of tool_cohortroles to create for the user.
+     * @throws \core_competency\invalid_persistent_exception
+     * @throws coding_exception
+     */
+    protected function setup_test_scenario_data($userid, $nocohortroles) {
+        $roleid = create_role('Sausage Roll', 'sausageroll', 'mmmm');
+
+        for ($c = 0; $c < $nocohortroles; $c++) {
+            $cohort = $this->getDataGenerator()->create_cohort();
+
+            $params = (object)array(
+                'userid' => $userid,
+                'roleid' => $roleid,
+                'cohortid' => $cohort->id
+            );
+
+            api::create_cohort_role_assignment($params);
+        }
+    }
+
+}
index 3132033..999ba5e 100644 (file)
@@ -100,7 +100,7 @@ $string['expiry'] = 'Expiry';
 $string['expandplugin'] = 'Expand and collapse plugin.';
 $string['expandplugintype'] = 'Expand and collapse plugin type.';
 $string['explanationtitle'] = 'Icons used on this page and what they mean.';
-$string['external'] = 'External';
+$string['external'] = 'Additional';
 $string['externalexplanation'] = 'An additional plugin installed on this site.';
 $string['frontpagecourse'] = 'Front page course';
 $string['gdpr_art_6_1_a_description'] = 'The data subject has given consent to the processing of his or her personal data for one or more specific purposes';
@@ -168,7 +168,7 @@ $string['privacy:metadata:request'] = 'Information from personal data requests (
 $string['privacy:metadata:request:comments'] = 'Any user comments accompanying the request.';
 $string['privacy:metadata:request:userid'] = 'The ID of the user to whom the request belongs';
 $string['privacy:metadata:request:requestedby'] = 'The ID of the user making the request, if made on behalf of another user.';
-$string['privacy:metadata:request:dpocomment'] = 'Any comments made by the site\'s respective privacy officer regarding the request.';
+$string['privacy:metadata:request:dpocomment'] = 'Any comments made by the site\'s Data Protection Officer regarding the request.';
 $string['privacy:metadata:request:timecreated'] = 'The timestamp indicating when the request was made by the user.';
 $string['protected'] = 'Protected';
 $string['protectedlabel'] = 'The retention of this data has a higher legal precedent over a user\'s request to be forgotten. This data will only be deleted after the retention period has expired.';
index c8271f3..b074060 100644 (file)
@@ -24,7 +24,7 @@
 
 $string['classname'] = 'Class name';
 $string['component'] = 'Component';
-$string['configmessageinboundhost'] = 'The address of the server that Moodle should check mail against. To specify a non-default port, you can use the [server]:[port], for example mail.example.com:587 format. If you leave this field blank, Moodle will use the default port for the type of mail server you specify.';
+$string['configmessageinboundhost'] = 'The address of the server that Moodle should check mail against. To specify a non-default port, use [server]:[port], for example mail.example.com:587. If a port isn\'t specified, the default port for the type of mail server will be used.';
 $string['defaultexpiration'] = 'Default address expiry period';
 $string['defaultexpiration_help'] = 'When an email address is generated by the handler, it can be set to automatically expire after a period of time, so that it can no longer be used. It is advisable to set an expiry period.';
 $string['description'] = 'Description';
@@ -45,7 +45,7 @@ $string['invalid_recipient_handler'] = 'If a valid message is received but the s
 This handler processes those replies.
 
 It is not possible to disable sender verification of this handler because the user may reply from an incorrect email address if their email client configuration is incorrect.';
-$string['invalid_recipient_handler_name'] = 'Invalid recipient handler';
+$string['invalid_recipient_handler_name'] = 'Invalid sender handler';
 $string['invalidrecipientdescription'] = 'The message "{$a->subject}" could not be authenticated, since it was sent from a different email address than in your user profile. For the message to be authenticated, you need to reply to this message.';
 $string['invalidrecipientdescriptionhtml'] = 'The message "{$a->subject}" could not be authenticated, since it was sent from a different email address than in your user profile. For the message to be authenticated, you need to reply to this message.';
 $string['invalidrecipientfinal'] = 'The message "{$a->subject}" could not be authenticated. Please check that you are sending your message from the same email address as in your profile.';
index 9ef98e1..43c8464 100644 (file)
@@ -277,6 +277,7 @@ class api {
             $settings->tool_mobile_customlangstrings = get_config('tool_mobile', 'customlangstrings');
             $settings->tool_mobile_disabledfeatures = get_config('tool_mobile', 'disabledfeatures');
             $settings->tool_mobile_custommenuitems = get_config('tool_mobile', 'custommenuitems');
+            $settings->tool_mobile_apppolicy = get_config('tool_mobile', 'apppolicy');
         }
 
         return $settings;
diff --git a/admin/tool/mobile/classes/privacy/provider.php b/admin/tool/mobile/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..c29c0a1
--- /dev/null
@@ -0,0 +1,142 @@
+<?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/>.
+/**
+ * Privacy Subsystem implementation for tool_mobile.
+ *
+ * @package    tool_mobile
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tool_mobile\privacy;
+defined('MOODLE_INTERNAL') || die();
+use \core_privacy\local\request\writer;
+use \core_privacy\local\metadata\collection;
+use \core_privacy\local\request\contextlist;
+use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\transform;
+
+/**
+ * Privacy provider for tool_mobile.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\user_preference_provider,
+    \core_privacy\local\request\plugin\provider {
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection     $collection The initialised item collection to add items to.
+     * @return  collection     A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        // There is a one user preference.
+        $collection->add_user_preference('tool_mobile_autologin_request_last',
+            'privacy:metadata:preference:tool_mobile_autologin_request_last');
+        $collection->add_subsystem_link('core_userkey', [], 'privacy:metadata:core_userkey');
+
+        return $collection;
+    }
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param int $userid The user to search.
+     * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        $sql = "SELECT ctx.id
+                  FROM {user_private_key} k
+                  JOIN {user} u ON k.userid = u.id
+                  JOIN {context} ctx ON ctx.instanceid = u.id AND ctx.contextlevel = :contextlevel
+                 WHERE k.userid = :userid AND k.script = 'tool_mobile'";
+        $params = ['userid' => $userid, 'contextlevel' => CONTEXT_USER];
+        $contextlist = new contextlist();
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        // If the user has data, then only the CONTEXT_USER should be present so get the first context.
+        $contexts = $contextlist->get_contexts();
+        if (count($contexts) == 0) {
+            return;
+        }
+        $context = reset($contexts);
+        // Sanity check that context is at the user context level, then get the userid.
+        if ($context->contextlevel !== CONTEXT_USER) {
+            return;
+        }
+        // Export associated userkeys.
+        \core_userkey\privacy\provider::export_userkeys($context, [], 'tool_mobile');
+    }
+    /**
+     * Export all user preferences for the plugin.
+     *
+     * @param   int         $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $autologinrequestlast = get_user_preferences('tool_mobile_autologin_request_last', null, $userid);
+        if ($autologinrequestlast !== null) {
+            $time = transform::datetime($autologinrequestlast);
+            writer::export_user_preference('tool_mobile',
+                'tool_mobile_autologin_request_last',
+                $time,
+                get_string('privacy:metadata:preference:tool_mobile_autologin_request_last', 'tool_mobile')
+            );
+        }
+    }
+    /**
+     * Delete all use data which matches the specified deletion_criteria.
+     *
+     * @param context $context A user context.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        // Sanity check that context is at the user context level, then get the userid.
+        if ($context->contextlevel !== CONTEXT_USER) {
+            return;
+        }
+        $userid = $context->instanceid;
+        // Delete all the userkeys.
+        \core_userkey\privacy\provider::delete_userkeys('tool_mobile', $userid);
+    }
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        // If the user has data, then only the user context should be present so get the first context.
+        $contexts = $contextlist->get_contexts();
+        if (count($contexts) == 0) {
+            return;
+        }
+        $context = reset($contexts);
+        // Sanity check that context is at the user context level, then get the userid.
+        if ($context->contextlevel !== CONTEXT_USER) {
+            return;
+        }
+        $userid = $context->instanceid;
+        // Delete all the userkeys.
+        \core_userkey\privacy\provider::delete_userkeys('tool_mobile', $userid);
+    }
+}
\ No newline at end of file
index 1693ba5..195af12 100644 (file)
@@ -25,6 +25,8 @@
 $string['adodbdebugwarning'] = 'ADOdb debugging is enabled. It should be disabled in the external database authentication or external database enrolment plugin settings.';
 $string['androidappid'] = 'Android app\'s unique identifier';
 $string['androidappid_desc'] = 'This setting may be left as default unless you have a custom Android app.';
+$string['apppolicy'] = 'App policy URL';
+$string['apppolicy_help'] = 'The URL of a policy for app users which is listed on the About page in the app. If the field is left empty, the site policy URL will be used instead.';
 $string['autologinkeygenerationlockout'] = 'Auto-login key generation is blocked. You need to wait 6 minutes between requests.';
 $string['autologinnotallowedtoadmins'] = 'Auto-login is not allowed for site admins.';
 $string['cachedef_plugininfo'] = 'This stores the list of plugins with mobile addons';
@@ -65,7 +67,7 @@ $string['invalidcertificatechainwarning'] = 'It seems that the certificate chain
 $string['invalidcertificateexpiredatewarning'] = 'It seems that the HTTPS certificate for the site has expired.';
 $string['invalidcertificatestartdatewarning'] = 'It seems that the HTTPS certificate for the site is not yet valid (with a start date in the future).';
 $string['invalidprivatetoken'] = 'Invalid private token. Token should not be empty or passed via GET parameter.';
-$string['invaliduserquotawarning'] = 'The user quota (userquota) is set to an invalid number. It should be set to a valid number (an integer value) in Site policies.';
+$string['invaliduserquotawarning'] = 'The user quota (userquota) is set to an invalid number. It should be set to a valid number (an integer value) in Site security settings.';
 $string['iosappid'] = 'iOS app\'s unique identifier';
 $string['iosappid_desc'] = 'This setting may be left as default unless you have a custom iOS app.';
 $string['loginintheapp'] = 'Via the app';
@@ -92,3 +94,5 @@ $string['smartappbanners'] = 'App Banners';
 $string['typeoflogin'] = 'Type of login';
 $string['typeoflogin_desc'] = 'If the site uses a SSO authentication method, then select via a browser window or via an embedded browser. An embedded browser provides a better user experience, though it doesn\'t work with all SSO plugins.';
 $string['getmoodleonyourmobile'] = 'Get the mobile app';
+$string['privacy:metadata:preference:tool_mobile_autologin_request_last'] = 'The date of the last auto-login key request. Between each request 6 minutes are required.';
+$string['privacy:metadata:core_userkey'] = 'User\'s keys used to create auto-login key for the current user.';
index 8af64c6..125a72a 100644 (file)
@@ -43,6 +43,9 @@ if ($hassiteconfig) {
                 new lang_string('configenablemobilewebservice', 'admin', $enablemobiledoclink), $default));
     }
 
+    $temp->add(new admin_setting_configtext('tool_mobile/apppolicy', new lang_string('apppolicy', 'tool_mobile'),
+        new lang_string('apppolicy_help', 'tool_mobile'), '', PARAM_URL));
+
     $ADMIN->add('mobileapp', $temp);
 
     // Show only mobile settings if the mobile service is enabled.
index 0b8873b..d5a8a34 100644 (file)
@@ -173,6 +173,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
             array('name' => 'tool_mobile_customlangstrings', 'value' => ''),
             array('name' => 'tool_mobile_disabledfeatures', 'value' => ''),
             array('name' => 'tool_mobile_custommenuitems', 'value' => ''),
+            array('name' => 'tool_mobile_apppolicy', 'value' => ''),
         );
         $this->assertCount(0, $result['warnings']);
         $this->assertEquals($expected, $result['settings']);
diff --git a/admin/tool/mobile/tests/privacy_provider_test.php b/admin/tool/mobile/tests/privacy_provider_test.php
new file mode 100644 (file)
index 0000000..d553a10
--- /dev/null
@@ -0,0 +1,132 @@
+<?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/>.
+/**
+ * Base class for unit tests for tool_mobile.
+ *
+ * @package    tool_mobile
+ * @category   test
+ * @copyright  2018 Carlos Escobedo <carlos@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 \core_privacy\local\request\transform;
+use \core_privacy\local\request\approved_contextlist;
+use \tool_mobile\privacy\provider;
+
+/**
+ * Unit tests for the tool_mobile implementation of the privacy API.
+ *
+ * @copyright  2018 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_mobile_privacy_testcase extends \core_privacy\tests\provider_testcase {
+
+    /**
+     * Basic setup for these tests.
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+    }
+
+    /**
+     * Test to check export_user_preferences.
+     * returns user preferences data.
+     */
+    public function test_export_user_preferences() {
+        $user = $this->getDataGenerator()->create_user();
+        $expectedtime = time();
+        set_user_preference('tool_mobile_autologin_request_last', time(), $user);
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(\context_system::instance());
+        $prefs = $writer->get_user_preferences('tool_mobile');
+        $time = transform::datetime($expectedtime);
+        $this->assertEquals($time, $prefs->tool_mobile_autologin_request_last->value);
+        $this->assertEquals(get_string('privacy:metadata:preference:tool_mobile_autologin_request_last', 'tool_mobile'),
+            $prefs->tool_mobile_autologin_request_last->description);
+    }
+    /**
+     * Test getting the context for the user ID related to this plugin.
+     */
+    public function test_get_contexts_for_userid() {
+        // Create user and Mobile user keys.
+        $user = $this->getDataGenerator()->create_user();
+        $context = \context_user::instance($user->id);
+        $key = get_user_key('tool_mobile', $user->id);
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $this->assertEquals($context->id, $contextlist->current()->id);
+    }
+    /**
+     * Test that data is exported correctly for this plugin.
+     */
+    public function test_export_user_data() {
+        global $DB;
+        // Create user and Mobile user keys.
+        $user = $this->getDataGenerator()->create_user();
+        $context = \context_user::instance($user->id);
+        $keyvalue = get_user_key('tool_mobile', $user->id);
+        $key = $DB->get_record('user_private_key', ['value' => $keyvalue]);
+        // Validate exported data.
+        $this->setUser($user);
+        $writer = writer::with_context($context);
+        $this->assertFalse($writer->has_any_data());
+        $this->export_context_data_for_user($user->id, $context, 'tool_mobile');
+        $userkeydata = $writer->get_related_data([], 'userkeys');
+        $this->assertCount(1, $userkeydata->keys);
+        $this->assertEquals($key->script, reset($userkeydata->keys)->script);
+    }
+    /**
+     * Test for provider::delete_data_for_all_users_in_context().
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        // Create user and Mobile user keys.
+        $user = $this->getDataGenerator()->create_user();
+        $context = \context_user::instance($user->id);
+        $keyvalue = get_user_key('tool_mobile', $user->id);
+        $key = $DB->get_record('user_private_key', ['value' => $keyvalue]);
+        // Before deletion, we should have 1 user_private_key.
+        $count = $DB->count_records('user_private_key', ['script' => 'tool_mobile']);
+        $this->assertEquals(1, $count);
+        // Delete data.
+        provider::delete_data_for_all_users_in_context($context);
+        // After deletion, the user_private_key entries should have been deleted.
+        $count = $DB->count_records('user_private_key', ['script' => 'tool_mobile']);
+        $this->assertEquals(0, $count);
+    }
+    /**
+     * Test for provider::delete_data_for_user().
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+        // Create user and Mobile user keys.
+        $user = $this->getDataGenerator()->create_user();
+        $context = \context_user::instance($user->id);
+        $keyvalue = get_user_key('tool_mobile', $user->id);
+        $key = $DB->get_record('user_private_key', ['value' => $keyvalue]);
+        // Before deletion, we should have 1 user_private_key.
+        $count = $DB->count_records('user_private_key', ['script' => 'tool_mobile']);
+        $this->assertEquals(1, $count);
+        // Delete data.
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $approvedcontextlist = new approved_contextlist($user, 'tool_mobile', $contextlist->get_contextids());
+        provider::delete_data_for_user($approvedcontextlist);
+        // After deletion, the user_private_key entries should have been deleted.
+        $count = $DB->count_records('user_private_key', ['script' => 'tool_mobile']);
+        $this->assertEquals(0, $count);
+    }
+}
\ No newline at end of file
index 81d20bb..01b18fd 100644 (file)
@@ -97,9 +97,9 @@ $string['privacy:metadata:timecreatedsub'] = 'When this subscription was created
 $string['privacy:metadata:timemodifiedrule'] = 'When this rule was last modified';
 $string['privacy:metadata:timesent'] = 'When the message was sent';
 $string['privacy:metadata:timewindow'] = 'Time window in seconds';
-$string['privacy:metadata:userid'] = 'Id of user who created the rule';
-$string['privacy:metadata:useridhistory'] = 'User to whom this notification was sent';
-$string['privacy:metadata:useridsub'] = 'User id of the subscriber';
+$string['privacy:metadata:userid'] = 'The ID of user who created the rule.';
+$string['privacy:metadata:useridhistory'] = 'The ID of the user to whom this notification was sent';
+$string['privacy:metadata:useridsub'] = 'The ID of the subscriber.';
 $string['privacy:subscriptions'] = 'My event monitor subscriptions';
 $string['processevents'] = 'Process events';
 $string['rulename'] = 'Rule name';
index d218563..5febbbe 100644 (file)
@@ -118,7 +118,7 @@ $string['policyversionacceptedinotherlang'] = 'This policy version has been agre
 $string['previousversions'] = '{$a} previous versions';
 $string['privacy:metadata:acceptances'] = 'Information from policies agreements made by the users of this site.';
 $string['privacy:metadata:acceptances:policyversionid'] = 'The ID of the accepted version policy.';
-$string['privacy:metadata:acceptances:userid'] = 'The ID of the user who has agreed to the policy.';
+$string['privacy:metadata:acceptances:userid'] = 'The ID of the user who agreed to the policy.';
 $string['privacy:metadata:acceptances:status'] = 'The status of the agreement: 0 if not accepted; 1 otherwise.';
 $string['privacy:metadata:acceptances:lang'] = 'The current language displayed when the policy is accepted.';
 $string['privacy:metadata:acceptances:usermodified'] = 'The ID of the user accepting the policy, if made on behalf of another user.';
index a51aa69..a7b9bf2 100644 (file)
@@ -293,7 +293,7 @@ class auth_email_external extends external_api {
         $data['email2'] = $data['email'];
         // Force policy agreed if a site policy is set. The client is responsible of implementing the interface check.
         $manager = new \core_privacy\local\sitepolicy\manager();
-        if (!$manager->is_defined()) {
+        if ($manager->is_defined()) {
             $data['policyagreed'] = 1;
         }
         unset($data['recaptcharesponse']);
index 38f388a..1bb9de5 100644 (file)
@@ -24,4 +24,4 @@
 
 $string['activity_modules:addinstance'] = 'Add a new activities block';
 $string['pluginname'] = 'Activities';
-$string['privacy:metadata'] = 'The Activites block only shows data stored in other locations.';
+$string['privacy:metadata'] = 'The Activities block only shows data stored in other locations.';
index 23a2b10..97dec82 100644 (file)
@@ -65,4 +65,4 @@ $string['worstgrade'] = 'The lowest grade:';
 $string['worstgrades'] = 'The {$a} lowest grades:';
 $string['worstgroupgrade'] = 'The group with the lowest average:';
 $string['worstgroupgrades'] = 'The {$a} groups with the lowest average:';
-$string['privacy:metadata'] = 'The Activites results block only shows data stored in other locations.';
+$string['privacy:metadata'] = 'The Activity results block only shows data stored in other locations.';
index df44819..d1cb144 100644 (file)
@@ -28,4 +28,4 @@ $string['numbadgestodisplay'] = 'Number of latest badges to display';
 $string['nothingtodisplay'] = 'You have no badges to display';
 $string['badges:addinstance'] = 'Add a new My latest badges block';
 $string['badges:myaddinstance'] = 'Add a new My latest badges block to Dashboard';
-$string['privacy:metadata'] = 'The Badges block only shows data stored in other locations.';
+$string['privacy:metadata'] = 'The Latest badges block only shows data stored in other locations.';
index f565520..0b951f3 100644 (file)
@@ -26,4 +26,4 @@ $string['calendar_upcoming:addinstance'] = 'Add a new upcoming events block';
 $string['calendar_upcoming:myaddinstance'] = 'Add a new upcoming events block to Dashboard';
 $string['gotocalendar'] = 'Go to calendar...';
 $string['pluginname'] = 'Upcoming events';
-$string['privacy:metadata'] = 'The Upcoming calendar events block only displays existing calendar data.';
+$string['privacy:metadata'] = 'The Upcoming events block only displays existing calendar data.';
index 4534d40..902dd5f 100644 (file)
@@ -26,4 +26,4 @@
 $string['coursesummary'] = 'Course summary';
 $string['course_summary:addinstance'] = 'Add a new course/site summary block';
 $string['pluginname'] = 'Course/site summary';
-$string['privacy:metadata'] = 'The Course and site summaryblock only shows information about courses and does not store data itself.';
+$string['privacy:metadata'] = 'The Course/site summary block only shows information about courses and does not store data itself.';
index bf0f6d5..29b53ad 100644 (file)
@@ -24,4 +24,4 @@
 
 $string['login:addinstance'] = 'Add a new login block';
 $string['pluginname'] = 'Login';
-$string['privacy:metadata'] = 'The Login block only provides a way to login and does not store any data itself.';
+$string['privacy:metadata'] = 'The Login block only provides a way to log in and does not store any data itself.';
index 24a7767..b7de027 100644 (file)
@@ -45,7 +45,7 @@ $string['myprofile:addinstance'] = 'Add a new logged in user block';
 $string['myprofile:myaddinstance'] = 'Add a new logged in user block to Dashboard';
 $string['myprofile_settings'] = 'Visible user information';
 $string['pluginname'] = 'Logged in user';
-$string['privacy:metadata'] = 'The Logged in users block only shows information about logged in users and does not store data itself.';
+$string['privacy:metadata'] = 'The Logged in user block only shows information about the logged in user and does not store data itself.';
 
 // Deprecated since Moodle 3.2.
 $string['display_un'] = 'Display name';
index 31a0066..f0791fc 100644 (file)
@@ -26,4 +26,4 @@ $string['pluginname'] = 'Private files';
 $string['privatefiles'] = 'Private files';
 $string['private_files:addinstance'] = 'Add a new private files block';
 $string['private_files:myaddinstance'] = 'Add a new private files block to Dashboard';
-$string['privacy:metadata'] = 'The Private files block only provides a view of, and link to, the user private files.';
+$string['privacy:metadata'] = 'The Private files block only provides a view of, and a link to, the user\'s private files.';
index 3b169ae..05ecc00 100644 (file)
@@ -47,4 +47,4 @@ $string['scienceandtech'] = 'Science &amp; Tech';
 $string['sports'] = 'Sports';
 $string['tag_youtube:addinstance'] = 'Add a new YouTube block';
 $string['travel'] = 'Travel &amp; Places';
-$string['privacy:metadata'] = 'The Youtube block only shows data stored in other locations.';
+$string['privacy:metadata'] = 'The YouTube block only shows data stored in other locations.';
index 8471cf8..683d570 100644 (file)
@@ -24,4 +24,4 @@
 
 $string['name'] = 'Gregorian';
 $string['pluginname'] = 'Gregorian calendar type';
-$string['privacy:metadata'] = 'The Gregorian Calendar format plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The Gregorian calendar format plugin does not store any personal data.';
diff --git a/cohort/classes/privacy/provider.php b/cohort/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..4e625e2
--- /dev/null
@@ -0,0 +1,194 @@
+<?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/>.
+
+/**
+ * Privacy class for requesting user data.
+ *
+ * @package    core_cohort
+ * @copyright  2018 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_cohort\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+
+/**
+ * Privacy class for requesting user data.
+ *
+ * @copyright  2018 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\plugin\provider {
+
+    /**
+     * Return the fields which contain personal data.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table('cohort_members', [
+                'cohortid' => 'privacy:metadata:cohort_members:cohortid',
+                'userid' => 'privacy:metadata:cohort_members:userid',
+                'timeadded' => 'privacy:metadata:cohort_members:timeadded'
+            ], 'privacy:metadata:cohort_members');
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param int $userid The user to search.
+     * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        $sql = "SELECT ctx.id
+                  FROM {context} ctx
+            INNER JOIN {cohort} c ON c.contextid = ctx.id
+            INNER JOIN {cohort_members} cm ON cm.cohortid = c.id
+                 WHERE cm.userid = :userid AND (ctx.contextlevel = :contextlevel1 OR ctx.contextlevel = :contextlevel2)";
+        $params = [
+            'userid'        => $userid,
+            'contextlevel1' => CONTEXT_SYSTEM,
+            'contextlevel2' => CONTEXT_COURSECAT,
+        ];
+        $contextlist = new contextlist();
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        // Remove contexts different from SYSTEM or COURSECAT.
+        $contexts = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_SYSTEM || $context->contextlevel == CONTEXT_COURSECAT) {
+                $carry[] = $context->id;
+            }
+            return $carry;
+        }, []);
+
+        if (empty($contexts)) {
+            return;
+        }
+
+        // Get cohort data.
+        $userid = $contextlist->get_user()->id;
+        list($contextsql, $contextparams) = $DB->get_in_or_equal($contexts, SQL_PARAMS_NAMED);
+        $sql = "SELECT c.name,
+                       c.idnumber,
+                       c.description,
+                       c.visible,
+                       cm.timeadded,
+                       ctx.id as contextid
+                  FROM {context} ctx
+            INNER JOIN {cohort} c ON c.contextid = ctx.id
+            INNER JOIN {cohort_members} cm ON cm.cohortid = c.id
+                 WHERE ctx.id {$contextsql}
+                       AND cm.userid = :userid";
+        $params = [
+                'userid'        => $userid
+            ] + $contextparams;
+
+        $cohorts = $DB->get_recordset_sql($sql, $params);
+        foreach ($cohorts as $cohort) {
+            $alldata[$cohort->contextid][] = (object)[
+                    'name' => $cohort->name,
+                    'idnumber' => $cohort->idnumber,
+                    'visible' => transform::yesno($cohort->visible),
+                    'timeadded' => transform::datetime($cohort->timeadded),
+                ];
+        }
+        $cohorts->close();
+
+        // Export cohort data.
+        array_walk($alldata, function($data, $contextid) {
+            $context = \context::instance_by_id($contextid);
+            writer::with_context($context)->export_related_data([], 'cohort', $data);
+        });
+
+    }
+
+    /**
+     * Delete all use data which matches the specified context.
+     *
+     * @param context $context A user context.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        if (empty($context)) {
+            return;
+        }
+
+        if (!$context instanceof \context_system && !$context instanceof \context_coursecat) {
+            return;
+        }
+
+        static::delete_data($context);
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        if (empty($contextlist->count())) {
+            return;
+        }
+
+        $userid = $contextlist->get_user()->id;
+        foreach ($contextlist->get_contexts() as $context) {
+            if (!$context instanceof \context_system && !$context instanceof \context_coursecat) {
+                return;
+            }
+            static::delete_data($context, $userid);
+        }
+    }
+
+    /**
+     * Delete data related to a context and user (if defined).
+     *
+     * @param context $context A context.
+     * @param int $userid The user ID.
+     */
+    protected static function delete_data(\context $context, int $userid = null) {
+        global $DB;
+
+        $cohortids = $DB->get_fieldset_select('cohort', 'id', 'contextid = :contextid', ['contextid' => $context->id]);
+        foreach ($cohortids as $cohortid) {
+            $params = ['cohortid' => $cohortid];
+            if (!empty($userid)) {
+                $params['userid'] = $userid;
+            }
+            $DB->delete_records('cohort_members', $params);
+        }
+    }
+}
diff --git a/cohort/tests/privacy_test.php b/cohort/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..eba3d65
--- /dev/null
@@ -0,0 +1,218 @@
+<?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/>.
+
+/**
+ * Base class for unit tests for core_cohort.
+ *
+ * @package    core_cohort
+ * @category   test
+ * @copyright  2018 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use core_cohort\privacy\provider;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\writer;
+use core_privacy\tests\provider_testcase;
+
+/**
+ * Unit tests for cohort\classes\privacy\provider.php
+ *
+ * @copyright  2018 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_cohort_testcase extends provider_testcase {
+
+    /**
+     * Basic setup for these tests.
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+    }
+
+    /**
+     * Test getting the context for the user ID related to this plugin.
+     */
+    public function test_get_contexts_for_userid() {
+        // Create system cohort and category cohort.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = \context_system::instance();
+        $categorycohort = $this->getDataGenerator()->create_cohort([
+                'contextid' => $coursecategoryctx->id,
+                'name' => 'Category cohort 1',
+            ]);
+        $systemcohort = $this->getDataGenerator()->create_cohort([
+                'contextid' => $systemctx->id,
+                'name' => 'System cohort 1'
+            ]);
+
+        // Create user and add to the system and category cohorts.
+        $user = $this->getDataGenerator()->create_user();
+        cohort_add_member($categorycohort->id, $user->id);
+        cohort_add_member($systemcohort->id, $user->id);
+
+        // User is member of 2 cohorts.
+        $contextlist = provider::get_contexts_for_userid($user->id);
+        $this->assertCount(2, (array) $contextlist->get_contextids());
+        $this->assertContains($coursecategoryctx->id, $contextlist->get_contextids());
+        $this->assertContains($systemctx->id, $contextlist->get_contextids());
+    }
+
+    /**
+     * Test that data is exported correctly for this plugin.
+     */
+    public function test_export_user_data() {
+        // Create system cohort and category cohort.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = \context_system::instance();
+        $categorycohort = $this->getDataGenerator()->create_cohort([
+                'contextid' => $coursecategoryctx->id,
+                'name' => 'Category cohort 1',
+            ]);
+        $systemcohort1 = $this->getDataGenerator()->create_cohort([
+                'contextid' => $systemctx->id,
+                'name' => 'System cohort 1'
+            ]);
+        $systemcohort2 = $this->getDataGenerator()->create_cohort([
+                'contextid' => $systemctx->id,
+                'name' => 'System cohort 2'
+            ]);
+
+        // Create user and add to the system and category cohorts.
+        $user = $this->getDataGenerator()->create_user();
+        cohort_add_member($categorycohort->id, $user->id);
+        cohort_add_member($systemcohort1->id, $user->id);
+        cohort_add_member($systemcohort2->id, $user->id);
+
+        // Validate system cohort exported data.
+        $writer = writer::with_context($systemctx);
+        $this->assertFalse($writer->has_any_data());
+        $this->export_context_data_for_user($user->id, $systemctx, 'core_cohort');
+        $data = $writer->get_related_data([], 'cohort');
+        $this->assertCount(2, $data);
+
+        // Validate category cohort exported data.
+        $writer = writer::with_context($coursecategoryctx);
+        $this->assertFalse($writer->has_any_data());
+        $this->export_context_data_for_user($user->id, $coursecategoryctx, 'core_cohort');
+        $data = $writer->get_related_data([], 'cohort');
+        $this->assertCount(1, $data);
+        $this->assertEquals($categorycohort->name, reset($data)->name);
+    }
+
+    /**
+     * Test for provider::delete_data_for_all_users_in_context().
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        // Create system cohort and category cohort.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = \context_system::instance();
+        $categorycohort = $this->getDataGenerator()->create_cohort([
+                'contextid' => $coursecategoryctx->id,
+                'name' => 'Category cohort 1',
+                'idnumber' => '',
+                'description' => ''
+            ]);
+        $systemcohort = $this->getDataGenerator()->create_cohort([
+                'contextid' => $systemctx->id,
+                'name' => 'System cohort 1'
+            ]);
+
+        // Create user and add to the system and category cohorts.
+        $user = $this->getDataGenerator()->create_user();
+        cohort_add_member($categorycohort->id, $user->id);
+        cohort_add_member($systemcohort->id, $user->id);
+
+        // Before deletion, we should have 2 entries in the cohort_members table.
+        $count = $DB->count_records('cohort_members');
+        $this->assertEquals(2, $count);
+
+        // Delete data based on system context.
+        provider::delete_data_for_all_users_in_context($systemctx);
+
+        // After deletion, the cohort_members entries should have been deleted.
+        $count = $DB->count_records('cohort_members');
+        $this->assertEquals(1, $count);
+
+        // Delete data based on category context.
+        provider::delete_data_for_all_users_in_context($coursecategoryctx);
+
+        // After deletion, the cohort_members entries should have been deleted.
+        $count = $DB->count_records('cohort_members');
+        $this->assertEquals(0, $count);
+    }
+
+    /**
+     * Test for provider::delete_data_for_user().
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        // Create system cohort and category cohort.
+        $coursecategory = $this->getDataGenerator()->create_category();
+        $coursecategoryctx = \context_coursecat::instance($coursecategory->id);
+        $systemctx = \context_system::instance();
+        $categorycohort = $this->getDataGenerator()->create_cohort([
+                'contextid' => $coursecategoryctx->id,
+                'name' => 'Category cohort 1',
+                'idnumber' => '',
+                'description' => ''
+            ]);
+        $systemcohort = $this->getDataGenerator()->create_cohort([
+                'contextid' => $systemctx->id,
+                'name' => 'System cohort 1'
+            ]);
+
+        // Create user and add to the system and category cohorts.
+        $user1 = $this->getDataGenerator()->create_user();
+        cohort_add_member($categorycohort->id, $user1->id);
+        cohort_add_member($systemcohort->id, $user1->id);
+
+        // Create another user and add to the system and category cohorts.
+        $user2 = $this->getDataGenerator()->create_user();
+        cohort_add_member($categorycohort->id, $user2->id);
+        cohort_add_member($systemcohort->id, $user2->id);
+
+        // Create another user and add to the system cohort.
+        $user3 = $this->getDataGenerator()->create_user();
+        cohort_add_member($systemcohort->id, $user3->id);
+
+        // Before deletion, we should have 5 entries in the cohort_members table.
+        $count = $DB->count_records('cohort_members');
+        $this->assertEquals(5, $count);
+
+        $contextlist = provider::get_contexts_for_userid($user1->id);
+        $approvedcontextlist = new approved_contextlist($user1, 'cohort', $contextlist->get_contextids());
+        provider::delete_data_for_user($approvedcontextlist);
+
+        // After deletion, the cohort_members entries for the first student should have been deleted.
+        $count = $DB->count_records('cohort_members', ['userid' => $user1->id]);
+        $this->assertEquals(0, $count);
+        $count = $DB->count_records('cohort_members');
+        $this->assertEquals(3, $count);
+
+        // Confirm that the cohorts hasn't been removed.
+        $cohortscount = $DB->get_records('cohort');
+        $this->assertCount(2, (array) $cohortscount);
+    }
+}
index b2d0b1d..2062044 100644 (file)
@@ -2988,6 +2988,8 @@ class course_request {
     public function approve() {
         global $CFG, $DB, $USER;
 
+        require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
         $user = $DB->get_record('user', array('id' => $this->properties->requester, 'deleted'=>0), '*', MUST_EXIST);
 
         $courseconfig = get_config('moodlecourse');
@@ -3022,6 +3024,8 @@ class course_request {
             $data->enddate        = usergetmidnight(time()) + $courseconfig->courseduration;
         }
 
+        list($data->fullname, $data->shortname) = restore_dbops::calculate_course_names(0, $data->fullname, $data->shortname);
+
         $course = create_course($data);
         $context = context_course::instance($course->id, MUST_EXIST);
 
index cf1176b..c7517f9 100644 (file)
@@ -23,6 +23,6 @@
  */
 
 $string['dataformat'] = 'Comma separated values (.csv)';
-$string['privacy:metadata'] = 'The CSV Data format plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The CSV data format plugin does not store any personal data.';
 $string['shortname'] = 'CSV';
 
index cd90da3..b09f1f8 100644 (file)
@@ -23,6 +23,6 @@
  */
 
 $string['dataformat'] = 'Microsoft Excel (.xlsx)';
-$string['privacy:metadata'] = 'The Excel Data format plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The Excel data format plugin does not store any personal data.';
 $string['shortname'] = 'Excel';
 
index e5efa77..0d62cfb 100644 (file)
@@ -23,6 +23,6 @@
  */
 
 $string['dataformat'] = 'HTML table';
-$string['privacy:metadata'] = 'The HTML Data format plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The HTML table data format plugin does not store any personal data.';
 $string['shortname'] = 'HTML';
 
index 43540ab..a8028a4 100644 (file)
@@ -23,6 +23,6 @@
  */
 
 $string['dataformat'] = 'Javascript Object Notation (.json)';
-$string['privacy:metadata'] = 'The JSON Data format plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The JavaScript Object Notation data format plugin does not store any personal data.';
 $string['shortname'] = 'JSON';
 
index edf5a27..1d2773c 100644 (file)
@@ -23,6 +23,6 @@
  */
 
 $string['dataformat'] = 'OpenDocument (.ods)';
-$string['privacy:metadata'] = 'The OpenDoc Data format plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The OpenDocument data format plugin does not store any personal data.';
 $string['shortname'] = 'OpenDoc';
 
index dcb5f29..da4cd68 100644 (file)
@@ -129,6 +129,7 @@ class enrol_manual_enrol_users_form extends moodleform {
 
         $mform->addElement('checkbox', 'recovergrades', get_string('recovergrades', 'enrol'));
         $mform->setAdvanced('recovergrades');
+        $mform->setDefault('recovergrades', $CFG->recovergradesdefault);
         $mform->addElement('select', 'duration', get_string('defaultperiod', 'enrol_manual'), $periodmenu);
         $mform->setDefault('duration', $defaultperiod);
         $mform->setAdvanced('duration');
index e77ddb5..a28d07d 100644 (file)
@@ -30,7 +30,7 @@ $string['assignrole'] = 'Assign role';
 $string['assignroles'] = 'Assign roles';
 $string['browseusers'] = 'Browse users';
 $string['browsecohorts'] = 'Browse cohorts';
-$string['confirmbulkdeleteenrolment'] = 'Are you sure you want to delete these users enrolments?';
+$string['confirmbulkdeleteenrolment'] = 'Are you sure you want to delete these user enrolments?';
 $string['defaultstart'] = 'Default enrolment start';
 $string['defaultperiod'] = 'Default enrolment duration';
 $string['defaultperiod_desc'] = 'Default length of time that the enrolment is valid. If set to zero, the enrolment duration will be unlimited by default.';
index 500e693..6aa0de2 100644 (file)
@@ -39,7 +39,7 @@ $string['enrolperiod_desc'] = 'Default length of time that the enrolment is vali
 $string['enrolperiod_help'] = 'Length of time that the enrolment is valid, starting with the moment the user is enrolled. If disabled, the enrolment duration will be unlimited.';
 $string['enrolstartdate'] = 'Start date';
 $string['enrolstartdate_help'] = 'If enabled, users can be enrolled from this date onward only.';
-$string['errdisabled'] = 'PayPal plugin is disabled and does not handle payment notifications.';
+$string['errdisabled'] = 'The PayPal enrolment plugin is disabled and does not handle payment notifications.';
 $string['erripninvalid'] = 'Instant payment notification has not been verified by PayPal.';
 $string['errpaypalconnect'] = 'Could not connect to {$a->url} to verify the instant payment notification: {$a->result}';
 $string['expiredaction'] = 'Enrolment expiry action';
@@ -56,7 +56,7 @@ $string['paypal:unenrolself'] = 'Unenrol self from the course';
 $string['paypalaccepted'] = 'PayPal payments accepted';
 $string['pluginname'] = 'PayPal';
 $string['pluginname_desc'] = 'The PayPal module allows you to set up paid courses.  If the cost for any course is zero, then students are not asked to pay for entry.  There is a site-wide cost that you set here as a default for the whole site and then a course setting that you can set for each course individually. The course cost overrides the site cost.';
-$string['processexpirationstask'] = 'Process expirations task';
+$string['processexpirationstask'] = 'PayPal enrolment send expiry notifications task';
 $string['sendpaymentbutton'] = 'Send payment via PayPal';
 $string['status'] = 'Allow PayPal enrolments';
 $string['status_desc'] = 'Allow users to use PayPal to enrol into a course by default.';
index 75fc027..7dc5684 100644 (file)
@@ -28,7 +28,7 @@ $string['canntenrollate'] = 'You cannot enrol any more, since enrolment ended on
 $string['cohortnonmemberinfo'] = 'Only members of cohort \'{$a}\' can self-enrol.';
 $string['cohortonly'] = 'Only cohort members';
 $string['cohortonly_help'] = 'Self enrolment may be restricted to members of a specified cohort only. Note that changing this setting has no effect on existing enrolments.';
-$string['confirmbulkdeleteenrolment'] = 'Are you sure you want to delete these users enrolments?';
+$string['confirmbulkdeleteenrolment'] = 'Are you sure you want to delete these user enrolments?';
 $string['customwelcomemessage'] = 'Custom welcome message';
 $string['customwelcomemessage_help'] = 'A custom welcome message may be added as plain text or Moodle-auto format, including HTML tags and multi-lang tags.
 
index 207dbc2..45e9725 100644 (file)
@@ -432,7 +432,7 @@ $string['debugnormal'] = 'NORMAL: Show errors, warnings and notices';
 $string['debugpageinfo'] = 'Show page information';
 $string['debugsmtp'] = 'Debug email sending';
 $string['debugstringids'] = 'Show origin of languages strings';
-$string['debugstringids_desc'] = 'This option is designed to help translators. When this option is enabled, if you add the parameter strings=1 to a request URL, it will show the language file and string id beside each string that is output.';
+$string['debugstringids_desc'] = 'If enabled, language string components and identifiers are displayed when ?strings=1 or &strings=1 is appended to the page URL.';
 $string['debugvalidators'] = 'Show validator links';
 $string['defaultcity'] = 'Default city';
 $string['defaultcity_help'] = 'A city entered here will be the default city when creating new user accounts.';
@@ -834,7 +834,7 @@ $string['passwordresettime'] = 'Maximum time to validate password reset request'
 $string['passwordreuselimit'] = 'Password rotation limit';
 $string['passwordreuselimit_desc'] = 'Number of times a user must change their password before they are allowed to reuse a password. Hashes of previously used passwords are stored in local database table. This feature might not be compatible with some external authentication plugins.';
 $string['pathtodot'] = 'Path to dot';
-$string['pathtodot_help'] = 'Path to dot. Probably something like /usr/bin/dot. To be able to generate graphics from DOT files, you must have installed the dot executable and point to it here. Note that, for now, this only used by the profiling features (Development->Profiling) built into Moodle.';
+$string['pathtodot_help'] = 'Path to dot. On Linux it is something like /usr/bin/dot. On Windows it is something like C:\Program Files (x86)\Graphviz2.38\bin\dot.exe. On Mac it is something like /opt/local/bin/dot. To be able to generate graphics from DOT files, you must have installed the dot executable and point to it here.';
 $string['pathtodu'] = 'Path to du';
 $string['pathtogs'] = 'Path to ghostscript';
 $string['pathtogs_help'] = 'On most Linux installs, this can be left as \'/usr/bin/gs\'. On Windows it will be something like \'c:\\gs\\bin\\gswin32c.exe\' (make sure there are no spaces in the path - if necessary copy the files \'gswin32c.exe\' and \'gsdll32.dll\' to a new folder without a space in the path)';
@@ -1057,15 +1057,15 @@ $string['sitemaintenanceon'] = 'Your site is currently in maintenance mode (only
 $string['sitemaintenanceoncli'] = 'Your site is currently in CLI maintenance mode, no web access is allowed.';
 $string['sitemaintenancewarning'] = 'Your site is currently in maintenance mode (only admins can log in).  To return this site to normal operation, <a href="maintenance.php">disable maintenance mode</a>.';
 $string['sitemaintenancewarning2'] = 'Your site is currently in maintenance mode (only admins can log in).  To return this site to normal operation, <a href="{$a}">disable maintenance mode</a>.';
-$string['sitepolicies'] = 'Site policies';
+$string['sitepolicies'] = 'Site security settings';
 $string['sitepolicy'] = 'Site policy URL';
-$string['sitepolicy_help'] = 'If you have a site policy that all registered users must see and agree to before using this site, then specify the URL to it here, otherwise leave this field blank. This setting can contain any public URL.';
+$string['sitepolicy_help'] = 'The URL of the site policy that all registered users must see and agree to before accessing the site. Note that this setting will only have an effect if the site policy handler is set to default (core).';
 $string['sitepolicyhandler'] = 'Site policy handler';
-$string['sitepolicyhandler_desc'] = 'Select the component to handle user agreements to site policies. The default core handler provides a simple functionality controlled by the two other settings `sitepolicy` and `sitepolicyguest`. Alternative handlers may be provided by additional plugins and offer more advanced control of site policies.';
+$string['sitepolicyhandler_desc'] = 'This determines how policies and user consents are managed. The default (core) handler enables a site policy URL and a site policy URL for guests to be specified. The policies handler enables site, privacy and other policies to be set. It also enables user consents to be viewed and, if necessary, consent on behalf of minors to be given.';
 $string['sitepolicyhandlercore'] = 'Default (core)';
 $string['sitepolicyhandlerplugin'] = '{$a->name} ({$a->component})';
 $string['sitepolicyguest'] = 'Site policy URL for guests';
-$string['sitepolicyguest_help'] = 'If you have a site policy that all guests must see and agree to before using this site, then specify the URL to it here, otherwise leave this field blank. This setting can contain any public URL. Note: access of not-logged-in users may be prevented with forcelogin setting.';
+$string['sitepolicyguest_help'] = 'The URL of the site policy that all guests must see and agree to before accessing the site. Note that this setting will only have an effect if the site policy handler is set to default (core).';
 $string['sitesectionhelp'] = 'If selected, a topic section will be displayed on the site\'s front page.';
 $string['slasharguments'] = 'Use slash arguments';
 $string['slashargumentswarning'] = 'It is recommended that the use of slash arguments is enabled. In future it will be required. For more details, see the documentation <a href="https://docs.moodle.org/en/admin/environment/slasharguments">Using slash arguments</a>.';
index 643fab4..a96fb4f 100644 (file)
@@ -86,5 +86,5 @@ $string['predictionsprocessor_help'] = 'A predictions processor is the machine-l
 $string['processingsitecontents'] = 'Processing site contents';
 $string['successfullyanalysed'] = 'Successfully analysed';
 $string['timesplittingmethod'] = 'Time-splitting method';
-$string['timesplittingmethod_help'] = 'The time-splitting method divides the course duration into parts; the predictions engine will run at the end of these parts. It is recommended that you only enable the time-splitting methods you may be interested in using. The evaluation process will iterate through all enabled time-spitting methods, so the more time-splitting methods enabled, the slower the evaluation process will be.';
+$string['timesplittingmethod_help'] = 'The time-splitting method divides the course duration into parts; the predictions engine will run at the end of these parts. It is recommended that you only enable the time-splitting methods you may be interested in using. The evaluation process will iterate through all enabled time-splitting methods, so the more time-splitting methods enabled, the slower the evaluation process will be.';
 $string['viewprediction'] = 'View prediction details';
index 28ac515..cb7597e 100644 (file)
@@ -224,7 +224,7 @@ $string['criteria_6_help'] = 'Allows a badge to be awarded to users for completi
 $string['criteria_7'] = 'Awarded badges';
 $string['criteria_7_help'] = 'Allows a badge to be awarded to users based on the other badges thay have earned.';
 $string['criteria_8'] = 'Cohort membership';
-$string['criteria_8_help'] = 'Allows a badge to be awarded to users for becoming a member of certian cohorts.';
+$string['criteria_8_help'] = 'Allows a badge to be awarded to users based on cohort membership.';
 $string['criterror'] = 'Current parameters issues';
 $string['criterror_help'] = 'This fieldset shows all parameters that were initially added to this badge requirement but are no longer available. It is recommended that you un-check such parameters to make sure that users can earn this badge in the future.';
 $string['currentimage'] = 'Current image';
index 3b0053f..0ca7302 100644 (file)
@@ -132,7 +132,7 @@ $string['privacy:metadata:core_comments'] = 'Comments associated with blog entri
 $string['privacy:metadata:core_files'] = 'Files attached to blog entries';
 $string['privacy:metadata:core_tag'] = 'Tags associated with blog entries';
 $string['privacy:metadata:external'] = 'A link to an external RSS feed';
-$string['privacy:metadata:external:userid'] = 'The user ID';
+$string['privacy:metadata:external:userid'] = 'The ID of the user who added the external blog entry.';
 $string['privacy:metadata:external:name'] = 'The name of the feed';
 $string['privacy:metadata:external:description'] = 'The description of the feed';
 $string['privacy:metadata:external:url'] = 'The URL of the feed';
@@ -140,14 +140,14 @@ $string['privacy:metadata:external:filtertags'] = 'The list of tags to filter th
 $string['privacy:metadata:external:timemodified'] = 'Date at which the association was last modified';
 $string['privacy:metadata:external:timefetched'] = 'Date at which the feed was last fetched';
 $string['privacy:metadata:post'] = 'The information related to blog entries';
-$string['privacy:metadata:post:userid'] = 'The user ID';
-$string['privacy:metadata:post:subject'] = 'The subject of the entry';
-$string['privacy:metadata:post:summary'] = 'The summary of the entry';
-$string['privacy:metadata:post:content'] = 'The content of an external entry';
+$string['privacy:metadata:post:userid'] = 'The ID of the user who added the blog entry.';
+$string['privacy:metadata:post:subject'] = 'Blog entry title.';
+$string['privacy:metadata:post:summary'] = 'Blog entry.';
+$string['privacy:metadata:post:content'] = 'The content of an external blog entry.';
 $string['privacy:metadata:post:uniquehash'] = 'A unique identifier for an external entry, typically a URL.';
 $string['privacy:metadata:post:publishstate'] = 'Whether the entry is visible to others or not';
-$string['privacy:metadata:post:created'] = 'Date at which the entry was created';
-$string['privacy:metadata:post:lastmodified'] = 'Date at which the entry was last modified';
+$string['privacy:metadata:post:created'] = 'Date when the entry was created.';
+$string['privacy:metadata:post:lastmodified'] = 'Date when the entry was last modified.';
 $string['privacy:metadata:post:usermodified'] = 'The user who last modified the entry';
 $string['privacy:path:blogassociations'] = 'Associated blog posts';
 $string['privacy:unknown'] = 'Unknown';
index ee7503b..cfda2a1 100644 (file)
@@ -70,6 +70,10 @@ $string['nocomponent'] = 'Created manually';
 $string['potusers'] = 'Potential users';
 $string['potusersmatching'] = 'Potential matching users';
 $string['preview'] = 'Preview';
+$string['privacy:metadata:cohort_members'] = 'Information about the user\'s cohort.';
+$string['privacy:metadata:cohort_members:cohortid'] = 'The ID of the cohort';
+$string['privacy:metadata:cohort_members:timeadded'] = 'The timestamp indicating when the user was added to the cohort';
+$string['privacy:metadata:cohort_members:userid'] = 'The ID of the user which is associated to the cohort';
 $string['removeuserwarning'] = 'Removing users from a cohort may result in unenrolling of users from multiple courses which includes deleting of user settings, grades, group membership and other user information from affected courses.';
 $string['selectfromcohort'] = 'Select members from cohort';
 $string['systemcohorts'] = 'System cohorts';
index 54c35b5..013b923 100644 (file)
@@ -117,7 +117,7 @@ $string['pleaseenteralt'] = 'Please enter the alternate text';
 $string['popupeditor'] = 'Enlarge Editor';
 $string['preformatted'] = 'Preformatted';
 $string['preview'] = 'Preview';
-$string['privacy:metadata:preference:htmleditor'] = 'The preferred editor to use when using an HTML Text Area';
+$string['privacy:metadata:preference:htmleditor'] = 'Text editor preference.';
 $string['privacy:preference:htmleditor'] = 'Your preferred editor to use for writing HTML text is {$a}';
 $string['properties'] = 'Properties';
 $string['redo'] = 'Redo your last action';
index 3d9055f..4032ffb 100644 (file)
@@ -1434,7 +1434,7 @@ $string['notifyloginfailuresmessageend'] = 'You can view these logs at {$a}';
 $string['notifyloginfailuresmessagestart'] = 'Here is a list of failed login attempts at {$a} since you were last notified';
 $string['notifyloginfailuressubject'] = '{$a} :: Failed logins notification';
 $string['notincluded'] = 'Not included';
-$string['notingroup'] = 'Sorry, but you need to be part of a group to see this activity.';
+$string['notingroup'] = 'Sorry, but you need to be part of a group to see this page.';
 $string['notpublic'] = 'Not public!';
 $string['nousersfound'] = 'No users found';
 $string['nousersmatching'] = 'No users matching \'{$a}\' were found';
index ea1e734..216b1ab 100644 (file)
@@ -38,3 +38,4 @@ $string['reseteveryonesdashboard'] = 'Reset Dashboard for all users';
 $string['reseteveryonesprofile'] = 'Reset profile for all users';
 $string['resetpage'] = 'Reset page to default';
 $string['reseterror'] = 'There was an error resetting your page';
+$string['privacy:metadata:core_my:preference:user_home_page_preference'] = 'The user home page preference configured for the Dashboard page.';
index c2b388d..6fb2f45 100644 (file)
@@ -58,6 +58,13 @@ $string['page-notes-x'] = 'Any note pages';
 $string['page-notes-index'] = 'Notes main page';
 $string['personal'] = 'personal';
 $string['personalnotes'] = 'Personal notes';
+$string['privacy:metadata:core_notes'] = 'The Notes component stores user notes within the core subsystem.';
+$string['privacy:metadata:core_notes:content'] = 'The content of the note.';
+$string['privacy:metadata:core_notes:courseid'] = 'The Id of the course associated with the note.';
+$string['privacy:metadata:core_notes:created'] = 'The creation date/time for the note.';
+$string['privacy:metadata:core_notes:lastmodified'] = 'The last modified date/time for the note.';
+$string['privacy:metadata:core_notes:publishstate'] = 'The publish state of the note.';
+$string['privacy:metadata:core_notes:userid'] = 'The Id of the user associated with the note.';
 $string['publishstate'] = 'Context';
 $string['publishstate_help'] = 'A note\'s context determines who can see the note.
 
index 809e938..7447f82 100644 (file)
@@ -24,6 +24,5 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-
 $string['privacy:metadata:core_userkey'] = 'User\'s keys used to access RSS from a URL';
 $string['rss'] = 'RSS';
index 563e2e1..4507a97 100644 (file)
@@ -97,8 +97,8 @@ $string['privacy:metadata:tag:name'] = 'The name of the tag - this is the normal
 $string['privacy:metadata:tag:rawname'] = 'The name of the tag - this is the display name.';
 $string['privacy:metadata:tag:description'] = 'The description of the tag.';
 $string['privacy:metadata:tag:flag'] = 'Whether a tag has been flagged as inappropriate.';
-$string['privacy:metadata:tag:timemodified'] = 'The last time that the tag was last modified.';
-$string['privacy:metadata:tag:userid'] = 'The user who first created the tag.';
+$string['privacy:metadata:tag:timemodified'] = 'The time that the tag was last modified.';
+$string['privacy:metadata:tag:userid'] = 'The ID of the user who created the tag.';
 $string['privacy:metadata:taginstance'] = 'The link between each tag and where it is used.';
 $string['privacy:metadata:taginstance:tagid'] = 'The link to the tag.';
 $string['privacy:metadata:taginstance:ordering'] = 'The relative order of this tag.';
index 18a5b8f..0e90141 100644 (file)
@@ -36,7 +36,7 @@ $string['pathtoclamdesc'] = 'If the running method is set to "command line", ent
 $string['pathtounixsocket'] = 'Unix domain socket';
 $string['pathtounixsocketdesc'] = 'If the running method is set to "Unix domain socket", enter the path to ClamAV Unix socket here. On Debian Linux this will be /var/run/clamav/clamd.ctl. Please make sure that clamav daemon has read access to uploaded files, the easiest way to ensure that is to add \'clamav\' user to your webserver group (\'www-data\' on Debian Linux).';
 $string['pluginname'] = 'ClamAV antivirus';
-$string['privacy:metadata'] = 'The ClamAV Antivirus plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The ClamAV antivirus plugin does not store any personal data.';
 $string['quarantinedir'] = 'Quarantine directory';
 $string['runningmethod'] = 'Running method';
 $string['runningmethoddesc'] = 'Method of running ClamAV. Command line is used by default, however on Unix systems better performance can be obtained by using system sockets.';
index fe62b3a..ffe8c16 100644 (file)
@@ -47,7 +47,7 @@ $string['recover'] = 'Recover';
 $string['infostatus'] = 'Information';
 $string['warningstatus'] = 'Warning';
 $string['autosaves'] = 'Editor autosave information';
-$string['privacy:metadata:database:atto_autosave'] = 'Editor drafts which was automatically saved.';
-$string['privacy:metadata:database:atto_autosave:userid'] = 'The ID of the user who\'s data was saved.';
+$string['privacy:metadata:database:atto_autosave'] = 'Automatically saved text editor drafts.';
+$string['privacy:metadata:database:atto_autosave:userid'] = 'The ID of the user whose data was saved.';
 $string['privacy:metadata:database:atto_autosave:drafttext'] = 'The text which was saved.';
 $string['privacy:metadata:database:atto_autosave:timemodified'] = 'The time that content was modified.';
index e4df7ef..354bda9 100644 (file)
@@ -25,4 +25,4 @@
  */
 
 $string['pluginname'] = 'Plain text area';
-$string['privacy:metadata'] = 'The editor_textarea plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The Plain text area editor plugin does not store any personal data.';
index 82dd895..8008f97 100644 (file)
@@ -894,4 +894,4 @@ $string['advanced:cut_desc'] = 'Cut';
 $string['advanced:paste_desc'] = 'Paste';
 $string['advanced:shortcuts_desc'] = 'Accessibility help';
 $string['autosave:restore_content'] = 'Restore auto-saved content';
-$string['privacy:metadata'] = 'The editor_tinymce plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The TinyMCE HTML editor plugin does not store any personal data.';
index 1a6ceff..fca1d43 100644 (file)
@@ -26,4 +26,4 @@ $string['pluginname'] = 'Toolbar Toggle';
 
 /* All lang strings used from TinyMCE JavaScript code must be named 'pluginname:stringname', no need to create langs/en_dlg.js */
 $string['pdw:desc'] = 'Toolbar Toggle';
-$string['privacy:metadata'] = 'The TinyMCE Toolebar toggle plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The TinyMCE Toolbar toggle plugin does not store any personal data.';
index affe8e6..8474022 100644 (file)
@@ -46,7 +46,7 @@
         ]
     }
 }}
-<div class="form-filetypes-descriptions">
+<div class="form-filetypes-descriptions w-100">
 {{#hasdescriptions}}
     <ul class="list-unstyled unstyled">
         {{#descriptions}}
index 7cde09c..dfe2ad6 100644 (file)
@@ -505,7 +505,7 @@ class core_component_testcase extends advanced_testcase {
         $this->assertCount(5, core_component::get_component_classes_in_namespace('core_user', 'output\\myprofile'));
 
         // Without namespace it returns classes/ classes.
-        $this->assertCount(2, core_component::get_component_classes_in_namespace('tool_mobile', ''));
+        $this->assertCount(3, core_component::get_component_classes_in_namespace('tool_mobile', ''));
         $this->assertCount(2, core_component::get_component_classes_in_namespace('tool_filetypes'));
     }
 
index 3ba1e84..51c2d73 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-confirm/moodle-core-notification-confirm-debug.js and b/lib/yui/build/moodle-core-notification-confirm/moodle-core-notification-confirm-debug.js differ
index 3c20d46..e75c59c 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-confirm/moodle-core-notification-confirm-min.js and b/lib/yui/build/moodle-core-notification-confirm/moodle-core-notification-confirm-min.js differ
index 3ba1e84..51c2d73 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-confirm/moodle-core-notification-confirm.js and b/lib/yui/build/moodle-core-notification-confirm/moodle-core-notification-confirm.js differ
index 0a13ee5..cddd2ca 100644 (file)
@@ -63,14 +63,14 @@ Y.extend(CONFIRM, M.core.notification.info, {
         this.publish('complete');
         this.publish('complete-yes');
         this.publish('complete-no');
-        this._yesButton = Y.Node.create('<input type="button" class="btn btn-primary m-r-1" id="id_yuiconfirmyes-' +
+        this._yesButton = Y.Node.create('<input type="button" class="btn btn-primary" id="id_yuiconfirmyes-' +
                                         this.get('COUNT') + '" value="' + this.get(CONFIRMYES) + '" />');
-        this._noButton = Y.Node.create('<input type="button" class="btn btn-secondary m-r-1" id="id_yuiconfirmno-' +
+        this._noButton = Y.Node.create('<input type="button" class="btn btn-secondary" id="id_yuiconfirmno-' +
                                         this.get('COUNT') + '" value="' + this.get(CONFIRMNO) + '" />');
         this._question = Y.Node.create('<div class="confirmation-message">' + this.get(QUESTION) + '</div>');
         var content = Y.Node.create('<div class="confirmation-dialogue"></div>')
                         .append(this._question)
-                        .append(Y.Node.create('<div class="confirmation-buttons form-inline"></div>')
+                        .append(Y.Node.create('<div class="confirmation-buttons form-inline justify-content-around"></div>')
                             .append(this._yesButton)
                             .append(this._noButton));
         this.get(BASE).addClass('moodle-dialogue-confirm');
index 666fcda..f95ee5a 100644 (file)
@@ -43,19 +43,19 @@ $string['notconfigured'] = 'The Airnotifier server hasn\'t been configured so Ai
 $string['pluginname'] = 'Mobile';
 $string['privacy:appiddescription'] = 'This is an identifier to the application being used.';
 $string['privacy:enableddescription'] = 'If this device is enabled for airnotifier.';
-$string['privacy:metadata:enabled'] = 'If the airnotifier device is enabled.';
+$string['privacy:metadata:enabled'] = 'Whether the airnotifier device is enabled.';
 $string['privacy:metadata:date'] = 'The date that the message was sent.';
 $string['privacy:metadata:externalpurpose'] = 'This information is sent to an external site to be ultimately delivered to the mobile device of the user.';
 $string['privacy:metadata:fullmessage'] = 'The full message.';
 $string['privacy:metadata:notification'] = 'If this message is a notification.';
-$string['privacy:metadata:smallmessage'] = 'An section of the message.';
+$string['privacy:metadata:smallmessage'] = 'A section of the message.';
 $string['privacy:metadata:subject'] = 'The subject line of the message.';
 $string['privacy:metadata:tableexplanation'] = 'Airnotifier device information is stored here.';
 $string['privacy:metadata:userdeviceid'] = 'The ID linking to the user\'s mobile device';
-$string['privacy:metadata:userfromfullname'] = 'The full name of the user that sent the message.';
+$string['privacy:metadata:userfromfullname'] = 'The full name of the user who sent the message.';
 $string['privacy:metadata:userfromid'] = 'The user ID of the author of the message.';
-$string['privacy:metadata:userid'] = 'The user ID.';
-$string['privacy:metadata:username'] = 'The username for the user.';
+$string['privacy:metadata:userid'] = 'The ID of the user who sent the message.';
+$string['privacy:metadata:username'] = 'The username of the user.';
 $string['privacy:metadata:usersubsystem'] = 'This plugin is connected to the user subsystem.';
 $string['privacy:subcontext'] = 'Message Airnotifier';
 $string['sitemustberegistered'] = 'In order to use the public Airnotifier instance you must register your site with Moodle.net';
index b40b681..91c854c 100644 (file)
@@ -35,8 +35,8 @@ $string['jabberpassword'] = 'Jabber password';
 $string['jabberport'] = 'Jabber port';
 $string['notconfigured'] = 'The Jabber server hasn\'t been configured so Jabber messages cannot be sent';
 $string['pluginname'] = 'Jabber';
-$string['privacy:metadata:contexturl'] = 'The url to view the event.';
+$string['privacy:metadata:contexturl'] = 'The URL to view the event.';
 $string['privacy:metadata:externalpurpose'] = 'This information is sent to an external Jabber server to be ultimately delivered as a notification message to the recipient.';
-$string['privacy:metadata:smallmessage'] = 'The small version of the message.';
+$string['privacy:metadata:smallmessage'] = 'A section of the message.';
 $string['privacy:metadata:userfrom'] = 'The user sending the notification message.';
 $string['privacy:metadata:userto'] = 'The recipient of the notification message.';
index 06d30eb..8fc4e4c 100644 (file)
@@ -596,7 +596,7 @@ class mod_choice_external extends external_api {
                 'choiceid' => new external_value(PARAM_INT, 'choice instance id'),
                 'responses' => new external_multiple_structure(
                     new external_value(PARAM_INT, 'response id'),
-                    'Array of response ids, empty for deleting all the user responses',
+                    'Array of response ids, empty for deleting all the current user responses.',
                     VALUE_DEFAULT,
                     array()
                 ),
@@ -608,7 +608,7 @@ class mod_choice_external extends external_api {
      * Delete the given submitted responses in a choice
      *
      * @param int $choiceid the choice instance id
-     * @param array $responses the response ids,  empty for deleting all the user responses
+     * @param array $responses the response ids,  empty for deleting all the current user responses
      * @return array status information and warnings
      * @throws moodle_exception
      * @since Moodle 3.0
@@ -633,33 +633,38 @@ class mod_choice_external extends external_api {
 
         require_capability('mod/choice:choose', $context);
 
-        // If we have the capability, delete all the passed responses.
-        if (has_capability('mod/choice:deleteresponses', $context)) {
-            if (empty($params['responses'])) {
-                // Get all the responses for the choice.
-                $params['responses'] = array_keys(choice_get_all_responses($choice));
-            }
-            $status = choice_delete_responses($params['responses'], $choice, $cm, $course);
-        } else if ($choice->allowupdate) {
-            // Check if we can delate our own responses.
-            $timenow = time();
-            if (!empty($choice->timeclose) && ($timenow > $choice->timeclose)) {
-                throw new moodle_exception("expired", "choice", '', userdate($choice->timeclose));
+        $candeleteall = has_capability('mod/choice:deleteresponses', $context);
+        if ($candeleteall || $choice->allowupdate) {
+
+            // Check if we can delete our own responses.
+            if (!$candeleteall) {
+                $timenow = time();
+                if (!empty($choice->timeclose) && ($timenow > $choice->timeclose)) {
+                    throw new moodle_exception("expired", "choice", '', userdate($choice->timeclose));
+                }
             }
-            // Delete only our responses.
-            $myresponses = array_keys(choice_get_my_response($choice));
 
             if (empty($params['responses'])) {
-                $todelete = $myresponses;
+                // No responses indicated so delete only my responses.
+                $todelete = array_keys(choice_get_my_response($choice));
             } else {
+                // Fill an array with the responses that can be deleted for this choice.
+                if ($candeleteall) {
+                    // Teacher/managers can delete any.
+                    $allowedresponses = array_keys(choice_get_all_responses($choice));
+                } else {
+                    // Students can delete only their own responses.
+                    $allowedresponses = array_keys(choice_get_my_response($choice));
+                }
+
                 $todelete = array();
                 foreach ($params['responses'] as $response) {
-                    if (!in_array($response, $myresponses)) {
+                    if (!in_array($response, $allowedresponses)) {
                         $warnings[] = array(
                             'item' => 'response',
                             'itemid' => $response,
                             'warningcode' => 'nopermissions',
-                            'message' => 'No permission to delete this response'
+                            'message' => 'Invalid response id, the response does not exist or you are not allowed to delete it.'
                         );
                     } else {
                         $todelete[] = $response;
index ee568e2..fb5e6de 100644 (file)
@@ -111,7 +111,7 @@ $string['previewonly'] = 'This is just a preview of the available options for th
 $string['privacy'] = 'Privacy of results';
 $string['privacy:metadata:choice_answers'] = 'Information about the user\'s chosen answer(s) for a given choice activity';
 $string['privacy:metadata:choice_answers:choiceid'] = 'The ID of the choice activity the user is providing answer for';
-$string['privacy:metadata:choice_answers:optionid'] = 'The ID of option that the user selected';
+$string['privacy:metadata:choice_answers:optionid'] = 'The ID of the option that the user selected.';
 $string['privacy:metadata:choice_answers:userid'] = 'The ID of the user answering this choice activity';
 $string['privacy:metadata:choice_answers:timemodified'] = 'The timestamp indicating when the choice was modified by the user';
 $string['publish'] = 'Publish results';
index 4902a56..2919495 100644 (file)
@@ -502,6 +502,7 @@ class mod_choice_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(0, choice_get_my_response($choice));
 
         // Now, as an admin we must be able to delete all the responses under any condition.
+        $this->setUser($student);
         // Submit again the responses.
         $results = mod_choice_external::submit_choice_response($choice->id, array($options[1], $options[2]));
         $results = external_api::clean_returnvalue(mod_choice_external::submit_choice_response_returns(), $results);
@@ -518,19 +519,48 @@ class mod_choice_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(0, $results['warnings']);
 
         // Submit again the responses.
+        $this->setUser($student);
         $DB->set_field('choice', 'timeclose', 0, array('id' => $choice->id));
         $results = mod_choice_external::submit_choice_response($choice->id, array($options[1], $options[2]));
         $results = external_api::clean_returnvalue(mod_choice_external::submit_choice_response_returns(), $results);
-        // With other user account too, so we can test all the responses are deleted.
-        choice_user_submit_response( array($options[1], $options[2]), $choice, $student->id, $course, $cm);
 
-        // Test deleting all (not passing the answers ids), event not only mine.
+        // Test admin try to delete his own responses (he didn't respond so nothing should be deleted).
+        $this->setAdminUser();
+        $results = mod_choice_external::delete_choice_responses($choice->id);
+        $results = external_api::clean_returnvalue(mod_choice_external::delete_choice_responses_returns(), $results);
+        $this->assertFalse($results['status']);
+        $this->assertCount(0, $results['warnings']);
+        $allresponses = choice_get_all_responses($choice);
+        $this->assertCount(2, $allresponses);   // No responses deleted (admin didn't submit any).
+
+        // Now admin submit a couple of responses more.
+        $results = mod_choice_external::submit_choice_response($choice->id, array($options[1], $options[2]));
+        $results = external_api::clean_returnvalue(mod_choice_external::submit_choice_response_returns(), $results);
+        $allresponses = choice_get_all_responses($choice);
+        $this->assertCount(4, $allresponses);
+        // Admin responses are deleted when passing an empty array.
         $results = mod_choice_external::delete_choice_responses($choice->id);
         $results = external_api::clean_returnvalue(mod_choice_external::delete_choice_responses_returns(), $results);
+        $this->assertTrue($results['status']);
+        $this->assertCount(0, $results['warnings']);
+        $allresponses = choice_get_all_responses($choice);
+        $this->assertCount(2, $allresponses);
+
+        // Now admin will delete all the other users responses.
+        $results = mod_choice_external::delete_choice_responses($choice->id, array_keys($allresponses));
+        $results = external_api::clean_returnvalue(mod_choice_external::delete_choice_responses_returns(), $results);
 
         $this->assertTrue($results['status']);
         $this->assertCount(0, $results['warnings']);
-        $this->assertCount(0, choice_get_all_responses($choice));
+        $allresponses = choice_get_all_responses($choice);
+        $this->assertCount(0, $allresponses);   // Now all the responses were deleted.
+
+        // Admin try do delete an invalid response.
+        $results = mod_choice_external::delete_choice_responses($choice->id, array(-1));
+        $results = external_api::clean_returnvalue(mod_choice_external::delete_choice_responses_returns(), $results);
+
+        $this->assertFalse($results['status']);
+        $this->assertCount(1, $results['warnings']);
 
         // Now, in the DB 0 responses.
         $this->setUser($student);
index 06f2c66..bb8a147 100644 (file)
@@ -220,8 +220,8 @@ $string['position'] = 'Position';
 $string['previous_page'] = 'Previous page';
 $string['privacy:metadata:completed'] = 'A record of the submissions to the feedback';
 $string['privacy:metadata:completed:anonymousresponse'] = 'Whether the submission is to be used anonymously.';
-$string['privacy:metadata:completed:timemodified'] = 'The time at which the submission was last modified.';
-$string['privacy:metadata:completed:userid'] = 'The user ID';
+$string['privacy:metadata:completed:timemodified'] = 'The time when the submission was last modified.';
+$string['privacy:metadata:completed:userid'] = 'The ID of the user who completed the feedback activity.';
 $string['privacy:metadata:completedtmp'] = 'A record of the submissions which are still in progress.';
 $string['privacy:metadata:value'] = 'A record of the answer to a question.';
 $string['privacy:metadata:value:value'] = 'The chosen answer.';
index fd8139c..e1607c4 100644 (file)
@@ -49,7 +49,7 @@ $string['modulenameplural'] = 'Folders';
 $string['newfoldercontent'] = 'New folder content';
 $string['page-mod-folder-x'] = 'Any folder module page';
 $string['page-mod-folder-view'] = 'Folder module main page';
-$string['privacy:metadata'] = 'The mod_folder plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The Folder resource plugin does not store any personal data.';
 $string['pluginadministration'] = 'Folder administration';
 $string['pluginname'] = 'Folder';
 $string['display'] = 'Display folder contents';
index 9389dab..7a44ae7 100644 (file)
@@ -44,7 +44,7 @@ Feature: Students can edit or delete their forum posts within a set time limit
   Scenario: Time limit expires
     Given I log out
     And I log in as "admin"
-    And I navigate to "Security > Site policies" in site administration
+    And I navigate to "Security > Site security settings" in site administration
     And I set the field "Maximum time to edit posts" to "1 minutes"
     And I press "Save changes"
     And I am on "Course 1" course homepage with editing mode on
index 62891ff..6394572 100644 (file)
@@ -48,7 +48,7 @@ Labels may be used
 * To add a short description to a course section';
 $string['modulename_link'] = 'mod/label/view';
 $string['modulenameplural'] = 'Labels';
-$string['privacy:metadata'] = 'The mod_label plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The Label resource plugin does not store any personal data.';
 $string['pluginadministration'] = 'Label administration';
 $string['pluginname'] = 'Label';
 $string['search:activity'] = 'Label';
diff --git a/mod/lesson/classes/privacy/provider.php b/mod/lesson/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..a2b0706
--- /dev/null
@@ -0,0 +1,585 @@
+<?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/>.
+
+/**
+ * Data provider.
+ *
+ * @package    mod_lesson
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_lesson\privacy;
+defined('MOODLE_INTERNAL') || die();
+
+use context;
+use context_helper;
+use context_module;
+use stdClass;
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\helper;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+
+require_once($CFG->dirroot . '/mod/lesson/locallib.php');
+require_once($CFG->dirroot . '/mod/lesson/pagetypes/essay.php');
+require_once($CFG->dirroot . '/mod/lesson/pagetypes/matching.php');
+require_once($CFG->dirroot . '/mod/lesson/pagetypes/multichoice.php');
+
+/**
+ * Data provider class.
+ *
+ * @package    mod_lesson
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    \core_privacy\local\metadata\provider,
+    \core_privacy\local\request\plugin\provider,
+    \core_privacy\local\request\user_preference_provider {
+
+    /**
+     * Returns metadata.
+     *
+     * @param collection $collection The initialised collection to add items to.
+     * @return collection A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table('lesson_attempts', [
+            'userid' => 'privacy:metadata:attempts:userid',
+            'pageid' => 'privacy:metadata:attempts:pageid',
+            'answerid' => 'privacy:metadata:attempts:answerid',
+            'retry' => 'privacy:metadata:attempts:retry',
+            'correct' => 'privacy:metadata:attempts:correct',
+            'useranswer' => 'privacy:metadata:attempts:useranswer',
+            'timeseen' => 'privacy:metadata:attempts:timeseen',
+        ], 'privacy:metadata:attempts');
+
+        $collection->add_database_table('lesson_grades', [
+            'userid' => 'privacy:metadata:grades:userid',
+            'grade' => 'privacy:metadata:grades:grade',
+            'completed' => 'privacy:metadata:grades:completed',
+            // The column late is not used.
+        ], 'privacy:metadata:grades');
+
+        $collection->add_database_table('lesson_timer', [
+            'userid' => 'privacy:metadata:timer:userid',
+            'starttime' => 'privacy:metadata:timer:starttime',
+            'lessontime' => 'privacy:metadata:timer:lessontime',
+            'completed' => 'privacy:metadata:timer:completed',
+            'timemodifiedoffline' => 'privacy:metadata:timer:timemodifiedoffline',
+        ], 'privacy:metadata:timer');
+
+        $collection->add_database_table('lesson_branch', [
+            'userid' => 'privacy:metadata:branch:userid',
+            'pageid' => 'privacy:metadata:branch:pageid',
+            'retry' => 'privacy:metadata:branch:retry',
+            'flag' => 'privacy:metadata:branch:flag',
+            'timeseen' => 'privacy:metadata:branch:timeseen',
+            'nextpageid' => 'privacy:metadata:branch:nextpageid',
+        ], 'privacy:metadata:branch');
+
+        $collection->add_database_table('lesson_overrides', [
+            'userid' => 'privacy:metadata:overrides:userid',
+            'available' => 'privacy:metadata:overrides:available',
+            'deadline' => 'privacy:metadata:overrides:deadline',
+            'timelimit' => 'privacy:metadata:overrides:timelimit',
+            'review' => 'privacy:metadata:overrides:review',
+            'maxattempts' => 'privacy:metadata:overrides:maxattempts',
+            'retake' => 'privacy:metadata:overrides:retake',
+            'password' => 'privacy:metadata:overrides:password',
+        ], 'privacy:metadata:overrides');
+
+        $collection->add_user_preference('lesson_view', 'privacy:metadata:userpref:lessonview');
+
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param int $userid The user to search.
+     * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : \core_privacy\local\request\contextlist {
+        $contextlist = new \core_privacy\local\request\contextlist();
+
+        $sql = "
+            SELECT DISTINCT ctx.id
+              FROM {lesson} l
+              JOIN {modules} m
+                ON m.name = :lesson
+              JOIN {course_modules} cm
+                ON cm.instance = l.id
+               AND cm.module = m.id
+              JOIN {context} ctx
+                ON ctx.instanceid = cm.id
+               AND ctx.contextlevel = :modulelevel
+         LEFT JOIN {lesson_attempts} la
+                ON la.lessonid = l.id
+         LEFT JOIN {lesson_branch} lb
+                ON lb.lessonid = l.id
+         LEFT JOIN {lesson_grades} lg
+                ON lg.lessonid = l.id
+         LEFT JOIN {lesson_overrides} lo
+                ON lo.lessonid = l.id
+         LEFT JOIN {lesson_timer} lt
+                ON lt.lessonid = l.id
+             WHERE la.userid = :userid1
+                OR lb.userid = :userid2
+                OR lg.userid = :userid3
+                OR lt.userid = :userid4
+                OR lo.userid = :userid5";
+
+        $params = [
+            'lesson' => 'lesson',
+            'modulelevel' => CONTEXT_MODULE,
+            'userid1' => $userid,
+            'userid2' => $userid,
+            'userid3' => $userid,
+            'userid4' => $userid,
+            'userid5' => $userid,
+        ];
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        $user = $contextlist->get_user();
+        $userid = $user->id;
+        $cmids = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_MODULE) {
+                $carry[] = $context->instanceid;
+            }
+            return $carry;
+        }, []);
+        if (empty($cmids)) {
+            return;
+        }
+
+        // If the context export was requested, then let's at least describe the lesson.
+        foreach ($cmids as $cmid) {
+            $context = context_module::instance($cmid);
+            $contextdata = helper::get_context_data($context, $user);
+            helper::export_context_files($context, $user);
+            writer::with_context($context)->export_data([], $contextdata);
+        }
+
+        // Find the lesson IDs.
+        $lessonidstocmids = static::get_lesson_ids_to_cmids_from_cmids($cmids);
+
+        // Prepare the common SQL fragments.
+        list($inlessonsql, $inlessonparams) = $DB->get_in_or_equal(array_keys($lessonidstocmids), SQL_PARAMS_NAMED);
+        $sqluserlesson = "userid = :userid AND lessonid $inlessonsql";
+        $paramsuserlesson = array_merge($inlessonparams, ['userid' => $userid]);
+
+        // Export the overrides.
+        $recordset = $DB->get_recordset_select('lesson_overrides', $sqluserlesson, $paramsuserlesson);
+        static::recordset_loop_and_export($recordset, 'lessonid', null, function($carry, $record) {
+            // We know that there is only one row per lesson, so no need to use $carry.
+            return (object) [
+                'available' => $record->available !== null ? transform::datetime($record->available) : null,
+                'deadline' => $record->deadline !== null ? transform::datetime($record->deadline) : null,
+                'timelimit' => $record->timelimit !== null ? format_time($record->timelimit) : null,
+                'review' => $record->review !== null ? transform::yesno($record->review) : null,
+                'maxattempts' => $record->maxattempts,
+                'retake' => $record->retake !== null ? transform::yesno($record->retake) : null,
+                'password' => $record->password,
+            ];
+        }, function($lessonid, $data) use ($lessonidstocmids) {
+            $context = context_module::instance($lessonidstocmids[$lessonid]);
+            writer::with_context($context)->export_related_data([], 'overrides', $data);
+        });
+
+        // Export the grades.
+        $recordset = $DB->get_recordset_select('lesson_grades', $sqluserlesson, $paramsuserlesson, 'lessonid, completed');
+        static::recordset_loop_and_export($recordset, 'lessonid', [], function($carry, $record) {
+            $carry[] = (object) [
+                'grade' => $record->grade,
+                'completed' => transform::datetime($record->completed),
+            ];
+            return $carry;
+        }, function($lessonid, $data) use ($lessonidstocmids) {
+            $context = context_module::instance($lessonidstocmids[$lessonid]);
+            writer::with_context($context)->export_related_data([], 'grades', (object) ['grades' => $data]);
+        });
+
+        // Export the timers.
+        $recordset = $DB->get_recordset_select('lesson_timer', $sqluserlesson, $paramsuserlesson, 'lessonid, starttime');
+        static::recordset_loop_and_export($recordset, 'lessonid', [], function($carry, $record) {
+            $carry[] = (object) [
+                'starttime' => transform::datetime($record->starttime),
+                'lastactivity' => transform::datetime($record->lessontime),
+                'completed' => transform::yesno($record->completed),
+                'timemodifiedoffline' => $record->timemodifiedoffline ? transform::datetime($record->timemodifiedoffline) : null,
+            ];
+            return $carry;
+        }, function($lessonid, $data) use ($lessonidstocmids) {
+            $context = context_module::instance($lessonidstocmids[$lessonid]);
+            writer::with_context($context)->export_related_data([], 'timers', (object) ['timers' => $data]);
+        });
+
+        // Export the attempts and branches.
+        $sql = "
+            SELECT " . $DB->sql_concat('lp.id', "':'", 'COALESCE(la.id, 0)', "':'", 'COALESCE(lb.id, 0)') . " AS uniqid,
+                   lp.lessonid,
+
+                   lp.id AS page_id,
+                   lp.qtype AS page_qtype,
+                   lp.qoption AS page_qoption,
+                   lp.title AS page_title,
+                   lp.contents AS page_contents,
+                   lp.contentsformat AS page_contentsformat,
+
+                   la.id AS attempt_id,
+                   la.retry AS attempt_retry,
+                   la.correct AS attempt_correct,
+                   la.useranswer AS attempt_useranswer,
+                   la.timeseen AS attempt_timeseen,
+
+                   lb.id AS branch_id,
+                   lb.retry AS branch_retry,
+                   lb.timeseen AS branch_timeseen,
+
+                   lpb.id AS nextpage_id,
+                   lpb.title AS nextpage_title
+
+              FROM {lesson_pages} lp
+         LEFT JOIN {lesson_attempts} la
+                ON la.pageid = lp.id
+               AND la.userid = :userid1
+         LEFT JOIN {lesson_branch} lb
+                ON lb.pageid = lp.id
+               AND lb.userid = :userid2
+         LEFT JOIN {lesson_pages} lpb
+                ON lpb.id = lb.nextpageid
+             WHERE lp.lessonid $inlessonsql
+               AND (la.id IS NOT NULL OR lb.id IS NOT NULL)
+          ORDER BY lp.lessonid, lp.id, la.retry, lb.retry, la.id, lb.id";
+        $params = array_merge($inlessonparams, ['userid1' => $userid, 'userid2' => $userid]);
+
+        $recordset = $DB->get_recordset_sql($sql, $params);
+        static::recordset_loop_and_export($recordset, 'lessonid', [], function($carry, $record) use ($lessonidstocmids) {
+            $context = context_module::instance($lessonidstocmids[$record->lessonid]);
+            $options = ['context' => $context];
+
+            $take = isset($record->attempt_retry) ? $record->attempt_retry : $record->branch_retry;
+            if (!isset($carry[$take])) {
+                $carry[$take] = (object) [
+                    'number' => $take + 1,
+                    'answers' => [],
+                    'jumps' => []
+                ];
+            }
+
+            $pagefilespath = [get_string('privacy:path:pages', 'mod_lesson'), $record->page_id];
+            writer::with_context($context)->export_area_files($pagefilespath, 'mod_lesson', 'page_contents', $record->page_id);
+            $pagecontents = format_text(
+                writer::with_context($context)->rewrite_pluginfile_urls(
+                    $pagefilespath,
+                    'mod_lesson',
+                    'page_contents',
+                    $record->page_id,
+                    $record->page_contents
+                ),
+                $record->page_contentsformat,
+                $options
+            );
+
+            $pagebase = [
+                'id' => $record->page_id,
+                'page' => $record->page_title,
+                'contents' => $pagecontents,
+                'contents_files_folder' => implode('/', $pagefilespath)
+            ];
+
+            if (isset($record->attempt_id)) {
+                $carry[$take]->answers[] = array_merge($pagebase, static::transform_attempt($record, $context));
+
+            } else if (isset($record->branch_id)) {
+                if (!empty($record->nextpage_id)) {
+                    $wentto = $record->nextpage_title . " (id: {$record->nextpage_id})";
+                } else {
+                    $wentto = get_string('endoflesson', 'mod_lesson');
+                }
+                $carry[$take]->jumps[] = array_merge($pagebase, [
+                    'went_to' => $wentto,
+                    'timeseen' => transform::datetime($record->attempt_timeseen)
+                ]);
+            }
+
+            return $carry;
+
+        }, function($lessonid, $data) use ($lessonidstocmids) {
+            $context = context_module::instance($lessonidstocmids[$lessonid]);
+            writer::with_context($context)->export_related_data([], 'attempts', (object) [
+                'attempts' => array_values($data)
+            ]);
+        });
+    }
+
+    /**
+     * Export all user preferences for the plugin.
+     *
+     * @param int $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $lessonview = get_user_preferences('lesson_view', null, $userid);
+        if ($lessonview !== null) {
+            $value = $lessonview;
+
+            // The code seems to indicate that there also is the option 'simple', but it's not
+            // described nor accessible from anywhere so we won't describe it more than being 'simple'.
+            if ($lessonview == 'full') {
+                $value = get_string('full', 'mod_lesson');
+            } else if ($lessonview == 'collapsed') {
+                $value = get_string('collapsed', 'mod_lesson');
+            }
+
+            writer::export_user_preference('mod_lesson', 'lesson_view', $lessonview,
+                get_string('privacy:metadata:userpref:lessonview', 'mod_lesson'));
+        }
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param context $context The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(context $context) {
+        global $DB;
+
+        if ($context->contextlevel != CONTEXT_MODULE) {
+            return;
+        }
+
+        $lessonid = static::get_lesson_id_from_context($context);
+
+        $DB->delete_records('lesson_attempts', ['lessonid' => $lessonid]);
+        $DB->delete_records('lesson_branch', ['lessonid' => $lessonid]);
+        $DB->delete_records('lesson_grades', ['lessonid' => $lessonid]);
+        $DB->delete_records('lesson_timer', ['lessonid' => $lessonid]);
+        $DB->delete_records_select('lesson_overrides', 'lessonid = :id AND userid IS NOT NULL', ['id' => $lessonid]);
+
+        $fs = get_file_storage();
+        $fs->delete_area_files($context->id, 'mod_lesson', 'essay_responses');
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+
+        $userid = $contextlist->get_user()->id;
+        $cmids = array_reduce($contextlist->get_contexts(), function($carry, $context) {
+            if ($context->contextlevel == CONTEXT_MODULE) {
+                $carry[] = $context->instanceid;
+            }
+            return $carry;
+        }, []);
+        if (empty($cmids)) {
+            return;
+        }
+
+        // Find the lesson IDs.
+        $lessonidstocmids = static::get_lesson_ids_to_cmids_from_cmids($cmids);
+        $lessonids = array_keys($lessonidstocmids);
+        if (empty($lessonids)) {
+            return;
+        }
+
+        // Prepare the SQL we'll need below.
+        list($insql, $inparams) = $DB->get_in_or_equal($lessonids, SQL_PARAMS_NAMED);
+        $sql = "lessonid $insql AND userid = :userid";
+        $params = array_merge($inparams, ['userid' => $userid]);
+
+        // Delete the attempt files.
+        $fs = get_file_storage();
+        $recordset = $DB->get_recordset_select('lesson_attempts', $sql, $params, '', 'id, lessonid');
+        foreach ($recordset as $record) {
+            $cmid = $lessonidstocmids[$record->lessonid];
+            $context = context_module::instance($cmid);
+            $fs->delete_area_files($context->id, 'mod_lesson', 'essay_responses', $record->id);
+        }
+        $recordset->close();
+
+        // Delete all the things.
+        $DB->delete_records_select('lesson_attempts', $sql, $params);
+        $DB->delete_records_select('lesson_branch', $sql, $params);
+        $DB->delete_records_select('lesson_grades', $sql, $params);
+        $DB->delete_records_select('lesson_timer', $sql, $params);
+        $DB->delete_records_select('lesson_overrides', $sql, $params);
+    }
+
+    /**
+     * Get a survey ID from its context.
+     *
+     * @param context_module $context The module context.
+     * @return int
+     */
+    protected static function get_lesson_id_from_context(context_module $context) {
+        $cm = get_coursemodule_from_id('lesson', $context->instanceid);
+        return $cm ? (int) $cm->instance : 0;
+    }
+
+    /**
+     * Return a dict of lesson IDs mapped to their course module ID.
+     *
+     * @param array $cmids The course module IDs.
+     * @return array In the form of [$lessonid => $cmid].
+     */
+    protected static function get_lesson_ids_to_cmids_from_cmids(array $cmids) {
+        global $DB;
+        list($insql, $inparams) = $DB->get_in_or_equal($cmids, SQL_PARAMS_NAMED);
+        $sql = "
+            SELECT l.id, cm.id AS cmid
+              FROM {lesson} l
+              JOIN {modules} m
+                ON m.name = :lesson
+              JOIN {course_modules} cm
+                ON cm.instance = l.id
+               AND cm.module = m.id
+             WHERE cm.id $insql";
+        $params = array_merge($inparams, ['lesson' => 'lesson']);
+        return $DB->get_records_sql_menu($sql, $params);
+    }
+
+    /**
+     * Loop and export from a recordset.
+     *
+     * @param moodle_recordset $recordset The recordset.
+     * @param string $splitkey The record key to determine when to export.
+     * @param mixed $initial The initial data to reduce from.
+     * @param callable $reducer The function to return the dataset, receives current dataset, and the current record.
+     * @param callable $export The function to export the dataset, receives the last value from $splitkey and the dataset.
+     * @return void
+     */
+    protected static function recordset_loop_and_export(\moodle_recordset $recordset, $splitkey, $initial,
+            callable $reducer, callable $export) {
+
+        $data = $initial;
+        $lastid = null;
+
+        foreach ($recordset as $record) {
+            if ($lastid && $record->{$splitkey} != $lastid) {
+                $export($lastid, $data);
+                $data = $initial;
+            }
+            $data = $reducer($data, $record);
+            $lastid = $record->{$splitkey};
+        }
+        $recordset->close();
+
+        if (!empty($lastid)) {
+            $export($lastid, $data);
+        }
+    }
+
+    /**
+     * Transform an attempt.
+     *
+     * @param stdClass $data Data from the database, as per the exporting method.
+     * @param context_module $context The module context.
+     * @return array
+     */
+    protected static function transform_attempt(stdClass $data, context_module $context) {
+        global $DB;
+
+        $options = ['context' => $context];
+        $answer = $data->attempt_useranswer;
+        $response = null;
+        $responsefilesfolder = null;
+
+        if ($answer !== null) {
+            if ($data->page_qtype == LESSON_PAGE_ESSAY) {
+                // Essay questions serialise data in the answer field.
+                $info = \lesson_page_type_essay::extract_useranswer($answer);
+                $answer = format_text($info->answer, $info->answerformat, $options);
+                if ($info->response !== null) {
+                    // We export the files in a subfolder to avoid conflicting files, and tell the user
+                    // where those files were exported. That is because we are not using a subfolder for
+                    // every single essay response.
+                    $responsefilespath = [get_string('privacy:path:essayresponses', 'mod_lesson'), $data->attempt_id];
+                    $responsefilesfolder = implode('/', $responsefilespath);
+                    $response = format_text(
+                        writer::with_context($context)->rewrite_pluginfile_urls(
+                            $responsefilespath,
+                            'mod_lesson',
+                            'essay_responses',
+                            $data->attempt_id,
+                            $info->response
+                        ),
+                        $info->responseformat,
+                        $options
+                    );
+                    writer::with_context($context)->export_area_files($responsefilespath, 'mod_lesson',
+                        'essay_responses', $data->page_id);
+                }
+
+            } else if ($data->page_qtype == LESSON_PAGE_MULTICHOICE && $data->page_qoption) {
+                // Multiple choice quesitons with multiple answers encode the answers.
+                list($insql, $inparams) = $DB->get_in_or_equal(explode(',', $answer), SQL_PARAMS_NAMED);
+                $orderby = 'id, ' . $DB->sql_order_by_text('answer') . ', answerformat';
+                $records = $DB->get_records_select('lesson_answers', "id $insql", $inparams, $orderby);
+                $answer = array_values(array_map(function($record) use ($options) {
+                    return format_text($record->answer, $record->answerformat, $options);
+                }, empty($records) ? [] : $records));
+
+            } else if ($data->page_qtype == LESSON_PAGE_MATCHING) {
+                // Matching questions need sorting.
+                $chosen = explode(',', $answer);
+                $answers = $DB->get_records_select('lesson_answers', 'pageid = :pageid', ['pageid' => $data->page_id],
+                    'id', 'id, answer, answerformat', 2); // The two first entries are not options.
+                $i = -1;
+                $answer = array_values(array_map(function($record) use (&$i, $chosen, $options) {
+                    $i++;
+                    return [
+                        'label' => format_text($record->answer, $record->answerformat, $options),
+                        'matched_with' => array_key_exists($i, $chosen) ? $chosen[$i] : null
+                    ];
+                }, empty($answers) ? [] : $answers));
+            }
+        }
+
+        $result = [
+            'answer' => $answer,
+            'correct' => transform::yesno($data->attempt_correct),
+            'timeseen' => transform::datetime($data->attempt_timeseen),
+        ];
+
+        if ($response !== null) {
+            $result['response'] = $response;
+            $result['response_files_folder'] = $responsefilesfolder;
+        }
+
+        return $result;
+    }
+
+}
index 52cf786..286811d 100644 (file)
@@ -438,6 +438,43 @@ $string['preview'] = 'Preview';
 $string['previewlesson'] = 'Preview {$a}';
 $string['previewpagenamed'] = 'Preview page: {$a}';
 $string['previouspage'] = 'Previous page';
+$string['privacy:metadata:attempts:userid'] = 'The user ID';
+$string['privacy:metadata:attempts:pageid'] = 'The page ID';
+$string['privacy:metadata:attempts:answerid'] = 'The answer ID';
+$string['privacy:metadata:attempts:retry'] = 'The attempt number';
+$string['privacy:metadata:attempts:correct'] = 'Whether the attempt was correct';
+$string['privacy:metadata:attempts:useranswer'] = 'Details about the user\'s answer';
+$string['privacy:metadata:attempts:timeseen'] = 'Time at which the attempt was made';
+$string['privacy:metadata:attempts'] = 'A record of page attempts';
+$string['privacy:metadata:grades:userid'] = 'The user ID';
+$string['privacy:metadata:grades:grade'] = 'The grade given';
+$string['privacy:metadata:grades:completed'] = 'The date at which the grade was given';
+$string['privacy:metadata:grades'] = 'A record of the grades for each lesson';
+$string['privacy:metadata:timer:userid'] = 'The user ID';
+$string['privacy:metadata:timer:starttime'] = 'The date at which the attempt started';
+$string['privacy:metadata:timer:lessontime'] = 'The last moment when we recorded activity';
+$string['privacy:metadata:timer:completed'] = 'Whether the attempt is complete';
+$string['privacy:metadata:timer:timemodifiedoffline'] = 'The last moment when we recorded activity from the mobile app';
+$string['privacy:metadata:timer'] = 'A record of a lesson attempt';
+$string['privacy:metadata:branch:userid'] = 'The user ID';
+$string['privacy:metadata:branch:pageid'] = 'The page ID';
+$string['privacy:metadata:branch:retry'] = 'The attempt number';
+$string['privacy:metadata:branch:flag'] = 'Whether the next page was calculated randomely';
+$string['privacy:metadata:branch:timeseen'] = 'Time at which the page was viewed ';
+$string['privacy:metadata:branch:nextpageid'] = 'The next page ID';
+$string['privacy:metadata:branch'] = 'A record of the pages viewed';
+$string['privacy:metadata:overrides:userid'] = 'The user ID';
+$string['privacy:metadata:overrides:available'] = 'Time at which the students can start attempting the lesson';
+$string['privacy:metadata:overrides:deadline'] = 'Time by which students must have completed their attempt';
+$string['privacy:metadata:overrides:timelimit'] = 'Time limit to complete the lesson, in seconds.';
+$string['privacy:metadata:overrides:review'] = 'Whether trying a question again is allowed';
+$string['privacy:metadata:overrides:maxattempts'] = 'The maximium number of attempts';
+$string['privacy:metadata:overrides:retake'] = 'Whether re-takes are allowed';
+$string['privacy:metadata:overrides:password'] = 'The password to access the lesson';
+$string['privacy:metadata:overrides'] = 'A record of overrides per lesson';
+$string['privacy:metadata:userpref:lessonview'] = 'The preferred display mode when editing lessons';
+$string['privacy:path:essayresponses'] = 'Essay responses';
+$string['privacy:path:pages'] = 'Pages';
 $string['processerror'] = 'Error occurred during processing!';
 $string['progressbar'] = 'Progress bar';
 $string['progressbar_help'] = 'If enabled, a bar is displayed at the bottom of lesson pages showing approximate percentage of completion.';
diff --git a/mod/lesson/tests/privacy_test.php b/mod/lesson/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..896aa67
--- /dev/null
@@ -0,0 +1,756 @@
+<?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/>.
+
+/**
+ * Data provider tests.
+ *
+ * @package    mod_lesson
+ * @category   test
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+use mod_lesson\privacy\provider;
+
+/**
+ * Data provider testcase class.
+ *
+ * @package    mod_lesson
+ * @category   test
+ * @copyright  2018 Frédéric Massart
+ * @author     Frédéric Massart <fred@branchup.tech>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_lesson_privacy_testcase extends provider_testcase {
+
+    public function setUp() {
+        global $PAGE;
+        $this->setAdminUser();  // The data generator complains without this.
+        $this->resetAfterTest();
+        $PAGE->get_renderer('core');
+    }
+
+    public function test_get_contexts_for_userid() {
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $u3 = $dg->create_user();
+        $u4 = $dg->create_user();
+        $u5 = $dg->create_user();
+        $u6 = $dg->create_user();
+
+        $cm1 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm2 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm3 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+        $cm3ctx = context_module::instance($cm3->cmid);
+
+        $this->create_attempt($cm1, $u1);
+        $this->create_grade($cm2, $u2);
+        $this->create_timer($cm3, $u3);
+        $this->create_branch($cm2, $u4);
+        $this->create_override($cm1, $u5);
+
+        $this->create_attempt($cm2, $u6);
+        $this->create_grade($cm2, $u6);
+        $this->create_timer($cm1, $u6);
+        $this->create_branch($cm2, $u6);
+        $this->create_override($cm3, $u6);
+
+        $contextids = provider::get_contexts_for_userid($u1->id)->get_contextids();
+        $this->assertCount(1, $contextids);
+        $this->assertTrue(in_array($cm1ctx->id, $contextids));
+
+        $contextids = provider::get_contexts_for_userid($u2->id)->get_contextids();
+        $this->assertCount(1, $contextids);
+        $this->assertTrue(in_array($cm2ctx->id, $contextids));
+
+        $contextids = provider::get_contexts_for_userid($u3->id)->get_contextids();
+        $this->assertCount(1, $contextids);
+        $this->assertTrue(in_array($cm3ctx->id, $contextids));
+
+        $contextids = provider::get_contexts_for_userid($u4->id)->get_contextids();
+        $this->assertCount(1, $contextids);
+        $this->assertTrue(in_array($cm2ctx->id, $contextids));
+
+        $contextids = provider::get_contexts_for_userid($u5->id)->get_contextids();
+        $this->assertCount(1, $contextids);
+        $this->assertTrue(in_array($cm1ctx->id, $contextids));
+
+        $contextids = provider::get_contexts_for_userid($u6->id)->get_contextids();
+        $this->assertCount(3, $contextids);
+        $this->assertTrue(in_array($cm1ctx->id, $contextids));
+        $this->assertTrue(in_array($cm2ctx->id, $contextids));
+        $this->assertTrue(in_array($cm3ctx->id, $contextids));
+    }
+
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $cm1 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm2 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm3 = $dg->create_module('lesson', ['course' => $c1]);
+
+        $c1ctx = context_course::instance($c1->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+        $cm3ctx = context_module::instance($cm3->cmid);
+
+        $this->create_attempt($cm1, $u1);
+        $this->create_grade($cm1, $u1);
+        $this->create_timer($cm1, $u1);
+        $this->create_branch($cm1, $u1);
+        $this->create_override($cm1, $u1);
+
+        $this->create_attempt($cm1, $u2);
+        $this->create_grade($cm1, $u2);
+        $this->create_timer($cm1, $u2);
+        $this->create_branch($cm1, $u2);
+        $this->create_override($cm1, $u2);
+
+        $this->create_attempt($cm2, $u1);
+        $this->create_grade($cm2, $u1);
+        $this->create_timer($cm2, $u1);
+        $this->create_branch($cm2, $u1);
+        $this->create_override($cm2, $u1);
+        $this->create_attempt($cm2, $u2);
+        $this->create_grade($cm2, $u2);
+        $this->create_timer($cm2, $u2);
+        $this->create_branch($cm2, $u2);
+        $this->create_override($cm2, $u2);
+
+        $assertcm1nochange = function() use ($DB, $u1, $u2, $cm1) {
+            $this->assertTrue($DB->record_exists('lesson_attempts', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_grades', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_timer', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_branch', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_overrides', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_attempts', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_grades', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_timer', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_branch', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_overrides', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+        };
+        $assertcm2nochange = function() use ($DB, $u1, $u2, $cm2) {
+            $this->assertTrue($DB->record_exists('lesson_attempts', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_grades', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_timer', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_branch', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_overrides', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_attempts', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_grades', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_timer', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_branch', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_overrides', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+        };
+
+        // Confirm existing state.
+        $assertcm1nochange();
+        $assertcm2nochange();
+
+        // Delete the course: no change.
+        provider::delete_data_for_all_users_in_context(context_course::instance($c1->id));
+        $assertcm1nochange();
+        $assertcm2nochange();
+
+        // Delete another module: no change.
+        provider::delete_data_for_all_users_in_context(context_module::instance($cm3->cmid));
+        $assertcm1nochange();
+        $assertcm2nochange();
+
+        // Delete cm1: no change in cm2.
+        provider::delete_data_for_all_users_in_context(context_module::instance($cm1->cmid));
+        $assertcm2nochange();
+        $this->assertFalse($DB->record_exists('lesson_attempts', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_grades', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_timer', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_branch', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_overrides', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_attempts', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_grades', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_timer', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_branch', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_overrides', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+    }
+
+    public function test_delete_data_for_user() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $cm1 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm2 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm3 = $dg->create_module('lesson', ['course' => $c1]);
+
+        $c1ctx = context_course::instance($c1->id);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+        $cm3ctx = context_module::instance($cm3->cmid);
+
+        $this->create_attempt($cm1, $u1);
+        $this->create_grade($cm1, $u1);
+        $this->create_timer($cm1, $u1);
+        $this->create_branch($cm1, $u1);
+        $this->create_override($cm1, $u1);
+        $this->create_attempt($cm1, $u2);
+        $this->create_grade($cm1, $u2);
+        $this->create_timer($cm1, $u2);
+        $this->create_branch($cm1, $u2);
+        $this->create_override($cm1, $u2);
+
+        $this->create_attempt($cm2, $u1);
+        $this->create_grade($cm2, $u1);
+        $this->create_timer($cm2, $u1);
+        $this->create_branch($cm2, $u1);
+        $this->create_override($cm2, $u1);
+        $this->create_attempt($cm2, $u2);
+        $this->create_grade($cm2, $u2);
+        $this->create_timer($cm2, $u2);
+        $this->create_branch($cm2, $u2);
+        $this->create_override($cm2, $u2);
+
+        $assertu1nochange = function() use ($DB, $u1, $cm1, $cm2) {
+            $this->assertTrue($DB->record_exists('lesson_attempts', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_grades', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_timer', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_branch', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_overrides', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_attempts', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_grades', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_timer', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_branch', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_overrides', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+        };
+        $assertu2nochange = function() use ($DB, $u2, $cm1, $cm2) {
+            $this->assertTrue($DB->record_exists('lesson_attempts', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_grades', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_timer', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_branch', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_overrides', ['userid' => $u2->id, 'lessonid' => $cm1->id]));
+            $this->assertTrue($DB->record_exists('lesson_attempts', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_grades', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_timer', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_branch', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+            $this->assertTrue($DB->record_exists('lesson_overrides', ['userid' => $u2->id, 'lessonid' => $cm2->id]));
+        };
+
+        // Confirm existing state.
+        $assertu1nochange();
+        $assertu2nochange();
+
+        // Delete the course: no change.
+        provider::delete_data_for_user(new approved_contextlist($u1, 'mod_lesson', [context_course::instance($c1->id)->id]));
+        $assertu1nochange();
+        $assertu2nochange();
+
+        // Delete another module: no change.
+        provider::delete_data_for_user(new approved_contextlist($u1, 'mod_lesson', [context_module::instance($cm3->cmid)->id]));
+        $assertu1nochange();
+        $assertu2nochange();
+
+        // Delete u1 in cm1: no change for u2 and in cm2.
+        provider::delete_data_for_user(new approved_contextlist($u1, 'mod_lesson', [context_module::instance($cm1->cmid)->id]));
+        $assertu2nochange();
+        $this->assertFalse($DB->record_exists('lesson_attempts', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_grades', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_timer', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_branch', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertFalse($DB->record_exists('lesson_overrides', ['userid' => $u1->id, 'lessonid' => $cm1->id]));
+        $this->assertTrue($DB->record_exists('lesson_attempts', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+        $this->assertTrue($DB->record_exists('lesson_grades', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+        $this->assertTrue($DB->record_exists('lesson_timer', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+        $this->assertTrue($DB->record_exists('lesson_branch', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+        $this->assertTrue($DB->record_exists('lesson_overrides', ['userid' => $u1->id, 'lessonid' => $cm2->id]));
+    }
+
+    public function test_export_data_for_user_overrides() {
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $cm1 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm2 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+
+        $now = time();
+        $this->create_override($cm1, $u1); // All null.
+        $this->create_override($cm2, $u1, [
+            'available' => $now - 3600,
+            'deadline' => $now + 3600,
+            'timelimit' => 123,
+            'review' => 1,
+            'maxattempts' => 1,
+            'retake' => 0,
+            'password' => '1337 5p34k'
+        ]);
+        $this->create_override($cm1, $u2, [
+            'available' => $now - 1230,
+            'timelimit' => 456,
+            'maxattempts' => 5,
+            'retake' => 1,
+        ]);
+
+        provider::export_user_data(new approved_contextlist($u1, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm1ctx)->get_data([]);
+        $this->assertNotEmpty($data);
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'overrides');
+        $this->assertNull($data->available);
+        $this->assertNull($data->deadline);
+        $this->assertNull($data->timelimit);
+        $this->assertNull($data->review);
+        $this->assertNull($data->maxattempts);
+        $this->assertNull($data->retake);
+        $this->assertNull($data->password);
+
+        $data = writer::with_context($cm2ctx)->get_data([]);
+        $this->assertNotEmpty($data);
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'overrides');
+        $this->assertEquals(transform::datetime($now - 3600), $data->available);
+        $this->assertEquals(transform::datetime($now + 3600), $data->deadline);
+        $this->assertEquals(format_time(123), $data->timelimit);
+        $this->assertEquals(transform::yesno(true), $data->review);
+        $this->assertEquals(1, $data->maxattempts);
+        $this->assertEquals(transform::yesno(false), $data->retake);
+        $this->assertEquals('1337 5p34k', $data->password);
+
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm1ctx)->get_data([]);
+        $this->assertNotEmpty($data);
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'overrides');
+        $this->assertEquals(transform::datetime($now - 1230), $data->available);
+        $this->assertNull($data->deadline);
+        $this->assertEquals(format_time(456), $data->timelimit);
+        $this->assertNull($data->review);
+        $this->assertEquals(5, $data->maxattempts);
+        $this->assertEquals(transform::yesno(true), $data->retake);
+        $this->assertNull($data->password);
+
+        $data = writer::with_context($cm2ctx)->get_data([]);
+        $this->assertNotEmpty($data);
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'overrides');
+        $this->assertEmpty($data);
+    }
+
+    public function test_export_data_for_user_grades() {
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $cm1 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm2 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+
+        $now = time();
+        $this->create_grade($cm2, $u1, ['grade' => 33.33, 'completed' => $now - 3600]);
+        $this->create_grade($cm2, $u1, ['grade' => 50, 'completed' => $now - 1600]);
+        $this->create_grade($cm2, $u1, ['grade' => 81.23, 'completed' => $now - 100]);
+        $this->create_grade($cm1, $u2, ['grade' => 99.98, 'completed' => $now - 86400]);
+
+        provider::export_user_data(new approved_contextlist($u1, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'grades');
+        $this->assertEmpty($data);
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'grades');
+        $this->assertNotEmpty($data);
+        $this->assertCount(3, $data->grades);
+        $this->assertEquals(33.33, $data->grades[0]->grade);
+        $this->assertEquals(50, $data->grades[1]->grade);
+        $this->assertEquals(81.23, $data->grades[2]->grade);
+        $this->assertEquals(transform::datetime($now - 3600), $data->grades[0]->completed);
+        $this->assertEquals(transform::datetime($now - 1600), $data->grades[1]->completed);
+        $this->assertEquals(transform::datetime($now - 100), $data->grades[2]->completed);
+
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'grades');
+        $this->assertEmpty($data);
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'grades');
+        $this->assertNotEmpty($data);
+        $this->assertCount(1, $data->grades);
+        $this->assertEquals(99.98, $data->grades[0]->grade);
+        $this->assertEquals(transform::datetime($now - 86400), $data->grades[0]->completed);
+    }
+
+    public function test_export_data_for_user_timers() {
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $cm1 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm2 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+
+        $now = time();
+        $this->create_timer($cm2, $u1, ['starttime' => $now - 2000, 'lessontime' => $now + 3600, 'completed' => 0,
+            'timemodifiedoffline' => $now - 7000]);
+        $this->create_timer($cm2, $u1, ['starttime' => $now - 1000, 'lessontime' => $now + 1600, 'completed' => 0]);
+        $this->create_timer($cm2, $u1, ['starttime' => $now - 500, 'lessontime' => $now + 100, 'completed' => 1]);
+        $this->create_timer($cm1, $u2, ['starttime' => $now - 1000, 'lessontime' => $now + 1800, 'completed' => 1]);
+
+        provider::export_user_data(new approved_contextlist($u1, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'timers');
+        $this->assertEmpty($data);
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'timers');
+        $this->assertNotEmpty($data);
+        $this->assertCount(3, $data->timers);
+        $this->assertEquals(transform::datetime($now - 2000), $data->timers[0]->starttime);
+        $this->assertEquals(transform::datetime($now + 3600), $data->timers[0]->lastactivity);
+        $this->assertEquals(transform::yesno(false), $data->timers[0]->completed);
+        $this->assertEquals(transform::datetime($now - 7000), $data->timers[0]->timemodifiedoffline);
+
+        $this->assertEquals(transform::datetime($now - 1000), $data->timers[1]->starttime);
+        $this->assertEquals(transform::datetime($now + 1600), $data->timers[1]->lastactivity);
+        $this->assertEquals(transform::yesno(false), $data->timers[1]->completed);
+        $this->assertNull($data->timers[1]->timemodifiedoffline);
+
+        $this->assertEquals(transform::datetime($now - 500), $data->timers[2]->starttime);
+        $this->assertEquals(transform::datetime($now + 100), $data->timers[2]->lastactivity);
+        $this->assertEquals(transform::yesno(true), $data->timers[2]->completed);
+        $this->assertNull($data->timers[2]->timemodifiedoffline);
+
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'timers');
+        $this->assertEmpty($data);
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'timers');
+        $this->assertCount(1, $data->timers);
+        $this->assertEquals(transform::datetime($now - 1000), $data->timers[0]->starttime);
+        $this->assertEquals(transform::datetime($now + 1800), $data->timers[0]->lastactivity);
+        $this->assertEquals(transform::yesno(true), $data->timers[0]->completed);
+        $this->assertNull($data->timers[0]->timemodifiedoffline);
+    }
+
+    public function test_export_data_for_user_attempts() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+        $lg = $dg->get_plugin_generator('mod_lesson');
+
+        $c1 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+
+        $cm1 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm2 = $dg->create_module('lesson', ['course' => $c1]);
+        $cm1ctx = context_module::instance($cm1->cmid);
+        $cm2ctx = context_module::instance($cm2->cmid);
+
+        $page1 = $lg->create_content($cm1);
+        $page2 = $lg->create_question_truefalse($cm1);
+        $page3 = $lg->create_question_multichoice($cm1);
+        $page4 = $lg->create_question_multichoice($cm1, [
+            'qoption' => 1,
+            'answer_editor' => [
+                ['text' => 'Cats', 'format' => FORMAT_PLAIN, 'score' => 1],
+                ['text' => 'Dogs', 'format' => FORMAT_PLAIN, 'score' => 1],
+                ['text' => 'Birds', 'format' => FORMAT_PLAIN, 'score' => 0],
+            ],
+            'jumpto' => [LESSON_NEXTPAGE, LESSON_NEXTPAGE, LESSON_THISPAGE]
+        ]);
+        $page4answers = array_keys($DB->get_records('lesson_answers', ['pageid' => $page4->id], 'id'));
+        $page5 = $lg->create_question_matching($cm1, [
+            'answer_editor' => [
+                2 => ['text' => 'The plural of cat', 'format' => FORMAT_PLAIN],
+                3 => ['text' => 'The plural of dog', 'format' => FORMAT_PLAIN],
+                4 => ['text' => 'The plural of bird', 'format' => FORMAT_PLAIN],
+            ],
+            'response_editor' => [
+                2 => 'Cats',
+                3 => 'Dogs',
+                4 => 'Birds',
+            ]
+        ]);
+        $page6 = $lg->create_question_shortanswer($cm1);
+        $page7 = $lg->create_question_numeric($cm1);
+        $page8 = $lg->create_question_essay($cm1);
+        $page9 = $lg->create_content($cm1);
+
+        $pageb1 = $lg->create_content($cm2);
+        $pageb2 = $lg->create_question_truefalse($cm2);
+        $pageb3 = $lg->create_question_truefalse($cm2);
+
+        $this->create_branch($cm1, $u1, ['pageid' => $page1->id, 'nextpageid' => $page2->id]);
+        $this->create_attempt($cm1, $u1, ['pageid' => $page2->id, 'useranswer' => 'This is true']);
+        $this->create_attempt($cm1, $u1, ['pageid' => $page3->id, 'useranswer' => 'A', 'correct' => 1]);
+        $this->create_attempt($cm1, $u1, ['pageid' => $page4->id,
+            'useranswer' => implode(',', array_slice($page4answers, 0, 2))]);
+        $this->create_attempt($cm1, $u1, ['pageid' => $page5->id, 'useranswer' => 'Cats,Birds,Dogs']);
+        $this->create_attempt($cm1, $u1, ['pageid' => $page6->id, 'useranswer' => 'Hello world!']);
+        $this->create_attempt($cm1, $u1, ['pageid' => $page7->id, 'useranswer' => '1337']);
+        $this->create_attempt($cm1, $u1, ['pageid' => $page8->id, 'useranswer' => serialize((object) [
+            'sent' => 0, 'graded' => 0, 'score' => 0, 'answer' => 'I like cats', 'answerformat' => FORMAT_PLAIN,
+            'response' => 'Me too!', 'responseformat' => FORMAT_PLAIN
+        ])]);
+        $this->create_branch($cm1, $u1, ['pageid' => $page9->id, 'nextpageid' => 0]);
+
+        provider::export_user_data(new approved_contextlist($u1, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'attempts');
+        $this->assertEmpty($data);
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'attempts');
+        $this->assertNotEmpty($data);
+        $this->assertCount(1, $data->attempts);
+        $this->assertEquals(1, $data->attempts[0]->number);
+        $this->assertCount(2, $data->attempts[0]->jumps);
+        $this->assertCount(7, $data->attempts[0]->answers);
+        $jump = $data->attempts[0]->jumps[0];
+        $this->assert_attempt_page($page1, $jump);
+        $this->assertTrue(strpos($jump['went_to'], $page2->title) !== false);
+        $jump = $data->attempts[0]->jumps[1];
+        $this->assert_attempt_page($page9, $jump);
+        $this->assertEquals(get_string('endoflesson', 'mod_lesson'), $jump['went_to']);
+        $answer = $data->attempts[0]->answers[0];
+        $this->assert_attempt_page($page2, $answer);
+        $this->assertEquals(transform::yesno(false), $answer['correct']);
+        $this->assertEquals('This is true', $answer['answer']);
+        $answer = $data->attempts[0]->answers[1];
+        $this->assert_attempt_page($page3, $answer);
+        $this->assertEquals(transform::yesno(true), $answer['correct']);
+        $this->assertEquals('A', $answer['answer']);
+        $answer = $data->attempts[0]->answers[2];
+        $this->assert_attempt_page($page4, $answer);
+        $this->assertEquals(transform::yesno(false), $answer['correct']);
+        $this->assertCount(2, $answer['answer']);
+        $this->assertTrue(in_array('Cats', $answer['answer']));
+        $this->assertTrue(in_array('Dogs', $answer['answer']));
+        $answer = $data->attempts[0]->answers[3];
+        $this->assert_attempt_page($page5, $answer);
+        $this->assertEquals(transform::yesno(false), $answer['correct']);
+        $this->assertCount(3, $answer['answer']);
+        $this->assertEquals('The plural of cat', $answer['answer'][0]['label']);
+        $this->assertEquals('Cats', $answer['answer'][0]['matched_with']);
+        $this->assertEquals('The plural of dog', $answer['answer'][1]['label']);
+        $this->assertEquals('Birds', $answer['answer'][1]['matched_with']);
+        $this->assertEquals('The plural of bird', $answer['answer'][2]['label']);
+        $this->assertEquals('Dogs', $answer['answer'][2]['matched_with']);
+        $answer = $data->attempts[0]->answers[4];
+        $this->assert_attempt_page($page6, $answer);
+        $this->assertEquals(transform::yesno(false), $answer['correct']);
+        $this->assertEquals('Hello world!', $answer['answer']);
+        $answer = $data->attempts[0]->answers[5];
+        $this->assert_attempt_page($page7, $answer);
+        $this->assertEquals(transform::yesno(false), $answer['correct']);
+        $this->assertEquals('1337', $answer['answer']);
+        $answer = $data->attempts[0]->answers[6];
+        $this->assert_attempt_page($page8, $answer);
+        $this->assertEquals(transform::yesno(false), $answer['correct']);
+        $this->assertEquals('I like cats', $answer['answer']);
+        $this->assertEquals('Me too!', $answer['response']);
+
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'attempts');
+        $this->assertEmpty($data);
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'attempts');
+        $this->assertEmpty($data);
+
+        // Let's mess with the data by creating an additional attempt for u1, and create data for u1 and u2 in the other cm.
+        $this->create_branch($cm1, $u1, ['pageid' => $page1->id, 'nextpageid' => $page3->id, 'retry' => 1]);
+        $this->create_attempt($cm1, $u1, ['pageid' => $page3->id, 'useranswer' => 'B', 'retry' => 1]);
+
+        $this->create_branch($cm2, $u1, ['pageid' => $pageb1->id, 'nextpageid' => $pageb2->id]);
+        $this->create_attempt($cm2, $u1, ['pageid' => $pageb2->id, 'useranswer' => 'Abc']);
+
+        $this->create_branch($cm2, $u2, ['pageid' => $pageb1->id, 'nextpageid' => $pageb3->id]);
+        $this->create_attempt($cm2, $u2, ['pageid' => $pageb3->id, 'useranswer' => 'Def']);
+
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u1, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'attempts');
+        $this->assertNotEmpty($data);
+        $this->assertCount(2, $data->attempts);
+        $this->assertEquals(1, $data->attempts[0]->number);
+        $this->assertCount(2, $data->attempts[0]->jumps);
+        $this->assertCount(7, $data->attempts[0]->answers);
+        $attempt = $data->attempts[1];
+        $this->assertEquals(2, $attempt->number);
+        $this->assertCount(1, $attempt->jumps);
+        $this->assertCount(1, $attempt->answers);
+        $this->assert_attempt_page($page1, $attempt->jumps[0]);
+        $this->assertTrue(strpos($attempt->jumps[0]['went_to'], $page3->title) !== false);
+        $this->assert_attempt_page($page3, $attempt->answers[0]);
+        $this->assertEquals('B', $attempt->answers[0]['answer']);
+
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'attempts');
+        $this->assertCount(1, $data->attempts);
+        $attempt = $data->attempts[0];
+        $this->assertEquals(1, $attempt->number);
+        $this->assertCount(1, $attempt->jumps);
+        $this->assertCount(1, $attempt->answers);
+        $this->assert_attempt_page($pageb1, $attempt->jumps[0]);
+        $this->assertTrue(strpos($attempt->jumps[0]['went_to'], $pageb2->title) !== false);
+        $this->assert_attempt_page($pageb2, $attempt->answers[0]);
+        $this->assertEquals('Abc', $attempt->answers[0]['answer']);
+
+        writer::reset();
+        provider::export_user_data(new approved_contextlist($u2, 'mod_lesson', [$cm1ctx->id, $cm2ctx->id]));
+        $data = writer::with_context($cm1ctx)->get_related_data([], 'attempts');
+        $this->assertEmpty($data);
+
+        $data = writer::with_context($cm2ctx)->get_related_data([], 'attempts');
+        $this->assertCount(1, $data->attempts);
+        $attempt = $data->attempts[0];
+        $this->assertEquals(1, $attempt->number);
+        $this->assertCount(1, $attempt->jumps);
+        $this->assertCount(1, $attempt->answers);
+        $this->assert_attempt_page($pageb1, $attempt->jumps[0]);
+        $this->assertTrue(strpos($attempt->jumps[0]['went_to'], $pageb3->title) !== false);
+        $this->assert_attempt_page($pageb3, $attempt->answers[0]);
+        $this->assertEquals('Def', $attempt->answers[0]['answer']);
+    }
+
+    /**
+     * Assert the page details of an attempt.
+     *
+     * @param object $page The expected page info.
+     * @param array $attempt The exported attempt details.
+     * @return void
+     */
+    protected function assert_attempt_page($page, $attempt) {
+        $this->assertEquals($page->id, $attempt['id']);
+        $this->assertEquals($page->title, $attempt['page']);
+        $this->assertEquals(format_text($page->contents, $page->contentsformat), $attempt['contents']);
+    }
+
+    /**
+     * Create an attempt (answer to a question).
+     *
+     * @param object $lesson The lesson.
+     * @param object $user The user.
+     * @param array $options Options.
+     * @return object
+     */
+    protected function create_attempt($lesson, $user, array $options = []) {
+        global $DB;
+        $record = (object) array_merge([
+            'lessonid' => $lesson->id,
+            'userid' => $user->id,
+            'pageid' => 0,
+            'answerid' => 0,
+            'retry' => 0,
+            'correct' => 0,
+            'useranswer' => '',
+            'timeseen' => time(),
+        ], $options);
+        $record->id = $DB->insert_record('lesson_attempts', $record);
+        return $record;
+    }
+
+    /**
+     * Create a grade.
+     *
+     * @param object $lesson The lesson.
+     * @param object $user The user.
+     * @param array $options Options.
+     * @return object
+     */
+    protected function create_grade($lesson, $user, array $options = []) {
+        global $DB;
+        $record = (object) array_merge([
+            'lessonid' => $lesson->id,
+            'userid' => $user->id,
+            'late' => 0,
+            'grade' => 50.0,
+            'completed' => time(),
+        ], $options);
+        $record->id = $DB->insert_record('lesson_grades', $record);
+        return $record;
+    }
+
+    /**
+     * Create a timer.
+     *
+     * @param object $lesson The lesson.
+     * @param object $user The user.
+     * @param array $options Options.
+     * @return object
+     */
+    protected function create_timer($lesson, $user, array $options = []) {
+        global $DB;
+        $record = (object) array_merge([
+            'lessonid' => $lesson->id,
+            'userid' => $user->id,
+            'starttime' => time() - 600,
+            'lessontime' => time(),
+            'completed' => 1,
+            'timemodifiedoffline' => 0,
+        ], $options);
+        $record->id = $DB->insert_record('lesson_timer', $record);
+        return $record;
+    }
+
+    /**
+     * Create a branch (choice on page).
+     *
+     * @param object $lesson The lesson.
+     * @param object $user The user.
+     * @param array $options Options.
+     * @return object
+     */
+    protected function create_branch($lesson, $user, array $options = []) {
+        global $DB;
+        $record = (object) array_merge([
+            'lessonid' => $lesson->id,
+            'userid' => $user->id,
+            'pageid' => 0,
+            'retry' => 0,
+            'flag' => 0,
+            'timeseen' => time(),
+            'nextpageid' => 0,
+        ], $options);
+        $record->id = $DB->insert_record('lesson_branch', $record);
+        return $record;
+    }
+
+    /**
+     * Create an override.
+     *
+     * @param object $lesson The lesson.
+     * @param object $user The user.
+     * @param array $options Options.
+     * @return object
+     */
+    protected function create_override($lesson, $user, array $options = []) {
+        global $DB;
+        $record = (object) array_merge([
+            'lessonid' => $lesson->id,
+            'userid' => $user->id,
+        ], $options);
+        $record->id = $DB->insert_record('lesson_overrides', $record);
+        return $record;
+    }
+}
index 8d0a6ae..e68fdc5 100644 (file)
  */
 
 $string['alwaysgs'] = 'Use this service for grade sync and column management ';
-$string['grade_synchronization'] = 'IMS LTI Assignment and Grade Services';
-$string['grade_synchronization_help'] = 'Use the IMS LTI Assignment and Grade Service to synchronize the grades instead Basic Outcomes.
+$string['grade_synchronization'] = 'IMS LTI Assignment and Grade Services';
+$string['grade_synchronization_help'] = 'Whether to use the IMS LTI Assignment and Grade Services to synchronise grades instead of the Basic Outcomes service.
 
-* **Do not use this service** -  This will use the basic outcomes features and configuration
+* **Do not use this service** -  Basic Outcomes features and configuration will be used
 * **Use this service for grade sync only** - The service will populate the grades in an already existing gradebook column, but it will not be able to create new columns
-* **Use this service for grade sync and column management** -  The service will be able to create and update gradebook columns and manage the grades. ';
+* **Use this service for grade sync and column management** -  The service will be able to create and update gradebook columns and manage the grades.';
 $string['modulename'] = 'LTI Grades';
 $string['nevergs'] = 'Do not use this service';
 $string['partialgs'] = 'Use this service for grade sync only';
index d164de2..c105fd7 100644 (file)
@@ -64,5 +64,5 @@ $string['printheading'] = 'Display page name';
 $string['printheadingexplain'] = 'Display page name above content?';
 $string['printintro'] = 'Display page description';
 $string['printintroexplain'] = 'Display page description above content?';
-$string['privacy:metadata'] = 'The mod_page plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The Page resource plugin does not store any personal data.';
 $string['search:activity'] = 'Page';
index 8efabaf..284ae1a 100644 (file)
@@ -93,7 +93,7 @@ $string['popupwidth'] = 'Pop-up width (in pixels)';
 $string['popupwidthexplain'] = 'Specifies default width of popup windows.';
 $string['printintro'] = 'Display resource description';
 $string['printintroexplain'] = 'Display resource description below content? Some display types may not display description even if enabled.';
-$string['privacy:metadata'] = 'The mod_resource plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The File resource plugin does not store any personal data.';
 $string['resource:addinstance'] = 'Add a new resource';
 $string['resourcecontent'] = 'Files and subfolders';
 $string['resourcedetails_sizetype'] = '{$a->size} {$a->type}';
index 8264fad..ad91832 100644 (file)
@@ -233,15 +233,15 @@ $string['pluginname'] = 'Survey';
 $string['preferred'] = 'Preferred';
 $string['preferredclass'] = 'Class preferred';
 $string['preferredstudent'] = '{$a} preferred';
-$string['privacy:metadata:analysis'] = 'A record of individual\'s answers analysis.';
-$string['privacy:metadata:analysis:notes'] = 'Notes saved against an individual\'s answers.';
-$string['privacy:metadata:analysis:userid'] = 'The user whose answers it is';
+$string['privacy:metadata:analysis'] = 'A record of survey answers analysis.';
+$string['privacy:metadata:analysis:notes'] = 'Notes saved against a user\'s answers.';
+$string['privacy:metadata:analysis:userid'] = 'The ID of the user answering the survey.';
 $string['privacy:metadata:answers'] = 'A collection of answers to surveys.';
 $string['privacy:metadata:answers:answer1'] = 'Field to store the answer to a question.';
 $string['privacy:metadata:answers:answer2'] = 'Additional field to store the answer to a question.';
 $string['privacy:metadata:answers:question'] = 'The question.';
-$string['privacy:metadata:answers:time'] = 'The time at which the answer was posted.';
-$string['privacy:metadata:answers:userid'] = 'The user who submitted their answer.';
+$string['privacy:metadata:answers:time'] = 'The time when the answer was posted.';
+$string['privacy:metadata:answers:userid'] = 'The ID of the user who submitted their answer.';
 $string['question'] = 'Question';
 $string['questions'] = 'Questions';
 $string['questionsnotanswered'] = 'Some of the multiple choice questions have not been answered.';
index 7b670a8..ad7b42d 100644 (file)
@@ -70,7 +70,7 @@ $string['popupwidth'] = 'Pop-up width (in pixels)';
 $string['popupwidthexplain'] = 'Specifies default width of popup windows.';
 $string['printintro'] = 'Display URL description';
 $string['printintroexplain'] = 'Display URL description below content? Some display types may not display description even if enabled.';
-$string['privacy:metadata'] = 'The mod_url plugin does not store any personal data.';
+$string['privacy:metadata'] = 'The URL resource plugin does not store any personal data.';
 $string['rolesinparams'] = 'Include role names in parameters';
 $string['search:activity'] = 'URL';
 $string['serverurl'] = 'Server URL';
diff --git a/my/classes/privacy/provider.php b/my/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..4aef2a4
--- /dev/null
@@ -0,0 +1,76 @@
+<?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/>.
+/**
+ * Privacy Subsystem implementation for core_my.
+ *
+ * @package    core_my
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_my\privacy;
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\context;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\writer;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for core_my implementing metadata, plugin, and user_preference providers.
+ *
+ * @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\provider,
+    \core_privacy\local\request\user_preference_provider {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection     A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_user_preference(
+            'user_home_page_preference',
+            'privacy:metadata:core_my:preference:user_home_page_preference'
+        );
+
+        return $collection;
+    }
+
+    /**
+     * Export all user preferences for the plugin.
+     *
+     * @param   int $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $userhomepagepreference = get_user_preferences('user_home_page_preference', null, $userid);
+
+        if (null !== $userhomepagepreference) {
+            writer::export_user_preference(
+                'core_my',
+                'user_home_page_preference',
+                $userhomepagepreference,
+                get_string('privacy:metadata:core_my:preference:user_home_page_preference', 'core_my')
+            );
+        }
+    }
+
+}
diff --git a/my/tests/privacy_test.php b/my/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..fb9aaec
--- /dev/null
@@ -0,0 +1,73 @@
+<?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 core_my implementation of the privacy API.
+ *
+ * @package    core_my
+ * @category   test
+ * @copyright  2018 Zig Tan <zig@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 \core_my\privacy\provider;
+
+/**
+ * Unit tests for the core_my implementation of the privacy API.
+ *
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_my_privacy_testcase extends \core_privacy\tests\provider_testcase {
+
+    /**
+     * Test for provider::test_export_user_preferences().
+     */
+    public function test_export_user_preferences() {
+        global $DB;
+
+        // Test setup.
+        $this->resetAfterTest(true);
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        // Add a user home page preference for the User.
+        set_user_preference('user_home_page_preference', HOMEPAGE_MY);
+
+        // Test the user preference exists.
+        $params = [
+            'userid' => $user->id,
+            'name' => 'user_home_page_preference'
+        ];
+
+        $preferences = $DB->get_record('user_preferences', $params);
+        $this->assertEquals('user_home_page_preference', $preferences->name);
+
+        // Test the user preferences export contains 1 user preference record for the User.
+        provider::export_user_preferences($user->id);
+        $contextuser = context_user::instance($user->id);
+        $writer = writer::with_context($contextuser);
+        $this->assertTrue($writer->has_any_data());
+
+        $prefs = $writer->get_user_preferences('core_my');
+        $this->assertCount(1, (array) $prefs);
+        $this->assertEquals(HOMEPAGE_MY, $prefs->user_home_page_preference->value);
+    }
+
+}
diff --git a/notes/classes/privacy/provider.php b/notes/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..07f086c
--- /dev/null
@@ -0,0 +1,218 @@
+<?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/>.
+
+/**
+ * Privacy Subsystem implementation for core_notes.
+ *
+ * @package    core_notes
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_notes\privacy;
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\contextlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\writer;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/notes/lib.php');
+
+/**
+ * Implementation of the privacy subsystem plugin provider for core_notes.
+ *
+ * @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\provider, \core_privacy\local\request\plugin\provider {
+
+    /**
+     * Return the fields which contain personal data.
+     *
+     * @param collection $items a reference to the collection to use to store the metadata.
+     * @return collection the updated collection of metadata items.
+     */
+    public static function get_metadata(collection $items) : collection {
+        // The core_notes components utilises the shared mdl_post table.
+        $items->add_database_table(
+            'post',
+            [
+                'content' => 'privacy:metadata:core_notes:content',
+                'courseid' => 'privacy:metadata:core_notes:courseid',
+                'created' => 'privacy:metadata:core_notes:created',
+                'lastmodified' => 'privacy:metadata:core_notes:lastmodified',
+                'publishstate' => 'privacy:metadata:core_notes:publishstate',
+                'userid' => 'privacy:metadata:core_notes:userid'
+            ],
+            'privacy:metadata:core_notes'
+        );
+
+        return $items;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param int $userid the userid.
+     * @return contextlist the list of contexts containing user info for the user.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        global $DB;
+
+        $contextlist = new contextlist();
+
+        $publishstates = [
+            NOTES_STATE_PUBLIC,
+            NOTES_STATE_SITE
+        ];
+        list($publishstatesql, $publishstateparams) = $DB->get_in_or_equal($publishstates, SQL_PARAMS_NAMED);
+
+        // Retrieve all the Course contexts associated with notes written by the user, and also written about the user.
+        // Only notes written about the user that are public or site wide will be exported.
+        $sql = "SELECT c.id
+                  FROM {context} c
+            INNER JOIN {post} p ON p.courseid = c.instanceid AND c.contextlevel = :contextcoursewrittenby
+                 WHERE p.module = 'notes'
+                   AND p.usermodified = :usermodified
+                 UNION
+                SELECT c.id
+                  FROM {context} c
+            INNER JOIN {post} p ON p.courseid = c.instanceid AND c.contextlevel = :contextcoursewrittenfor
+                 WHERE p.module = 'notes'
+                   AND p.userid = :userid
+                   AND p.publishstate {$publishstatesql}";
+
+        $params = [
+            'contextcoursewrittenby'  => CONTEXT_COURSE,
+            'usermodified'            => $userid,
+            'contextcoursewrittenfor' => CONTEXT_COURSE,
+            'userid'                  => $userid
+        ];
+        $params += $publishstateparams;
+
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export personal data for the given approved_contextlist.
+     * User and context information is contained within the contextlist.
+     *
+     * @param approved_contextlist $contextlist a list of contexts approved for export.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        if (empty($contextlist->count())) {
+            return;
+        }
+
+        $userid = $contextlist->get_user()->id;
+
+        list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
+
+        // Export all notes written by and written about the user, and organize it by the associated Course context(s).
+        $sql = "SELECT p.courseid as courseid,
+                       p.content as content,
+                       p.publishstate as publishstate,
+                       p.userid as userid,
+                       p.usermodified as usermodified,
+                       p.created as datecreated,
+                       p.lastmodified as datemodified
+                  FROM {context} c
+            INNER JOIN {post} p ON p.courseid = c.instanceid AND c.contextlevel = :contextcourse
+                 WHERE p.module = 'notes'
+                   AND (p.usermodified = :usermodified OR p.userid = :userid)
+                   AND c.id {$contextsql}";
+
+        $params = [
+            'contextcourse' => CONTEXT_COURSE,
+            'usermodified'  => $userid,
+            'userid'        => $userid
+        ];
+        $params += $contextparams;
+
+        $notes = $DB->get_recordset_sql($sql, $params);
+        foreach ($notes as $note) {
+            $contextcourse = \context_course::instance($note->courseid);
+
+            // The exported notes will be organized in {Course Context}/Notes/{publishstate}/usernote-{userid}.json.
+            $subcontext = [
+                get_string('notes', 'notes'),
+                $note->publishstate
+            ];
+
+            $name = 'usernote-' . transform::user($note->userid);
+
+            $notecontent = (object) [
+               'content' => $note->content,
+               'publishstate' => $note->publishstate,
+               'userid' => transform::user($note->userid),
+               'usermodified' => transform::user($note->usermodified),
+               'datecreated' => transform::datetime($note->datecreated),
+               'datemodified' => transform::datetime($note->datemodified)
+            ];
+
+            writer::with_context($contextcourse)->export_related_data($subcontext, $name, $notecontent);
+        }
+        $notes->close();
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param \context $context the context to delete in.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        global $DB;
+
+        if (empty($context)) {
+            return;
+        }
+
+        $DB->delete_records('post', ['module' => 'notes', 'courseid' => $context->instanceid]);
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist a list of contexts approved for deletion.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+
+        if (empty($contextlist->count())) {
+            return;
+        }
+
+        $userid = $contextlist->get_user()->id;
+
+        foreach ($contextlist->get_contexts() as $context) {
+            $conditions = [
+                'module'        => 'notes',
+                'courseid'      => $context->instanceid,
+                'usermodified'  => $userid
+            ];
+
+            $DB->delete_records('post', $conditions);
+        }
+    }
+}
diff --git a/notes/tests/privacy_test.php b/notes/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..c3b7e94
--- /dev/null
@@ -0,0 +1,377 @@
+<?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 core_notes implementation of the privacy API.
+ *
+ * @package    core_notes
+ * @category   test
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+global $CFG;
+
+require_once($CFG->dirroot . "/notes/lib.php");
+
+use \core_notes\privacy\provider;
+use \core_privacy\local\request\writer;
+use \core_privacy\local\request\approved_contextlist;
+
+/**
+ * Unit tests for the core_notes implementation of the privacy API.
+ *
+ * @copyright  2018 Zig Tan <zig@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_notes_privacy_testcase extends \core_privacy\tests\provider_testcase {
+
+    /**
+     * Test for provider::get_contexts_for_userid().
+     */
+    public function test_get_contexts_for_userid() {
+        global $DB;
+
+        // Test setup.
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        set_config('enablenotes', true);
+
+        $teacher1 = $this->getDataGenerator()->create_user();
+        $this->setUser($teacher1);
+        $teacherrole = $DB->get_record('role', array('shortname' => 'teacher'));
+
+        $student = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+
+        // Create Courses, then enrol a teacher and a student.
+        $nocourses = 5;
+        $courses = [];
+        $coursecontextids = [];
+        for ($c = 1; $c <= $nocourses; $c++) {
+            $course = $this->getDataGenerator()->create_course();
+            $coursecontext = context_course::instance($course->id);
+
+            role_assign($teacherrole->id, $teacher1->id, $coursecontext->id);
+            role_assign($studentrole->id, $student->id, $coursecontext->id);
+
+            // Only create private user notes (i.e. NOTES_STATE_DRAFT) for student in Course 1, 2, 3 written by the teacher.
+            if ($c <= 3) {
+                $this->help_create_user_note(
+                    $student->id,
+                    NOTES_STATE_DRAFT,
+                    $course->id,
+                    "Test private user note about the student in Course $c by the teacher"
+                );
+            }
+
+            $courses[$c] = $course;
+            $coursecontextids[] = $coursecontext->id;
+        }
+
+        // Test Teacher 1's contexts equals 3 because only 3 user notes were added for Course 1, 2, and 3.
+        // Course 4 and 5 does not have any notes associated with it, so the contexts should not be returned.
+        $contexts = provider::get_contexts_for_userid($teacher1->id);
+        $this->assertCount(3, $contexts->get_contextids());
+
+        // Test the Student's contexts is 0 because the notes written by the teacher are private.
+        $contexts = provider::get_contexts_for_userid($student->id);
+        $this->assertCount(0, $contexts->get_contextids());
+
+        // Add a public user note (i.e. NOTES_STATE_PUBLIC) written by the Teacher about the Student in Course 4.
+        $course = $courses[4];
+        $this->help_create_user_note(
+            $student->id,
+            NOTES_STATE_PUBLIC,
+            $course->id,
+            "Test public user note about the student in Course 4 by the teacher"
+        );
+
+        // Test Teacher 1's contexts equals 4 after adding a public note about a student in Course 4.
+        $contexts = provider::get_contexts_for_userid($teacher1->id);
+        $this->assertCount(4, $contexts->get_contextids());
+
+        // Test the Student's contexts is 1 for Course 4 because there is a public note written by the teacher.
+        $contexts = provider::get_contexts_for_userid($student->id);
+        $this->assertCount(1, $contexts->get_contextids());
+
+        // Add a site-wide user note (i.e. NOTES_STATE_SITE) written by the Teacher 1 about the Student in Course 3.
+        $course = $courses[3];
+        $this->help_create_user_note(
+            $student->id,
+            NOTES_STATE_SITE,
+            $course->id,
+            "Test site-wide user note about the student in Course 3 by the teacher"
+        );
+
+        // Test the Student's contexts is 2 for Courses 3, 4 because there is a public and site-wide note written by the Teacher.
+        $contexts = provider::get_contexts_for_userid($student->id);
+        $this->assertCount(2, $contexts->get_contextids());
+
+        // Add a site-wide user note for the Teacher 1 by another Teacher 2 in Course 5.
+        $teacher2 = $this->getDataGenerator()->create_user();
+        $this->setUser($teacher2);
+
+        $course = $courses[5];
+        $this->help_create_user_note(
+            $teacher1->id,
+            NOTES_STATE_SITE,
+            $course->id,
+            "Test site-wide user note about the teacher in Course 5 by another teacher"
+        );
+
+        // Test Teacher 1's contexts equals 5 after adding the note from another teacher.
+        $contextlist = provider::get_contexts_for_userid($teacher1->id);
+        $this->assertCount(5, $contextlist->get_contextids());
+
+        // Test Teacher 1's contexts match the contexts of the Courses associated with notes created.
+        $this->assertEmpty(array_diff($coursecontextids, $contextlist->get_contextids()));
+    }
+
+    /**
+     * Test for provider::export_user_data().
+     */
+    public function test_export_user_data() {
+        global $DB;
+
+        // Test setup.
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        set_config('enablenotes', true);
+
+        $teacher1 = $this->getDataGenerator()->create_user();
+        $this->setUser($teacher1);
+        $teacherrole = $DB->get_record('role', array('shortname' => 'teacher'));
+
+        $nocourses = 5;
+        $nostudents = 2;
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+
+        $courses = [];
+        $coursecontextids = [];
+        for ($c = 1; $c <= $nocourses; $c++) {
+            // Create a Course, then enrol a teacher and enrol 2 students.
+            $course = $this->getDataGenerator()->create_course();
+            $coursecontext = context_course::instance($course->id);
+
+            role_assign($teacherrole->id, $teacher1->id, $coursecontext->id);
+
+            // Only create public user notes (i.e. NOTES_STATE_PUBLIC) for students in Course 1, 2, 3 written by the teacher.
+            if ($c <= 3) {
+                for ($s = 0; $s < $nostudents; $s++) {
+                    $student = $this->getDataGenerator()->create_user();
+                    role_assign($studentrole->id, $student->id, $coursecontext->id);
+
+                    // Create test public user note data written for students by the teacher.
+                    $this->help_create_user_note(
+                        $student->id,
+                        NOTES_STATE_PUBLIC,
+                        $course->id,
+                        "Test public user note for student $s in Course $c by the teacher"
+                    );
+                }
+                // Store the Course context for those which have test notes added for verification.
+                $coursecontextids[] = $coursecontext->id;
+            }
+
+            $courses[$c] = $course;
+        }
+
+        // Add a site-wide user note for Teacher 1 by another Teacher 2 in Course 4.
+        $teacher2 = $this->getDataGenerator()->create_user();
+        $this->setUser($teacher2);
+
+        $course = $courses[4];
+        $this->help_create_user_note(
+            $teacher1->id,
+            NOTES_STATE_SITE,
+            $course->id,
+            "Test site-wide user note about the teacher in Course 4 by another teacher"
+        );
+        // Store the Course context for those which have test notes added for verification.
+        $coursecontextids[] = context_course::instance($course->id)->id;
+
+        // Add a private user note for Teacher 1 by another Teacher 2 in Course 5.
+        $course = $courses[5];
+        $this->help_create_user_note(
+            $teacher1->id,
+            NOTES_STATE_DRAFT,
+            $course->id,
+            "Test private user note about the teacher in Course 5 by another teacher"
+        );
+
+        // Test the number of contexts returned matches the Course contexts created with notes.
+        $contextlist = provider::get_contexts_for_userid($teacher1->id);
+        $this->assertEmpty(array_diff($coursecontextids, $contextlist->get_contextids()));
+
+        $approvedcontextlist = new approved_contextlist($teacher1, 'core_notes', $contextlist->get_contextids());
+
+        // Retrieve User notes created by the teacher.
+        provider::export_user_data($approvedcontextlist);
+
+        // Test the core_notes data is exported at the Course context level and has content.
+        foreach ($contextlist as $context) {
+            $this->assertEquals(CONTEXT_COURSE, $context->contextlevel);
+
+            $writer = writer::with_context($context);
+            $this->assertTrue($writer->has_any_data());
+        }
+    }
+
+    /**
+     * Test for provider::delete_data_for_all_users_in_context().
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        // Test setup.
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        set_config('enablenotes', true);
+
+        $teacher = $this->getDataGenerator()->create_user();
+        $this->setUser($teacher);
+        $teacherrole = $DB->get_record('role', array('shortname' => 'teacher'));
+
+        $nocourses = 2;
+        $nostudents = 5;
+        $nonotes = 7;
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+
+        $n = 0;
+        for ($c = 0; $c < $nocourses; $c++) {
+            // Create a Course, then enrol a teacher and enrol 2 students.
+            $course = $this->getDataGenerator()->create_course();
+            $coursecontext = context_course::instance($course->id);
+
+            role_assign($teacherrole->id, $teacher->id, $coursecontext->id);
+
+            for ($s = 0; $s < $nostudents; $s++) {
+                if ($n < $nonotes) {
+                    $student = $this->getDataGenerator()->create_user();
+                    role_assign($studentrole->id, $student->id, $coursecontext->id);
+
+                    // Create test note data.
+                    $this->help_create_user_note(
+                        $student->id,
+                        NOTES_STATE_PUBLIC,
+                        $course->id,
+                        "Test user note for student $s in Course $c"
+                    );
+                }
+                $n++;
+            }
+        }
+
+        // Test the number of contexts returned equals the number of Courses created with user notes for its students.
+        $contextlist = provider::get_contexts_for_userid($teacher->id);
+        $this->assertCount($nocourses, $contextlist->get_contextids());
+
+        // Test the created user note records in mdl_post table matches the test number of user notes specified.
+        $notes = $DB->get_records('post', ['module' => 'notes', 'usermodified' => $teacher->id]);
+        $this->assertCount($nonotes, $notes);
+
+        // Delete all user note records in mdl_post table by the specified Course context.
+        foreach ($contextlist->get_contexts() as $context) {
+            provider::delete_data_for_all_users_in_context($context);
+        }
+
+        // Test the core_note records in mdl_post table is equals zero.
+        $notes = $DB->get_records('post', ['module' => 'notes', 'usermodified' => $teacher->id]);
+        $this->assertCount(0, $notes);
+    }
+
+    /**
+     * Test for provider::delete_data_for_user().
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        // Test setup.
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        set_config('enablenotes', true);
+
+        $teacher = $this->getDataGenerator()->create_user();
+        $this->setUser($teacher);
+        $teacherrole = $DB->get_record('role', array('shortname' => 'teacher'));
+
+        $nocourses = 2;
+        $nostudents = 5;
+        $nonotes = 7;
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+
+        $n = 0;
+        for ($c = 0; $c < $nocourses; $c++) {
+            // Create a Course, then enrol a teacher and enrol 2 students.
+            $course = $this->getDataGenerator()->create_course();
+            $coursecontext = context_course::instance($course->id);
+
+            role_assign($teacherrole->id, $teacher->id, $coursecontext->id);
+
+            for ($s = 0; $s < $nostudents; $s++) {
+                if ($n < $nonotes) {
+                    $student = $this->getDataGenerator()->create_user();
+                    role_assign($studentrole->id, $student->id, $coursecontext->id);
+
+                    // Create test note data.
+                    $this->help_create_user_note(
+                        $student->id,
+                        NOTES_STATE_PUBLIC,
+                        $course->id,
+                        "Test user note for student $s in Course $c"
+                    );
+                }
+                $n++;
+            }
+        }
+
+        // Test the number of contexts returned equals the number of Courses created with user notes for its students.
+        $contextlist = provider::get_contexts_for_userid($teacher->id);
+        $this->assertCount($nocourses, $contextlist->get_contextids());
+
+        // Test the created user note records in mdl_post table matches the test number of user notes specified.
+        $notes = $DB->get_records('post', ['module' => 'notes', 'usermodified' => $teacher->id]);
+        $this->assertCount($nonotes, $notes);
+
+        // Delete all user note records in mdl_post table created by the specified teacher.
+        $approvedcontextlist = new approved_contextlist($teacher, 'core_notes', $contextlist->get_contextids());
+        provider::delete_data_for_user($approvedcontextlist);
+
+        // Test the core_note records in mdl_post table is equals zero.
+        $notes = $DB->get_records('post', ['module' => 'notes', 'usermodified' => $teacher->id]);
+        $this->assertCount(0, $notes);
+    }
+
+    /**
+     * Helper function to create user notes for testing.
+     *
+     * @param int       $userid   The ID of the User associated with the note.
+     * @param string    $state    The publish status
+     * @param int       $courseid The ID of the Course associated with the note.
+     * @param string    $content  The note content.
+     */
+    protected function help_create_user_note($userid, $state, $courseid, $content) {
+        $note = (object) [
+            'userid' => $userid,
+            'publishstate' => $state,
+            'courseid' => $courseid,
+            'content' => $content,
+        ];
+        note_save($note);
+    }
+}
index 4e19d53..348a2ec 100644 (file)
@@ -57,10 +57,12 @@ class qbank_chooser extends \core\output\chooser {
                     return new qbank_chooser_item($qtype, $context);
                 }, $real));
 
-        $sections[] = new chooser_section('other', new lang_string('other'),
-                array_map(function($qtype) use ($context) {
+        if (!empty($fake)) {
+            $sections[] = new chooser_section('other', new lang_string('other'),
+                array_map(function ($qtype) use ($context) {
                     return new qbank_chooser_item($qtype, $context);
                 }, $fake));
+        }
 
         parent::__construct(new moodle_url('/question/question.php'),
                 new lang_string('chooseqtypetoadd', 'question'), $sections, 'qtype');
index f561c47..4c40775 100644 (file)
@@ -57,4 +57,4 @@ $string['privacy:metadata:repository_flickr_public'] = 'The Flickr public reposi
 $string['privacy:metadata:repository_flickr_public:author'] = 'The Flickr public repository content author.';
 $string['privacy:metadata:repository_flickr_public:email_address'] = 'The Flickr public repository user email address.';
 $string['privacy:metadata:repository_flickr_public:text'] = 'The Flickr public repository user search text.';
-$string['privacy:metadata:repository_flickr_public:user_id'] = 'The Flickr public repository user id.';
+$string['privacy:metadata:repository_flickr_public:user_id'] = 'The Flickr public repository user ID.';
index 0d5ac75..4b64a8b 100644 (file)
@@ -35,5 +35,5 @@ $string['sortpublished'] = 'Date Published';
 $string['sortrating'] = 'Rating';
 $string['sortrelevance'] = 'Relevance';
 $string['sortviewcount'] = 'View Count';
-$string['privacy:metadata:repository_youtube'] = 'The Youtube videos repository plugin does not store any personal data, but does transmit user data from Moodle to the remote system.';
-$string['privacy:metadata:repository_youtube:searchtext'] = 'The Youtube videos repository user search text query.';
+$string['privacy:metadata:repository_youtube'] = 'The YouTube videos repository plugin does not store any personal data, but does transmit user data from Moodle to the remote system.';
+$string['privacy:metadata:repository_youtube:searchtext'] = 'The YouTube videos repository user search text query.';
index 6d569cf..4815076 100644 (file)
@@ -40,7 +40,7 @@ use \core_privacy\local\request\writer;
  */
 class provider implements
         \core_privacy\local\metadata\provider,
-        \core_privacy\local\request\plugin\provider {
+        \core_privacy\local\request\subsystem\provider {
 
     /**
      * Return the fields which contain personal data.
index 659cc1b..a2d4127 100644 (file)
         content: "#{$breadcrumb-divider}"; /* stylelint-disable-line declaration-block-no-duplicate-properties */
     }
 }
+
+/*rtl:raw:
+.dropdown-menu {
+    right: auto;
+    left: 0;
+    text-align: right;
+}
+*/
\ No newline at end of file
index f3181ca..599c67d 100644 (file)
@@ -216,16 +216,4 @@ $bs4aspacers: map-merge((
   .col-xs-#{$i} {
     @extend .col-#{$i};
   }
-}
-
-// These have been removed in BS4 Stable. Adding in so RTL dropdowns will work.
-/*rtl:raw:
-.dropdown-menu-right {
-    right: 0;
-    left: auto;
-}
-.dropdown-menu-left {
-    right: auto;
-    left: 0;
-}
-*/
+}
\ No newline at end of file
index bbf579a..b93e086 100644 (file)
@@ -1241,9 +1241,6 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
 .moodle-dialogue-confirm .confirmation-message {
     margin: 0.5rem 0;
 }
-.moodle-dialogue-confirm .confirmation-buttons {
-    text-align: right;
-}
 
 .moodle-dialogue-confirm .confirmation-dialogue input {
     min-width: 80px;
index e44cffb..86642a9 100644 (file)
@@ -10,7 +10,7 @@
 }
 
 .mform .containsadvancedelements .advanced.show {
-    display: block;
+    display: flex;
 }
 
 #adminsettings span.error {
@@ -415,3 +415,9 @@ textarea[data-auto-rows] {
         display: none;
     }
 }
+
+// The autocomplete popup needs a display:block container to correctly position to popup.
+.form-inline[data-fieldtype="autocomplete"],
+.form-inline[data-fieldtype="tags"] {
+    display: block;
+}
index 4d29565..f559f3b 100644 (file)
@@ -33,6 +33,10 @@ $icon-big-height: 64px;
     color: $navbar-dark-color !important; /* stylelint-disable-line declaration-no-important */
 }
 
+.action-menu-item a:first-of-type > .icon {
+    margin-left: 0.5rem;
+}
+
 // YUI 2 Tree View icons must not have a margin left.
 .ygtvcell .icon {
     margin-left: 0 !important; /* stylelint-disable-line declaration-no-important */
index c728549..8b505ea 100644 (file)
@@ -231,9 +231,10 @@ body.path-question-type {
         width: auto;
         height: auto;
         padding: 0;
-        margin: 0;
+        margin: 0 0.5rem 0 0;
         overflow: visible;
         clip: auto;
+        clip-path: none;
         border: 0;
     }
 }
@@ -634,7 +635,8 @@ body.path-question-type .mform fieldset.hidden {
 }
 
 // Fix for accurate positioning of ddwtos drag and drop question types.
-.que.ddwtos {
+.que.ddwtos,
+.que.ddwtos .drop {
     box-sizing: content-box;
 }
 
index 081b145..fcfc75b 100644 (file)
@@ -12,7 +12,7 @@ $gray-800: #373a3c !default;
 $gray-900: #212529 !default;
 $black:    #000 !default;
 
-$blue:    #3097d1 !default;
+$blue:    #1177d1 !default;
 $indigo:  #6610f2 !default;
 $purple:  #613d7c !default;
 $pink:    #e83e8c !default;
index 92aa3a6..c6878ea 100644 (file)
 <div class="action-menu {{classes}} d-inline"{{#attributes}} {{name}}="{{value}}"{{/attributes}}>
     {{#primary}}
 
-        <div class="{{classes}} d-flex justify-content-end "{{#attributes}} {{name}}="{{value}}"{{/attributes}}>
+        <div class="{{classes}} d-flex "{{#attributes}} {{name}}="{{value}}"{{/attributes}}>
 
             {{#prioritise}}{{> core/action_menu_trigger }}{{/prioritise}}
 
             {{#items}}
-                <div>
+                <div class="action-menu-item">
                     {{> core/action_menu_item }}
                 </div>
             {{/items}}
 
             {{^prioritise}}
-                <div>
+                <div class="action-menu-trigger">
                     {{> core/action_menu_trigger }}
                 </div>
             {{/prioritise}}
diff --git a/user/profile/field/checkbox/classes/privacy/provider.php b/user/profile/field/checkbox/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..71c5ec9
--- /dev/null
@@ -0,0 +1,166 @@
+<?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/>.
+
+/**
+ * Privacy class for requesting user data.
+ *
+ * @package    profilefield_checkbox
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace profilefield_checkbox\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\local\metadata\collection;
+use \core_privacy\local\request\contextlist;
+use \core_privacy\local\request\approved_contextlist;
+
+/**
+ * Privacy class for requesting user data.
+ *
+ * @package    profilefield_checkbox
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\plugin\provider {
+
+    /**
+     * Returns meta data about this system.
+     *
+     * @param   collection $collection The initialised collection to add items to.
+     * @return  collection A listing of user data stored through this system.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table('user_info_data', [
+            'userid' => 'privacy:metadata:profilefield_checkbox:userid',
+            'fieldid' => 'privacy:metadata:profilefield_checkbox:fieldid',
+            'data' => 'privacy:metadata:profilefield_checkbox:data',
+            'dataformat' => 'privacy:metadata:profilefield_checkbox:dataformat'
+        ], 'privacy:metadata:profilefield_checkbox:tableexplanation');
+        return $collection;
+    }
+
+    /**
+     * Get the list of contexts that contain user information for the specified user.
+     *
+     * @param   int         $userid     The user to search.
+     * @return  contextlist $contextlist  The contextlist containing the list of contexts used in this plugin.
+     */
+    public static function get_contexts_for_userid(int $userid) : contextlist {
+        $sql = "SELECT ctx.id
+                FROM {user_info_data} uid
+                JOIN {user_info_field} uif
+                    ON uid.fieldid = uif.id
+                JOIN {context} ctx
+                    ON ctx.instanceid = uid.userid
+                        AND ctx.contextlevel = :contextlevel
+                WHERE uid.userid = :userid
+                    AND uif.datatype = :datatype";
+        $params = [
+            'userid' => $userid,
+            'contextlevel' => CONTEXT_USER,
+            'datatype' => 'checkbox'
+        ];
+        $contextlist = new contextlist();
+        $contextlist->add_from_sql($sql, $params);
+
+        return $contextlist;
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        $results = static::get_records($contextlist->get_user()->id);
+        foreach ($results as $result) {
+            $data = (object) [
+                'name' => $result->name,
+                'description' => $result->description,
+                'data' => $result->data
+            ];
+            \core_privacy\local\request\writer::with_context($contextlist->current())->export_data([
+                get_string('pluginname', 'profilefield_checkbox')], $data);
+        }
+    }
+
+    /**
+     * Delete all use data which matches the specified deletion_criteria.
+     *
+     * @param   context $context A user context.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        // Delete data only for user context.
+        if ($context->contextlevel == CONTEXT_USER) {
+            static::delete_data($context->instanceid);
+        }
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param   approved_contextlist    $contextlist    The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        static::delete_data($contextlist->get_user()->id);
+    }
+
+    /**
+     * Delete data related to a userid.
+     *
+     * @param  int $userid The user ID
+     */
+    protected static function delete_data($userid) {
+        global $DB;
+
+        $params = [
+            'userid' => $userid,
+            'datatype' => 'checkbox'
+        ];
+
+        $DB->delete_records_select('user_info_data', "fieldid IN (
+                SELECT id FROM {user_info_field} WHERE datatype = :datatype)
+                AND userid = :userid", $params);
+    }
+
+    /**
+     * Get records related to this plugin and user.
+     *
+     * @param  int $userid The user ID
+     * @return array An array of records.
+     */
+    protected static function get_records($userid) {
+        global $DB;
+
+        $sql = "SELECT *
+                FROM {user_info_data} uid
+                JOIN {user_info_field} uif
+                    ON uid.fieldid = uif.id
+                WHERE uid.userid = :userid
+                    AND uif.datatype = :datatype";
+        $params = [
+            'userid' => $userid,
+            'datatype' => 'checkbox'
+        ];
+
+        return $DB->get_records_sql($sql, $params);
+    }
+}
index 2d6c7cb..b588e3b 100644 (file)
@@ -23,3 +23,8 @@
  */
 
 $string['pluginname'] = 'Checkbox';
+$string['privacy:metadata:profilefield_checkbox:userid'] = 'The ID of the user which data is stored by the Checkbox plugin.';
+$string['privacy:metadata:profilefield_checkbox:fieldid'] = 'The ID of the profile field.';
+$string['privacy:metadata:profilefield_checkbox:data'] = 'The stored user data.';
+$string['privacy:metadata:profilefield_checkbox:dataformat'] = 'The format of the stored user data.';
+$string['privacy:metadata:profilefield_checkbox:tableexplanation'] = 'Additional user information is stored here.';
diff --git a/user/profile/field/checkbox/tests/privacy_test.php b/user/profile/field/checkbox/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..453ebe2
--- /dev/null
@@ -0,0 +1,208 @@
+<?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/>.
+
+/**
+ * Base class for unit tests for profilefield_checkbox.
+ *
+ * @package    profilefield_checkbox
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+use \core_privacy\tests\provider_testcase;
+
+/**
+ * Unit tests for user\profile\field\checkbox\classes\privacy\provider.php
+ *
+ * @copyright  2018 Mihail Geshoski <mihail@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class profilefield_checkbox_testcase extends provider_testcase {
+
+    /**
+     * Basic setup for these tests.
+     */
+    public function setUp() {
+        $this->resetAfterTest(true);
+    }
+
+    /**
+     * Test getting the context for the user ID related to this plugin.
+     */
+    public function test_get_contexts_for_userid() {
+        global $DB;
+        // Create profile category.
+        $categoryid = $this->add_profile_category();
+        // Create profile field.
+        $profilefieldid = $this->add_profile_field($categoryid, 'checkbox');
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $this->add_user_info_data($user->id, $profilefieldid, 'test data');
+        // Get the field that was created.
+        $userfielddata = $DB->get_records('user_info_data', array('userid' => $user->id));
+        // Confirm we got the right number of user field data.
+        $this->assertCount(1, $userfielddata);
+        $context = context_user::instance($user->id);
+        $contextlist = \profilefield_checkbox\privacy\provider::get_contexts_for_userid($user->id);
+        $this->assertEquals($context, $contextlist->current());
+    }
+
+    /**
+     * Test that data is exported correctly for this plugin.
+     */
+    public function test_export_user_data() {
+        // Create profile category.
+        $categoryid = $this->add_profile_category();
+        // Create checkbox profile field.
+        $checkboxprofilefieldid = $this->add_profile_field($categoryid, 'checkbox');
+        // Create datetime profile field.
+        $datetimeprofilefieldid = $this->add_profile_field($categoryid, 'datetime');
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $context = context_user::instance($user->id);
+        // Add checkbox user info data.
+        $this->add_user_info_data($user->id, $checkboxprofilefieldid, 'test data');
+        // Add datetime user info data.
+        $this->add_user_info_data($user->id, $datetimeprofilefieldid, '1524067200');
+        $writer = \core_privacy\local\request\writer::with_context($context);
+        $this->assertFalse($writer->has_any_data());
+        $this->export_context_data_for_user($user->id, $context, 'profilefield_checkbox');
+        $data = $writer->get_data([get_string('pluginname', 'profilefield_checkbox')]);
+        $this->assertCount(3, (array) $data);
+        $this->assertEquals('Test field', $data->name);
+        $this->assertEquals('This is a test.', $data->description);
+        $this->assertEquals('test data', $data->data);
+    }
+
+    /**
+     * Test that user data is deleted using the context.
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+        // Create profile category.
+        $categoryid = $this->add_profile_category();
+        // Create checkbox profile field.
+        $checkboxprofilefieldid = $this->add_profile_field($categoryid, 'checkbox');
+        // Create datetime profile field.
+        $datetimeprofilefieldid = $this->add_profile_field($categoryid, 'datetime');
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $context = context_user::instance($user->id);
+        // Add checkbox user info data.
+        $this->add_user_info_data($user->id, $checkboxprofilefieldid, 'test data');
+        // Add datetime user info data.
+        $this->add_user_info_data($user->id, $datetimeprofilefieldid, '1524067200');
+        // Check that we have two entries.
+        $userinfodata = $DB->get_records('user_info_data', ['userid' => $user->id]);
+        $this->assertCount(2, $userinfodata);
+        \profilefield_checkbox\privacy\provider::delete_data_for_all_users_in_context($context);
+        // Check that the correct profile field has been deleted.
+        $userinfodata = $DB->get_records('user_info_data', ['userid' => $user->id]);
+        $this->assertCount(1, $userinfodata);
+        $this->assertNotEquals('test data', reset($userinfodata)->data);
+    }
+
+    /**
+     * Test that user data is deleted for this user.
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+        // Create profile category.
+        $categoryid = $this->add_profile_category();
+        // Create checkbox profile field.
+        $checkboxprofilefieldid = $this->add_profile_field($categoryid, 'checkbox');
+        // Create datetime profile field.
+        $datetimeprofilefieldid = $this->add_profile_field($categoryid, 'datetime');
+        // Create a user.
+        $user = $this->getDataGenerator()->create_user();
+        $context = context_user::instance($user->id);
+        // Add checkbox user info data.
+        $this->add_user_info_data($user->id, $checkboxprofilefieldid, 'test data');
+        // Add datetime user info data.
+        $this->add_user_info_data($user->id, $datetimeprofilefieldid, '1524067200');
+        // Check that we have two entries.
+        $userinfodata = $DB->get_records('user_info_data', ['userid' => $user->id]);
+        $this->assertCount(2, $userinfodata);
+        $approvedlist = new \core_privacy\local\request\approved_contextlist($user, 'profilefield_checkbox',
+            [$context->id]);
+        \profilefield_checkbox\privacy\provider::delete_data_for_user($approvedlist);
+        // Check that the correct profile field has been deleted.
+        $userinfodata = $DB->get_records('user_info_data', ['userid' => $user->id]);
+        $this->assertCount(1, $userinfodata);
+        $this->assertNotEquals('test data', reset($userinfodata)->data);
+    }
+
+    /**
+     * Add dummy user info data.
+     *
+     * @param int $userid The ID of the user
+     * @param int $fieldid The ID of the field
+     * @param string $data The data
+     */
+    private function add_user_info_data($userid, $fieldid, $data) {
+        global $DB;
+        $userinfodata = array(
+            'userid' => $userid,
+            'fieldid' => $fieldid,
+            'data' => $data,
+            'dataformat' => 0
+        );
+
+        $DB->insert_record('user_info_data', $userinfodata);
+    }
+
+    /**
+     * Add dummy profile category.
+     *
+     * @return int The ID of the profile category
+     */
+    private function add_profile_category() {
+        global $DB;
+        // Create a new profile category.
+        $cat = new stdClass();
+        $cat->name = 'Test category';
+        $cat->sortorder = 1;
+
+        return $DB->insert_record('user_info_category', $cat);
+    }
+
+    /**
+     * Add dummy profile field.
+     *
+     * @param int $categoryid The ID of the profile category
+     * @param string $datatype The datatype of the profile field
+     * @return int The ID of the profile field
+     */
+    private function add_profile_field($categoryid, $datatype) {
+        global $DB;
+        // Create a new profile field.
+        $data = new stdClass();
+        $data->datatype = $datatype;
+        $data->shortname = 'tstField';
+        $data->name = 'Test field';
+        $data->description = 'This is a test.';
+        $data->required = false;
+        $data->locked = false;
+        $data->forceunique = false;
+        $data->signup = false;
+        $data->visible = '0';
+        $data->categoryid = $categoryid;
+
+        return $DB->insert_record('user_info_field', $data);
+    }
+}
index 55de453..6d73885 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2018042500.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2018042500.01;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.