Merge branch 'MDL-62188-master' of git://github.com/sarjona/moodle
authorJun Pataleta <jun@moodle.com>
Thu, 26 Apr 2018 06:50:04 +0000 (14:50 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 26 Apr 2018 06:50:04 +0000 (14:50 +0800)
75 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]
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/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/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/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/lang/en/choice.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
repository/flickr_public/lang/en/repository_flickr_public.php
repository/youtube/lang/en/repository_youtube.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
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 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 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 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 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 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 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..cfcffd2
--- /dev/null
@@ -0,0 +1,584 @@
+<?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);
+                $records = $DB->get_records_select('lesson_answers', "id $insql", $inparams, 'id, answer, answerformat');
+                $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';
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 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 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.